Create a new action and develop graphQL together

:bulb: This article was written based on NineChronicles.Headless version v100330-1.[1]

Let’s develop!

Clone lib9c and NineChronicles.Headless repositories

NineChronicles.Headless contains lib9c as submodule so you can choose this instead of separate lib9c repo.

Clone lib9c.

$ git clone https://github.com/planetarium/lib9c.git
$ cd lib9c
$ git branch development
$ git submodule update --init --recursive

Clone NineChronicles.Headless.

$ git clone https://github.com/planetarium/NineChronicles.Headless.git
$ cd NineChronicles.Headless
$ git branch development
$ git submodule update --init --recursive

Create a new action in lib9c

  • Create a new branch(e.g., feature/add-new-action) and checkout.
  • Open lib9c solution with your IDE(e.g., Visual Studio Code, Rider).

We will consider the Lib9c and Lib9c.Tests projects only.

First of all, I run unit tests of Lib9c.Tests.

Select "Run Unit Tests" menu

Create ChangeAvatarName script to Lib9c/Action.

namespace Nekoyume.Action
{
    public class ChangeAvatarName
    {
    }
}

Attach Serializable and ActionType attribute.

[Serializable]
[ActionType("change_avatar_name")]
public class ChangeAvatarName
{
}

Inherit GameAction.

using System.Collections.Immutable;
using Bencodex.Types;
using Libplanet.Action;

namespace Nekoyume.Action
{
    [Serializable]
    [ActionType("change_avatar_name")]
    public class ChangeAvatarName : GameAction
    {
        protected override IImmutableDictionary<string, IValue> PlainValueInternal { get; }

        protected override void LoadPlainValueInternal(
            IImmutableDictionary<string, IValue> plainValue)
        {
            throw new System.NotImplementedException();
        }

        public override IAccountStateDelta Execute(IActionContext context)
        {
            throw new System.NotImplementedException();
        }
    }
}

Add the required members.

...
using using Libplanet;
...

[Serializable]
[ActionType("change_avatar_name")]
public class ChangeAvatarName : GameAction
{
    public Address TargetAvatarAddr;
    public string Name;

    ...
}

Implement PlainValueInternal property and LoadPlainValueInternal() method.

  1. (Recommended) Optimized way.
...
using System.Collections.Generic;
using Nekoyume.Model.State;
...

[Serializable]
[ActionType("change_avatar_name")]
public class ChangeAvatarName : GameAction
{
    protected override IImmutableDictionary<string, IValue> PlainValueInternal =>
        new Dictionary<string, IValue>
        {
            ["l"] = new List<IValue>
            {
                TargetAvatarAddr.Serialize(),
                Name.Serialize()
            }.Serialize(),
        }.ToImmutableDictionary();

    protected override void LoadPlainValueInternal(
        IImmutableDictionary<string, IValue> plainValue)
    {
        var list = (List)plainValue["l"];
        TargetAvatarAddr = list[0].ToAddress();
        Name = list[1].ToDotnetString();
    }

    ...
}
  1. Readable way.
...
using System.Collections.Generic;
using Nekoyume.Model.State;
...

[Serializable]
[ActionType("change_avatar_name")]
public class ChangeAvatarName : GameAction
{
    protected override IImmutableDictionary<string, IValue> PlainValueInternal =>
        new Dictionary<string, IValue>
        {
            ["target_avatar_address"] = TargetAvatarAddr.Serialize(),
            ["name"] = Name.Serialize(),
        }.ToImmutableDictionary();

    protected override void LoadPlainValueInternal(
        IImmutableDictionary<string, IValue> plainValue)
    {
        TargetAvatarAddr = plainValue["target_avatar_address"].ToAddress();
        Name = plainValue["name"].ToDotnetString();
    }

    ...
}

Implement Execute() method.

[Serializable]
[ActionType("change_avatar_name")]
public class ChangeAvatarName : GameAction
{
    ...

    public override IAccountStateDelta Execute(IActionContext context)
    {
        // Return the previous states when the context in rehearsal.
        if (context.Rehearsal)
        {
            return context.PreviousStates;
        }

        // Validate member fields.
        if (!Regex.IsMatch(Name, GameConfig.AvatarNickNamePattern))
        {
            throw new InvalidNamePatternException(
                $"Aborted as the input name({Name}) does not follow the allowed name pattern.");
        }

        var states = context.PreviousStates;

        // Get the avatar state from the previous states.
        if (!states.TryGetAgentAvatarStatesV2(
                context.Signer,
                TargetAvatarAddr,
                out var agentState,
                out var avatarState,
                out var migrationRequired))
        {
            throw new FailedLoadStateException(
                    "Aborted as the avatar state of the signer was failed to load.");
        }

        // Set name.
        avatarState.name = Name;

        // Update the avatar state to the next states.
        return states.SetState(avatarState.address, avatarState.SerializeV2());
    }
}

OK. That’s it! Let’s write unit tests for this.

Create ChangeAvatarNameTest script to Lib9c.Tests/Action.

namespace Lib9c.Tests.Action
{
    public class ChangeAvatarNameTest
    {
    }
}

