3. Implement Custom Commands

This section assumes you have completed tutorials 1 and 2.

Introduction

Let's experience the customization capabilities of Diarkis by implementing a custom command.

In this tutorial, we will implement a command with the following features:

  • A command to attack enemies in a Room

  • Command version: 2 (versions 0 and 1 are used for built-in commands)

  • Command ID: 300

  • Request: Send a command specifying an attack type (type) (1: Melee, 2: Ranged)

  • Response: Return who dealt damage, the damage value, and the total damage value

  • Push notifications to all room members

The implementation flow is as follows:

  • Write code to generate a payload using puffer (a data serializer provided by Diarkis)

  • Implement the custom command on the server

  • Implement the command in the test client

Puffer Module (Data Serializer provided by Diarkis)

Puffer is a tool that generates code to serialize and deserialize data from JSON.

The output supports C++, C#, and Go, simplifying the implementation of packet data for clients developed with Unreal or Unity and servers developed with Go.

For more details on Puffer, please refer to the API reference.

https://docs.diarkis.io/docs/server/v1.0.0/diarkis/puffer/index.html

Creating JSON Files for Custom Command Data Definitions

💾 Add ./puffer/json_definitions/custom/attack.json.

The attack data is used for custom command requests.

  • type: Stores the attack type as uint8

attackResult is used for responses and push notifications to room members.

  • type: Stores the attack type as uint8

  • uid: Identifier of who dealt damage as a string

  • damage: Damage value as uint16

  • totalDamage: Total damage value as uint16

{
  "attack": {
    "ver": 2,
    "cmd": 300,
    "package": "custom",
    "properties": {
      "type": "u8"
    }
  },
  "attackResult": {
    "ver": 2,
    "cmd": 300,
    "package": "custom",
    "properties": {
      "type": "u8",
      "uid": "string",
      "damage": "u16",
      "totalDamage": "u16"
    }
  }
}

Code Generation

Run the following command to generate code. The code is output to each language's directory under puffer.

$ make gen
# Code is output to each language's directory under puffer
puffer
├── cpp/custom/Attack.h
├── cpp/custom/AttackResult.h
├── cs/custom/Attack.cs
├── cs/custom/AttackResult.cs
├── go/custom/attack.go
└── go/custom/attackresult.go

Implementing the attack Command Handler on the Server

Let's implement the attack command on the server.

💾 Add ./cmds/custom/attack.go.

Comments within the code describe what is being implemented, so please read them as well.

package customcmds

import (
	"errors"
	// pattack is the puffer package we generated earlier.
	pattack "handson/puffer/go/custom"

	"github.com/Diarkis/diarkis/derror"
	"github.com/Diarkis/diarkis/room"
	"github.com/Diarkis/diarkis/server"
	"github.com/Diarkis/diarkis/user"
	"github.com/Diarkis/diarkis/util"
)

