413 lines
11 KiB
Go
413 lines
11 KiB
Go
package integration
|
|
|
|
import (
|
|
"aego/contracts/debugdraw"
|
|
"aego/contracts/persistence"
|
|
"aego/modules/debug"
|
|
"aego/modules/physics"
|
|
"aego/save"
|
|
"aego/script/runtime"
|
|
"aego/script/vm"
|
|
"os"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"aego/core/mathx"
|
|
"aego/editor/core/history/commands"
|
|
"aego/editor/core/play"
|
|
"aego/engine"
|
|
"aego/platform"
|
|
"aego/platform/input"
|
|
"aego/scene"
|
|
"aego/sdk"
|
|
)
|
|
|
|
func keyHold(s interface{ Input() *input.State }, k input.Scancode, down bool) {
|
|
kind := platform.EvKeyUp
|
|
if down {
|
|
kind = platform.EvKeyDown
|
|
}
|
|
platform.ApplyInput(s.Input(), platform.Event{Kind: kind, Key: input.KeyEvent{Scan: k}})
|
|
}
|
|
|
|
func drive(e interface{ Input() *input.State }, pc *play.Controller, from, to int, at func(frame int)) {
|
|
for i := from; i < to; i++ {
|
|
e.Input().BeginFrame()
|
|
keyHold(e, input.KeyD, i%400 >= 20 && i%400 < 200)
|
|
keyHold(e, input.KeyA, i%400 >= 220 && i%400 < 380)
|
|
keyHold(e, input.KeySpace, i%90 >= 60 && i%90 < 64)
|
|
pc.Tick(1.0 / 60)
|
|
if at != nil {
|
|
at(i + 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func movePlayer(t *testing.T, pc *play.Controller, to mathx.Vec2) {
|
|
t.Helper()
|
|
sc := pc.Scene()
|
|
h := sc.Find(sc.Root(), "Player")
|
|
before := sc.Local(h)
|
|
after := before
|
|
after.Position = to
|
|
if err := pc.PlayDoc().History.Exec(&commands.SetTransform{Node: sc.ID(h), Before: before, After: after}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func playerPos(pc *play.Controller) mathx.Vec2 {
|
|
sc := pc.Scene()
|
|
return sc.Local(sc.Find(sc.Root(), "Player")).Position
|
|
}
|
|
|
|
func pausedHash(pc *play.Controller) [32]byte {
|
|
pc.Pause()
|
|
defer pc.Pause()
|
|
return pc.StateHash()
|
|
}
|
|
|
|
func TestKeyframeSeekMatchesLinearRun(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
pc.SetKeyEvery(300)
|
|
marks := map[int][32]byte{}
|
|
drive(s.Host.Engine, pc, 0, 10000, func(f int) {
|
|
if f == 5000 || f == 7777 {
|
|
marks[f] = pausedHash(pc)
|
|
}
|
|
})
|
|
if tl := pc.Timeline(); tl.Keys < 30 || tl.KeysOff != "" || tl.Usable != 10000 {
|
|
t.Fatalf("timeline %+v, want 10000 frames with keys", tl)
|
|
}
|
|
for _, n := range []int{5000, 7777, 5000} {
|
|
if err := pc.Seek(n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pc.StateHash() != marks[n] {
|
|
t.Fatalf("state after seeking to %d differs from the linear run", n)
|
|
}
|
|
if r := pc.Replayed(); r > 300 {
|
|
t.Fatalf("seek to %d replayed %d frames, want at most 300", n, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyframeBudgetRaisesTheFloor(t *testing.T) {
|
|
const limit = 1 << 20
|
|
s, pc := rewindSessionWith(t, limit, combatModule{})
|
|
pc.SetKeyEvery(60)
|
|
drive(s.Host.Engine, pc, 0, 3000, nil)
|
|
tl := pc.Timeline()
|
|
if tl.Floor == 0 || tl.Keys == 0 || tl.KeyBytes > limit {
|
|
t.Fatalf("timeline %+v, want a raised floor within %d bytes", tl, limit)
|
|
}
|
|
if err := pc.Seek(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c := pc.Timeline().Cursor; c != tl.Floor {
|
|
t.Fatalf("seek left of the floor landed on %d, want %d", c, tl.Floor)
|
|
}
|
|
}
|
|
|
|
func TestKeyframeEditAfterRewindEndsHistory(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
drive(s.Host.Engine, pc, 0, 600, nil)
|
|
if err := pc.Seek(200); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
movePlayer(t, pc, mathx.Vec2{X: -3, Y: 0})
|
|
pc.Pause()
|
|
drive(s.Host.Engine, pc, 200, 220, nil)
|
|
if tl := pc.Timeline(); tl.Usable != 200 || tl.Why == "" || !tl.Beyond {
|
|
t.Fatalf("timeline %+v, an edit at frame 200 must end the history there", tl)
|
|
}
|
|
}
|
|
|
|
func TestKeyframeEditAtFrameZeroRebases(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
pc.Pause()
|
|
want := mathx.Vec2{X: -3, Y: 0}
|
|
movePlayer(t, pc, want)
|
|
if err := pc.Seek(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := playerPos(pc); p != want {
|
|
t.Fatalf("player at %v after seeking to 0, want the edit %v", p, want)
|
|
}
|
|
pc.Pause()
|
|
drive(s.Host.Engine, pc, 0, 30, nil)
|
|
if tl := pc.Timeline(); tl.Why != "" || tl.Usable != 30 {
|
|
t.Fatalf("timeline %+v, a rebased history must keep recording", tl)
|
|
}
|
|
if err := pc.Seek(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := playerPos(pc); p != want {
|
|
t.Fatalf("player at %v after 30 frames and a seek to 0, want %v", p, want)
|
|
}
|
|
}
|
|
|
|
type opaqueModule struct{}
|
|
|
|
type opaqueState struct{}
|
|
|
|
func (opaqueModule) Info() sdk.ModuleInfo {
|
|
return sdk.ModuleInfo{ID: "opaque", Version: "0.1.0"}
|
|
}
|
|
|
|
func (opaqueModule) Register(*sdk.Registry) error {
|
|
return nil
|
|
}
|
|
|
|
func (opaqueModule) AttachWorld(ctx *sdk.AttachContext) error {
|
|
if ctx.Play && ctx.World != nil {
|
|
ctx.World.SetService("opaque.state", &opaqueState{})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (opaqueModule) DetachWorld(ctx *sdk.AttachContext) {
|
|
if ctx.Play && ctx.World != nil {
|
|
ctx.World.RemoveService("opaque.state")
|
|
}
|
|
}
|
|
|
|
func TestKeyframesOffFallsBackToReboot(t *testing.T) {
|
|
s, pc := rewindSessionWith(t, 0, combatModule{}, opaqueModule{})
|
|
var mark [32]byte
|
|
drive(s.Host.Engine, pc, 0, 400, func(f int) {
|
|
if f == 150 {
|
|
mark = playHash(pc)
|
|
}
|
|
})
|
|
if tl := pc.Timeline(); tl.KeysOff != "opaque.state" || tl.Keys != 0 {
|
|
t.Fatalf("timeline %+v, want keyframes off because of opaque.state", tl)
|
|
}
|
|
if err := pc.Seek(150); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if playHash(pc) != mark {
|
|
t.Fatal("physics differs after a reboot seek with keyframes off")
|
|
}
|
|
}
|
|
|
|
func TestKeyframeReplayWritesNoSaves(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
e := s.Host.Engine
|
|
drive(e, pc, 0, 99, nil)
|
|
e.Input().BeginFrame()
|
|
keyHold(e, input.KeyF5, true)
|
|
pc.Tick(1.0 / 60)
|
|
keyHold(e, input.KeyF5, false)
|
|
drive(e, pc, 100, 160, nil)
|
|
st, ok := engine.Service[*save.Store](e, persistence.StoreID)
|
|
if !ok {
|
|
t.Skip("no save store in the editor session")
|
|
}
|
|
path, err := st.Path("quick")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
s.Step()
|
|
if _, err := os.Stat(path); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the live quick save was never written")
|
|
}
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pc.Seek(50); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pc.Seek(150); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for range 10 {
|
|
s.Step()
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Fatal("a replayed frame wrote the save slot again")
|
|
}
|
|
}
|
|
|
|
func TestKeyframeSeekInsideCoroutineWait(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
pc.Pause()
|
|
sc := pc.Scene()
|
|
enemy := sc.Local(sc.Find(sc.Root(), "EnemyLeft")).Position
|
|
movePlayer(t, pc, mathx.Vec2{X: enemy.X - 0.74, Y: enemy.Y - 0.05})
|
|
pc.Pause()
|
|
pc.SetKeyEvery(10)
|
|
hashes := map[int][32]byte{}
|
|
hit := 0
|
|
for i := 0; i < 240; i++ {
|
|
pc.Tick(1.0 / 60)
|
|
hashes[i+1] = pausedHash(pc)
|
|
if h, ok := scene.Get[Health](pc.Scene(), pc.Scene().Find(pc.Scene().Root(), "Player")); ok && h.Hits > 0 && hit == 0 {
|
|
hit = i + 1
|
|
}
|
|
}
|
|
if hit == 0 {
|
|
t.Skip("the player was never hit, so the blink coroutine never ran")
|
|
}
|
|
_ = s
|
|
for _, n := range []int{hit + 3, hit + 9, hit + 15} {
|
|
if err := pc.Seek(n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f := playerField(t, pc, "flash"); f.Float() <= 0 {
|
|
t.Fatalf("frame %d is not inside the blink wait, flash %v", n, f.Float())
|
|
}
|
|
if pc.StateHash() != hashes[n] {
|
|
t.Fatalf("state after seeking into the blink wait at %d differs", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func playerField(t *testing.T, pc *play.Controller, name string) vm.Value {
|
|
t.Helper()
|
|
rt, ok := engine.ServiceOf[*runtime.Runtime](pc.World(), runtime.ServiceID)
|
|
if !ok {
|
|
t.Fatal("no script runtime in the play world")
|
|
}
|
|
sc := pc.Scene()
|
|
id, ok := rt.InstanceOf(sc.Find(sc.Root(), "Player"), playerType)
|
|
if !ok {
|
|
t.Fatal("player has no PlayerController instance")
|
|
}
|
|
v, _ := rt.Field(id, name)
|
|
return v
|
|
}
|
|
|
|
func TestKeyframeCaptureTime(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
drive(s.Host.Engine, pc, 0, 300, nil)
|
|
pc.SetKeyEvery(1)
|
|
var times []time.Duration
|
|
for range 100 {
|
|
pc.Tick(1.0 / 60)
|
|
times = append(times, pc.Timeline().KeyTime)
|
|
}
|
|
slices.Sort(times)
|
|
med := times[len(times)/2]
|
|
t.Logf("median keyframe capture: %v", med)
|
|
if med > time.Millisecond {
|
|
t.Fatalf("median keyframe capture took %v, want at most 1 ms", med)
|
|
}
|
|
}
|
|
|
|
func TestKeyframeScrubFollowsTheJump(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
e := s.Host.Engine
|
|
ys := map[int]float32{}
|
|
for i := 0; i < 400; i++ {
|
|
keyHold(e, input.KeySpace, i%120 >= 60 && i%120 < 64)
|
|
pc.Tick(1.0 / 60)
|
|
ys[i+1] = playerPos(pc).Y
|
|
}
|
|
lo, hi := ys[1], ys[1]
|
|
for _, y := range ys {
|
|
lo, hi = min(lo, y), max(hi, y)
|
|
}
|
|
if hi-lo < 0.5 {
|
|
t.Fatalf("the player never jumped: y in [%v, %v]", lo, hi)
|
|
}
|
|
for _, n := range []int{70, 80, 90, 75, 62, 85, 300, 190, 195, 185, 70} {
|
|
if err := pc.Scrub(n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if y := playerPos(pc).Y; y != ys[n] {
|
|
t.Fatalf("scrubbed to %d: player y %v, recorded %v", n, y, ys[n])
|
|
}
|
|
}
|
|
}
|
|
|
|
func shapeLines(t *testing.T, pc *play.Controller) int {
|
|
t.Helper()
|
|
d, ok := engine.ServiceOf[*debug.Service](pc.World(), debugdraw.ServiceID)
|
|
if !ok {
|
|
t.Fatal("no debug draw service in the play world")
|
|
}
|
|
n := 0
|
|
d.EachChannel(func(name string, count int, _ uint64) {
|
|
if name == physics.ChannelShapes {
|
|
n = count
|
|
}
|
|
})
|
|
return n
|
|
}
|
|
|
|
func TestKeyframeSeekKeepsOverlayVisibility(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
pc.SetKeyEvery(60)
|
|
drive(s.Host.Engine, pc, 0, 400, nil)
|
|
if n := shapeLines(t, pc); n != 0 {
|
|
t.Fatalf("%d physics shape primitives while the overlay is off", n)
|
|
}
|
|
d, _ := engine.ServiceOf[*debug.Service](pc.World(), debugdraw.ServiceID)
|
|
d.Hide(physics.ChannelShapes, false)
|
|
drive(s.Host.Engine, pc, 400, 401, nil)
|
|
for _, n := range []int{100, 120, 60, 300} {
|
|
if err := pc.Seek(n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if shapeLines(t, pc) == 0 {
|
|
t.Fatalf("no physics shapes at frame %d after turning the overlay on at frame 400", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadWhilePaused(t *testing.T) {
|
|
s, pc := rewindSession(t)
|
|
e := s.Host.Engine
|
|
drive(e, pc, 0, 120, nil)
|
|
svc, ok := engine.ServiceOf[persistence.Service](pc.World(), persistence.ServiceID)
|
|
if !ok {
|
|
t.Fatal("no save service in the play world")
|
|
}
|
|
if err := svc.Save("paused"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pc.Tick(1.0 / 60)
|
|
saved := playerPos(pc)
|
|
st, _ := engine.Service[*save.Store](e, persistence.StoreID)
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for !st.Exists("paused") {
|
|
s.Step()
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the save was never written")
|
|
}
|
|
}
|
|
drive(e, pc, 121, 240, nil)
|
|
if playerPos(pc) == saved {
|
|
t.Fatal("the player did not move after saving")
|
|
}
|
|
if pc.State() == play.Playing {
|
|
pc.Pause()
|
|
}
|
|
before := pc.Scene()
|
|
svc, _ = engine.ServiceOf[persistence.Service](pc.World(), persistence.ServiceID)
|
|
if err := svc.Load("paused"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for deadline = time.Now().Add(5 * time.Second); pc.Scene() == before; {
|
|
pc.Tick(1.0 / 60)
|
|
s.Step()
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("loading while paused never happened")
|
|
}
|
|
}
|
|
if pc.State() != play.Paused {
|
|
t.Fatalf("state %v after loading while paused, want paused", pc.State())
|
|
}
|
|
if p := playerPos(pc); p.Sub(saved).Len() > 1e-3 {
|
|
t.Fatalf("player at %v after loading, saved at %v", p, saved)
|
|
}
|
|
}
|