At first, initialize some members in constructor. This logic almost same with other action test code, so feel free to copy and paste it.

namespace Lib9c.Tests.Action
{
    using Libplanet;
    using Libplanet.Action;
    using Libplanet.Crypto;
    using Nekoyume;
    using Nekoyume.Action;
    using Nekoyume.Model.State;
    using Nekoyume.TableData;
    using static SerializeKeys;

    public class ChangeAvatarNameTest
    {
        private readonly IAccountStateDelta _initialStates;
        private readonly TableSheets _tableSheets;
        private readonly Address _agentAddress;
        private readonly Address _avatarAddress;

        public ChangeAvatarNameTest()
        {
            _initialStates = new State();
            var sheets = TableSheetsImporter.ImportSheets();
            foreach (var (key, value) in sheets)
            {
                _initialStates = _initialStates
                    .SetState(Addresses.TableSheet.Derive(key), value.Serialize());
            }

            _tableSheets = new TableSheets(sheets);

            _agentAddress = new PrivateKey().ToAddress();
            var agentState = new AgentState(_agentAddress);

            _avatarAddress = _agentAddress.Derive("avatar");
            agentState.avatarAddresses.Add(0, _avatarAddress);
            var inventoryAddr = _avatarAddress.Derive(LegacyInventoryKey);
            var worldInformationAddr = _avatarAddress.Derive(LegacyWorldInformationKey);
            var questListAddr = _avatarAddress.Derive(LegacyQuestListKey);

            var gameConfigState = new GameConfigState(sheets[nameof(GameConfigSheet)]);
            var avatarState = new AvatarState(
                _avatarAddress,
                _agentAddress,
                0,
                _tableSheets.GetAvatarSheets(),
                gameConfigState,
                new PrivateKey().ToAddress(),
                "Avatar0"
            );

            _initialStates = _initialStates
                .SetState(_agentAddress, agentState.Serialize())
                .SetState(_avatarAddress, avatarState.SerializeV2())
                .SetState(inventoryAddr, avatarState.inventory.Serialize())
                .SetState(worldInformationAddr, avatarState.worldInformation.Serialize())
                .SetState(questListAddr, avatarState.questList.Serialize())
                .SetState(gameConfigState.address, gameConfigState.Serialize());

            for (var i = 0; i < GameConfig.SlotCount; i++)
            {
                var addr = CombinationSlotState.DeriveAddress(_avatarAddress, i);
                const int unlock = GameConfig.RequireClearedStageLevel.CombinationEquipmentAction;
                _initialStates = _initialStates.SetState(
                    addr,
                    new CombinationSlotState(addr, unlock).Serialize());
            }
        }
    }
}

Now we test action’s Execute() method.

public class ChangeAvatarNameTest
{
    ...

    [Fact]
    public void Execute()
    {
        // Create action.
        var action = new ChangeAvatarName
        {
            TargetAvatarAddr = _avatarAddress,
            Name = "Joy",
        };

        // Execute action.
        var nextStates = action.Execute(new ActionContext
        {
            PreviousStates = _initialStates,
            Signer = _agentAddress,
            Rehearsal = false,
        });

        // Check next states.
        Assert.Equal("Joy", nextStates.GetAvatarState(_avatarAddress).name);
    }
}

How is it? Simple, right?
Now let’s raise most exceptions that can be raised from this action, not just on success.

public class ChangeAvatarNameTest
{
    ...

    [Fact]
    public void Execute_Success()
    {
        Execute(_initialStates, _avatarAddress, "Joy");
    }

    [Fact]
    public void Execute_Throw_AgentStateNotContainsAvatarAddressException()
    {
        var invalidAddr = new PrivateKey().ToAddress();
        Assert.Throws<AgentStateNotContainsAvatarAddressException>(() =>
            Execute(_initialStates, invalidAddr, "Joy"));
    }

    [Theory]
    [InlineData("J")]
    [InlineData("Joy!")]
    [InlineData("J o y")]
    public void Execute_Throw_InvalidNamePatternException(string name)
    {
        Assert.Throws<InvalidNamePatternException>(() =>
            Execute(_initialStates, _avatarAddress, name));
    }

    private void Execute(
        IAccountStateDelta previousStates,
        Address targetAvatarAddr,
        string name)
    {
        // Create action.
        var action = new ChangeAvatarName
        {
            TargetAvatarAddr = targetAvatarAddr,
            Name = name,
        };

        // Execute action.
        var nextStates = action.Execute(new ActionContext
        {
            PreviousStates = previousStates,
            Signer = _agentAddress,
            Rehearsal = false,
        });

        // Check next states.
        var avatarState = nextStates.GetAvatarState(_avatarAddress);
        Assert.Equal(targetAvatarAddr, avatarState.address);
        Assert.Equal(name, avatarState.name);
    }
}

As you can see, reusing the Execute() method simplifies the entire code.

Finally, add the newly created action type to the action serialization/deserialization test case.
The test method is ActionEvaluationTest.Serialize_With_MessagePack().

And add ChangeAvatarName case to ActionEvaluationTest.GetType() method.

As we finish developing lib9c, I create a pull request to the remote repository.

