Files
aego/tests/integration/try_motion_test.go
sanspie a46347e2c6
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 23m57s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
editor: add art studio and stage 12.5 workflows
2026-11-08 18:47:57 +03:00

254 lines
8.3 KiB
Go

package integration
import (
"fmt"
"math/rand"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"aego/app/run"
"aego/contracts/physics2d"
"aego/engine"
"aego/modules/gameplay"
"aego/platform"
"aego/platform/input"
"aego/scene"
)
// TestTryScenesNeverTrapThePlayer mashes keys in every docs try scene with a Motion 2D player and checks it never ends up inside a collider or unable to move.
func TestTryScenesNeverTrapThePlayer(t *testing.T) {
docs := filepath.Join("..", "..", "modules", "gameplay", "res", "docs")
scenes, err := filepath.Glob(filepath.Join(docs, "try", "*.aescene"))
if err != nil {
t.Fatal(err)
}
// Personal component demos may be distinct from the original interactive
// guides. Keep exercising those original Player scenes with all the same
// seeds and frames rather than losing coverage when a guide is moved.
guides, err := filepath.Glob(filepath.Join(docs, "*GuideOriginal.aescene"))
if err != nil {
t.Fatal(err)
}
scenes = append(scenes, guides...)
for _, name := range []string{
"MotionGuideOriginal", "HitboxGuideOriginal", "HurtboxGuideOriginal",
"KillZoneGuideOriginal", "CheckpointGuideOriginal", "RespawnGuideOriginal", "PickupGuideOriginal",
} {
path := filepath.Join(docs, name+".aescene")
data, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(data), `"gameplay.Motion2D"`) || !strings.Contains(string(data), `name="Player"`) {
t.Fatalf("original playable guide %s is missing or changed: %v", name, err)
}
}
for _, path := range scenes {
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"gameplay.Motion2D"`) {
continue
}
// This component-specific demo has a deliberately input-free kinematic
// marker, not a controllable Player. Its actual movement and Reset are
// covered separately by TestMotionTryMovesRealBodyAndResets.
if filepath.Dir(path) == filepath.Join(docs, "try") && filepath.Base(path) == "Motion2D.aescene" {
if !strings.Contains(string(data), `name="Motion marker"`) || strings.Contains(string(data), `name="Player"`) {
t.Fatal("Motion2D component demo changed; review the movement stress test")
}
continue
}
name := strings.TrimSuffix(filepath.Base(path), ".aescene")
t.Run(name, func(t *testing.T) {
for seed := int64(1); seed <= 12; seed++ {
t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
mash(t, openAt(t, tryProject(t, path)), "Player", platformerKeys, seed)
})
}
})
}
}
// TestGuideScenesNeverTrapThePlayer does the same for every scene of the platformer guide and the recipes, with the scripts written above it.
func TestGuideScenesNeverTrapThePlayer(t *testing.T) {
for _, page := range []string{"platformer", "recipes"} {
md, err := os.ReadFile(filepath.Join("..", "..", "engine", "res", "docs", page+".md"))
if err != nil {
t.Fatal(err)
}
files := map[string]string{}
for i, b := range fences(string(md)) {
switch b[0] {
case "aes check":
m := scriptName.FindStringSubmatch(b[1])
files[m[1]] = b[1]
case "aescene check":
if len(files) == 0 {
continue
}
t.Run(fmt.Sprintf("%s/%d", page, i), func(t *testing.T) {
root := t.TempDir()
writeProject(t, root, b[1], files)
for seed := int64(1); seed <= 4; seed++ {
t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
mash(t, openAt(t, root), "Player", platformerKeys, seed)
})
}
})
}
}
}
}
var scriptName = regexp.MustCompile(`(?m)^script (\w+)`)
// fences are the fenced blocks of a page as info string and code.
func fences(md string) [][2]string {
var out [][2]string
parts := strings.Split(md, "```")
for i := 1; i+1 < len(parts); i += 2 {
info, code, _ := strings.Cut(parts[i], "\n")
out = append(out, [2]string{strings.TrimSpace(info), code})
}
return out
}
func writeProject(t *testing.T, root, scene string, scripts map[string]string) {
must := func(err error) {
if err != nil {
t.Fatal(err)
}
}
must(os.WriteFile(filepath.Join(root, "project.aego"), []byte("project version=1 {\n module=\"game\"\n}\n"), 0o644))
must(os.MkdirAll(filepath.Join(root, "scenes"), 0o755))
must(os.MkdirAll(filepath.Join(root, "scripts", "game"), 0o755))
must(os.WriteFile(filepath.Join(root, "scenes", "main.aescene"), []byte(scene), 0o644))
for name, src := range scripts {
must(os.WriteFile(filepath.Join(root, "scripts", "game", name+".aes"), []byte(src), 0o644))
}
}
func tryProject(t *testing.T, scenePath string) string {
root := t.TempDir()
must := func(err error) {
if err != nil {
t.Fatal(err)
}
}
must(os.WriteFile(filepath.Join(root, "project.aego"), []byte("project version=1 {\n module=\"game\"\n}\n"), 0o644))
must(os.MkdirAll(filepath.Join(root, "scenes"), 0o755))
must(os.MkdirAll(filepath.Join(root, "scripts", "demo"), 0o755))
sc, err := os.ReadFile(scenePath)
must(err)
must(os.WriteFile(filepath.Join(root, "scenes", "main.aescene"), sc, 0o644))
src, err := os.ReadFile(strings.TrimSuffix(scenePath, ".aescene") + ".aes")
must(err)
must(os.WriteFile(filepath.Join(root, "scripts", "demo", "try.aes"), src, 0o644))
return root
}
// TestExamplesNeverTrapThePlayer does the same in the platformer and top-down examples, enemies, pits and checkpoints included.
func TestExamplesNeverTrapThePlayer(t *testing.T) {
for seed := int64(1); seed <= 8; seed++ {
t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
// openAt registers cleanup with its owning test. Close each engine
// before opening the next instead of retaining all worker pools until
// the entire 16-case stress test has completed.
t.Run("platformer", func(t *testing.T) {
pf := openPlatformer(t)
mash(t, pf.app, "Player", append(platformerKeys, input.KeyJ), seed)
})
t.Run("topdown", func(t *testing.T) {
app, _ := openExample(t, "topdown")
mash(t, app, "Hero", append(topDownKeys, input.KeyJ), seed)
})
})
}
}
var (
platformerKeys = []input.Scancode{input.KeyLeft, input.KeyRight, input.KeySpace, input.KeySpace, input.KeyUp}
topDownKeys = []input.Scancode{input.KeyLeft, input.KeyRight, input.KeyUp, input.KeyDown}
)
func mash(t *testing.T, app *run.App, name string, keys []input.Scancode, seed int64) {
t.Helper()
sc := app.Scene
player := sc.Find(sc.Root(), name)
if player.IsZero() {
t.Fatal("no Player node")
}
q, _ := engine.ServiceOf[physics2d.Queries](app.Engine.MainWorld(), physics2d.ServiceID)
d := app.Driver()
key := func(k input.Scancode, down bool) {
kind := platform.EvKeyUp
if down {
kind = platform.EvKeyDown
}
platform.ApplyInput(app.Engine.Input(), platform.Event{Kind: kind, Key: input.KeyEvent{Scan: k}})
}
step := func(n int) {
for i := 0; i < n; i++ {
app.Engine.Step(d)
}
}
m, _ := scene.Get[gameplay.Motion2D](sc, player)
f := physics2d.QueryFilter{LayerMask: uint32(m.CollisionMask), ExcludeBody: player}
hits := make([]physics2d.CastHit, 8)
rng := rand.New(rand.NewSource(seed))
held := map[input.Scancode]bool{}
inside, prevInside := 0, 0
for frame := 0; frame < 1800; frame++ {
if frame%6 == 0 {
k := keys[rng.Intn(len(keys))]
held[k] = !held[k]
key(k, held[k])
}
step(1)
deep := false
for _, h := range hits[:q.Penetration(player, f, hits)] {
// a kinematic platform may press the player into the floor; walking out is checked below
deep = deep || h.Depth > 0.05 && sc.Name(h.Node) != "Lift"
}
if inside = 0; deep {
inside = prevInside + 1
}
if prevInside = inside; inside > 10 {
t.Fatalf("seed %d frame %d: player %v stays inside a collider", seed, frame, sc.WorldPosition(player))
}
if frame%300 != 299 {
continue
}
for k := range held {
key(k, false)
held[k] = false
}
if hp, ok := scene.Get[gameplay.Health](sc, player); ok && hp.Dead {
step(120)
}
if !canMove(sc, player, key, step, slices.Contains(keys, input.KeyDown)) {
t.Fatalf("seed %d frame %d: player stuck at %v", seed, frame, sc.WorldPosition(player))
}
}
}
func canMove(sc *scene.Scene, player scene.NodeHandle, key func(input.Scancode, bool), step func(int), vertical bool) bool {
dirs := []input.Scancode{input.KeyLeft, input.KeyRight}
if vertical {
dirs = append(dirs, input.KeyUp, input.KeyDown)
}
for _, k := range dirs {
p := sc.WorldPosition(player)
key(k, true)
step(20)
key(k, false)
if q := sc.WorldPosition(player); q.Sub(p).Len() > 0.3 {
return true
}
}
return false
}