scene_run plays the game the way a player would and checks the result in the same run. input presses actions by frame: press, hold over a range, release, and axis values. assert reads values with the paths scene_set takes, at a frame (@120) or at any frame of the run, with ~= comparing numbers within 0.01, and exists or gone for nodes; the report gives ok or failed with the value and the frame, a failed check fails the run, and the CLI exits with 2. shot captures up to three frames from a run with a hidden window: the answer carries them as images scaled to 640 wide (scale changes it) and the full frames stay under .aego/cache/runs. While the editor is open, the CLI and aego mcp go through it. The editor serves the agent commands on a second loopback MCP server over the same bridge and writes its address in .aego/editor.lock. A change becomes an Undo step named after its origin, via CLI or via agent with the client's name, and asset creation works because the editor does it. Window > Confirm Changes from Outside Agents (editor.proxy.confirm) makes these changes wait in the Agent panel instead; follow mode shows outside calls like the panel's agent's. When the editor does not answer, the CLI works on the files as before. The native Windows and macOS jobs of ci-platforms are named smoke: the Windows build itself is cross-compiled on the Linux runner.
211 lines
5.6 KiB
Go
211 lines
5.6 KiB
Go
package mutate
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"aego/core/errs"
|
|
"aego/core/ids"
|
|
"aego/core/log"
|
|
"aego/scene"
|
|
"aego/schema"
|
|
)
|
|
|
|
const (
|
|
pathTransform = "transform"
|
|
pathEnabled = "enabled"
|
|
)
|
|
|
|
type target struct {
|
|
h scene.NodeHandle
|
|
td *schema.TypeDescriptor
|
|
fd *schema.FieldDescriptor
|
|
pose string
|
|
base string
|
|
lane string
|
|
index int
|
|
}
|
|
|
|
func (d *Document) node(ref string) (scene.NodeHandle, error) {
|
|
s := d.Scene
|
|
ref = strings.TrimSpace(ref)
|
|
switch {
|
|
case ref == "" || ref == "/" || ref == RootName:
|
|
return s.Root(), nil
|
|
case strings.HasPrefix(ref, "#"):
|
|
id, err := ids.ParseNodeID(ref[1:])
|
|
if err != nil {
|
|
return scene.NodeHandle{}, errs.Wrap(err, errs.SceneDeadNode, "bad node id").With(log.F("node", ref))
|
|
}
|
|
h := s.Handle(id)
|
|
if h.IsZero() || !s.IsAlive(h) {
|
|
return h, errs.New(errs.SceneDeadNode, "no node with this id", log.F("node", ref))
|
|
}
|
|
return h, nil
|
|
}
|
|
rest := strings.TrimPrefix(strings.TrimPrefix(ref, "/"), RootName+"/")
|
|
h := s.Find(s.Root(), rest)
|
|
if h.IsZero() || !s.IsAlive(h) {
|
|
return h, errs.New(errs.SceneDeadNode, "no node at this path", log.F("node", ref))
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
func (d *Document) Node(ref string) (scene.NodeHandle, error) {
|
|
return d.node(ref)
|
|
}
|
|
|
|
func (d *Document) nodeName(h scene.NodeHandle) string {
|
|
s := d.Scene
|
|
if h == s.Root() {
|
|
return RootName
|
|
}
|
|
return RootName + s.Path(h)
|
|
}
|
|
|
|
func idRef(s *scene.Scene, h scene.NodeHandle) string {
|
|
if h == s.Root() {
|
|
return RootName
|
|
}
|
|
return "#" + s.ID(h).String()
|
|
}
|
|
|
|
func (d *Document) resolve(name string) []*schema.TypeDescriptor {
|
|
reg := d.reg
|
|
if td, ok := reg.ByName(name); ok && (td.Kind == schema.TypeComponent || td.Kind == schema.TypeScript) {
|
|
return []*schema.TypeDescriptor{td}
|
|
}
|
|
var found []*schema.TypeDescriptor
|
|
for _, list := range [][]*schema.TypeDescriptor{reg.Components(), reg.Scripts()} {
|
|
for _, td := range list {
|
|
if strings.HasSuffix(td.FullName, "."+name) || strings.EqualFold(td.DisplayName, name) {
|
|
found = append(found, td)
|
|
}
|
|
}
|
|
}
|
|
return schema.PreferProject(found)
|
|
}
|
|
|
|
func ambiguous(name string, found []*schema.TypeDescriptor) error {
|
|
names := make([]string, len(found))
|
|
for i, td := range found {
|
|
names[i] = td.FullName
|
|
}
|
|
return errs.New(errs.SchemaUnknownType, "ambiguous component, use the full name", log.F("component", name), log.F("candidates", strings.Join(names, ", ")))
|
|
}
|
|
|
|
// attachable is a component or a script, the things add-comp and remove-comp take.
|
|
func (d *Document) attachable(name string) (*schema.TypeDescriptor, error) {
|
|
switch found := d.resolve(name); len(found) {
|
|
case 1:
|
|
return found[0], nil
|
|
case 0:
|
|
return nil, errs.New(errs.SchemaUnknownType, "unknown component or script", log.F("component", name))
|
|
default:
|
|
return nil, ambiguous(name, found)
|
|
}
|
|
}
|
|
|
|
func field(td *schema.TypeDescriptor, name string) (*schema.FieldDescriptor, bool) {
|
|
if fd, ok := td.Field(name); ok {
|
|
return fd, true
|
|
}
|
|
for i := range td.Fields {
|
|
fd := &td.Fields[i]
|
|
if strings.EqualFold(fd.Name, name) {
|
|
return fd, true
|
|
}
|
|
for _, f := range fd.Former {
|
|
if strings.EqualFold(f, name) {
|
|
return fd, true
|
|
}
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func splitIndex(seg string) (string, int, error) {
|
|
open := strings.IndexByte(seg, '[')
|
|
if open < 0 {
|
|
return seg, -1, nil
|
|
}
|
|
if !strings.HasSuffix(seg, "]") {
|
|
return "", 0, errs.New(errs.EditorProps, "bad index in field path", log.F("segment", seg))
|
|
}
|
|
n, err := strconv.Atoi(seg[open+1 : len(seg)-1])
|
|
if err != nil || n < 0 {
|
|
return "", 0, errs.New(errs.EditorProps, "bad index in field path", log.F("segment", seg))
|
|
}
|
|
return seg[:open], n, nil
|
|
}
|
|
|
|
func (d *Document) target(nodeRef, path string) (target, error) {
|
|
path = strings.TrimPrefix(path, "script/")
|
|
h, err := d.node(nodeRef)
|
|
if err != nil {
|
|
return target{}, err
|
|
}
|
|
t := target{h: h, index: -1}
|
|
parts := strings.Split(path, ".")
|
|
switch strings.ToLower(parts[0]) {
|
|
case pathEnabled:
|
|
if len(parts) != 1 {
|
|
return t, errs.New(errs.EditorProps, "enabled has no parts", log.F("path", path))
|
|
}
|
|
t.pose, t.base = pathEnabled, pathEnabled
|
|
return t, nil
|
|
case pathTransform:
|
|
if len(parts) < 2 || len(parts) > 3 {
|
|
return t, errs.New(errs.EditorProps, "use transform.position, transform.rotation or transform.scale", log.F("path", path))
|
|
}
|
|
t.pose = strings.ToLower(parts[1])
|
|
switch t.pose {
|
|
case "position", "rotation", "scale":
|
|
default:
|
|
return t, errs.New(errs.EditorProps, "use transform.position, transform.rotation or transform.scale", log.F("path", path))
|
|
}
|
|
t.base = pathTransform + "." + t.pose
|
|
if len(parts) == 3 {
|
|
t.lane = strings.ToLower(parts[2])
|
|
}
|
|
return t, nil
|
|
}
|
|
var miss error
|
|
for i := len(parts) - 1; i >= 1; i-- {
|
|
name := strings.Join(parts[:i], ".")
|
|
found := d.resolve(name)
|
|
if len(found) != 1 {
|
|
if len(found) > 1 && miss == nil {
|
|
miss = ambiguous(name, found)
|
|
}
|
|
continue
|
|
}
|
|
td := found[0]
|
|
name, idx, err := splitIndex(parts[i])
|
|
if err != nil {
|
|
return t, err
|
|
}
|
|
fd, ok := field(td, name)
|
|
if !ok {
|
|
return t, errs.New(errs.EditorProps, td.FullName+" has no field "+name, log.F("path", path))
|
|
}
|
|
t.td, t.fd, t.index = td, fd, idx
|
|
t.base = td.FullName + "." + fd.Name
|
|
switch rest := parts[i+1:]; len(rest) {
|
|
case 0:
|
|
case 1:
|
|
t.lane = strings.ToLower(rest[0])
|
|
default:
|
|
return t, errs.New(errs.EditorProps, "nested struct fields are not addressable yet", log.F("path", path))
|
|
}
|
|
if t.lane != "" && t.index >= 0 {
|
|
return t, errs.New(errs.EditorProps, "a path takes a list index or a lane, not both", log.F("path", path))
|
|
}
|
|
return t, nil
|
|
}
|
|
if miss != nil {
|
|
return t, miss
|
|
}
|
|
return t, errs.New(errs.EditorProps, "the path names no component", log.F("path", path))
|
|
}
|