Files
aego/tests/integration/platformer_test.go

508 lines
13 KiB
Go

package integration
import (
"io/fs"
"math"
"os"
"path/filepath"
"strings"
"testing"
"aego/app/run"
"aego/contracts/persistence"
"aego/core/ids"
"aego/core/log"
"aego/core/mathx"
"aego/modules/physics"
"aego/modules/physics/components"
"aego/modules/savegame"
"aego/modules/spawn"
"aego/platform"
"aego/platform/input"
"aego/project"
"aego/save"
"aego/scene"
"aego/scene/sceneio"
"aego/script/vm"
"aego/sdk"
)
type Health struct {
Max float32 `aego:"max,min=1"`
Current float32 `aego:"current,min=0"`
Hits int64 `aego:"hits"`
}
type EnemyStats struct {
Speed float32 `aego:"speed,min=0,max=10"`
Cooldown float32 `aego:"cooldown,min=0,max=5"`
Damage int64 `aego:"damage,min=0"`
Tint mathx.Color `aego:"tint"`
}
type combatModule struct{}
func (combatModule) Info() sdk.ModuleInfo {
return sdk.ModuleInfo{ID: "mygame", Version: "0.1.0"}
}
var healthHit sdk.Signal1[int64]
func (combatModule) Register(r *sdk.Registry) error {
b := sdk.Component[Health](r, sdk.Name("game.Health"), sdk.Persist("current", "hits"))
healthHit = b.Signal1[int64]("Hit", "Hit fires when Damage lands; hits is the new hit count.", "hits")
if err := b.Err(); err != nil {
return err
}
if err := sdk.DataType[EnemyStats](r, sdk.Name("mygame.EnemyStats")).Err(); err != nil {
return err
}
sdk.Live[EnemyStats](r, "speed")
m := sdk.ScriptModule(r, "mygame/combat")
sdk.Proc2(m, "Damage", func(c *sdk.ScriptCtx, target sdk.NodeHandle, amount int64) {
if c.Scene == nil || !c.Scene.IsAlive(target) {
c.Fail("damage target is nil or destroyed")
return
}
h, ok := sdk.Get[Health](c.Scene, target)
if !ok {
return
}
h.Current -= float32(amount)
h.Hits++
if h.Current <= 0 {
h.Current = h.Max
}
healthHit.Emit(c.World, target, h.Hits)
})
sdk.Func1(m, "HealthOf", func(c *sdk.ScriptCtx, target sdk.NodeHandle) float32 {
if h, ok := sdk.Get[Health](c.Scene, target); ok {
return h.Current
}
return 0
})
return nil
}
const playerType = "mygame.player.PlayerController"
func copyExample(t testing.TB, name string) string {
t.Helper()
src := filepath.Join("..", "..", "examples", name)
dst := t.TempDir()
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
if d.IsDir() {
switch rel {
case ".aego", "native", "exports":
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(dst, rel), 0o755)
}
if rel == "go.mod" || rel == "go.sum" {
return nil
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
if rel == "project.aego" {
data = []byte(strings.Replace(string(data), "native=true", "native=false", 1))
}
return os.WriteFile(filepath.Join(dst, rel), data, 0o644)
})
if err != nil {
t.Fatal(err)
}
return dst
}
func openAt(t testing.TB, root string, mods ...sdk.Module) *run.App {
t.Helper()
p, err := project.Load(root)
if err != nil {
t.Fatal(err)
}
app, err := run.Open(p, run.Config{Headless: true, FrameTime: 1.0 / 60, Seed: 42, Logger: log.Nop(), ScenePath: filepath.Join(root, "scenes", "main.aescene")}, mods...)
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Close)
return app
}
func openExample(t testing.TB, name string, mods ...sdk.Module) (*run.App, string) {
t.Helper()
root := copyExample(t, name)
return openAt(t, root, mods...), root
}
type platformer struct {
app *run.App
root string
player scene.NodeHandle
enemy scene.NodeHandle
pid vm.InstanceID
}
func platformerAt(t testing.TB, root string) *platformer {
t.Helper()
app := openAt(t, root, combatModule{})
sc := app.Scene
pf := &platformer{app: app, root: root, player: sc.Find(sc.Root(), "Player"), enemy: sc.Find(sc.Root(), "EnemyLeft")}
id, ok := app.Scripts.InstanceOf(pf.player, playerType)
if !ok {
t.Fatal("player has no PlayerController instance")
}
pf.pid = id
return pf
}
func openPlatformer(t testing.TB) *platformer {
t.Helper()
return platformerAt(t, copyExample(t, "platformer"))
}
func (pf *platformer) hitPlayer(t testing.TB) {
t.Helper()
sc := pf.app.Scene
for i := 0; i < 240 && pf.health().Hits == 0; i++ {
e := sc.Local(pf.enemy).Position
sc.SetPosition(pf.player, mathx.Vec2{X: e.X - 0.74, Y: e.Y - 0.05})
pf.step(1)
}
if pf.health().Hits == 0 {
t.Fatal("the player was never hit")
}
}
func (pf *platformer) field(name string) vm.Value {
v, _ := pf.app.Scripts.Field(pf.pid, name)
return v
}
func (pf *platformer) step(n int) {
d := pf.app.Driver()
for i := 0; i < n; i++ {
pf.app.Engine.Step(d)
}
}
func (pf *platformer) health() Health {
h, _ := scene.Get[Health](pf.app.Scene, pf.player)
return *h
}
func (pf *platformer) body(h scene.NodeHandle) components.Body2D {
b, _ := scene.Get[components.Body2D](pf.app.Scene, h)
return *b
}
func (pf *platformer) y() float32 {
return pf.app.Scene.Local(pf.player).Position.Y
}
func (pf *platformer) hold(k input.Scancode, down bool) {
kind := platform.EvKeyUp
if down {
kind = platform.EvKeyDown
}
platform.ApplyInput(pf.app.Engine.Input(), platform.Event{Kind: kind, Key: input.KeyEvent{Scan: k}})
}
func (pf *platformer) noFaults(t testing.TB) {
t.Helper()
if f := pf.app.Scripts.Faults(); len(f) > 0 {
t.Fatalf("%d script faults, first: %s %s", len(f), f[0].Type, f[0].Fault.Message)
}
}
func TestPlatformerSceneIsByteStable(t *testing.T) {
pf := openPlatformer(t)
data, _ := os.ReadFile(filepath.Join(pf.root, "scenes", "main.aescene"))
if string(sceneio.Save(pf.app.Scene)) != string(data) {
t.Fatal("platformer scene is not byte-stable")
}
}
func TestPlatformerStandsOnGround(t *testing.T) {
pf := openPlatformer(t)
pf.step(120)
if vy := pf.body(pf.player).Velocity.Y; math.Abs(float64(vy)) > 0.05 {
t.Fatalf("player still moves vertically: %v", vy)
}
if y := pf.y(); y < 2.9 || y > 3.05 {
t.Fatalf("player rests at y=%v, want about 3", y)
}
if pf.field("ground").Int() == 0 {
t.Fatal("OnCollisionEnter never reported the floor")
}
pf.noFaults(t)
}
func TestPlatformerJump(t *testing.T) {
pf := openPlatformer(t)
pf.step(60)
y0 := pf.y()
pf.hold(input.KeySpace, true)
pf.step(3)
pf.hold(input.KeySpace, false)
top := y0
for i := 0; i < 60; i++ {
pf.step(1)
top = min(top, pf.y())
}
if y0-top < 1.5 {
t.Fatalf("jump rose %v units, want at least 1.5", y0-top)
}
pf.step(120)
if pf.field("ground").Int() == 0 || math.Abs(float64(pf.y()-y0)) > 0.05 {
t.Fatalf("player did not land: y=%v ground=%d", pf.y(), pf.field("ground").Int())
}
pf.noFaults(t)
}
func TestPlatformerStompEmitsDied(t *testing.T) {
pf := openPlatformer(t)
sc := pf.app.Scene
pf.step(30)
for i := 0; i < 120 && pf.field("kills").Int() == 0 && sc.IsAlive(pf.enemy); i++ {
e := sc.Local(pf.enemy).Position
sc.SetPosition(pf.player, mathx.Vec2{X: e.X, Y: e.Y - 1.3})
pf.step(1)
}
pf.step(20)
if k := pf.field("kills").Int(); k != 1 {
t.Fatalf("kills %d", k)
}
if sc.IsAlive(pf.enemy) {
t.Fatal("the stomped enemy is still alive")
}
pf.noFaults(t)
}
func TestPlatformerBlinkSurvivesReload(t *testing.T) {
pf := openPlatformer(t)
sc := pf.app.Scene
pf.step(30)
for i := 0; i < 240 && pf.health().Hits == 0; i++ {
e := sc.Local(pf.enemy).Position
sc.SetPosition(pf.player, mathx.Vec2{X: e.X - 0.74, Y: e.Y - 0.05})
pf.step(1)
}
if pf.health().Hits == 0 {
t.Fatal("the player was never hit")
}
sc.SetPosition(pf.player, mathx.Vec2{X: 13, Y: 3})
pf.step(1)
m := pf.app.Scripts.Machine()
blink := pf.field("blink")
if !m.CoRunning(blink) {
t.Fatal("Blink is not running after a hit")
}
before := pf.health()
canceled := pf.app.Scripts.CoStats().Canceled
path := filepath.Join(pf.root, "scripts", "player", "player.aes")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(strings.Replace(string(data), "yield wait(0.1)", "yield wait(0.05)", 1)), 0o644); err != nil {
t.Fatal(err)
}
pf.app.Workspace.Changed([]string{path})
if _, _, err := pf.app.Pipeline.Scan(); err != nil {
t.Fatal(err)
}
batch, err := pf.app.Pipeline.ImportAll()
if err != nil {
t.Fatal(err)
}
if err := pf.app.Loader.Swap(batch.Imported, pf.app.Pipeline.Artifacts()); err != nil {
t.Fatal(err)
}
pf.step(2)
if got := pf.app.Scripts.CoStats().Canceled; got != canceled+1 {
t.Fatalf("canceled %d, want %d", got, canceled+1)
}
after := pf.field("blink")
if after == blink || !m.CoRunning(after) {
t.Fatal("Reloaded did not restart Blink")
}
if pf.health() != before {
t.Fatalf("health changed across reload: %+v → %+v", before, pf.health())
}
}
func TestPlatformerDrawsSprites(t *testing.T) {
pf := openPlatformer(t)
pf.step(3)
if st := pf.app.Runner.Stats(); st.Views < 1 || st.Sprites < 1 || st.MissingAssets != 0 {
t.Fatalf("runner stats = %+v", st)
}
}
func TestPlatformerSteadyFrameAllocs(t *testing.T) {
pf := openPlatformer(t)
pf.step(240)
d := pf.app.Driver()
if n := testing.AllocsPerRun(120, func() { pf.app.Engine.Step(d) }); n >= 1 {
t.Fatalf("%v allocations per steady frame", n)
}
}
func BenchmarkPlatformerFrame(b *testing.B) {
pf := openPlatformer(b)
pf.step(240)
d := pf.app.Driver()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
pf.app.Engine.Step(d)
}
}
func TestPlatformerEnemySpeedFromData(t *testing.T) {
pf := openPlatformer(t)
pf.step(30)
if vx := math.Abs(float64(pf.body(pf.enemy).Velocity.X)); vx != 2.5 {
t.Fatalf("enemy speed %v, want 2.5 from enemy_stats.aedata", vx)
}
pf.noFaults(t)
}
const enemyPrefab = "7a1e0c5b3d9f4a2e8c6b1d0f9e8a7c02"
func (pf *platformer) drive(from, to int) {
for i := from; i < to; i++ {
pf.hold(input.KeyD, i >= 40 && i < 120)
pf.hold(input.KeyA, i >= 200 && i < 300)
pf.hold(input.KeySpace, (i >= 130 && i < 134) || (i >= 320 && i < 324))
pf.step(1)
}
}
func (pf *platformer) physicsHash() [32]byte {
return physics.SimOf(pf.app.Engine.MainWorld()).World().Snapshot().Hash()
}
func TestPlatformerReplayIsDeterministic(t *testing.T) {
a := openPlatformer(t)
a.drive(0, 200)
h200, s200 := a.physicsHash(), string(sceneio.Save(a.app.Scene))
a.drive(200, 400)
h400, s400 := a.physicsHash(), string(sceneio.Save(a.app.Scene))
a.noFaults(t)
b := openPlatformer(t)
b.drive(0, 200)
if b.physicsHash() != h200 {
t.Fatal("physics differs at frame 200 after replay")
}
if string(sceneio.Save(b.app.Scene)) != s200 {
t.Fatal("scene differs at frame 200 after replay")
}
b.drive(200, 400)
if b.physicsHash() != h400 {
t.Fatal("physics differs at frame 400 after replay")
}
if string(sceneio.Save(b.app.Scene)) != s400 {
t.Fatal("scene differs at frame 400 after replay")
}
for _, f := range []string{"ground", "kills", "jumpWait"} {
if a.field(f) != b.field(f) {
t.Fatalf("script field %s differs after replay", f)
}
}
}
func TestPlatformerSaveRoundTrip(t *testing.T) {
pf := openPlatformer(t)
st := save.NewStore(t.TempDir())
pf.app.Engine.SetService(persistence.StoreID, st)
sc := pf.app.Scene
pf.step(30)
for i := 0; i < 120 && pf.field("kills").Int() == 0 && sc.IsAlive(pf.enemy); i++ {
e := sc.Local(pf.enemy).Position
sc.SetPosition(pf.player, mathx.Vec2{X: e.X, Y: e.Y - 1.3})
pf.step(1)
}
pf.step(20)
if pf.field("kills").Int() != 1 || sc.IsAlive(pf.enemy) {
t.Fatal("the stomp did not kill EnemyLeft")
}
enemyID := sc.ID(pf.enemy)
w := pf.app.Engine.MainWorld()
prefab, _ := ids.ParseAssetID(enemyPrefab)
spawned, err := spawn.Of(w).Spawn(prefab, scene.NodeHandle{}, mathx.Vec2{X: 2, Y: 2.9}, 0)
if err != nil {
t.Fatal(err)
}
pid, ok := spawn.Of(w).PersistentID(spawned)
if !ok {
t.Fatal("the spawned enemy has no persistent id")
}
pf.step(5)
hp, _ := scene.Get[Health](sc, pf.player)
hp.Current, hp.Hits = 2, 3
sc.SetPosition(pf.player, mathx.Vec2{X: -2, Y: 1})
at := sc.Local(spawned).Position
svc := savegame.Of(w)
doc, err := svc.Capture("round")
if err != nil {
t.Fatal(err)
}
if err := st.Write("round", save.Encode(doc), nil); err != nil {
t.Fatal(err)
}
pf.step(30)
if hp, _ := scene.Get[Health](pf.app.Scene, pf.player); hp != nil {
hp.Current, hp.Hits = 5, 0
}
before := pf.app.Scene
if err := svc.Load("round"); err != nil {
t.Fatal(err)
}
pf.step(2)
sc = pf.app.Scene
if sc == before {
t.Fatal("the scene was not reloaded")
}
player := sc.Find(sc.Root(), "Player")
if x := sc.Local(player).Position.X; math.Abs(float64(x+2)) > 1e-3 {
t.Fatalf("player x = %v, want -2", x)
}
if h, _ := scene.Get[Health](sc, player); h == nil || h.Current != 2 || h.Hits != 3 {
t.Fatalf("health = %+v, want current 2 and hits 3", h)
}
if h := sc.Handle(enemyID); !h.IsZero() && sc.IsAlive(h) {
t.Fatal("the killed enemy came back")
}
back, ok := spawn.Of(pf.app.Engine.MainWorld()).ByPersistentID(pid)
if !ok {
t.Fatal("the spawned enemy is not restored with its persistent id")
}
if p := sc.Local(back).Position; p.Sub(at).Len() > 0.5 {
t.Fatalf("spawned enemy at %v, saved at %v", p, at)
}
id, ok := pf.app.Scripts.InstanceOf(player, playerType)
if !ok {
t.Fatal("the reloaded player has no script")
}
if k, _ := pf.app.Scripts.Field(id, "kills"); k.Int() != 1 {
t.Fatalf("persisted kills = %d, want 1", k.Int())
}
pf.noFaults(t)
}