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
254 lines
8.3 KiB
Go
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
|
|
}
|