Compare commits
48
Commits
v0.9.0-beta.1
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
824aa2e9a0 | ||
|
|
c2736c158f | ||
|
|
d4b5b88be1 | ||
|
|
78e86970d7 | ||
|
|
a6ebf841d0 | ||
|
|
f5944907cc | ||
|
|
d436311f69 | ||
|
|
6d7adba259 | ||
|
|
1e28291320 | ||
|
|
49c9e97203 | ||
|
|
a1eb467600 | ||
|
|
a25db02077 | ||
|
|
239a449fe2 | ||
|
|
b6cc16531d | ||
|
|
70be14646a | ||
|
|
70534c271c | ||
|
|
a15d2a1fe3 | ||
|
|
6e8ebcd90f | ||
|
|
adcdd5fbad | ||
|
|
0ec28ad86b | ||
|
|
ba9d35443f | ||
|
|
0fa39a3e17 | ||
|
|
fa6f37b7f5 | ||
|
|
6151233e05 | ||
|
|
53ed8c6476 | ||
|
|
ff896506f5 | ||
|
|
8a842b19a6 | ||
|
|
f22242e5aa | ||
|
|
2e10968d48 | ||
|
|
2fcbaa6c12 | ||
|
|
67e2d77823 | ||
|
|
55871f666b | ||
|
|
ef59ae297f | ||
|
|
7b83e82c6e | ||
|
|
515f4ca1b3 | ||
|
|
3d8e3b2fd3 | ||
|
|
c6a24ba393 | ||
|
|
7dca06e4b1 | ||
|
|
6dfdb824e2 | ||
|
|
de49a53172 | ||
|
|
1030dc9a43 | ||
|
|
3d25734dfa | ||
|
|
12f628c469 | ||
|
|
1529213434 | ||
|
|
f8bb865920 | ||
|
|
f5fb32565e | ||
|
|
f1a8f538d3 | ||
|
|
963b01a011 |
@@ -0,0 +1,4 @@
|
||||
# Agents working on Aego
|
||||
|
||||
- Binaries go to `.cache/bin` only. Never run `go build` without `-o` in the repository root, and build test binaries only with `go test -c -o .cache/bin/<name>.test`. `make clean` empties `.cache/bin`; `make ci` fails when the root holds a binary or a `*.test` file (`tools/ci-clean-root.sh`).
|
||||
- Run the full `make ci` before every push. Between pushes, run the checks that cover the change: the tests of the changed packages, `make lint`, `make depcheck`, the docs check and the golden tests when the UI changed.
|
||||
@@ -17,7 +17,9 @@ env:
|
||||
libasound2-dev libpulse-dev libdrm-dev libgbm-dev libudev-dev libdbus-1-dev libibus-1.0-dev
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
|
||||
smoke:
|
||||
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
|
||||
if: vars.PLATFORM_RUNNERS == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -37,6 +37,10 @@ jobs:
|
||||
- run: go vet ./platform/sdl
|
||||
- run: make lint
|
||||
- run: make depcheck
|
||||
- name: packaging tools
|
||||
run: sudo apt-get install -y nsis squashfs-tools
|
||||
- name: installer dry run
|
||||
run: make nsis-check
|
||||
|
||||
build:
|
||||
strategy:
|
||||
@@ -123,6 +127,9 @@ jobs:
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the key that signs release.json and the public half the binaries trust; without them no release.json is published (D362)
|
||||
AEGO_RELEASE_KEY: ${{ secrets.AEGO_RELEASE_KEY }}
|
||||
AEGO_UPDATE_KEY: ${{ vars.AEGO_UPDATE_KEY || secrets.AEGO_UPDATE_KEY }}
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -18,3 +18,11 @@ tools/genicons/genicons
|
||||
/tests/golden/testdata/actual-*
|
||||
tools/websmoke/node_modules/
|
||||
tools/websmoke/golden/actual-*.png
|
||||
|
||||
# build output belongs in .cache/bin; these catch stray builds in the root
|
||||
/aego-*
|
||||
/i18n
|
||||
*.prof
|
||||
*.pprof
|
||||
/data*.txt
|
||||
*.key
|
||||
|
||||
+174
-29
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
# Aego
|
||||
|
||||
Make 2D games in Go. One editor, one project, desktop and web.
|
||||
|
||||
Build scenes in the editor, script them in a small Go-like language, extend the engine with Go when you need to, and ship the same project to Linux, Windows, macOS and the browser. Deterministic simulation, rewind, physics, animation, audio and cameras come built in.
|
||||
|
||||
- Documentation and the playground: <https://aego.akarpov.ru>
|
||||
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
|
||||
- Contributing, check levels and rules for engine code: [docs/contributing.md](docs/contributing.md)
|
||||
- Architecture and decisions: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
+238
File diff suppressed because it is too large
Load Diff
+234
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func clip(loop LoopMode, ts ...float32) *Clip {
|
||||
c := &Clip{Length: 1, Loop: loop}
|
||||
for _, t := range ts {
|
||||
c.Events = append(c.Events, Event{T: t, Name: "e"})
|
||||
}
|
||||
c.Fix()
|
||||
return c
|
||||
}
|
||||
|
||||
func evs(cs []Crossing) []int32 {
|
||||
out := make([]int32, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.Event
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cycles(cs []Crossing) []int64 {
|
||||
out := make([]int64, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.Cycle
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expect[T comparable](t *testing.T, name string, got, want []T) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("%s: got %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnce(t *testing.T) {
|
||||
c := clip(LoopOnce, 0, 0.5, 1)
|
||||
expect(t, "start", evs(Crossings(c, LoopOnce, 0, 0.6, true, nil)), []int32{0, 1})
|
||||
expect(t, "end", evs(Crossings(c, LoopOnce, 0.6, 1.5, false, nil)), []int32{2})
|
||||
expect(t, "after", evs(Crossings(c, LoopOnce, 1, 2, false, nil)), []int32{})
|
||||
}
|
||||
|
||||
func TestZeroKeyEveryCycle(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0)
|
||||
expect(t, "start", cycles(Crossings(c, LoopRepeat, 0, 0.5, true, nil)), []int64{0})
|
||||
expect(t, "next", cycles(Crossings(c, LoopRepeat, 0.5, 1.5, false, nil)), []int64{1})
|
||||
expect(t, "two", cycles(Crossings(c, LoopRepeat, 1.5, 3.2, false, nil)), []int64{2, 3})
|
||||
}
|
||||
|
||||
func TestLengthKeyOncePerCycle(t *testing.T) {
|
||||
c := clip(LoopRepeat, 1)
|
||||
expect(t, "start", evs(Crossings(c, LoopRepeat, 0, 0.5, true, nil)), []int32{})
|
||||
expect(t, "first", cycles(Crossings(c, LoopRepeat, 0.5, 1, false, nil)), []int64{0})
|
||||
expect(t, "second", cycles(Crossings(c, LoopRepeat, 1, 2, false, nil)), []int64{1})
|
||||
}
|
||||
|
||||
func TestSameMoment(t *testing.T) {
|
||||
c := clip(LoopOnce, 0.5, 0.5)
|
||||
expect(t, "forward", evs(Crossings(c, LoopOnce, 0, 1, false, nil)), []int32{0, 1})
|
||||
expect(t, "backward", evs(Crossings(c, LoopOnce, 1, 0, false, nil)), []int32{0, 1})
|
||||
}
|
||||
|
||||
func TestStillAndBackward(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0.2, 0.8)
|
||||
expect(t, "speed 0", evs(Crossings(c, LoopRepeat, 0.3, 0.3, false, nil)), []int32{})
|
||||
got := Crossings(c, LoopRepeat, 1.5, 0.1, false, nil)
|
||||
expect(t, "backward events", evs(got), []int32{0, 1, 0})
|
||||
expect(t, "backward cycles", cycles(got), []int64{1, 0, 0})
|
||||
}
|
||||
|
||||
func TestManyCycles(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0.5)
|
||||
expect(t, "three", cycles(Crossings(c, LoopRepeat, 0, 3, true, nil)), []int64{0, 1, 2})
|
||||
}
|
||||
|
||||
func TestPingPongTurns(t *testing.T) {
|
||||
c := clip(LoopPingPong, 0, 1)
|
||||
got := Crossings(c, LoopPingPong, 0, 4, true, nil)
|
||||
expect(t, "events", evs(got), []int32{0, 1, 0, 1, 0})
|
||||
expect(t, "cycles", cycles(got), []int64{0, 0, 2, 2, 4})
|
||||
mid := clip(LoopPingPong, 0.25)
|
||||
got = Crossings(mid, LoopPingPong, 0, 2, true, nil)
|
||||
if len(got) != 2 || got[0].T != 0.25 || got[1].T != 1.75 {
|
||||
t.Fatalf("mid %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossingsNoAllocs(t *testing.T) {
|
||||
c := clip(LoopRepeat, 0, 0.25, 0.5, 0.75)
|
||||
buf := make([]Crossing, 0, 64)
|
||||
if n := testing.AllocsPerRun(100, func() { buf = Crossings(c, LoopRepeat, 0.1, 3.9, false, buf) }); n != 0 {
|
||||
t.Fatalf("Crossings allocates %v", n)
|
||||
}
|
||||
}
|
||||
+332
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
const layoutSrc = `controller version=1 {
|
||||
entry="Idle"
|
||||
param "Speed" kind=float {}
|
||||
state "Idle" {}
|
||||
state "Run" {}
|
||||
state "Jump" pos=[500, -40] {}
|
||||
state "Lost" {}
|
||||
transition "Idle" to="Run" duration=0.1 {
|
||||
cond "Speed" op=gt value=0.1 {}
|
||||
}
|
||||
transition "Run" to="Idle" duration=0.1 {
|
||||
cond "Speed" op=le value=0.1 {}
|
||||
}
|
||||
transition "any" to="Jump" {
|
||||
cond "Speed" op=lt value=0 {}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func layoutGraph(t *testing.T) *Graph {
|
||||
t.Helper()
|
||||
doc, err := aetext.Parse([]byte(layoutSrc), "l.aectrl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, probs, err := DecodeGraph(doc)
|
||||
if err != nil || len(probs) > 0 {
|
||||
t.Fatalf("%v %+v", err, probs)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestLayoutPlacesEveryStateApart(t *testing.T) {
|
||||
g := layoutGraph(t)
|
||||
g.Layout()
|
||||
seen := map[[2]float32]string{}
|
||||
for _, s := range g.States {
|
||||
if !s.HasPos {
|
||||
t.Fatalf("%s has no position", s.Name)
|
||||
}
|
||||
if o, dup := seen[s.Pos]; dup {
|
||||
t.Fatalf("%s and %s share %v", s.Name, o, s.Pos)
|
||||
}
|
||||
seen[s.Pos] = s.Name
|
||||
}
|
||||
idle, run, jump := g.States[0].Pos, g.States[1].Pos, g.States[2].Pos
|
||||
if idle[0] != 0 || run[0] != LayoutColumn {
|
||||
t.Fatalf("entry %v, next %v: the entry is the first column", idle, run)
|
||||
}
|
||||
if jump != [2]float32{500, -40} {
|
||||
t.Fatalf("a written position moved to %v", jump)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPosRoundTrips(t *testing.T) {
|
||||
g := layoutGraph(t)
|
||||
g.Layout()
|
||||
text := aetext.Write(EncodeGraph(g))
|
||||
doc, err := aetext.Parse(text, "l.aectrl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, _, err := DecodeGraph(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin, err := UnmarshalGraph(MarshalGraph(back))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range g.States {
|
||||
if back.States[i].Pos != g.States[i].Pos || bin.States[i].Pos != g.States[i].Pos {
|
||||
t.Fatalf("%s: text %v binary %v, want %v", g.States[i].Name, back.States[i].Pos, bin.States[i].Pos, g.States[i].Pos)
|
||||
}
|
||||
}
|
||||
if _, err := UnmarshalGraph(MarshalGraph(back)[:len(MarshalGraph(back))-4]); err == nil {
|
||||
t.Fatal("a truncated state was accepted")
|
||||
}
|
||||
}
|
||||
+185
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
package anim
|
||||
|
||||
// RootDelta is the change of a root track between two clip times: a looping clip adds its end minus its start for every cycle crossed, a ping-pong clip only moves back and forth.
|
||||
func RootDelta(c *Clip, s *Sampler, track int, loop LoopMode, t0, t1 float64) [2]float32 {
|
||||
if !(c.Length > 0) || t0 == t1 || track < 0 || track >= len(c.Tracks) {
|
||||
return [2]float32{}
|
||||
}
|
||||
u0, k0 := c.Local(loop, t0)
|
||||
u1, k1 := c.Local(loop, t1)
|
||||
a := s.Track(c, track, u0).L
|
||||
b := s.Track(c, track, u1).L
|
||||
d := [2]float32{b[0] - a[0], b[1] - a[1]}
|
||||
if loop == LoopRepeat && k1 != k0 {
|
||||
start := s.Track(c, track, 0).L
|
||||
end := s.Track(c, track, c.Length).L
|
||||
n := float32(k1 - k0)
|
||||
d[0] += n * (end[0] - start[0])
|
||||
d[1] += n * (end[1] - start[1])
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"aego/core/curve"
|
||||
"aego/schema"
|
||||
)
|
||||
|
||||
func walkClip(loop LoopMode) *Clip {
|
||||
c := &Clip{Length: 1, Loop: loop, Tracks: []Track{{
|
||||
Property: RootProp, Kind: schema.KindVec2, Root: true,
|
||||
Keys: []Key{{T: 0, V: Lanes([4]float32{0, 0}), Interp: curve.InterpLinear}, {T: 1, V: Lanes([4]float32{1, 0.5})}},
|
||||
}}}
|
||||
c.Fix()
|
||||
return c
|
||||
}
|
||||
|
||||
func TestRootDelta(t *testing.T) {
|
||||
var s Sampler
|
||||
loop := walkClip(LoopRepeat)
|
||||
for _, tc := range []struct {
|
||||
t0, t1 float64
|
||||
want [2]float32
|
||||
}{
|
||||
{0.25, 0.75, [2]float32{0.5, 0.25}},
|
||||
{0.5, 1.5, [2]float32{1, 0.5}},
|
||||
{0.25, 2.25, [2]float32{2, 1}},
|
||||
{1.25, 0.25, [2]float32{-1, -0.5}},
|
||||
{0.75, 1, [2]float32{0.25, 0.125}},
|
||||
} {
|
||||
d := RootDelta(loop, &s, 0, LoopRepeat, tc.t0, tc.t1)
|
||||
if !near(d[0], tc.want[0]) || !near(d[1], tc.want[1]) {
|
||||
t.Fatalf("loop %v..%v: %v, want %v", tc.t0, tc.t1, d, tc.want)
|
||||
}
|
||||
}
|
||||
ping := walkClip(LoopPingPong)
|
||||
if d := RootDelta(ping, &s, 0, LoopPingPong, 0.5, 1.5); !near(d[0], 0) || !near(d[1], 0) {
|
||||
t.Fatalf("ping-pong turns back: %v", d)
|
||||
}
|
||||
once := walkClip(LoopOnce)
|
||||
if d := RootDelta(once, &s, 0, LoopOnce, 0.75, 3); !near(d[0], 0.25) {
|
||||
t.Fatalf("once stops at the end: %v", d)
|
||||
}
|
||||
if d := RootDelta(loop, &s, 0, LoopRepeat, 0.5, 0.5); d != ([2]float32{}) {
|
||||
t.Fatalf("no time, no motion: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCheck(t *testing.T) {
|
||||
c := walkClip(LoopRepeat)
|
||||
if ps := c.Check(nil); len(ps) != 0 {
|
||||
t.Fatalf("a good root track reports %+v", ps)
|
||||
}
|
||||
c.Tracks[0].Target = "Body"
|
||||
if ps := c.Check(nil); len(ps) != 1 || ps[0].Kind != ProblemRoot {
|
||||
t.Fatalf("a root track on a child: %+v", ps)
|
||||
}
|
||||
}
|
||||
+146
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
// shape lists the attributes, fields and child blocks a block of a clip or controller may hold.
|
||||
type shape struct {
|
||||
attrs []string
|
||||
fields []string
|
||||
blocks []string
|
||||
}
|
||||
|
||||
var (
|
||||
clipShape = shape{attrs: []string{"version", "length", "fps", "loop"}, blocks: []string{"track", "frames", "event"}}
|
||||
trackShape = shape{attrs: []string{"prop", "kind", "numeric", "root", "authority", "onEnd"}, blocks: []string{"key"}}
|
||||
keyShape = shape{attrs: []string{"t", "v", "interp", "tangent", "in", "out"}}
|
||||
framesShape = shape{attrs: []string{"start", "fps", "prop", "flipProp", "authority"}, fields: []string{"sprites", "durations", "flipX"}}
|
||||
eventShape = shape{attrs: []string{"t", "name", "payload", "mode"}}
|
||||
ctrlShape = shape{attrs: []string{"version", "ease", "entryPos", "anyPos"}, fields: []string{"entry"}, blocks: []string{"param", "state", "transition"}}
|
||||
paramShape = shape{attrs: []string{"kind", "default"}}
|
||||
stateShape = shape{attrs: []string{"clip", "speed", "loop", "pos", "events"}}
|
||||
transShape = shape{attrs: []string{"to", "duration", "exitTime", "interrupt"}, blocks: []string{"cond"}}
|
||||
condShape = shape{attrs: []string{"op", "value"}}
|
||||
)
|
||||
|
||||
// opening is how the block's first line reads with the attribute moved into it.
|
||||
func opening(n *aetext.Node, f aetext.Field) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Head)
|
||||
if n.Label != "" || n.Head == "frames" || n.Head == "track" {
|
||||
b.WriteString(" " + strconv.Quote(n.Label))
|
||||
}
|
||||
b.WriteString(" " + f.Name + "=" + aetext.FormatValue(f.Value) + " {")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// strict refuses names a block does not know, so a misspelt or misplaced setting is never dropped silently.
|
||||
func strict(n *aetext.Node, s shape) error {
|
||||
for _, f := range n.Attrs {
|
||||
switch {
|
||||
case slices.Contains(s.attrs, f.Name):
|
||||
case slices.Contains(s.fields, f.Name):
|
||||
return textErr(n, f.Name+" is a field of "+n.Head+": write "+f.Name+"="+aetext.FormatValue(f.Value)+" inside the block")
|
||||
default:
|
||||
return textErr(n, "unknown attribute "+f.Name+" of "+n.Head+"; it takes "+strings.Join(s.attrs, ", "))
|
||||
}
|
||||
}
|
||||
for _, f := range n.Fields {
|
||||
switch {
|
||||
case slices.Contains(s.fields, f.Name):
|
||||
case slices.Contains(s.attrs, f.Name):
|
||||
return textErr(n, f.Name+" is an attribute of "+n.Head+": write `"+opening(n, f)+"`")
|
||||
case len(s.fields) == 0:
|
||||
return textErr(n, "unknown field "+f.Name+": "+n.Head+" takes no fields")
|
||||
default:
|
||||
return textErr(n, "unknown field "+f.Name+" of "+n.Head+"; it takes "+strings.Join(s.fields, ", "))
|
||||
}
|
||||
}
|
||||
for _, b := range n.Blocks {
|
||||
if !slices.Contains(s.blocks, b.Head) {
|
||||
if len(s.blocks) == 0 {
|
||||
return textErr(b, n.Head+" holds no blocks, found "+b.Head)
|
||||
}
|
||||
return textErr(b, "unknown block "+b.Head+" in "+n.Head+"; it holds "+strings.Join(s.blocks, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
func decodeText(t *testing.T, src string, ctrl bool) error {
|
||||
t.Helper()
|
||||
n, err := aetext.Parse([]byte(src), "t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctrl {
|
||||
_, _, err = DecodeGraph(n)
|
||||
} else {
|
||||
_, err = Decode(n)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Every block of a clip refuses names it does not know instead of dropping them.
|
||||
func TestClipRefusesUnknownNames(t *testing.T) {
|
||||
cases := []struct{ name, src, want string }{
|
||||
{"root", `anim version=1 length=1 speed=2 {}`, "unknown attribute speed of anim"},
|
||||
{"root block", "anim version=1 length=1 {\n curve \"\" {}\n}\n", "unknown block curve"},
|
||||
{"track", "anim version=1 length=1 {\n track \"\" prop=\"SpriteRenderer.size\" kind=vec2 loop=true {}\n}\n", "unknown attribute loop of track"},
|
||||
{"key", "anim version=1 length=1 {\n track \"\" prop=\"SpriteRenderer.size\" kind=vec2 {\n key t=0 v=[1, 1] ease=linear {}\n }\n}\n", "unknown attribute ease of key"},
|
||||
{"frames", "anim version=1 length=1 {\n frames \"Slash\" {\n sprites=[asset(\"ae900000000000000000000000000b01\")]\n start=0.08\n }\n}\n", "start is an attribute of frames: write `frames \"Slash\" start=0.08 {`"},
|
||||
{"frames field", "anim version=1 length=1 {\n frames \"\" {\n sprites=[asset(\"ae900000000000000000000000000b01\")]\n duration=[0.1]\n }\n}\n", "unknown field duration of frames"},
|
||||
{"event", "anim version=1 length=1 {\n event t=0.1 name=\"Hit\" when=1 {}\n}\n", "unknown attribute when of event"},
|
||||
{"event field", "anim version=1 length=1 {\n event t=0.1 name=\"Hit\" {\n payload=1\n }\n}\n", "payload is an attribute of event"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := decodeText(t, c.src, false)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: got %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every block of a controller refuses names it does not know instead of dropping them.
|
||||
func TestControllerRefusesUnknownNames(t *testing.T) {
|
||||
cases := []struct{ name, src, want string }{
|
||||
{"root", "controller version=1 start=\"Idle\" {\n state \"Idle\" {}\n}\n", "unknown attribute start of controller"},
|
||||
{"root field", "controller version=1 {\n ease=linear\n state \"Idle\" {}\n}\n", "ease is an attribute of controller"},
|
||||
{"param", "controller version=1 {\n param \"Speed\" kind=float min=0 {}\n}\n", "unknown attribute min of param"},
|
||||
{"state", "controller version=1 {\n state \"Idle\" clipp=asset(\"a0a10000000000000000000000000005\") {}\n}\n", "unknown attribute clipp of state"},
|
||||
{"transition", "controller version=1 {\n state \"A\" {}\n state \"B\" {}\n transition \"A\" to=\"B\" exit=1 {}\n}\n", "unknown attribute exit of transition"},
|
||||
{"cond", "controller version=1 {\n param \"Speed\" kind=float {}\n state \"A\" {}\n state \"B\" {}\n transition \"A\" to=\"B\" {\n cond \"Speed\" op=gt val=1 {}\n }\n}\n", "unknown attribute val of cond"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := decodeText(t, c.src, true)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: got %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
package anim
|
||||
|
||||
import (
|
||||
"aego/core/ids"
|
||||
"aego/format/aetext"
|
||||
"aego/schema"
|
||||
)
|
||||
|
||||
type Value struct {
|
||||
L [4]float32
|
||||
I int64
|
||||
A ids.AssetID
|
||||
}
|
||||
|
||||
func Float(f float32) Value { return Value{L: [4]float32{f}} }
|
||||
func Lanes(l [4]float32) Value { return Value{L: l} }
|
||||
func Int(i int64) Value { return Value{I: i} }
|
||||
func Asset(id ids.AssetID) Value { return Value{A: id} }
|
||||
func (v Value) Truth() bool { return v.I != 0 }
|
||||
|
||||
func Bool(b bool) Value {
|
||||
if b {
|
||||
return Value{I: 1}
|
||||
}
|
||||
return Value{}
|
||||
}
|
||||
|
||||
type class uint8
|
||||
|
||||
const (
|
||||
classNone class = iota
|
||||
classLanes
|
||||
classInt
|
||||
classDiscrete
|
||||
classQuat
|
||||
classAsset
|
||||
)
|
||||
|
||||
func classOf(k schema.Kind) class {
|
||||
switch k {
|
||||
case schema.KindFloat32, schema.KindFloat64, schema.KindVec2, schema.KindVec3, schema.KindVec4, schema.KindColor, schema.KindRect:
|
||||
return classLanes
|
||||
case schema.KindInt32, schema.KindInt64:
|
||||
return classInt
|
||||
case schema.KindBool, schema.KindEnum:
|
||||
return classDiscrete
|
||||
case schema.KindQuat:
|
||||
return classQuat
|
||||
case schema.KindAssetRef:
|
||||
return classAsset
|
||||
}
|
||||
return classNone
|
||||
}
|
||||
|
||||
func lanesOf(k schema.Kind) int {
|
||||
switch k {
|
||||
case schema.KindFloat32, schema.KindFloat64:
|
||||
return 1
|
||||
case schema.KindVec2:
|
||||
return 2
|
||||
case schema.KindVec3:
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
func Supported(k schema.Kind) bool {
|
||||
return classOf(k) != classNone
|
||||
}
|
||||
|
||||
func Interpolates(k schema.Kind) bool {
|
||||
c := classOf(k)
|
||||
return c == classLanes || c == classQuat
|
||||
}
|
||||
|
||||
// LaneCount is the number of float lanes of a lane kind, 0 for discrete kinds.
|
||||
func LaneCount(k schema.Kind) int {
|
||||
if classOf(k) != classLanes && classOf(k) != classQuat {
|
||||
return 0
|
||||
}
|
||||
return lanesOf(k)
|
||||
}
|
||||
|
||||
// ValueText writes v the way a key stores it in .aeanim.
|
||||
func ValueText(kind schema.Kind, v Value) aetext.Value {
|
||||
return writeValue(kind, v)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tree(depth, fan int) NodeView {
|
||||
v := NodeView{Name: "n"}
|
||||
if depth > 0 {
|
||||
for i := 0; i < fan; i++ {
|
||||
v.Children = append(v.Children, tree(depth-1, fan))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestCapTreeKeepsTheFirstNodes(t *testing.T) {
|
||||
v := tree(4, 4)
|
||||
all := nodeCount(&v)
|
||||
left := capTree(&v, 50, 0)
|
||||
if kept := nodeCount(&v); kept != 50 || kept+left != all {
|
||||
t.Fatalf("kept %d and left %d of %d", kept, left, all)
|
||||
}
|
||||
more := 0
|
||||
var walk func(n *NodeView)
|
||||
walk = func(n *NodeView) {
|
||||
more += n.More
|
||||
for i := range n.Children {
|
||||
walk(&n.Children[i])
|
||||
}
|
||||
}
|
||||
walk(&v)
|
||||
if more != left {
|
||||
t.Fatalf("the more counts add up to %d, %d were left out", more, left)
|
||||
}
|
||||
v = tree(4, 2)
|
||||
if left := capTree(&v, 0, 2); nodeCount(&v) != 7 || left != 31-7 {
|
||||
t.Fatalf("depth 2 kept %d nodes", nodeCount(&v))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptWindow(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 1000; i++ {
|
||||
b.WriteString("line " + strconv.Itoa(i) + "\n")
|
||||
}
|
||||
text := b.String()
|
||||
if got, note := window(text, 0, 0, 2000); got != text || note != "" {
|
||||
t.Fatal("a short script is not read whole")
|
||||
}
|
||||
got, note := window(text, 0, 0, 400)
|
||||
if !strings.HasPrefix(got, "line 1\n") || !strings.HasSuffix(got, "line 400\n") || !strings.Contains(note, "1-400 of 1001") {
|
||||
t.Fatalf("the first window is wrong: %q", note)
|
||||
}
|
||||
got, _ = window(text, 600, 620, 400)
|
||||
if !strings.HasPrefix(got, "line 600\n") || !strings.HasSuffix(got, "line 620\n") {
|
||||
t.Fatalf("the asked range is wrong: %q", got[:20])
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -47,6 +48,12 @@ func Main(p *project.Project, args []string, stdout, stderr io.Writer, opt Optio
|
||||
fmt.Fprintln(stderr, "usage: aego "+c.Usage())
|
||||
return 2
|
||||
}
|
||||
if addr, token, ok := EditorProxy(p.Root); ok {
|
||||
out, err := proxyRun(addr, token, c, a)
|
||||
if !errors.As(err, new(errProxyDown)) {
|
||||
return report(stdout, stderr, f, out, err)
|
||||
}
|
||||
}
|
||||
s, err := Open(p, opt)
|
||||
if err != nil {
|
||||
return report(stdout, stderr, f, nil, err)
|
||||
|
||||
+33
-3
@@ -25,7 +25,11 @@ type Command struct {
|
||||
Doc string
|
||||
Args []Arg
|
||||
Write bool
|
||||
Run func(s *Session, a Args) (*Output, error)
|
||||
// Destroys marks commands that remove or move things; the editor never applies them without asking (D342).
|
||||
Destroys bool
|
||||
// Look is how the editor's Agent panel shows a call; every command has one.
|
||||
Look Look
|
||||
Run func(s *Session, a Args) (*Output, error)
|
||||
}
|
||||
|
||||
type Args map[string]any
|
||||
@@ -40,6 +44,14 @@ type Output struct {
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Text string `json:"-"`
|
||||
// Images go to the model as image content, such as the screenshots of a run.
|
||||
Images []Image `json:"-"`
|
||||
}
|
||||
|
||||
// Image is a picture an answer carries.
|
||||
type Image struct {
|
||||
Mime string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
var dryRun = Arg{Name: "dry-run", Type: "boolean", Doc: "report the changes without writing anything"}
|
||||
@@ -51,9 +63,17 @@ func catalog() []*Command {
|
||||
out = append(out, sceneCommands()...)
|
||||
out = append(out, prefabCommands()...)
|
||||
out = append(out, dataCommands()...)
|
||||
out = append(out, animCommands()...)
|
||||
out = append(out, ctrlCommands()...)
|
||||
out = append(out, refsCommands()...)
|
||||
out = append(out, assetCommands()...)
|
||||
out = append(out, schemaCommands()...)
|
||||
out = append(out, projectCommands()...)
|
||||
out = append(out, scriptCommands()...)
|
||||
ls := looks()
|
||||
for _, c := range out {
|
||||
c.Look = ls[c.Name]
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
@@ -254,9 +274,19 @@ func (o *Output) Print(w io.Writer) {
|
||||
}
|
||||
|
||||
func Markdown(w io.Writer) {
|
||||
fmt.Fprintln(w, "| Command | Meaning |")
|
||||
MarkdownIn(w, "Command", "Meaning", func(c *Command) string { return c.Doc })
|
||||
}
|
||||
|
||||
// MarkdownIn is the command table of the CLI reference with its headings and descriptions in another language.
|
||||
func MarkdownIn(w io.Writer, command, meaning string, doc func(c *Command) string) {
|
||||
fmt.Fprintln(w, "| "+command+" | "+meaning+" |")
|
||||
fmt.Fprintln(w, "|---|---|")
|
||||
for _, c := range all {
|
||||
fmt.Fprintf(w, "| `aego %s` | %s |\n", strings.ReplaceAll(c.Usage(), "|", `\|`), c.Doc)
|
||||
fmt.Fprintf(w, "| `aego %s` | %s |\n", strings.ReplaceAll(c.Usage(), "|", `\|`), doc(c))
|
||||
}
|
||||
}
|
||||
|
||||
// DocKey is the catalog key of a command's description, for the translated CLI reference.
|
||||
func DocKey(c *Command) string {
|
||||
return "cli.agent." + c.Name
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxBody = 8 << 20
|
||||
|
||||
// HTTP serves MCP as JSON-RPC over POST on loopback, for an agent the editor runs (D341).
|
||||
type HTTP struct {
|
||||
URL string
|
||||
Token string
|
||||
srv *http.Server
|
||||
s *Server
|
||||
}
|
||||
|
||||
// ListenHTTP serves on 127.0.0.1 at a free port with a fresh bearer token.
|
||||
func ListenHTTP(opt Options) (*HTTP, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var key [32]byte
|
||||
if _, err := rand.Read(key[:]); err != nil {
|
||||
ln.Close()
|
||||
return nil, err
|
||||
}
|
||||
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", Token: hex.EncodeToString(key[:]), s: newServer(opt)}
|
||||
h.s.ready.Store(true)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/mcp", h.serve)
|
||||
h.srv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||
go h.srv.Serve(ln)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *HTTP) authorized(r *http.Request) bool {
|
||||
got := r.Header.Get("Authorization")
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token)) == 1
|
||||
}
|
||||
|
||||
// local refuses a request whose Host is not loopback, so a web page cannot reach the server by DNS rebinding.
|
||||
func local(r *http.Request) bool {
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host
|
||||
}
|
||||
return host == "127.0.0.1" || host == "localhost"
|
||||
}
|
||||
|
||||
func (h *HTTP) serve(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case !local(r):
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
case !h.authorized(r):
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
case r.Method != http.MethodPost:
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rep, ok := h.s.respond(raw)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.Encode(rep)
|
||||
}
|
||||
|
||||
func (h *HTTP) Close() error {
|
||||
return h.srv.Close()
|
||||
}
|
||||
|
||||
// Definitions is what the model reads about this server before the first message.
|
||||
func (h *HTTP) Definitions() string {
|
||||
return h.s.Definitions()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"aego/app/agent"
|
||||
)
|
||||
|
||||
// Relay passes an outside agent's MCP session to the open editor, so its changes become the editor's Undo steps, named after the client.
|
||||
func Relay(in io.Reader, out io.Writer, addr, token string) error {
|
||||
client := "agent"
|
||||
sc := bufio.NewScanner(in)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), maxBody)
|
||||
for sc.Scan() {
|
||||
line := sc.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
params, _ := msg["params"].(map[string]any)
|
||||
switch msg["method"] {
|
||||
case "initialize":
|
||||
if info, ok := params["clientInfo"].(map[string]any); ok {
|
||||
if name, ok := info["name"].(string); ok && name != "" {
|
||||
client = name
|
||||
}
|
||||
}
|
||||
case "tools/call":
|
||||
if params != nil {
|
||||
args, _ := params["arguments"].(map[string]any)
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
args[agent.ViaArg] = "via agent: " + client
|
||||
params["arguments"] = args
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(msg)
|
||||
rep, err := agent.ProxyPost(addr, token, body)
|
||||
if err != nil {
|
||||
id, ok := msg["id"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rep, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32000, "message": "the editor closed; start the MCP server again: " + err.Error()}})
|
||||
}
|
||||
if len(rep) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := out.Write(append(rep, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
+94
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"aego/app/agent"
|
||||
"aego/project"
|
||||
)
|
||||
|
||||
// Source runs the tool catalog: the CLI opens a session per call, the editor runs on its own documents (D341).
|
||||
type Source interface {
|
||||
Run(name string, a agent.Args) (*agent.Output, error)
|
||||
Paths() ([]string, error)
|
||||
Project() *project.Project
|
||||
Rel(p string) (string, error)
|
||||
}
|
||||
|
||||
// Extra is a resource a Source adds to the list, such as the editor's selection.
|
||||
type Extra struct {
|
||||
URI string
|
||||
Name string
|
||||
Description string
|
||||
MimeType string
|
||||
Read func() (string, error)
|
||||
}
|
||||
|
||||
// Tool is a tool the host adds next to the agent commands, such as the editor's ask_user.
|
||||
type Tool struct {
|
||||
Name string
|
||||
Description string
|
||||
Schema map[string]any
|
||||
ReadOnly bool
|
||||
// Run answers the call; it may block while the user decides.
|
||||
Run func(args map[string]any) (text string, failed bool)
|
||||
}
|
||||
|
||||
type kitSource struct {
|
||||
kit *agent.Kit
|
||||
}
|
||||
|
||||
func (k kitSource) Run(name string, a agent.Args) (*agent.Output, error) {
|
||||
sess, err := k.kit.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sess.Close()
|
||||
return agent.Run(sess, name, a)
|
||||
}
|
||||
|
||||
func (k kitSource) Paths() ([]string, error) {
|
||||
sess, err := k.kit.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sess.Close()
|
||||
var out []string
|
||||
for _, a := range sess.Pipeline.Assets() {
|
||||
out = append(out, a.Path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k kitSource) Project() *project.Project {
|
||||
return k.kit.Project
|
||||
}
|
||||
|
||||
func (k kitSource) Rel(p string) (string, error) {
|
||||
return k.kit.Rel(p)
|
||||
}
|
||||
+49
-4
@@ -22,8 +22,8 @@ func prefabCommands() []*Command {
|
||||
{Name: "parent", Type: "string", Doc: "parent node, the root by default"}, {Name: "name", Type: "string", Doc: "name of the instance"}, argIndex}, Run: (*Session).prefabInstantiate},
|
||||
{Name: "prefab.apply", Doc: "write instance overrides into the prefab file", Write: true,
|
||||
Args: []Arg{argFile, argNode, level, owner, field, {Name: "nearest", Type: "boolean", Doc: "apply to the nearest nested prefab"}}, Run: (*Session).prefabApply},
|
||||
{Name: "prefab.revert", Doc: "drop instance overrides", Write: true, Args: []Arg{argFile, argNode, level, owner, field}, Run: (*Session).prefabRevert},
|
||||
{Name: "prefab.unpack", Doc: "turn an instance into plain nodes", Write: true,
|
||||
{Name: "prefab.revert", Doc: "drop instance overrides", Write: true, Destroys: true, Args: []Arg{argFile, argNode, level, owner, field}, Run: (*Session).prefabRevert},
|
||||
{Name: "prefab.unpack", Doc: "turn an instance into plain nodes", Write: true, Destroys: true,
|
||||
Args: []Arg{argFile, argNode, {Name: "deep", Type: "boolean", Doc: "unpack nested prefabs too"}}, Run: func(s *Session, a Args) (*Output, error) {
|
||||
return s.sceneTx(a, "Unpack Prefab", mutate.Unpack(a.Str("node"), a.Bool("deep")))
|
||||
}},
|
||||
@@ -44,7 +44,10 @@ func levelOf(a Args, def string) (prefab.Level, error) {
|
||||
}
|
||||
|
||||
func (s *Session) prefabCreate(a Args) (*Output, error) {
|
||||
rel, before, doc, err := s.scene(a)
|
||||
if out, ok, err := s.prefabCreateOpen(a); ok {
|
||||
return out, err
|
||||
}
|
||||
rel, before, doc, err := s.sceneFile(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,6 +84,48 @@ func (s *Session) prefabCreate(a Args) (*Output, error) {
|
||||
return &Output{Changes: res.Changes, Problems: res.Problems, Created: []string{id.String()}, Files: append([]string{out}, files...)}, err
|
||||
}
|
||||
|
||||
// prefabCreateOpen makes the prefab through the editor when the scene is open there, so unsaved edits count; false when there is no such editor or scene.
|
||||
func (s *Session) prefabCreateOpen(a Args) (*Output, bool, error) {
|
||||
ph, ok := s.host.(PrefabHost)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
rel, err := s.rel(a.Str("file"))
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
doc, open := s.host.OpenScene(rel)
|
||||
if !open {
|
||||
return nil, false, nil
|
||||
}
|
||||
out, err := s.rel(a.Str("out"))
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if path.Ext(out) != PrefabExt {
|
||||
out += PrefabExt
|
||||
}
|
||||
if s.exists(out) {
|
||||
return nil, true, errors.New(out + " already exists")
|
||||
}
|
||||
h, err := doc.Node(a.Str("node"))
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if err := refactor.CheckCreate(doc.Scene, h); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if a.Bool("dry-run") {
|
||||
return &Output{DryRun: true, Notes: []string{"would create " + out, "would link the node in " + rel + " as an Undo step"}}, true, nil
|
||||
}
|
||||
id, err := ph.CreatePrefab(rel, a.Str("node"), out)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
s.reload()
|
||||
return &Output{OK: true, Created: []string{id.String()}, Files: []string{out}}, true, nil
|
||||
}
|
||||
|
||||
func (s *Session) prefabInstantiate(a Args) (*Output, error) {
|
||||
op := mutate.Instantiate(a.Str("parent"), a.Str("prefab"))
|
||||
op.Name = a.Str("name")
|
||||
@@ -95,7 +140,7 @@ func (s *Session) prefabInstantiate(a Args) (*Output, error) {
|
||||
}
|
||||
|
||||
func (s *Session) prefabApply(a Args) (*Output, error) {
|
||||
rel, before, doc, err := s.scene(a)
|
||||
rel, before, doc, err := s.sceneFile(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -45,15 +45,15 @@ func refsCommands() []*Command {
|
||||
return []*Command{
|
||||
{Name: "refs.find", Doc: "files and fields that reference an asset", Args: []Arg{asset}, Run: (*Session).refsFind},
|
||||
{Name: "refs.missing", Doc: "references to assets and nodes that do not exist", Run: (*Session).refsMissing},
|
||||
{Name: "refs.fix", Doc: "point every reference to one asset at another (none clears them); originals go to .aego/recovery", Write: true,
|
||||
{Name: "refs.fix", Doc: "point every reference to one asset at another (none clears them); originals go to .aego/recovery", Write: true, Destroys: true,
|
||||
Args: []Arg{{Name: "from", Type: "string", Doc: "asset id or path being replaced", Required: true, Pos: true},
|
||||
{Name: "to", Type: "string", Doc: "replacement id or path, empty clears", Pos: true}}, Run: (*Session).refsFix},
|
||||
{Name: "asset.list", Doc: "assets with their ids and kinds", Args: []Arg{{Name: "kind", Type: "string", Doc: "only this kind: scene, prefab, texture, data, table, script…"}}, Run: (*Session).assetList},
|
||||
{Name: "asset.move", Doc: "move an asset with its .meta; the id and every reference stay", Write: true,
|
||||
{Name: "asset.move", Doc: "move an asset with its .meta; the id and every reference stay", Write: true, Destroys: true,
|
||||
Args: []Arg{asset, {Name: "to", Type: "string", Doc: "new path", Required: true, Pos: true}}, Run: (*Session).assetMove},
|
||||
{Name: "asset.rename", Doc: "rename an asset in its folder", Write: true,
|
||||
{Name: "asset.rename", Doc: "rename an asset in its folder", Write: true, Destroys: true,
|
||||
Args: []Arg{asset, {Name: "name", Type: "string", Doc: "new name without the extension", Required: true, Pos: true}}, Run: (*Session).assetRename},
|
||||
{Name: "asset.delete", Doc: "delete an asset; refuses while it is referenced unless --force", Write: true,
|
||||
{Name: "asset.delete", Doc: "delete an asset; refuses while it is referenced unless --force", Write: true, Destroys: true,
|
||||
Args: []Arg{asset, {Name: "force", Type: "boolean", Doc: "delete even when referenced; the references become missing"}}, Run: (*Session).assetDelete},
|
||||
}
|
||||
}
|
||||
|
||||
+274
-26
File diff suppressed because it is too large
Load Diff
+82
-6
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+162
-8
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
// Package backend runs a model for the Agent panel behind one interface: the Claude Code CLI, or a scripted fake for tests (D341).
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
type EventKind uint8
|
||||
|
||||
const (
|
||||
EvText EventKind = iota
|
||||
EvToolStart
|
||||
EvToolResult
|
||||
EvTurnEnd
|
||||
EvError
|
||||
// EvUsage reports Tokens, the context the model read for its last answer, and Model.
|
||||
EvUsage
|
||||
// EvCompact reports that the CLI compacted the history; Tokens is the context before it.
|
||||
EvCompact
|
||||
)
|
||||
|
||||
// ToolPrefix is how Claude Code names the tools of the aego MCP server.
|
||||
const ToolPrefix = "mcp__aego__"
|
||||
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
Text string
|
||||
Tool string
|
||||
CallID string
|
||||
Args json.RawMessage
|
||||
Result string
|
||||
Failed bool
|
||||
Err error
|
||||
// Cost in US dollars and Millis of the answer, on EvTurnEnd.
|
||||
Cost float64
|
||||
Millis int
|
||||
// Tokens on EvUsage and EvCompact; Window is the model's context size, on EvTurnEnd when the CLI reports it.
|
||||
Tokens int
|
||||
Window int
|
||||
Model string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Dir string
|
||||
Command string
|
||||
MCPURL string
|
||||
Token string
|
||||
System string
|
||||
// Record gets every line the CLI writes, for recording sessions the tests replay.
|
||||
Record io.Writer
|
||||
}
|
||||
|
||||
type Conversation interface {
|
||||
Send(text string) error
|
||||
Events() <-chan Event
|
||||
Cancel()
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Backend interface {
|
||||
Name() string
|
||||
Available(cfg Config) (string, error)
|
||||
Start(cfg Config) (Conversation, error)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client calls tools of the editor's MCP server, for the fake backend and tests.
|
||||
type Client struct {
|
||||
URL string
|
||||
Token string
|
||||
next atomic.Int64
|
||||
http http.Client
|
||||
}
|
||||
|
||||
func NewClient(url, token string) *Client {
|
||||
return &Client{URL: url, Token: token, http: http.Client{Timeout: 15 * time.Minute}}
|
||||
}
|
||||
|
||||
func (c *Client) Call(tool string, args map[string]any) (string, bool, error) {
|
||||
raw, err := c.CallRaw(tool, args)
|
||||
if err != nil {
|
||||
var rpc rpcErr
|
||||
if errors.As(err, &rpc) {
|
||||
return string(rpc), true, nil
|
||||
}
|
||||
return "", true, err
|
||||
}
|
||||
var res struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, x := range res.Content {
|
||||
b.WriteString(x.Text)
|
||||
}
|
||||
return b.String(), res.IsError, nil
|
||||
}
|
||||
|
||||
// rpcErr is an error the server answered instead of a result.
|
||||
type rpcErr string
|
||||
|
||||
func (e rpcErr) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
// CallRaw is the result of a tool call as the server sent it, images included.
|
||||
func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0", "id": c.next.Add(1), "method": "tools/call",
|
||||
"params": map[string]any{"name": tool, "arguments": args},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("mcp: %s", resp.Status)
|
||||
}
|
||||
var rep struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rep.Error != nil {
|
||||
return nil, rpcErr(rep.Error.Message)
|
||||
}
|
||||
return rep.Result, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package backend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeStreamLines(t *testing.T) {
|
||||
text := Decode([]byte(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"},{"type":"tool_use","id":"t1","name":"mcp__aego__scene_tree","input":{"file":"a"}}]}}`))
|
||||
if len(text) != 2 || text[0].Text != "hi" || text[1].Tool != "scene_tree" || text[1].CallID != "t1" {
|
||||
t.Fatalf("assistant line %+v", text)
|
||||
}
|
||||
res := Decode([]byte(`{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"ok"}]}]}}`))
|
||||
if len(res) != 1 || res[0].Result != "ok" || res[0].CallID != "t1" {
|
||||
t.Fatalf("tool result %+v", res)
|
||||
}
|
||||
end := Decode([]byte(`{"type":"result","is_error":true,"result":"boom"}`))
|
||||
if len(end) != 2 || end[0].Kind != EvError || end[1].Kind != EvTurnEnd {
|
||||
t.Fatalf("result line %+v", end)
|
||||
}
|
||||
if Decode([]byte(`not json`)) != nil {
|
||||
t.Fatal("garbage gave events")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user