-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add sample unit tests to the starter game template (#46)
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
Showing
8 changed files
with
303 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} | ||
} |