// attack performs an attack on enemies within a Room.
// 
// All Diarkis commands take specific arguments.
// - ver: Command version
// - cmd: Command ID
// - payload: Data passed to the command
// - userData: User data
func attack(ver uint8, cmd uint16, payload []byte, userData *user.User, next func(error)) {

	// Since the attack command is to attack enemies in a Room,
	// an error occurs if the user is not participating in a Room.
	// Check whether the user is participating by obtaining the roomID.
	roomID := room.GetRoomID(userData)
	// If the user is not participating in a room, roomID is returned as empty, so handle the error.
	if roomID == "" {
		// Return an error response to the user with userData.ServerRespond().
		err := errors.New("not in the room")
		userData.ServerRespond(derror.ErrData(err.Error(), derror.NotAllowed(0)), ver, cmd, server.Bad, true)
		// After returning the response, call next(err) and return.
		next(err)
		return
	}

	// After calling pattack.NewAttack(), deserialize the payload with req.Unpack(payload).
	req := pattack.NewAttack()
	req.Unpack(payload)

	// Calculate damage based on the Type.
	damage := 0
	switch req.Type {
	case 1: // Melee attack (D20)
		damage = util.RandomInt(1, 20)
	case 2: // Ranged attack (D12 + 3)
		damage = util.RandomInt(1, 12) + 3
	default:
		err := errors.New("invalid attack type")
		userData.ServerRespond(derror.ErrData(err.Error(), derror.InvalidParameter(0)), ver, cmd, server.Bad, true)
		next(err)
		return
	}

	// Add the calculated damage to the total damage of the enemy.
	// You can save information as a Property in the Room.
	// Here, damage is added against the key "DAMAGE".
	// By using room.IncrProperty, you can get the incremented value.
	// There are other functions available to handle Property. Please see the following for details.
	// https://docs.diarkis.io/docs/server/v1.0.0/diarkis/room/index.html
	updatedDamage, updated := room.IncrProperty(roomID, "DAMAGE", int64(damage))
	if !updated {
		err := errors.New("incr property failed")
		userData.ServerRespond(derror.ErrData(err.Error(), derror.Internal(0)), ver, cmd, server.Err, true)
		next(err)
		return
	}
	logger.Info("Room %s has been attacked by %s using %s attack by %d damage. Total damage: %d", roomID, userData.ID, req.Type, damage, updatedDamage)

	// Return who dealt damage, the damage value, and the total damage value, and notify room members.
	// After calling res := pattack.NewAttackResult(), set the return data.
	res := pattack.NewAttackResult()
	res.Type = req.Type
	res.Uid = userData.ID
	res.Damage = uint16(damage)
	res.TotalDamage = uint16(updatedDamage)

	// Use userData.ServerRespond() to respond to the user who executed the command.
	// Use room.Relay() to notify the other members in the room of the result.
	userData.ServerRespond(res.Pack(), ver, cmd, server.Ok, true)
	room.Relay(roomID, userData, ver, cmd, res.Pack(), true)
	// After returning the response, call next(nil) and return.
	next(nil)
}

Exposing the attack Command

After implementing the command, it needs to be exposed externally.

💾 Edit ./cmds/custom/main.go and add the following.

Add diarkisexec.SetServerCommandHandler() to Expose() to expose the attack command.

diff --git a/cmds/custom/main.go b/cmds/custom/main.go
index f2374ed..e98f5ef 100644
--- a/cmds/custom/main.go
+++ b/cmds/custom/main.go
@@ -37,6 +37,7 @@ func Expose() {
        diarkisexec.SetServerCommandHandler(custom.GetFieldInfoVer, custom.GetFieldInfoCmd, getFieldInfo)
        diarkisexec.SetServerCommandHandler(CustomVer, getUserStatusListCmdID, getUserStatusList)
        diarkisexec.SetServerCommandHandler(CustomVer, resonanceCmdID, resonanceCmd)
+       diarkisexec.SetServerCommandHandler(custom.AttackVer, custom.AttackCmd, attack)
 }

Adding the attack Command to the Test Client

To execute the server's attack command, add the attack command to the test client.

💾 Add ./testcli/handson/attack.go.

In addition to the setup for executing the client, we implement Attack() for sending commands to the server, onResponse() for handling responses, and onPush() for handling push notifications.

package handson

import (
	"fmt"

	pattack "handson/puffer/go/custom"

	"github.com/Diarkis/diarkis/client/go/tcp"
	"github.com/Diarkis/diarkis/client/go/udp"
)

type Handson struct {
	tcp *tcp.Client
	udp *udp.Client
}

func SetupHandsonAsTCP(c *tcp.Client) *Handson {
	h := &Handson{tcp: c}
	h.setup()
	return h
}

func SetupHandsonAsUDP(c *udp.Client) *Handson {
	h := &Handson{udp: c}
	h.setup()
	return h
}

func (h *Handson) setup() {
	if h.tcp != nil {
		h.tcp.OnResponse(h.onResponse)
		h.tcp.OnPush(h.onPush)
		return
	}
	if h.udp != nil {
		h.udp.OnResponse(h.onResponse)
		h.udp.OnPush(h.onPush)
	}
}

func (h *Handson) onResponse(ver uint8, cmd uint16, status uint8, payload []byte) {
	if ver != pattack.AttackVer || cmd != pattack.AttackCmd {
		return
	}
	if status != uint8(1) {
		fmt.Printf("Attack failed: %v\n", string(payload))
		return
	}
	res := pattack.NewAttackResult()
	err := res.Unpack(payload)
	if err != nil {
		fmt.Printf("Failed to unpack attack response: %v\n", err)
		return
	}
	fmt.Printf("You dealt %d damage. Total damage: %d\n", res.Damage, res.TotalDamage)
}

