Files

444 lines
9.1 KiB
Go

package runtime
import (
scriptstdlib "aego/modules/scripting/stdlib"
"strconv"
"testing"
"aego/engine"
"aego/platform/input"
"aego/scene"
"aego/scene/scriptcomp"
"aego/schema"
"aego/script/ast"
"aego/script/bind"
"aego/script/bytecode"
"aego/script/checker"
"aego/script/compiler"
"aego/script/diag"
"aego/script/parser"
"aego/script/source"
"aego/script/stdlib"
"aego/script/vm"
)
func compileGame(tb testing.TB, src string) (*scene.Types, *bind.Registry, *bytecode.Module) {
tb.Helper()
types := scene.NewTypes(schema.NewRegistry())
if err := scriptcomp.Register(types); err != nil {
tb.Fatal(err)
}
if err := types.Schema().Freeze(); err != nil {
tb.Fatal(err)
}
reg := bind.New()
scriptstdlib.Install(reg, stdlib.Options{Seed: 1})
BindComponents(reg, types.Schema())
if err := reg.Freeze(); err != nil {
tb.Fatal(err)
}
sm := source.NewManager()
f, ds := parser.ParseSource(sm, "game/main.aes", []byte(src))
a := checker.Check(sm, map[string][]*ast.File{"game": {f}}, reg.Surface(), checker.Options{Module: "game"})
ds = append(ds, a.Diagnostics...)
mod, cds := compiler.Compile(a, "game", compiler.Options{Sources: sm})
ds = append(ds, cds...)
if mod == nil || diag.HasErrors(ds) {
for _, d := range ds {
tb.Log(diag.Format(sm, d))
}
tb.Fatal("program does not compile")
}
return types, reg, mod
}
type rig struct {
rt *Runtime
s *scene.Scene
ctx engine.Context
}
func newRig(tb testing.TB, src string, nodes int) *rig {
tb.Helper()
types, reg, mod := compileGame(tb, src)
rt := New(Options{Bindings: reg, Schema: types.Schema(), VM: vm.Options{Budget: 100_000}})
if err := rt.Link([]*bytecode.Module{mod}); err != nil {
tb.Fatal(err)
}
w := engine.NewWorld("test")
w.Input = &input.State{}
return &rig{rt: rt, s: scene.New(types, nil, scene.Options{InitialNodes: nodes + 1}), ctx: engine.Context{World: w, Dt: 1.0 / 60}}
}
func (g *rig) run(tb testing.TB) {
tb.Helper()
g.s.Flush()
if err := g.rt.Instantiate(g.s); err != nil {
tb.Fatal(err)
}
}
func (g *rig) frame() {
g.ctx.Frame++
g.rt.Update(&g.ctx)
g.rt.Coroutines(&g.ctx)
g.s.Flush()
}
func (g *rig) field(tb testing.TB, h scene.NodeHandle, typ, name string) vm.Value {
tb.Helper()
id, ok := g.rt.InstanceOf(h, typ)
if !ok {
tb.Fatalf("%s has no %s", g.s.Name(h), typ)
}
v, ok := g.rt.Field(id, name)
if !ok {
tb.Fatalf("field %s is missing", name)
}
return v
}
func refTo(s *scene.Scene, h scene.NodeHandle, name string) schema.PropertyBag {
var b schema.PropertyBag
b.Set(name, schema.Node(s.ID(h)))
return b
}
const signalProgram = `package game
script Source {
signal Ping(n int)
var frames int
var Count int
func Update(dt float) {
frames++
if frames == 2 || frames == 3 {
Ping.Emit(frames)
}
}
}
script Listener {
@export var Src NodeRef
var mine int
var late int
var hooked bool
func Ready() {
s := Src.GetScript<Source>()
if s != nil {
s.Ping.Connect(OnPing)
}
}
func OnPing(n int) {
s := Src.GetScript<Source>()
s.Count++
mine = s.Count
if !hooked {
hooked = true
s.Ping.Connect(OnLate)
}
}
func OnLate(n int) {
late++
}
}
script Loop {
signal Echo()
var started bool
func Ready() {
Echo.Connect(OnEcho)
}
func Update(dt float) {
if !started {
started = true
Echo.Emit()
}
}
func OnEcho() {
Echo.Emit()
}
}
`
func listeners(g *rig, src scene.NodeHandle, n int) []scene.NodeHandle {
var out []scene.NodeHandle
for i := 0; i < n; i++ {
out = append(out, attach(g.s, "L"+strconv.Itoa(i), "game.Listener", refTo(g.s, src, "Src")))
}
return out
}
func TestSignalOrderTargetDeathLateConnect(t *testing.T) {
g := newRig(t, signalProgram, 4)
src := attach(g.s, "Source", "game.Source", schema.PropertyBag{})
ls := listeners(g, src, 3)
g.run(t)
g.frame()
g.s.Destroy(ls[1])
g.frame()
if a, b := g.field(t, ls[0], "game.Listener", "mine").Int(), g.field(t, ls[2], "game.Listener", "mine").Int(); a != 1 || b != 2 {
t.Fatalf("delivery order %d %d", a, b)
}
for _, h := range []scene.NodeHandle{ls[0], ls[2]} {
if l := g.field(t, h, "game.Listener", "late").Int(); l != 0 {
t.Fatal("a connection made during Emit was delivered by the same Emit")
}
}
g.frame()
for _, h := range []scene.NodeHandle{ls[0], ls[2]} {
if l := g.field(t, h, "game.Listener", "late").Int(); l != 1 {
t.Fatalf("late handler ran %d times", l)
}
}
if len(g.rt.Faults()) != 0 {
t.Fatalf("faults %v", g.rt.Faults()[0].Fault)
}
}
func TestSignalSourceDeathDropsConnections(t *testing.T) {
g := newRig(t, signalProgram, 3)
src := attach(g.s, "Source", "game.Source", schema.PropertyBag{})
listeners(g, src, 2)
g.run(t)
g.frame()
if n := g.rt.sig.Len(); n != 2 {
t.Fatalf("connections %d", n)
}
g.s.Destroy(src)
g.frame()
if n := g.rt.sig.Len(); n != 0 {
t.Fatalf("source death left %d connections", n)
}
}
func TestNestedEmitIsCallDepth(t *testing.T) {
g := newRig(t, signalProgram, 1)
attach(g.s, "Loop", "game.Loop", schema.PropertyBag{})
g.run(t)
g.frame()
g.frame()
fs := g.rt.Faults()
if len(fs) == 0 || fs[0].Fault.Code != vm.CodeCallDepth {
t.Fatalf("faults %+v", fs)
}
}
const asyncProgram = `package game
script Spin {
var n int
func Ready() {
start Loop()
}
func Loop() coroutine {
for {
n++
yield
}
}
}
script Waiter {
var childDone bool
var done bool
func Ready() {
start Main()
}
func Main() coroutine {
c := start Child()
yield c
done = true
}
func Child() coroutine {
yield
yield
childDone = true
}
}
script Timer {
var hit bool
func Ready() {
start Run()
}
func Run() coroutine {
yield wait(1.0)
hit = true
}
}
script Prey {
var Hp int = 3
}
script Hunter {
@export var Target NodeRef
var ref ScriptRef<Prey>
func Ready() {
ref = Target.GetScript<Prey>()
}
func Update(dt float) {
ref.Hp -= 1
}
}
script Hub {
signal Tick(n int)
var got int
func Ready() {
for i := 0; i < 10; i++ {
Tick.Connect(OnTick)
}
}
func Update(dt float) {
Tick.Emit(1)
}
func OnTick(n int) {
got += n
}
}
`
func TestCoroutineCancelledWithNode(t *testing.T) {
g := newRig(t, asyncProgram, 1)
h := attach(g.s, "Spin", "game.Spin", schema.PropertyBag{})
g.run(t)
g.frame()
g.frame()
if g.rt.CoStats().Live != 1 || g.field(t, h, "game.Spin", "n").Int() < 2 {
t.Fatalf("stats %+v", g.rt.CoStats())
}
g.s.Destroy(h)
g.frame()
if st := g.rt.CoStats(); st.Live != 0 || st.Canceled != 1 {
t.Fatalf("destroyed node left coroutines %+v", st)
}
}
func TestAwaitWakesInSameFrame(t *testing.T) {
g := newRig(t, asyncProgram, 1)
h := attach(g.s, "Waiter", "game.Waiter", schema.PropertyBag{})
g.run(t)
for i := 0; i < 6; i++ {
g.frame()
child := g.field(t, h, "game.Waiter", "childDone").Bool()
done := g.field(t, h, "game.Waiter", "done").Bool()
if child != done {
t.Fatalf("frame %d: child %v waiter %v", i+1, child, done)
}
}
if !g.field(t, h, "game.Waiter", "done").Bool() {
t.Fatal("waiter never woke")
}
}
func TestWaitUsesElapsed(t *testing.T) {
g := newRig(t, asyncProgram, 1)
g.ctx.Dt = 0.25
h := attach(g.s, "Timer", "game.Timer", schema.PropertyBag{})
g.run(t)
for i := 0; i < 3; i++ {
g.frame()
}
if g.field(t, h, "game.Timer", "hit").Bool() {
t.Fatal("wait(1.0) resumed after 0.75 s")
}
for i := 0; i < 3; i++ {
g.frame()
}
if !g.field(t, h, "game.Timer", "hit").Bool() {
t.Fatal("wait(1.0) did not resume after 1.5 s")
}
}
func TestScriptRefToDeadInstanceFaults(t *testing.T) {
g := newRig(t, asyncProgram, 2)
prey := attach(g.s, "Prey", "game.Prey", schema.PropertyBag{})
attach(g.s, "Hunter", "game.Hunter", refTo(g.s, prey, "Target"))
g.run(t)
g.frame()
if hp := g.field(t, prey, "game.Prey", "Hp").Int(); hp != 2 {
t.Fatalf("hp %d", hp)
}
g.s.Destroy(prey)
g.frame()
fs := g.rt.Faults()
if len(fs) != 1 || fs[0].Fault.Code != vm.CodeNil || fs[0].Type != "game.Hunter" {
t.Fatalf("faults %+v", fs)
}
}
func TestSignalsZeroAllocs(t *testing.T) {
g := newRig(t, asyncProgram, 1000)
for i := 0; i < 1000; i++ {
attach(g.s, "Hub"+strconv.Itoa(i), "game.Hub", schema.PropertyBag{})
}
g.run(t)
for i := 0; i < 30; i++ {
g.frame()
}
st := g.rt.Stats()
if st.Connections != 10_000 || st.Emits != 1000 || st.Handlers != 10_000 {
t.Fatalf("stats connections %d emits %d handlers %d", st.Connections, st.Emits, st.Handlers)
}
if allocs := testing.AllocsPerRun(50, g.frame); allocs > 0 {
t.Fatalf("%.2f Go allocations per frame with 10k connections and 1k emits", allocs)
}
}
func TestCoroutinesZeroAllocs(t *testing.T) {
g := newRig(t, asyncProgram, 2000)
for i := 0; i < 2000; i++ {
attach(g.s, "Spin"+strconv.Itoa(i), "game.Spin", schema.PropertyBag{})
}
g.run(t)
for i := 0; i < 30; i++ {
g.frame()
}
if allocs := testing.AllocsPerRun(50, g.frame); allocs > 0 {
t.Fatalf("%.2f Go allocations per frame with 2k coroutines", allocs)
}
}
func BenchmarkCoroutines2k(bm *testing.B) {
g := newRig(bm, asyncProgram, 2000)
for i := 0; i < 2000; i++ {
attach(g.s, "Spin"+strconv.Itoa(i), "game.Spin", schema.PropertyBag{})
}
g.run(bm)
for i := 0; i < 30; i++ {
g.frame()
}
bm.ReportAllocs()
bm.ResetTimer()
for i := 0; i < bm.N; i++ {
g.frame()
}
bm.ReportMetric(float64(g.rt.Stats().Resumes), "resumes/frame")
}