Develop graphQL in NineChronicles.Headless

  • Create a new branch(e.g., feature/add-new-action-query) and checkout.
  • Open NineChronicles.Headless.Executable solution with your IDE.

We will consider the NineChronicles.Headless and NineChronicles.Headless.Tests projects only.

Like lib9c, I run unit tests of NineChronicles.Headless.Tests.

Select "Run Unit Tests" menu

Write changeAvatarName action query to the constructor of ActionQuery.

Below is real code. This action query is make a ChangeAvatarName action. So it’s name is changeAvatarName.

Field<NonNullGraphType<ByteStringType>>(
    "changeAvatarName",
    arguments: new QueryArguments(),
    resolve: context => throw new NotImplementedException());

The ChangeAvatarName action requires Address TargetAvatarAddr and string Name arguments. So apply it.

Field<NonNullGraphType<ByteStringType>>(
    "changeAvatarName",
    arguments: new QueryArguments(
        new QueryArgument<NonNullGraphType<AddressType>>
        {
            Name = "targetAvatarAddr",
            Description = "The avatar address to change name."
        },
        new QueryArgument<NonNullGraphType<StringGraphType>>
        {
            Name = "name",
            Description = "The name to change.(2~20 characters)"
        }),
    resolve: context => throw new NotImplementedException());

At last, complete the resolve part.

Field<NonNullGraphType<ByteStringType>>(
    "changeAvatarName",
    arguments: new QueryArguments(
        new QueryArgument<NonNullGraphType<AddressType>>
        {
            Name = "targetAvatarAddr",
            Description = "The avatar address to change name."
        },
        new QueryArgument<NonNullGraphType<StringGraphType>>
        {
            Name = "name",
            Description = "The name to change.(2~20 characters only numbers and alphabets)"
        }),
    resolve: context =>
    {
        var targetAvatarAddr = context.GetArgument<Address>("targetAvatarAddr");
        var name = context.GetArgument<string>("name");
        if (!Regex.IsMatch(name, GameConfig.AvatarNickNamePattern))
        {
            throw new ExecutionError(
                $"Invalid name({name}): 2~20 characters only numbers and alphabets.");
        }

        var action = new ChangeAvatarName
        {
            TargetAvatarAddr = targetAvatarAddr,
            Name = name,
        };
        return Encode(context, action);
    });

Ok… we made a changeAvatarName action query. Now let’s write unit tests!
Create ChangeAvatarName() method to NineChronicles.Headless.Tests/GraphTypes/ActionQueryTest.

Below is the unit test.

[Theory]
[InlineData("", false)]
[InlineData("J", false)]
[InlineData("JJ", true)]
[InlineData("J ", false)]
[InlineData("J!", false)]
[InlineData("01234567890123456789", true)]
[InlineData("012345678901234567890", false)]
public async Task ChangeAvatarName(
    string name,
    bool errorsShouldBeNull)
{
    // Make a query.
    var targetAvatarAddr = new PrivateKey().ToAddress();
    var query = $@"{{
        changeAvatarName(
            targetAvatarAddr: ""{targetAvatarAddr}"",
            name: ""{name}""
        )
    }}";

    // Execute the query.
    var queryResult = await ExecuteQueryAsync<ActionQuery>(
        query,
        standaloneContext: _standaloneContext);

    // Assert.
    if (errorsShouldBeNull)
    {
        Assert.Null(queryResult.Errors);
    }
    else
    {
        Assert.NotNull(queryResult.Errors);
        return;
    }

    var data = (Dictionary<string, object>)((ExecutionNode)queryResult.Data!).ToValue()!;
    var plainValue = _codec.Decode(ByteUtil.ParseHex((string)data["changeAvatarName"]));
    Assert.IsType<Dictionary>(plainValue);
    var polymorphicAction = DeserializeNCAction(plainValue);
    var action = Assert.IsType<ChangeAvatarName>(polymorphicAction.InnerAction);
    Assert.Equal(targetAvatarAddr, action.TargetAvatarAddr);
    Assert.Equal(name, action.Name);
}

I also created a pull request for this.

Test the query in playground

In this article, I’ll use a app settings file. There are several appsettings.json files in the NineChronicles.Headless.Executable project.

Select the app settings files

I copy the appsettings.mainnet.json to appsettings.mainnet.single.json. And I add the StorePath option as I want to use.

And I edit the “PeerStrings” to be empty.

  • before

  • after

And open the terminal, and go to NineChronicles.Headless.Executable project. And run this project with the appsettings.mainnet.single.json file.

$ cd ./NineChronicles.Headless.Executable
$ pwd
~/NineChronicles.Headless/NineChronicles.Headless.Executable
$ dotnet run -- -C appsettings.mainnet.single.json
....

You can refer README in repo.

OK… now we can connect to graphQL playground. Open your web browser and connect to http://localhost:31280/ui/playground.

Click the “DOCS” button.


You can search the changeAvatarName that we developed above.

Now we type the query and click the play button, the result data will be display in right side.

That’s it! Thank you for follow this article. Have fun!


  1. lib9c:v100330, NineChronicles.Headless:v100330-1 ↩︎

1 Like