func (h *Handson) onPush(ver uint8, cmd uint16, payload []byte) {
	// no push
	if ver != pattack.AttackVer || cmd != pattack.AttackCmd {
		return
	}
	res := pattack.NewAttackResult()
	err := res.Unpack(payload)
	if err != nil {
		fmt.Printf("Failed to unpack attack response: %v\n", err)
	}
	fmt.Printf("%s dealt %d damage. Total damage: %d\n", res.Uid, res.Damage, res.TotalDamage)
}

func (h *Handson) Attack(attackType uint8) {
	req := pattack.NewAttack()
	req.Type = attackType
	if h.tcp != nil {
		h.tcp.Send(pattack.AttackVer, pattack.AttackCmd, req.Pack())
		return
	}
	if h.udp != nil {
		h.udp.Send(pattack.AttackVer, pattack.AttackCmd, req.Pack())
	}
}

Register the handson attack command in the test client.

💾 Edit ./testcli/main.go.

You can register new commands in the test client using cli.RegisterCommands(string, []cli.Command).

diff --git a/testcli/main.go b/testcli/main.go
index bdca4ab..2dbdd2c 100644
--- a/testcli/main.go
+++ b/testcli/main.go
@@ -3,8 +3,11 @@ package main
 import (
        "bufio"
        "fmt"
+       "handson/testcli/handson"
        "handson/testcli/resonance"
        "os"
+       "strconv"
+       "strings"

        "github.com/Diarkis/diarkis/client/go/test/cli"
 )
@@ -12,17 +15,24 @@ import (
 var (
        tcpResonance *resonance.Resonance
        udpResonance *resonance.Resonance
+       tcpHandson   *handson.Handson
+       udpHandson   *handson.Handson
 )

 func main() {
        cli.SetupBuiltInCommands()
        cli.RegisterCommands("test", []cli.Command{{CmdName: "resonate", Desc: "Resonate your message", CmdFunc: resonate}})
+       cli.RegisterCommands("handson", []cli.Command{
+               {CmdName: "attack", Desc: "Attack for hands-on", CmdFunc: attack},
+       })
        cli.Connect()
        if cli.TCPClient != nil {
                tcpResonance = resonance.SetupAsTCP(cli.TCPClient)
+               tcpHandson = handson.SetupHandsonAsTCP(cli.TCPClient)
        }
        if cli.UDPClient != nil {
                udpResonance = resonance.SetupAsUDP(cli.UDPClient)
+               udpHandson = handson.SetupHandsonAsUDP(cli.UDPClient)
        }
        cli.Run()
 }
@@ -45,3 +55,30 @@ func resonate() {
                udpResonance.Resonate(message)
        }
 }
+
+func attack() {
+       reader := bufio.NewReader(os.Stdin)
+       fmt.Println("Which client to join a room? [tcp/udp]")
+       client, _ := reader.ReadString('\n')
+       fmt.Println("Enter the attack type. [1: melee, 2: range]")
+       attackTypeStr, _ := reader.ReadString('\n')
+       attackTypeStr = strings.Trim(attackTypeStr, "\n")
+       attackType, err := strconv.Atoi(attackTypeStr)
+       if err != nil || attackType != 1 && attackType != 2 {
+               fmt.Println("Invalid attack type.")
+               return
+       }
+
+       switch client {
+       case "tcp\n":
+               if tcpHandson == nil {
+                       return
+               }
+               tcpHandson.Attack(uint8(attackType))
+       case "udp\n":
+               if udpHandson == nil {
+                       return
+               }
+               udpHandson.Attack(uint8(attackType))
+       }
+}

Generate the binary and restart the UDP server

Run make build-local again to generate the binary.

The server and test client will be updated.

After confirming there are no errors, stop and restart the UDP server.

Testing the attack Command

After executing room create and room join, issue the handson attack command and confirm that the damage is accumulated.

Execute handson attack with uid: test1

> handson attack
Which client to join a room? [tcp/udp]
udp
Enter the attack type. [1: melee, 2: range]
1
You dealt 13 damage. Total damage: 13

Receive push notification of the attack result with uid: test2

[UID: test2][SID(UDP): zzzz][RoomID: yyyy]
 > test1 dealt 13 damage. Total damage: 13

最終更新