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
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.
505 lines
13 KiB
Go
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
|
|
}
|