Files
aego/app/agent/session.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

505 lines
13 KiB
Go

package agent
import (
"bytes"
"errors"
"os"
"path"
"path/filepath"
"strings"
"aego/app"
"aego/app/assembly"
"aego/app/assembly/builtin"
"aego/asset"
"aego/asset/pipeline"
"aego/core/errs"
"aego/core/ids"
"aego/core/log"
"aego/engine/module"
"aego/format/aetext"
scriptsystems "aego/modules/scripting/systems"
"aego/project"
"aego/project/refactor"
"aego/project/refs"
"aego/render/mesh"
"aego/render/texture"
"aego/scene"
"aego/scene/mutate"
"aego/scene/sceneio"
"aego/schema"
"aego/script/bind"
"aego/script/bind/manifest"
"aego/script/reload"
"aego/script/runtime"
"aego/script/workspace"
"aego/sdk"
)
const (
SceneExt = ".aescene"
PrefabExt = ".aeprefab"
)
type RunRequest struct {
Scene string `json:"scene,omitempty"`
Frames int `json:"frames"`
Seed uint64 `json:"seed"`
// Watch names nodes whose world positions the report samples through the run.
Watch []string `json:"watch,omitempty"`
// Input is the actions the run presses: "30 press Jump; 10-60 hold Right; 0-40 axis Move 1".
Input string `json:"input,omitempty"`
// Asserts are checked through the run: "@120 Player.transform.position.y < 3", "EnemyLeft.Health.hp == 0", "exists Ledge".
Asserts []string `json:"asserts,omitempty"`
// Shots are the frames to capture, at most three; ShotWidth is the width they are scaled to for the answer.
Shots []int `json:"shots,omitempty"`
ShotWidth int `json:"shotWidth,omitempty"`
}
// Check is the result of one assertion: whether it held, the value found and at which frame.
type Check struct {
Assert string `json:"assert"`
OK bool `json:"ok"`
Value string `json:"value,omitempty"`
Frame int `json:"frame"`
Error string `json:"error,omitempty"`
}
// Shot is a captured frame: the full image under .aego/cache/runs, and a scaled PNG for the answer.
type Shot struct {
Frame int `json:"frame"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
PNG []byte `json:"png,omitempty"`
Error string `json:"error,omitempty"`
}
// Track is where a watched node was: at the start, at each quarter of the run and at the end.
type Track struct {
Node string `json:"node"`
Frames []int `json:"frames"`
At [][2]float32 `json:"at"`
Error string `json:"error,omitempty"`
}
type RunReport struct {
Frames int `json:"frames"`
Hash string `json:"hash"`
Counters map[string]uint64 `json:"counters,omitempty"`
Log []string `json:"log,omitempty"`
Error string `json:"error,omitempty"`
Tracks []Track `json:"tracks,omitempty"`
Checks []Check `json:"checks,omitempty"`
Shots []Shot `json:"shots,omitempty"`
}
type Runner func(p *project.Project, r RunRequest) (RunReport, error)
type Options struct {
Mods []sdk.Module
Run Runner
}
type Session struct {
Project *project.Project
Types *scene.Types
Modules *module.Manifest
Pipeline *pipeline.Pipeline
Library *sceneio.Library
opt Options
budget Budget
kit *Kit
libErrs []error
scriptErr error
check *workspace.Workspace
host Host
borrowed bool
methods *methodIndex
secs *module.Sections
}
// methodIndex lists the script methods the bindings give each component type, built on first use.
type methodIndex struct {
binds *bind.Registry
reg *schema.Registry
man *manifest.Manifest
}
func (m *methodIndex) of(comp string) []manifest.CompMethod {
if m.man == nil {
man, err := manifest.FromRegistry(m.binds, m.reg)
if err != nil {
return nil
}
m.man = man
}
var out []manifest.CompMethod
for _, cm := range m.man.CompMethods {
if cm.Comp == comp {
out = append(out, cm)
}
}
return out
}
// Host is the editor when a session runs inside it: open scenes are read and changed through their documents (D341).
type Host interface {
OpenScene(rel string) (*mutate.Document, bool)
SceneDirty(rel string) bool
ApplyScene(rel, label string, tx mutate.Tx) (*mutate.Result, error)
ApplyFile(rel string, ctrl bool, label string, tx mutate.Tx) (*mutate.Result, error)
Sections() *module.Sections
ApplyProject(id string, n *aetext.Node, label string) error
// ScriptText is the editor's buffer of an open script; false when the script is not open.
ScriptText(rel string) (string, bool)
// ApplyScript changes a script in its open buffer, as one Undo step there, or on disk.
ApplyScript(e ScriptEdit, label string) error
}
// PrefabHost is an editor that makes a prefab from a node of an open scene, saved or not, as an Undo step of that scene.
type PrefabHost interface {
CreatePrefab(rel, node, out string) (ids.AssetID, error)
}
// Attach makes a session over the editor's project, types and pipeline; Close leaves them open.
// Budget caps what read commands return; zero fields mean no cap, as the CLI has it.
type Budget struct {
TreeNodes int
TreeDepth int
ScriptLines int
}
// SetBudget sets the caps of scene.tree and script.read, for the agent in the editor.
func (s *Session) SetBudget(b Budget) {
s.budget = b
}
// SetRunner gives scene.run a way to run the game, for a session attached to an editor.
func (s *Session) SetRunner(r Runner) {
s.opt.Run = r
}
func Attach(p *project.Project, types *scene.Types, mods *module.Manifest, pl *pipeline.Pipeline, binds *bind.Registry, host Host) *Session {
s := &Session{Project: p, Types: types, Modules: mods, Pipeline: pl, host: host, borrowed: true}
if binds != nil {
s.methods = &methodIndex{binds: binds, reg: types.Schema()}
}
s.reload()
return s
}
// sections are the module sections of project.aego: the editor's live ones, or loaded once from the file.
func (s *Session) sections() (*module.Sections, error) {
if s.host != nil {
if secs := s.host.Sections(); secs != nil {
return secs, nil
}
return nil, errors.New("project sections are not available in this editor")
}
if s.secs == nil {
if s.Modules == nil {
return nil, errors.New("project sections are not available")
}
secs, err := s.Modules.LoadSections(s.Project.Block, project.CoreBlocks...)
if err != nil {
return nil, err
}
s.secs = secs
}
return s.secs, nil
}
func (s *Session) Env() mutate.Env {
return s.env()
}
type Kit struct {
Project *project.Project
Types *scene.Types
Modules *module.Manifest
imps *pipeline.ImporterRegistry
ws *workspace.Workspace
binds *bind.Registry
opt Options
idx *methodIndex
}
func NewKit(p *project.Project, opt Options) (*Kit, error) {
types, err := assembly.NewTypes()
if err != nil {
return nil, err
}
reg := bind.New()
man, err := builtin.Default().Load(types, reg, assembly.Options{}, sdk.AdaptAll(opt.Mods)...)
if err != nil {
return nil, err
}
ws, err := workspace.Open(workspace.Config{Root: p.Root, Module: p.Module, Scripts: p.ScriptsDir()})
if err != nil {
return nil, err
}
imps, err := app.DevImporters(ws, man)
if err != nil {
return nil, err
}
if err := types.Schema().Freeze(); err != nil {
return nil, err
}
if err := scriptsystems.Freeze(reg, types.Schema()); err != nil {
return nil, err
}
ws.SetBindings(reg.Table())
k := &Kit{Project: p, Types: types, Modules: man, imps: imps, ws: ws, binds: reg, opt: opt}
k.idx = &methodIndex{binds: reg, reg: types.Schema()}
return k, nil
}
func (k *Kit) Open() (*Session, error) {
pl, err := pipeline.Open(k.Project, k.imps, log.Nop())
if err != nil {
return nil, err
}
s := &Session{Project: k.Project, Types: k.Types, Modules: k.Modules, Pipeline: pl, opt: k.opt, kit: k, methods: k.idx}
if _, _, err := pl.Scan(); err != nil {
s.Close()
return nil, err
}
if _, err := pl.ImportAll(); err != nil {
s.Close()
return nil, err
}
if g := k.ws.Committed(); g.Modules != nil {
prog := reload.NewProgram(g.ID, g.Modules, g.Hashes)
if err := runtime.Publish(k.Types.Schema(), prog.Ordered(), k.binds.Surface()); err != nil {
s.scriptErr = err
}
}
s.reload()
return s, nil
}
func Open(p *project.Project, opt Options) (*Session, error) {
k, err := NewKit(p, opt)
if err != nil {
return nil, err
}
return k.Open()
}
func (k *Kit) Rel(p string) (string, error) {
return RelIn(k.Project.Root, p)
}
// RelIn is p relative to root, refused when it leaves root.
func RelIn(root, p string) (string, error) {
if p == "" {
return "", errors.New("a file path is needed")
}
if filepath.IsAbs(p) {
r, err := filepath.Rel(root, p)
if err != nil {
return "", errs.Wrap(err, errs.VFSBadPath, "the path is outside the project")
}
p = r
}
p = path.Clean(filepath.ToSlash(p))
if p == "." || p == ".." || strings.HasPrefix(p, "../") {
return "", errs.New(errs.VFSBadPath, "the path is outside the project", log.F("path", p))
}
return p, nil
}
func (s *Session) Close() {
if s.borrowed {
return
}
if s.Pipeline != nil {
s.Pipeline.Close()
s.Pipeline = nil
}
}
func (s *Session) release(fn func() error) error {
s.Pipeline.Close()
err := fn()
pl, oerr := pipeline.Open(s.Project, s.kit.imps, log.Nop())
if oerr != nil {
return oerr
}
s.Pipeline = pl
if _, _, serr := pl.Scan(); serr != nil && err == nil {
err = serr
}
s.reload()
return err
}
func (s *Session) reload() {
s.Library, s.libErrs = refactor.PrefabLibrary(s.Types.Schema(), s.Pipeline, sources{s.Pipeline})
}
type sources struct {
pl *pipeline.Pipeline
}
func (r sources) read(id ids.AssetID) ([]byte, bool) {
rel, ok := r.pl.PathOf(id)
if !ok {
return nil, false
}
b, err := os.ReadFile(r.pl.Abs(rel))
return b, err == nil
}
func (r sources) Texture(ids.AssetID) (texture.Region, bool) {
return texture.Region{}, false
}
func (r sources) Mesh(ids.AssetID) (*mesh.Mesh, bool) {
return nil, false
}
func (r sources) Scene(id ids.AssetID) ([]byte, bool) {
return r.read(id)
}
func (r sources) Prefab(id ids.AssetID) ([]byte, bool) {
return r.read(id)
}
func (r sources) Generation() uint32 {
return 0
}
func (r sources) Subscribe(func(asset.Reloaded)) func() {
return func() {}
}
func (s *Session) env() mutate.Env {
pl := s.Pipeline
return mutate.Env{
Types: s.Types,
Resolver: sources{pl},
Library: s.Library,
Assets: func(id ids.AssetID) bool { return refs.Known(pl, id) },
Lookup: pl.Lookup,
OnAdd: func(td *schema.TypeDescriptor, sc *scene.Scene, h scene.NodeHandle) {
module.RunOnAdd(module.AddContext{Scene: sc, Node: h}, td)
},
OnRemove: func(td *schema.TypeDescriptor, sc *scene.Scene, h scene.NodeHandle) {
module.RunOnRemoving(module.AddContext{Scene: sc, Node: h}, td)
},
}
}
func (s *Session) rel(p string) (string, error) {
return RelIn(s.Project.Root, p)
}
func (s *Session) read(rel string) ([]byte, error) {
b, err := os.ReadFile(s.Pipeline.Abs(rel))
if err != nil {
return nil, errs.Wrap(err, errs.VFSIO, "cannot read "+rel)
}
return b, nil
}
func (s *Session) exists(rel string) bool {
_, err := os.Stat(s.Pipeline.Abs(rel))
return err == nil
}
func (s *Session) asset(text string) (ids.AssetID, error) {
if id, err := ids.ParseAssetID(text); err == nil {
return id, nil
}
rel, err := s.rel(text)
if err != nil {
return ids.AssetID{}, err
}
if id, ok := s.Pipeline.Lookup(rel); ok {
return id, nil
}
return ids.AssetID{}, errs.New(errs.AssetNotFound, "no asset with this id or path", log.F("asset", text))
}
func (s *Session) label(id ids.AssetID) string {
if rel, ok := s.Pipeline.PathOf(id); ok {
return rel
}
return id.String()
}
func (s *Session) writable() error {
if s.Pipeline.Shared() {
return errs.New(errs.VFSIO, "another process, usually the editor, holds the asset index; create assets there or close it first")
}
return nil
}
func (s *Session) write(edits []refactor.Edit) ([]string, error) {
if len(edits) == 0 {
return nil, nil
}
if err := refactor.WriteAll(s.Pipeline.Abs, edits); err != nil {
return nil, err
}
rels := make([]string, len(edits))
prefab := false
for i, e := range edits {
rels[i] = e.Path
prefab = prefab || path.Ext(e.Path) == PrefabExt
}
_, err := refactor.Reimport(s.Pipeline, rels)
if prefab {
s.reload()
}
return rels, err
}
func (s *Session) save(rel string, before, after []byte) ([]string, error) {
if bytes.Equal(before, after) {
return nil, nil
}
return s.write([]refactor.Edit{{Path: rel, Before: before, After: after}})
}
func (s *Session) create(rel string, data []byte) (ids.AssetID, error) {
if err := s.writable(); err != nil {
return ids.AssetID{}, err
}
if s.exists(rel) {
return ids.AssetID{}, errs.New(errs.VFSBadPath, rel+" already exists")
}
abs := s.Pipeline.Abs(rel)
if err := project.WriteAtomic(abs, data); err != nil {
return ids.AssetID{}, errs.Wrap(err, errs.VFSIO, "cannot write "+rel)
}
undo := func() {
os.Remove(abs)
os.Remove(abs + pipeline.MetaSuffix)
s.Pipeline.Scan()
}
if _, _, err := s.Pipeline.Scan(); err != nil {
undo()
return ids.AssetID{}, err
}
id, ok := s.Pipeline.Lookup(rel)
if !ok {
undo()
return ids.AssetID{}, errs.New(errs.VFSBadPath, rel+" has no importer or is outside assets, scenes, scripts and prefabs")
}
batch, err := s.Pipeline.Import(id)
if err == nil && batch != nil {
if ferr, bad := batch.Failed[id]; bad {
err = ferr
}
}
if err != nil {
undo()
return ids.AssetID{}, err
}
return id, nil
}