diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d51459a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +env: + GO_VERSION: 1.22.1 + +jobs: + unit-test: + name: Unit Tests + runs-on: namespace-profile-linux-4vcpu-8gb-cached + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Golang + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + ## skip cache, use Namespace volume cache + cache: false + - name: Setup Namespace cache + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: go + - name: Run Unit Test + run: | + cd cardinal + make test diff --git a/cardinal/Makefile b/cardinal/Makefile index 73e0f0c..ea11f6f 100644 --- a/cardinal/Makefile +++ b/cardinal/Makefile @@ -24,3 +24,6 @@ lint-fix: @$(MAKE) lint-install @echo "--> Running golangci-lint" @golangci-lint run --timeout=10m --fix --concurrency 8 -v + +test: + @go test ./... diff --git a/cardinal/go.mod b/cardinal/go.mod index 0f15369..84c6ba0 100644 --- a/cardinal/go.mod +++ b/cardinal/go.mod @@ -40,6 +40,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.2 // indirect github.com/gofiber/swagger v0.1.14 // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20230901174712-0191c66da455 // indirect github.com/google/uuid v1.6.0 // indirect github.com/holiman/uint256 v1.2.3 // indirect @@ -89,6 +90,7 @@ require ( google.golang.org/protobuf v1.32.0 // indirect gopkg.in/DataDog/dd-trace-go.v1 v1.58.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.1 // indirect inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a // indirect pkg.world.dev/world-engine/rift v1.1.0-beta.0.20240402214846-de1fc179818a // indirect pkg.world.dev/world-engine/sign v1.0.1-beta // indirect diff --git a/cardinal/init_test.go b/cardinal/init_test.go new file mode 100644 index 0000000..0adc81d --- /dev/null +++ b/cardinal/init_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "testing" + + "pkg.world.dev/world-engine/cardinal" + "pkg.world.dev/world-engine/cardinal/search/filter" + "pkg.world.dev/world-engine/cardinal/testutils" + "pkg.world.dev/world-engine/cardinal/types" + + "github.com/argus-labs/starter-game-template/cardinal/component" +) + +// TestInitSystem_SpawnDefaultPlayersSystem_DefaultPlayersAreSpawned ensures a set of default players are created in the +// SpawnDefaultPlayersSystem. These players should only be created on tick 0. +func TestInitSystem_SpawnDefaultPlayersSystem_DefaultPlayersAreSpawned(t *testing.T) { + tf := testutils.NewTestFixture(t, nil) + MustInitWorld(tf.World) + + tf.DoTick() + // Do an extra tick to make sure the default players are only created once. + tf.DoTick() + + wCtx := cardinal.NewReadOnlyWorldContext(tf.World) + + foundPlayers := map[string]bool{} + searchErr := cardinal.NewSearch(wCtx, filter.Contains(component.Health{})).Each(func(id types.EntityID) bool { + player, err := cardinal.GetComponent[component.Player](wCtx, id) + if err != nil { + t.Fatalf("failed to get player: %v", err) + } + health, err := cardinal.GetComponent[component.Health](wCtx, id) + if err != nil { + t.Fatalf("failed to get health: %v", err) + } + if health.HP < 100 { + t.Fatalf("new player should have at least 100 health; got %v", health.HP) + } + foundPlayers[player.Nickname] = true + return true + }) + if searchErr != nil { + t.Fatalf("failed to perform search: %v", searchErr) + } + if len(foundPlayers) != 10 { + t.Fatalf("there should be 10 default players; got %v", foundPlayers) + } + for i := 0; i < 10; i++ { + wantName := fmt.Sprintf("default-%d", i) + if !foundPlayers[wantName] { + t.Fatalf("could not find player %q in %v", wantName, foundPlayers) + } + } +} diff --git a/cardinal/main.go b/cardinal/main.go index 40b0b5c..592f869 100644 --- a/cardinal/main.go +++ b/cardinal/main.go @@ -18,6 +18,14 @@ func main() { log.Fatal().Err(err).Msg("") } + MustInitWorld(w) + + Must(w.StartGame()) +} + +// MustInitWorld registers all components, messages, queries, and systems. This initialization happens in a helper +// function so that this can be used directly in tests. +func MustInitWorld(w *cardinal.World) { // Register components // NOTE: You must register your components here for it to be accessible. Must( @@ -48,7 +56,9 @@ func main() { system.PlayerSpawnerSystem, )) - Must(w.StartGame()) + Must(cardinal.RegisterInitSystems(w, + system.SpawnDefaultPlayersSystem, + )) } func Must(err ...error) { diff --git a/cardinal/system/default_spawner.go b/cardinal/system/default_spawner.go new file mode 100644 index 0000000..f135dcb --- /dev/null +++ b/cardinal/system/default_spawner.go @@ -0,0 +1,25 @@ +package system + +import ( + "fmt" + + "pkg.world.dev/world-engine/cardinal" + + comp "github.com/argus-labs/starter-game-template/cardinal/component" +) + +// SpawnDefaultPlayersSystem creates 10 players with nicknames "default-[0-9]". This System is registered as an +// Init system, meaning it will be executed exactly one time on tick 0. +func SpawnDefaultPlayersSystem(world cardinal.WorldContext) error { + for i := 0; i < 10; i++ { + name := fmt.Sprintf("default-%d", i) + _, err := cardinal.Create(world, + comp.Player{Nickname: name}, + comp.Health{HP: InitialHP}, + ) + if err != nil { + return err + } + } + return nil +} diff --git a/cardinal/system/player_spawner.go b/cardinal/system/player_spawner.go index 43c1360..0f8df2d 100644 --- a/cardinal/system/player_spawner.go +++ b/cardinal/system/player_spawner.go @@ -10,16 +10,19 @@ import ( "github.com/argus-labs/starter-game-template/cardinal/msg" ) +const ( + InitialHP = 100 +) + // PlayerSpawnerSystem spawns players based on `CreatePlayer` transactions. // This provides an example of a system that creates a new entity. func PlayerSpawnerSystem(world cardinal.WorldContext) error { return cardinal.EachMessage[msg.CreatePlayerMsg, msg.CreatePlayerResult]( world, func(create message.TxData[msg.CreatePlayerMsg]) (msg.CreatePlayerResult, error) { - maxHp := 100 id, err := cardinal.Create(world, comp.Player{Nickname: create.Msg.Nickname}, - comp.Health{HP: maxHp}, + comp.Health{HP: InitialHP}, ) if err != nil { return msg.CreatePlayerResult{}, fmt.Errorf("error creating player: %w", err) diff --git a/cardinal/system_test.go b/cardinal/system_test.go new file mode 100644 index 0000000..b2b6192 --- /dev/null +++ b/cardinal/system_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "testing" + + "pkg.world.dev/world-engine/cardinal" + "pkg.world.dev/world-engine/cardinal/receipt" + "pkg.world.dev/world-engine/cardinal/search/filter" + "pkg.world.dev/world-engine/cardinal/testutils" + "pkg.world.dev/world-engine/cardinal/types" + + "github.com/argus-labs/starter-game-template/cardinal/component" + "github.com/argus-labs/starter-game-template/cardinal/msg" +) + +const ( + attackMsgName = "game.attack-player" + createMsgName = "game.create-player" +) + +// TestSystem_AttackSystem_ErrorWhenTargetDoesNotExist ensures the attack message results in an error when the given +// target does not exist. Note, message errors are stored in receipts; they are NOT returned from the relevant system. +func TestSystem_AttackSystem_ErrorWhenTargetDoesNotExist(t *testing.T) { + tf := testutils.NewTestFixture(t, nil) + MustInitWorld(tf.World) + + txHash := tf.AddTransaction(getAttackMsgID(t, tf.World), msg.AttackPlayerMsg{ + TargetNickname: "does-not-exist", + }) + + tf.DoTick() + + gotReceipt := getReceiptFromPastTick(t, tf.World, txHash) + if len(gotReceipt.Errs) == 0 { + t.Fatal("expected error when target does not exist") + } +} + +// TestSystem_PlayerSpawnerSystem_CanCreatePlayer ensures the CreatePlayer message can be used to create a new player +// with the default amount of health. cardinal.NewSearch is used to find the newly created player. +func TestSystem_PlayerSpawnerSystem_CanCreatePlayer(t *testing.T) { + tf := testutils.NewTestFixture(t, nil) + MustInitWorld(tf.World) + + const nickname = "jeff" + createTxHash := tf.AddTransaction(getCreateMsgID(t, tf.World), msg.CreatePlayerMsg{ + Nickname: nickname, + }) + tf.DoTick() + + // Make sure the player creation was successful + createReceipt := getReceiptFromPastTick(t, tf.World, createTxHash) + if errs := createReceipt.Errs; len(errs) > 0 { + t.Fatalf("expected 0 errors when creating a player, got %v", errs) + } + + // Make sure the newly created player has 100 health + wCtx := cardinal.NewReadOnlyWorldContext(tf.World) + // This search demonstrates the use of a "Where" clause, which limits the search results to only the entity IDs + // that end up returning true from the anonymous function. In this case, we're looking for a specific nickname. + id := cardinal.NewSearch(wCtx, filter.All()).Where(func(id types.EntityID) bool { + player, err := cardinal.GetComponent[component.Player](wCtx, id) + if err != nil { + t.Fatalf("failed to get player component: %v", err) + } + return player.Nickname == nickname + }).MustFirst() + + health, err := cardinal.GetComponent[component.Health](wCtx, id) + if err != nil { + t.Fatalf("failed to find entity ID: %v", err) + } + if health.HP != 100 { + t.Fatalf("a newly created player should have 100 health; got %v", health.HP) + } +} + +// TestSystem_AttackSystem_AttackingTargetReducesTheirHealth ensures an attack message can find an existing target the +// reduce the target's health. +func TestSystem_AttackSystem_AttackingTargetReducesTheirHealth(t *testing.T) { + tf := testutils.NewTestFixture(t, nil) + MustInitWorld(tf.World) + + const target = "jeff" + + // Create an initial player + _ = tf.AddTransaction(getCreateMsgID(t, tf.World), msg.CreatePlayerMsg{ + Nickname: target, + }) + tf.DoTick() + + // Attack the player + attackTxHash := tf.AddTransaction(getAttackMsgID(t, tf.World), msg.AttackPlayerMsg{ + TargetNickname: target, + }) + tf.DoTick() + + // Make sure attack was successful + attackReceipt := getReceiptFromPastTick(t, tf.World, attackTxHash) + if errs := attackReceipt.Errs; len(errs) > 0 { + t.Fatalf("expected no errors when attacking a player; got %v", errs) + } + + // Find the attacked player and check their health. + wCtx := cardinal.NewReadOnlyWorldContext(tf.World) + var found bool + // This search demonstrates the "Each" pattern. Every entity ID is considered, and as long as the anonymous + // function return true, the search will continue. + searchErr := cardinal.NewSearch(wCtx, filter.All()).Each(func(id types.EntityID) bool { + player, err := cardinal.GetComponent[component.Player](wCtx, id) + if err != nil { + t.Fatalf("failed to get player component for %v", id) + } + if player.Nickname != target { + return true + } + // The player's nickname matches the target. This is the player we care about. + found = true + health, err := cardinal.GetComponent[component.Health](wCtx, id) + if err != nil { + t.Fatalf("failed to get health component for %v", id) + } + // The target started with 100 HP, -10 for the attack, +1 for regen + if health.HP != 91 { + t.Fatalf("attack target should end up with 91 hp, got %v", health.HP) + } + + return false + }) + if searchErr != nil { + t.Fatalf("error when performing search: %v", searchErr) + } + if !found { + t.Fatalf("failed to find target %q", target) + } +} + +func getCreateMsgID(t *testing.T, world *cardinal.World) types.MessageID { + return getMsgID(t, world, createMsgName) +} + +func getAttackMsgID(t *testing.T, world *cardinal.World) types.MessageID { + return getMsgID(t, world, attackMsgName) +} + +func getMsgID(t *testing.T, world *cardinal.World, fullName string) types.MessageID { + msg, ok := world.GetMessageByFullName(fullName) + if !ok { + t.Fatalf("failed to get %q message", fullName) + } + return msg.ID() +} + +// getReceiptFromPastTick search past ticks for a txHash that matches the given txHash. An error will be returned if +// the txHash cannot be found in Cardinal's history. +func getReceiptFromPastTick(t *testing.T, world *cardinal.World, txHash types.TxHash) receipt.Receipt { + tick := world.CurrentTick() + for { + tick-- + receipts, err := world.GetTransactionReceiptsForTick(tick) + if err != nil { + t.Fatal(err) + } + for _, r := range receipts { + if r.TxHash == txHash { + return r + } + } + } +}