Skip to content

Commit

Permalink
Add sample unit tests to the starter game template (#46)
Browse files Browse the repository at this point in the history
Add some sample unit test to the starter-game-template to demonstrate
how testutils.TestFixture can be used to facilitate unit tests.

Specifically:
- Add test for the Attack system to verify HP values are reduced.
- Add test for the Attack system to verify invalid targets will result
in a message error.
- Add test for the Spawn system to verify players can be spawned.
- Add a "recovery" system that will be run each time Cardinal is
restarted.
- Add a test for the recovery system to ensure the in-memory go object
is re-populated when Cardinal is restarted.
- Add an init system that creates some default players on tick 0.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a system to automatically spawn default players with
specific attributes at the start of the game.
- **Bug Fixes**
- Standardized initial health points for players to enhance consistency
in gameplay.
- **Tests**
- Added tests to ensure default players are spawned correctly and
systems like player creation and attacks function as expected.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
jerargus authored Apr 25, 2024
1 parent 41b10cc commit 20306e2
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 3 deletions.
32 changes: 32 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions cardinal/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
2 changes: 2 additions & 0 deletions cardinal/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions cardinal/init_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
12 changes: 11 additions & 1 deletion cardinal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -48,7 +56,9 @@ func main() {
system.PlayerSpawnerSystem,
))

Must(w.StartGame())
Must(cardinal.RegisterInitSystems(w,
system.SpawnDefaultPlayersSystem,
))
}

func Must(err ...error) {
Expand Down
25 changes: 25 additions & 0 deletions cardinal/system/default_spawner.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 5 additions & 2 deletions cardinal/system/player_spawner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
170 changes: 170 additions & 0 deletions cardinal/system_test.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}

0 comments on commit 20306e2

Please sign in to comment.