Files
aego/app/run/agent.go
T
sanspie a6ebf841d0
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 9m10s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m25s
ci / release (push) Failing after 4m59s
Stage 11b8: agents draw their art (D363)
asset.pixels: pixel art as a character grid and a palette; frames of one size become an atlas with a sprite per frame and a flipbook clip; a preview image in the answer.
asset.svg: an SVG subset drawn by an in-tree rasterizer (format/svg) for tiles, UI and icons.
art module: project.aego art { palette pixelsPerUnit }, the default palette and scale for drawings; Project Health HLT009 for pixel art outside the palette.
The agent prompts say to draw instead of building sprites from rectangles.
Agent runs open the asset index shared without a warning; prefab.create on a scene open in the editor goes through the prefab service, so unsaved scenes work.
scene_run with shot names xvfb when there is no display; local make ci lists untracked files outside .gitignore.
2026-10-26 19:36:15 +03:00

231 lines
6.2 KiB
Go

package run
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"image"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"aego/app/agent"
"aego/core/ids"
"aego/core/log"
"aego/core/rewind"
"aego/engine/recorder"
"aego/modules/audio"
"aego/modules/camera"
"aego/modules/gameplay"
"aego/modules/input"
"aego/project"
"aego/scene"
"aego/scene/mutate"
"aego/sdk"
)
const maxRunLog = 200
func AgentRunner(mods ...sdk.Module) agent.Runner {
return func(p *project.Project, r agent.RunRequest) (agent.RunReport, error) {
var buf bytes.Buffer
lg := log.New(log.NewTextSink(&buf))
lg.SetLevel(log.Warn)
frames, seed := r.Frames, r.Seed
if frames <= 0 {
frames = 120
}
if seed == 0 {
seed = 1
}
steps, err := parseInput(r.Input)
if err != nil {
return agent.RunReport{}, err
}
checks, err := parseChecks(r.Asserts)
if err != nil {
return agent.RunReport{}, err
}
if len(r.Shots) > maxShots {
return agent.RunReport{}, errors.New("at most " + strconv.Itoa(maxShots) + " screenshots a run")
}
cfg := Config{Headless: true, FrameTime: defaultDetStep, Seed: seed, ScenePath: r.Scene, Logger: lg, NoProfile: true, SharedIndex: true}
if len(r.Shots) > 0 {
cfg.Headless, cfg.Hidden = false, true
runtime.LockOSThread()
defer runtime.UnlockOSThread()
}
a, err := Open(p, cfg, mods...)
if err != nil {
if len(r.Shots) > 0 {
err = fmt.Errorf("%w; screenshots need xvfb-run or a display; run without shot or install xvfb", err)
}
return agent.RunReport{Log: logLines(buf.String())}, err
}
rep := agent.RunReport{}
driver := a.Driver()
tracks := watch(a, r.Watch)
var feed *injector
if len(steps) > 0 {
svc := input.Of(a.Engine.MainWorld())
if svc == nil {
a.Close()
return rep, errors.New("this game has no input module to press actions with")
}
for _, st := range steps {
if !svc.Has(st.action) {
a.Close()
return rep, errors.New("the project has no input action " + st.action)
}
}
feed = &injector{steps: steps, svc: svc}
}
doc := mutate.Wrap(a.Scene, "run", false, mutate.Env{})
sample := func() {
for i := range tracks {
if !tracks[i].h.IsZero() && a.Scene.IsAlive(tracks[i].h) {
p := a.Scene.WorldPosition(tracks[i].h)
tracks[i].Frames = append(tracks[i].Frames, rep.Frames)
tracks[i].At = append(tracks[i].At, [2]float32{p.X, p.Y})
}
}
}
name := strings.TrimSuffix(filepath.Base(r.Scene), filepath.Ext(r.Scene))
if name == "" || name == "." {
name = "main"
}
name += "-s" + strconv.FormatUint(seed, 10)
sample()
for _, c := range checks {
c.run(doc, 0)
}
for rep.Frames < frames {
if feed != nil {
if err := feed.frame(rep.Frames); err != nil {
rep.Error = err.Error()
break
}
}
var img *image.RGBA
var capErr error
shoot := slices.Contains(r.Shots, rep.Frames+1)
if shoot {
a.Engine.CaptureFrame(func(got *image.RGBA, e error) { img, capErr = got, e })
}
if !a.Engine.Step(driver) {
rep.Error = "the game stopped at frame " + strconv.Itoa(rep.Frames+1)
if err := a.Engine.Err(); err != nil {
rep.Error += ": " + err.Error()
}
break
}
rep.Frames++
if shoot {
rep.Shots = append(rep.Shots, takeShot(p.Root, name, rep.Frames, r.ShotWidth, img, capErr))
}
for _, c := range checks {
c.run(doc, rep.Frames)
}
if rep.Frames%max(1, frames/4) == 0 || rep.Frames == frames {
sample()
}
}
for _, c := range checks {
rep.Checks = append(rep.Checks, c.finish(rep.Frames))
}
for _, t := range tracks {
rep.Tracks = append(rep.Tracks, t.Track)
}
sum, _, _ := WorldHash(a.Engine.MainWorld(), rewind.NewHasher())
rep.Hash = hex.EncodeToString(sum[:])
rep.Counters = counters(a)
a.Close()
rep.Log = logLines(buf.String())
return rep, nil
}
}
func logLines(s string) []string {
var out []string
for _, l := range strings.Split(strings.TrimSpace(s), "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
if len(out) > maxRunLog {
out = append(out[:maxRunLog], "…")
}
return out
}
// counters sums what the modules counted during the run, for scene run and run_headless.
func counters(a *App) map[string]uint64 {
out := map[string]uint64{}
w := a.Engine.MainWorld()
if s := gameplay.Of(w); s != nil {
st := s.Stats()
out["gameplay.steps"], out["gameplay.casts"], out["gameplay.hits"] = st.Steps, st.Casts, st.Hits
out["gameplay.deaths"], out["gameplay.respawns"], out["gameplay.pickups"] = st.Deaths, st.Respawns, st.Pickups
}
if c := camera.Of(w); c != nil {
c.Outputs(func(camera.OutputInfo) { out["camera.outputs"]++ })
}
if m := audio.ModuleOf(a.Engine); m != nil {
c, st := m.Counts(), m.Stats()
out["audio.finished"], out["audio.stolen"], out["audio.rejected"], out["audio.starved"] = c.Finished, c.Stolen, c.Rejected, c.Starved
out["audio.underruns"] = st.Underruns
}
if r := w.Recorder; r != nil {
counts := r.Counts()
out["events.audio"], out["events.gameplay"] = counts[recorder.Audio], counts[recorder.Gameplay]
}
return out
}
type track struct {
agent.Track
h scene.NodeHandle
}
// watch finds the watched nodes in the running scene, by path from the root or #id.
func watch(a *App, paths []string) []track {
out := make([]track, 0, len(paths))
for _, p := range paths {
t := track{Track: agent.Track{Node: p}}
s := a.Scene
if hex, ok := strings.CutPrefix(p, "#"); ok {
if id, err := ids.ParseNodeID(hex); err == nil {
t.h = s.Handle(id)
}
} else {
t.h = s.Find(s.Root(), strings.TrimPrefix(strings.TrimPrefix(p, "/"), "Root/"))
}
if t.h.IsZero() || !s.IsAlive(t.h) {
t.Error = "no node " + p + " in the running scene"
}
out = append(out, t)
}
return out
}
// takeShot keeps a captured frame, or says why there is none.
func takeShot(root, name string, frame, width int, img *image.RGBA, err error) agent.Shot {
switch {
case err != nil:
return agent.Shot{Frame: frame, Error: err.Error()}
case img == nil:
return agent.Shot{Frame: frame, Error: "no frame was drawn; screenshots need xvfb-run or a display; run without shot or install xvfb"}
}
if width <= 0 {
width = shotWidth
}
s, err := shot(root, name, frame, width, img)
if err != nil {
s.Error = err.Error()
}
return s
}