plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


53de7e3 — Peter John 2026-11-03T21:43:41+05:30
Pure scene graph on enums: Node record + Kind enum, Custom(actor: Actor)
docs/plans/2026-11-03-pure-scene-enums.incidents.jsonl ADDED
@@ -0,0 +1,55 @@
1
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; cargo test -p plum-checker 21 | grep -E FAILED|failed; | grep -v 0"}
2
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; cargo build --quiet 21|tail -3; ./target/debug/plum test .superlite/re"}
3
+ {"file": "-", "line": 0, "context": "exit code 1: For more information, try '--help'.", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "20:43:44"}
4
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; cargo test -p plum-checker --test ownership_trait_args_test 21 | tail "}
5
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; D=/private/tmp/claude-501/-Users-pyrossh-Code-plum/7471cdc3-437e-41b8-9"}
6
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; D=/private/tmp/claude-501/-Users-pyrossh-Code-plum/7471cdc3-437e-41b8-9"}
7
+ {"file": "plum-std/game/SceneTree.plum", "line": 242, "context": "T2: bare variant-name pattern (MoveBy =>) on an inline-variant enum with a payload compiles but never matches; should match or be a compile error", "agent": "coordinator", "time": "20:44:46"}
8
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; D=/private/tmp/claude-501/-Users-pyrossh-Code-plum/7471cdc3-437e-41b8-9"}
9
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; D=/private/tmp/claude-501/-Users-pyrossh-Code-plum/7471cdc3-437e-41b8-9"}
10
+ {"kind":"rule-block","agent":"coordinator","tool":"Bash","target":"> MEMORY.md"}
11
+ {"file": "-", "line": 0, "context": "exit code 1: For more information, try '--help'.", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "20:51:19"}
12
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; cargo test -p plum-checker --test ownership_trait_args_test 21 | tail "}
13
+ {"file": "", "line": 0, "context": "verified: seam T1↔T3", "agent": "pt_review", "kind": "verified", "time": "20:56:59"}
14
+ {"file": "plum-core/", "line": 0, "context": "T3: change outside Owns blocked by checkpoint; nothing saved: 1 files under plum-core/, first 1: plum-core/src/union_lower.rs", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "20:57:29"}
15
+ {"file": "plum-core/", "line": 0, "context": "T3: change outside Owns blocked by checkpoint; nothing saved: 2 files under plum-core/, first 2: plum-core/src/union_lower.rs, plum-core/tests/forwarder_own_test.rs", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "20:58:28"}
16
+ {"file": "plum-core/", "line": 0, "context": "T4: change outside Owns blocked by checkpoint; nothing saved: 2 files under plum-core/, first 2: plum-core/src/union_lower.rs, plum-core/tests/forwarder_own_test.rs", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "20:58:28"}
17
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; cargo test -p plum-checker -p plum-core 21 | grep -E ^test result|FAI"}
18
+ {"file": "docs/", "line": 0, "context": "T1: change outside Owns blocked by checkpoint; nothing saved: 1 files under docs/, first 1: docs/plans/2026-11-03-state-view-parity.review.md", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:06:32"}
19
+ {"file": "plum-core/", "line": 0, "context": "T1: change outside Owns blocked by checkpoint; nothing saved: 2 files under plum-core/, first 2: plum-core/src/union_lower.rs, plum-core/tests/forwarder_own_test.rs", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:06:32"}
20
+ {"file": "docs/", "line": 0, "context": "T1: change outside Owns blocked by checkpoint; nothing saved: 1 files under docs/, first 1: docs/plans/2026-11-03-state-view-parity.review.md", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:06:35"}
21
+ {"file": "plum-core/", "line": 0, "context": "T1: change outside Owns blocked by checkpoint; nothing saved: 2 files under plum-core/, first 2: plum-core/src/union_lower.rs, plum-core/tests/forwarder_own_test.rs", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:06:35"}
22
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; sed -i 's/ cx.tys.insert(n, t);/ "}
23
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; sed -i '588s/cx.tys.insert(n, t);/let _ = (n, t);/' plum-checker/src/ow"}
24
+ {"file": "docs/", "line": 0, "context": "T1: change outside Owns blocked by checkpoint; nothing saved: 1 files under docs/, first 1: docs/plans/2026-11-03-state-view-parity.review.md", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:19:58"}
25
+ {"file": "docs/", "line": 0, "context": "T8: change outside Owns blocked by checkpoint; nothing saved: 1 files under docs/, first 1: docs/plans/2026-11-03-state-view-parity.review.md", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:19:58"}
26
+ {"file": "docs/", "line": 0, "context": "T3: change outside Owns blocked by checkpoint; nothing saved: 1 files under docs/, first 1: docs/plans/2026-11-03-state-view-parity.review.md", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:19:58"}
27
+ {"file": "docs/", "line": 0, "context": "T4: change outside Owns blocked by checkpoint; nothing saved: 1 files under docs/, first 1: docs/plans/2026-11-03-state-view-parity.review.md", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "21:19:58"}
28
+ {"file": "", "line": 0, "context": "verified: seam T1↔T3", "agent": "pt_review", "kind": "verified", "time": "21:20:05"}
29
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.childrenOf: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
30
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.custom: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
31
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.drawTree: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
32
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.localMatrix: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
33
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.group: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
34
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.sprite: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
35
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.label: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
36
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.button: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
37
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.custom: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
38
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 128, "context": "seam T3↔T4: T3 Produces: PureScene.hit: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:20:05"}
39
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 152, "context": "seam T4↔T6: T4 Produces: SceneTree.findHit: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:21:40"}
40
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 152, "context": "seam T4↔T6: T4 Produces: SceneTree.idAt: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:21:40"}
41
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 152, "context": "seam T4↔T6: T4 Produces: SceneTree.findPath: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:21:40"}
42
+ {"file": ".superlite/repros/empty_record_embed.plum", "line": 6, "context": "field-less record embedding trait: Panel() -> codegen unknown class 'Panel' (Panel__new). Workaround: dummy field", "agent": "T5", "kind": "workaround", "resolution": "dummy field in Panel", "confidence": "high", "time": "21:22:09"}
43
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 152, "context": "seam T4↔T5: T4 Produces: SceneTree.findHit: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:22:46"}
44
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 152, "context": "seam T4↔T5: T4 Produces: SceneTree.idAt: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:22:46"}
45
+ {"file": "docs/plans/2026-11-03-pure-scene-enums.md", "line": 152, "context": "seam T4↔T5: T4 Produces: SceneTree.findPath: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "21:22:46"}
46
+ {"file": "-", "line": 0, "context": "generic_funstar_wired: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "21:29:07"}
47
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum/plum-checker/tests; sed -i '' 's# /../.superlite/repros/g3_generic_funst"}
48
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; grep -n fixtures plum-checker/tests/*.rs | head; cargo test -q -p plu"}
49
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; touch plum-checker/src/lib.rs; cargo build --quiet 21 | tail -5; cargo"}
50
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum; cargo build -q -p plum-cli 21 | tail -5; ./target/debug/plum test plum"}
51
+ {"file": "plum-checker/src/ownership.rs", "line": 1, "context": "residual after F-FR-3: an alias returned from a call (c := first(n)) into an own value is not tracked as a view; using c after moving n is not rejected; needs lifetime inference (candidate for the su…", "agent": "coordinator", "time": "21:36:21"}
52
+ {"file": "-", "line": 0, "context": "filter_form_no_closure: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "21:41:05"}
53
+ {"file": "-", "line": 0, "context": "one_copy_per_distinct_type: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "21:41:05"}
54
+ {"kind":"rule-block","agent":"coordinator","tool":"Bash","target":"rm target/master-check"}
55
+ {"kind":"rule-block","agent":"coordinator","tool":"Bash","target":"rm /Users/pyrossh/Code/plum/target/master-check"}
docs/plans/2026-11-03-pure-scene-enums.md ADDED
@@ -0,0 +1,277 @@
1
+ # Pure scene graph on enums
2
+
3
+ **Review plan:** docs/plans/2026-11-03-pure-scene-enums.review.md
4
+ **Goal:** move std/game PureScene, SceneTree, SceneInput and Actions, plus `game_pure_scene.plum` and its stress fixture, from a 14-field `own trait Actor` and record unions to `own record Node` + `enum Kind`, with `Custom(actor: Actor)` (behaviour-only trait, like Rust's `Box<dyn Actor>`) for game kinds; fix the ownership gap the probe found.
5
+ **Architecture:** T1 checker ownership fix (borrowed arg to a trait method's borrowed param). T2 Actions: `record Action(elapsed, current, kind: ActionKind)`. T3 PureScene: Node/Kind/Actor, builders, draw, hit, Button input. T4 SceneTree + SceneInput over Node. T5 game, T6 stress fixture, T7 README + host checks + headed benchmark.
6
+ **Tech stack:** Rust workspace (plum-checker), Plum std (plum-std/game), plum-game host tests, playwright-rs web-test.
7
+ **Spec:** docs/plans/2026-11-03-pure-scene-enums.review.md
8
+ **Detail:** contracts
9
+ **Target-min:** 25
10
+ **Budget-usd:** 40
11
+
12
+ Global constraints:
13
+ - Language: Plum (std/examples) and Rust (checker). Fast Plum tests: `cargo build --quiet && ./target/debug/plum test <file.plum>` from the repo root; `touch` edited .rs files before building (cargo can skip recompiles). Rust: `cargo test -p plum-checker --test <file>`.
14
+ - Baselines: `./target/debug/plum test plum-std plum-examples` 632 passed; host: `cargo test --manifest-path plum-game/Cargo.toml --test host_test` 17 passed.
15
+ - Shapes (exact):
16
+ - `record Transform(x: Float, y: Float, width: Float, height: Float, originX: Float, originY: Float, scaleX: Float, scaleY: Float, rotation: Float)` with `fun localMatrix(self) -> Affine` and `fun contains(self, p: Vec2) -> Bool` (box test in local coords).
17
+ - `own record Node(Transform(x, y, width, height, originX, originY, scaleX, scaleY, rotation), id: Int, color: Color, visible: Bool, touchable: Touchable, actions: Slice[Action], kind: Kind)` (embedding: `n.x`, `n.localMatrix()` work directly).
18
+ - Child lists and test trees are built with variadic init, `Slice(a, b, c)` / `List(a, b)`, never `Slice[T]()` + push chains; there is no `[a, b]` literal.
19
+ - `enum Kind = | Group(children: Slice[Node]) | Sprite(tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) | Label(text: Str, size: Float) | Button(text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool) | Custom(actor: Actor)`
20
+ - `trait Actor =` no fields; `fun act(self, own n: Node, dt: Float) -> Node = n` and `fun draw(self, n: Node, m: Affine, tint: Color) = return` (defaults).
21
+ - `record Action(elapsed: Float, current: Action?, kind: ActionKind)`; `enum ActionKind = | MoveBy(dx: Float, dy: Float, duration: Float) | RotateBy(radians: Float, duration: Float) | Delay(duration: Float) | Sequence(steps: Slice[Action]) | Parallel(steps: Slice[Action]) | Forever(template: Action)`.
22
+ - Inline-variant fields can't be assigned: `n.kind = Button(...)` rebuilds a kind; Action keeps `elapsed`/`current` on the record.
23
+ - Plum: `test "name"` + `assert`; an assignment in a match arm and a nested `match` each go on their own line.
24
+ - Plum modules can't be stubbed: a module compiling against one still being rewritten may fail transiently; retry, don't edit it.
25
+ - Every existing test keeps its name and asserted values; local test helpers may change.
26
+ - Never `plum format` a real file (rewrites in place).
27
+ - Out of scope: std/game/Scene, game_scene.plum, scene.plum, trait_scene.plum, traits.plum, Picture, game_state_view; generic traits.
28
+ - A compiler gap hit while porting: `incident add`, report BLOCKED with a minimal repro in `.superlite/repros/`; never work around silently.
29
+
30
+ ## Main diagram
31
+ ```mermaid
32
+ flowchart LR
33
+ OW["T1 ownership.rs: trait-method borrowed args"] --> PS["T3 PureScene: Node, Kind, Actor"]
34
+ AC["T2 Actions: Action + ActionKind"] --> PS
35
+ PS --> ST["T4 SceneTree + SceneInput"]
36
+ ST --> G["T5 game_pure_scene.plum"]
37
+ ST --> F["T6 stress_pure_scene fixture"]
38
+ G --> D["T7 README, host checks, benchmark"]
39
+ F --> D
40
+ ```
41
+
42
+ ## File structure
43
+ Each task's `Owns` line is its file list; one responsibility per file as today.
44
+
45
+ Spec map: The new shape -> T2-T5; What you will get -> T3-T7; What will be built -> T1-T7; How it fits together -> main diagram; Decisions you can change -> defaults taken (box hit; Action record + enum); Risks -> T1 + incidents; How we will know it works -> task tests + T7; Rough time and cost -> schedule.
46
+
47
+ ## Task graph
48
+ ```mermaid
49
+ flowchart LR
50
+ T1[ownership fix]
51
+ T2[Actions]
52
+ T3[PureScene]
53
+ T4[SceneTree SceneInput]
54
+ T5[game]
55
+ T6[fixture]
56
+ T7[docs bench]
57
+ %% taskgraph
58
+ T1 --> T3
59
+ T3 --> T4
60
+ T4 --> T5
61
+ T4 --> T6
62
+ T5 --> T7
63
+ T6 --> T7
64
+ ```
65
+
66
+ Ruling: sequential - T3..T7 are Plum modules that import each other and Plum has no stubs, so each one's tests compile the previous module - cost if wrong: ~10 min.
67
+
68
+ ## Predicted schedule
69
+ Generated by `superlite-cli plan_tools lint`.
70
+
71
+ ## Checker
72
+
73
+ ### T1 ownership: borrowed argument to a trait method · Complexity 4 (O-1) · Est. context 40k
74
+ **Owns:** plum-checker/src/ownership.rs, plum-checker/tests/ownership_trait_args_test.rs
75
+ **Components:** 1
76
+ **LOC owned:** 90
77
+ **Tests:** fast
78
+ **Model:** sonnet
79
+ **Est. time:** 3 min
80
+ **Consumes:** none
81
+ **Produces:** ownership check accepts a borrowed `own` value passed to a borrowed param of a method called on a trait value
82
+ **Review Focus:** state: the fix must not let a borrowed value reach an `own` param or a stored field.
83
+ **Test code (named, exact):**
84
+ - `trait_method_borrowed_arg_ok`: the source of `.superlite/repros/custom_actor_probe2.plum` checks with no errors (`a.score(n)` where `score(self, n: Node)` and `n` is borrowed).
85
+ - `trait_method_own_arg_moves`: in the same program, a fn `fun g(own n: Node, a: Actor) -> Float = { m := a.act(n, 0.1); n.x }` reports `"n" used after it was moved`.
86
+ - `borrowed_to_own_trait_param_rejected`: `fun h(n: Node, a: Actor) -> Node = a.act(n, 0.1)` (borrowed `n` to `own` param) reports `cannot store borrowed "n"`.
87
+ - `plain_fn_borrowed_arg_still_ok`: an unchanged plain-fn borrowed call still checks clean.
88
+ **Steps:** 1 failing tests, 2 FAIL, 3 find why a trait-method call treats a borrowed param as a store (method param modes are not looked up through the trait / its embedders) and fix, 4 PASS, 5 `cargo test -p plum-checker` green.
89
+ **Notes:** the error comes from the "cannot store borrowed" path near ownership.rs:799-822; param modes for a call on a trait value should come from the trait's method declaration.
90
+
91
+ ### T8 trait forwarder keeps `own` (follow-up to T1, finding F-T1-1) · Complexity 3 (O-1) · Est. context 30k
92
+ **Owns:** plum-core/src/union_lower.rs, plum-core/tests/forwarder_own_test.rs
93
+ **Components:** 1
94
+ **LOC owned:** 40
95
+ **Tests:** fast
96
+ **Model:** sonnet
97
+ **Est. time:** 2 min
98
+ **Consumes:** none
99
+ **Produces:** synthesized trait forwarders copy `own` / `own self` from the trait method declaration
100
+ **Review Focus:** state: forwarder params carry the declared mode.
101
+ **Test code:** `forwarder_own_test.rs`: the lowered forwarder of `fun act(self, own n: Node, dt: Float) -> Node` has `n.isOwn == true`.
102
+ **Steps:** done by the F-T1-1 fixer; recorded here so its files have an owner.
103
+
104
+ ### T9 field-less record embedding a trait (compiler gap found by T5) · Complexity 4 (O-1) · Est. context 40k
105
+ **Owns:** plum-wasm-codegen/src/lib.rs, plum-core/src/embeds.rs, plum-core/src/ctors.rs, plum-wasm-codegen/tests/empty_record_embed_e2e_test.rs, plum-examples/game_pure_scene.plum
106
+ **Components:** 1
107
+ **LOC owned:** 60
108
+ **Tests:** fast
109
+ **Model:** sonnet
110
+ **Est. time:** 3 min
111
+ **Consumes:** none
112
+ **Produces:** `record Panel(Actor)` (no own fields, embeds a field-less trait) constructs with `Panel()` and dispatches its trait methods
113
+ **Review Focus:** state: zero-field struct type in wasm-gc; trait dispatch to it.
114
+ **Test code:** `empty_record_embed_e2e_test.rs`: `.superlite/repros/empty_record_embed.plum` runs and passes; a `List`/`Slice` of `Actor` mixing `Panel()` and a record with fields dispatches `draw` to each.
115
+ **Steps:** fix "codegen: unknown class or enum variant 'Panel' (in 'Panel__new')"; then remove the `tag: Int` workaround field from `Panel` in game_pure_scene.plum (tests unchanged).
116
+
117
+ ### T10 tracked test fixtures (final review F-FR-1) · Complexity 2 (O-1) · Est. context 25k
118
+ **Owns:** plum-checker/tests/fixtures/custom_actor_probe2.plum, plum-checker/tests/fixtures/g3_generic_funstar.plum, plum-checker/tests/optional_unpinned_tests.rs, plum-checker/tests/ownership_trait_args_test.rs
119
+ **Components:** 1
120
+ **LOC owned:** 40
121
+ **Tests:** fast
122
+ **Model:** sonnet
123
+ **Est. time:** 2 min
124
+ **Consumes:** none
125
+ **Produces:** checker tests no longer read git-ignored `.superlite/repros` files
126
+ **Review Focus:** file: fixtures tracked, paths relative to the test file.
127
+ **Test code:** `generic_funstar_wired` and the 7 ownership_trait_args tests pass from fixtures.
128
+ **Steps:** see .superlite/fixes/F-FR-1.md.
129
+
130
+ ### T11 bare pattern on a payload variant is an error (final review F-FR-2) · Complexity 4 (O-1) · Est. context 45k
131
+ **Owns:** plum-checker/src/lib.rs, plum-core/src/union_lower.rs, plum-checker/tests/variant_pattern_tests.rs
132
+ **Components:** 1
133
+ **LOC owned:** 60
134
+ **Tests:** fast
135
+ **Model:** sonnet
136
+ **Est. time:** 3 min
137
+ **Consumes:** none
138
+ **Produces:** `MoveBy =>` on a variant with fields is a positioned compile error; unit variants and union-enum members unchanged
139
+ **Review Focus:** deep: nested patterns; union-enum narrowing still works.
140
+ **Test code:** `bare_payload_variant_pattern_rejected`, `bare_unit_variant_pattern_ok`.
141
+ **Steps:** see .superlite/fixes/F-FR-2.md.
142
+
143
+ ### T12 ownership: projections after a move (final review F-FR-3) · Complexity 3 (O-1) · Est. context 35k
144
+ **Owns:** plum-checker/src/ownership.rs, plum-checker/tests/ownership_moves_test.rs
145
+ **Components:** 1
146
+ **LOC owned:** 40
147
+ **Tests:** fast
148
+ **Model:** sonnet
149
+ **Est. time:** 2 min
150
+ **Consumes:** none
151
+ **Produces:** negative tests prove no own projection survives a move; non-own reads before a move stay legal
152
+ **Review Focus:** state: soundness of moves.
153
+ **Test code:** `own_field_used_after_move_rejected`, `own_child_projection_after_move_rejected`, `non_own_value_read_before_move_ok`.
154
+ **Steps:** see .superlite/fixes/F-FR-3.md.
155
+
156
+ ## Std modules
157
+
158
+ **Probe entry:** run=./target/debug/plum test plum-std/game
159
+ Package: library/evaluator; categories: state, numeric, deep.
160
+
161
+ ### T2 Actions on Action + ActionKind · Complexity 5 (O-1) · Est. context 45k
162
+ **Owns:** plum-std/game/Actions.plum
163
+ **Components:** 1
164
+ **LOC owned:** 150
165
+ **Tests:** fast
166
+ **Model:** sonnet
167
+ **Est. time:** 4 min
168
+ **Consumes:** none
169
+ **Produces:** Actions.moveBy(dx: Float, dy: Float, secs: Float) -> Action
170
+ e.g. Actions.moveBy(10.0, 0.0, 1.0) -> Action(elapsed: 0.0, current: null, kind: MoveBy(dx: 10.0, dy: 0.0, duration: 1.0))
171
+ e.g. Actions.moveBy(0.0, 5.0, 0.0) -> Action(elapsed: 0.0, current: null, kind: MoveBy(dx: 0.0, dy: 5.0, duration: 0.0))
172
+ **Produces:** Actions.stepAll(p: Pose, acts: Slice[Action], dt: Float) -> Step
173
+ (rotateBy/delay/sequence/parallel/forever keep their current signatures)
174
+ e.g. Actions.stepAll(Pose(x: 0.0, y: 0.0, rotation: 0.0, scaleX: 1.0, scaleY: 1.0), [Actions.moveBy(10.0, 0.0, 1.0)], 0.5) -> Step(pose.x: 5.0, spare: 0.0, done: false)
175
+ e.g. same list stepped again with 0.6 -> pose.x 10.0 (cumulative), done: true, list now empty
176
+ **Review Focus:** numeric: zero-length actions finish at once (no 0/0); Forever with a zero-length template terminates; state: stepping stays in place (`step_all_no_alloc_steady_state` keeps passing).
177
+ **Test code:** all 13 existing tests keep names and values, including `actions_golden_values` (GoldRun rows unchanged in value; GoldRun may stay a record).
178
+ **Steps:** port types to the global-constraint shapes; `fresh` rebuilds an unstarted Action; Forever keeps the template in its kind and the running lap in `current`; run `plum test plum-std/game/Actions.plum`.
179
+
180
+ ### T3 PureScene on Node, Kind, Actor · Complexity 6 (O-1) · Est. context 50k
181
+ **Owns:** plum-std/game/PureScene.plum
182
+ **Components:** 1
183
+ **LOC owned:** 180
184
+ **Tests:** fast
185
+ **Model:** sonnet
186
+ **Est. time:** 5 min
187
+ **Consumes:** Actions.moveBy(dx: Float, dy: Float, secs: Float) -> Action (T2)
188
+ **Produces:** PureScene.childrenOf(n: Node) -> Slice[Node]?
189
+ e.g. PureScene.childrenOf(PureScene.group(1, 0.0, 0.0, 10.0, 10.0, Slice(sprite))) -> Slice(sprite)
190
+ e.g. PureScene.childrenOf(PureScene.label(2, "a", 0.0, 0.0, 12.0)) -> null
191
+ **Produces:** PureScene.custom(id: Int, x: Float, y: Float, w: Float, h: Float, actor: Actor) -> Node
192
+ e.g. PureScene.custom(7, 1.0, 2.0, 32.0, 32.0, ship) -> Node(id: 7, x: 1.0, y: 2.0, width: 32.0, height: 32.0, scale 1, rotation 0, color White, visible true, touchable Disabled, actions Slice[Action](), kind: Custom(actor: ship))
193
+ **Produces:** PureScene.drawTree(n: Node, parent: Affine, tint: Color)
194
+ e.g. PureScene.drawTree(custom node, Affine.Identity, Color.White) -> calls actor.draw(node, local matrix, White) once
195
+ **Produces:** PureScene.localMatrix(n: Node) -> Affine
196
+ **Produces:** PureScene.group(id: Int, x: Float, y: Float, w: Float, h: Float, children: Slice[Node]) -> Node · PureScene.sprite(id: Int, tex: Int, x: Float, y: Float, w: Float, h: Float) -> Node · PureScene.label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Node · PureScene.button(id: Int, text: Str, upTex: Int, downTex: Int, x: Float, y: Float, w: Float, h: Float) -> Node · PureScene.custom(id: Int, x: Float, y: Float, w: Float, h: Float, actor: Actor) -> Node
197
+ **Produces:** PureScene.hit(n: Node, p: Vec2) -> Bool · PureScene.onPointer(own n: Node, e: Pointer) -> Handled · `own record Handled(node: Node, events: Slice[UiEvent])` with `fun intoNode(own self) -> Node`
198
+ e.g. PureScene.hit(PureScene.sprite(1, 0, 0.0, 0.0, 10.0, 10.0) with touchable Enabled, Vec2(x: 5.0, y: 5.0)) -> true; Vec2(x: 11.0, y: 5.0) -> false
199
+ e.g. onPointer(button, Pointer(kind: Down, over: true)) -> events [UiEvent(id, Press)], node kind Button(pressed: true, over: true)
200
+ **Review Focus:** state: Button press/release/enter/exit/click sequence unchanged; deep: drawTree over nested groups.
201
+ **Test code:** existing 5 tests keep names and values; add `custom_actor_act_and_draw_dispatch`: a test record embedding `Actor` whose `act` adds 1.0 to `n.x`, a `custom` node at x 2.0, `act` via the node's kind -> x 3.0; `draw` reached once (counter field on the record).
202
+ **Steps:** Transform/Node/Kind/Actor per Global constraints (Node embeds Transform; localMatrix and the box hit move onto Transform); drawing matches on kind (Sprite/Label/Button paint, Group recurses, Custom calls `actor.draw`); `hit` is the old box test; onPointer handles Button by rebuilding its kind, everything else returns no events; run `plum test plum-std/game/PureScene.plum`.
203
+
204
+ ### T4 SceneTree and SceneInput over Node · Complexity 6 (O-1) · Est. context 55k
205
+ **Owns:** plum-std/game/SceneTree.plum, plum-std/game/SceneInput.plum
206
+ **Components:** 2
207
+ **LOC owned:** 170
208
+ **Tests:** fast
209
+ **Model:** sonnet
210
+ **Est. time:** 5 min
211
+ **Consumes:** PureScene.childrenOf(n: Node) -> Slice[Node]? (T3) · PureScene.onPointer(own n: Node, e: Pointer) -> Handled (T3) · Actions.stepAll(p: Pose, acts: Slice[Action], dt: Float) -> Step (T2)
212
+ **Produces:** SceneTree.update(own n: Node, dt: Float) -> Node
213
+ e.g. SceneTree.update(custom node x 1.0 whose actor adds 1.0 to n.x, 0.016) -> same node, x 2.0
214
+ e.g. SceneTree.update(group with child moveBy(10, 0, 1), 0.5) -> child x +5.0
215
+ **Produces:** SceneInput.stage(own root: Node) -> Stage
216
+ e.g. SceneInput.stage(root) -> Stage(root: root, camera: Affine.Identity, captured: null)
217
+ **Produces:** SceneTree.findHit(n: Node, parent: Affine, p: Vec2) -> Slice[Int]? · SceneTree.idAt(n: Node, path: Slice[Int], depth: Int) -> Int · SceneTree.findPath(n: Node, id: Int) -> Slice[Int]? · SceneTree.modify(own n: Node, id: Int, f: fn(Node) -> Node) -> Node · SceneTree.route(own n: Node, path: Slice[Int], depth: Int, e: Pointer) -> Handled
218
+ `own record Stage(root: Node, camera: Affine, captured: Capture?)` keeps input/frame/draw names and args.
219
+ **Review Focus:** state: in-place update (`update_changes_tree_in_place`, `input_changes_stage_in_place`); deep: routing through nested groups; numeric: rotated/scaled hit agrees with routing.
220
+ **Test code:** all 14 SceneTree and 10 SceneInput tests keep names and values.
221
+ **Steps:** update = `actor.act` for Custom, then actions on the pose, then Group children via `mapInPlace`; findHit/route match `Group(ks)`; run `plum test plum-std/game/SceneTree.plum plum-std/game/SceneInput.plum`.
222
+
223
+ ## Game and fixture
224
+
225
+ **Probe entry:** run=./target/debug/plum test plum-examples/game_pure_scene.plum
226
+ Package: data pipeline (game rules); categories: state, numeric.
227
+
228
+ ### T5 Orb Run on Node · Complexity 5 (O-1) · Est. context 55k
229
+ **Owns:** plum-examples/game_pure_scene.plum
230
+ **Components:** 1
231
+ **LOC owned:** 190
232
+ **Tests:** fast
233
+ **Model:** sonnet
234
+ **Est. time:** 5 min
235
+ **Consumes:** SceneTree.update(own n: Node, dt: Float) -> Node (T4) · SceneInput.stage(own root: Node) -> Stage (T4) · PureScene.custom(id: Int, x: Float, y: Float, w: Float, h: Float, actor: Actor) -> Node (T3)
236
+ **Produces:** none
237
+ **Review Focus:** state: orbs respawn, ship flashes on hit, restart button click; numeric: drift and wrap unchanged.
238
+ **Test code:** every existing `test` keeps name and values.
239
+ **Steps:** `Panel`, `Ship`, `Orb`, `Mine`, `Star` become records embedding `Actor` with only their own fields (clock, vx, vy, phase, spin, seed, tex, colour if needed); position/size live on the Node; `create` builders return Node via `PureScene.custom`; `act` moves `n.x`/`n.y`/`n.rotation`; helpers like `cx(a)` take Node; build layers and child lists with `Slice(a, b, ...)`; run the file's tests.
240
+
241
+ ### T6 stress_pure_scene on Node · Complexity 3 (O-1) · Est. context 35k
242
+ **Owns:** plum-game/web-test/fixtures/stress_pure_scene.plum
243
+ **Components:** 1
244
+ **LOC owned:** 120
245
+ **Tests:** fast
246
+ **Model:** sonnet
247
+ **Est. time:** 2.5 min
248
+ **Consumes:** SceneTree.update(own n: Node, dt: Float) -> Node (T4) · PureScene.drawTree(n: Node, parent: Affine, tint: Color) (T3)
249
+ **Produces:** none
250
+ **Review Focus:** numeric: same entity count and draw count per frame as before.
251
+ **Test code:** `./target/debug/plum compile plum-game/web-test/fixtures/stress_pure_scene.plum` (or `plum check`) succeeds; any `test` blocks keep values.
252
+ **Steps:** port the same way as T5; keep entity counts and per-frame behaviour.
253
+
254
+ ## Docs and benchmark
255
+
256
+ **Probe entry:** run=cargo test --manifest-path plum-game/Cargo.toml --test host_test
257
+ Package: file/generator (docs) + host checks; categories: protocol.
258
+
259
+ ### T7 README, host checks, benchmark · Complexity 3 (O-1) · Est. context 40k
260
+ **Status:** draft
261
+ **Owns:** README.md, plum-game/tests/host_test.rs, docs/plans/2026-11-03-pure-scene-enums.benchmark.md
262
+ **Components:** 2
263
+ **LOC owned:** 90
264
+ **Tests:** slow
265
+ **Model:** sonnet
266
+ **Est. time:** 4 min
267
+ **Consumes:** none
268
+ **Produces:** none
269
+ **Review Focus:** protocol: `pureSceneExampleRunsFrames`, `pureSceneMatchesSceneDrawCounts` and web check `pure_scene_renders_and_animates` pass.
270
+ **Test code:** `cargo test --manifest-path plum-game/Cargo.toml --test host_test` all pass; `cargo test --manifest-path plum-game/web-test/Cargo.toml --test web_check pure_scene` passes.
271
+ **Steps:** README "Pure scene graph" section and its example: Node + Kind, `Custom(actor: Actor)` with a short record example; headed benchmark: `cargo build --manifest-path plum-game/web-test/Cargo.toml` then `plum-game/web-test/target/debug/plum-web-test <fixture> --headed --uncapped --measure 10 --frames-wait 6 --json` for `stress_scene.plum` and `stress_pure_scene.plum` in the same session; write a table (avg, p95, update, draw) and the bar verdict (avg within +10%, p95 within +15% of mutable) to the benchmark file. If headed Chromium can't open, report DONE_WITH_CONCERNS with headless numbers labelled as such.
272
+
273
+ ## Review Focus
274
+ Per package lines above; checker: ownership stays sound.
275
+
276
+ ## Self-review
277
+ Every spec section maps (Spec map); Consumes match Produces; T5/T6 own distinct files.
docs/plans/2026-11-03-pure-scene-enums.progress.md ADDED
@@ -0,0 +1,52 @@
1
+ - start ad3ec48
2
+ - unblocked: dispatched T0 T1 at 20:41:23; waiting on fixes: T2 T3 T4 T5 T6 T7
3
+ - Ruling: T0 skipped - every stub file already exists and Plum modules can't be stubbed; real modules stand in - cost if wrong: none
4
+ - checkpoint T0 68b81bc54e Wave 0 skeleton
5
+ - Ruling: T4-T7 held as draft until their Plum dependencies checkpoint - lint drops the edges because examples exist, but Plum modules have no stubs, so a dependent compiles the real half-rewritten module - cost if wrong: ~10 min longer wall clock
6
+ - unblocked: dispatched T2 at 20:43:24; waiting on fixes: none (T4-T7 draft hold)
7
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.0
8
+ - Ruling: SceneTree helper ttActKind updated by T4 (owner) - T2 changed Action's shape - cost if wrong: none
9
+ - checkpoint T2 1a816dd148 Actions on Action + ActionKind
10
+ - tests fast passed=1 failed=0 skipped=0 seconds=271.1
11
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.0
12
+ - Ruling: [scope] user asked (mid-run) for Node to embed a Transform record and for variadic Slice(...)/List(...) construction instead of push chains in pure code and example; there is no [a, b] literal - applies to T3-T7, not yet dispatched - cost if wrong: none
13
+ - unblocked: dispatched T3 at 20:55:11 (reused T2 agent); waiting on fixes: none (T4-T7 draft hold)
14
+ - tests fast passed=1 failed=0 skipped=0 seconds=261.0
15
+ - checkpoint T1 1c6b96eb2d ownership: borrowed argument to a trait method
16
+ - checkpoint T3 c473382748 PureScene on Node, Kind, Actor
17
+ - unblocked: dispatched T4 (reused T2/T3 agent), F-T1-1 fixer, T3 review at 20:56:59; waiting on fixes: none (T5-T7 draft hold)
18
+ - T3 reviewed: no defects; +3 tests (enter/exit, non-button onPointer, invisible skip); 11 passed
19
+ - Ruling: [scope] T7 (README, host checks, headed benchmark) dropped by the user - PureScene may be retired after a state/view parity plan; T5-T6 still run so the tree compiles - cost if wrong: README pure-scene section stale until then
20
+ - tests fast passed=1 failed=0 skipped=0 seconds=263.3
21
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.7
22
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.4
23
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
24
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
25
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
26
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
27
+ - Ruling: T8 added for F-T1-1's plum-core files (union_lower.rs forwarder own flags + test) - T1's Owns block is frozen - cost if wrong: none
28
+ - tests fast passed=1 failed=0 skipped=0 seconds=280.1
29
+ - tests fast passed=1 failed=0 skipped=0 seconds=305.7
30
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.9
31
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
32
+ - checkpoint T8 ef98109949 trait forwarder keeps `own` (follow-up to T1, finding F-T1-1)
33
+ - checkpoint T3 52d52baeec PureScene on Node, Kind, Actor
34
+ - checkpoint T4 562509426a SceneTree and SceneInput over Node
35
+ - unblocked: dispatched T5 T6 at 21:20:44 (fresh; old agents evicted >80k); T1 T3 T4 T8 checkpointed; T7 dropped
36
+ - checkpoint T6 97e883c0a2 stress_pure_scene on Node
37
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.9
38
+ - checkpoint T5 4cb7ef4fbb Orb Run on Node
39
+ - Ruling: T9 added - T5 hit a codegen gap (field-less record embedding a trait: unknown variant 'Panel__new'); fix in the compiler and drop T5's visible tag-field workaround - cost if wrong: ~5 min
40
+ - unblocked: dispatched T9 (fixer) at 21:23:06; T5 T6 checkpointed
41
+ - tests fast passed=1 failed=0 skipped=0 seconds=221.8
42
+ - checkpoint T9 85f9e7d84d field-less record embedding a trait (compiler gap found by T5)
43
+ - tests full passed=80 failed=1 skipped=0 seconds=15.2
44
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.4
45
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
46
+ - Ruling: final review CHANGES_REQUESTED - findings 2 and 4 stale (T9 fixed Panel; union_lower checkpointed under T8); real: F-FR-1 test fixtures in git-ignored .superlite (T10), F-FR-2 bare payload-variant pattern silently never matches -> compile error (T11), F-FR-3 negative move/projection tests (T12); run_tests --fast filters cargo by path (runs ~nothing) so fixers run cargo directly - cost if wrong: ~15 min
47
+ - F-FR-3 fixed: views map (pattern/element projections of own roots) now error after move; residual call-result aliases logged
48
+ - checkpoint T10 20527fe08e tracked test fixtures (final review F-FR-1)
49
+ - checkpoint T11 b32e36ab14 bare pattern on a payload variant is an error (final review F-FR-2)
50
+ - checkpoint T12 5f840f27dc ownership: projections after a move (final review F-FR-3)
51
+ - tests full passed=609 failed=2 skipped=0 seconds=276.6
52
+ - Ruling: codegen_tests filter_form_no_closure + one_copy_per_distinct_type fail on clean master too (pre-existing since closure specialisation, merged 8d9b551); not caused by this run; follow-up offered - cost if wrong: none
docs/plans/2026-11-03-pure-scene-enums.report.json ADDED
@@ -0,0 +1,418 @@
1
+ {
2
+ "projection_note": "projection: fixed forecast saved at lint 2026-11-03T20:41:17 (plan edited since)",
3
+ "headline": {
4
+ "wall_min": {
5
+ "projected": 43.834,
6
+ "actual": 40.875
7
+ },
8
+ "cost_usd_agents": {
9
+ "projected": 1.105389,
10
+ "actual": 3.029868
11
+ },
12
+ "task_min": {
13
+ "projected": 58.138,
14
+ "actual": 6.813
15
+ },
16
+ "agents": {
17
+ "projected": 7,
18
+ "actual": 30
19
+ }
20
+ },
21
+ "feature": null,
22
+ "cost_split": {
23
+ "agents_usd": 1.156554,
24
+ "rework_usd": 1.873314,
25
+ "coordinator_usd": 14.923822,
26
+ "total_usd": 17.95369
27
+ },
28
+ "concurrency": {
29
+ "peak": 2,
30
+ "projected_peak": 2
31
+ },
32
+ "critical_path": {
33
+ "actual": [
34
+ "T3",
35
+ "T4",
36
+ "T5"
37
+ ],
38
+ "projected": [
39
+ "T1",
40
+ "T3",
41
+ "T4",
42
+ "T5",
43
+ "T7"
44
+ ],
45
+ "projected_min": 43.834
46
+ },
47
+ "model_mix": {
48
+ "unknown": {
49
+ "tasks": 5,
50
+ "cost_usd": 1.873314
51
+ },
52
+ "sonnet": {
53
+ "tasks": 5,
54
+ "cost_usd": 1.156554
55
+ }
56
+ },
57
+ "coordinator_cost_by_model": {
58
+ "opus": 14.923822
59
+ },
60
+ "quality": {
61
+ "tests": {
62
+ "passed": 609,
63
+ "failed": 2,
64
+ "skipped": 0,
65
+ "seconds": 276.6,
66
+ "mode": "full",
67
+ "failures": [
68
+ "-::filter_form_no_closure: failed",
69
+ "-::one_copy_per_distinct_type: failed"
70
+ ],
71
+ "skipped_tests": []
72
+ },
73
+ "first_time_pass": 0,
74
+ "first_time_n": 0,
75
+ "rework_dispatches": 6,
76
+ "open_incidents": 34
77
+ },
78
+ "tasks": [
79
+ {
80
+ "task": "T5",
81
+ "model": "sonnet",
82
+ "pred_min": 11.385,
83
+ "actual_min": 1.963,
84
+ "delta": {
85
+ "abs": -9.4217,
86
+ "pct": -82.8
87
+ },
88
+ "cost_usd": 0.351123,
89
+ "first_time_pass": null
90
+ },
91
+ {
92
+ "task": "T4",
93
+ "model": "sonnet",
94
+ "pred_min": 10.615,
95
+ "actual_min": 1.357,
96
+ "delta": {
97
+ "abs": -9.2583,
98
+ "pct": -87.2
99
+ },
100
+ "cost_usd": 0.195004,
101
+ "first_time_pass": null
102
+ },
103
+ {
104
+ "task": "T3",
105
+ "model": "sonnet",
106
+ "pred_min": 10.615,
107
+ "actual_min": 1.452,
108
+ "delta": {
109
+ "abs": -9.1633,
110
+ "pct": -86.3
111
+ },
112
+ "cost_usd": 0.292406,
113
+ "first_time_pass": null
114
+ },
115
+ {
116
+ "task": "T2",
117
+ "model": "sonnet",
118
+ "pred_min": 9.076,
119
+ "actual_min": 1.135,
120
+ "delta": {
121
+ "abs": -7.941,
122
+ "pct": -87.5
123
+ },
124
+ "cost_usd": 0.176055,
125
+ "first_time_pass": null
126
+ },
127
+ {
128
+ "task": "T6",
129
+ "model": "sonnet",
130
+ "pred_min": 5.228,
131
+ "actual_min": 0.907,
132
+ "delta": {
133
+ "abs": -4.3213,
134
+ "pct": -82.7
135
+ },
136
+ "cost_usd": 0.141966,
137
+ "first_time_pass": null
138
+ },
139
+ {
140
+ "task": "T1",
141
+ "model": null,
142
+ "pred_min": 4.074,
143
+ "actual_min": 0.0,
144
+ "delta": {
145
+ "abs": -4.074,
146
+ "pct": -100.0
147
+ },
148
+ "cost_usd": 1.061078,
149
+ "first_time_pass": null
150
+ },
151
+ {
152
+ "task": "T10",
153
+ "model": null,
154
+ "pred_min": null,
155
+ "actual_min": 0.0,
156
+ "delta": {
157
+ "abs": null,
158
+ "pct": null
159
+ },
160
+ "cost_usd": 0.141179,
161
+ "first_time_pass": null
162
+ },
163
+ {
164
+ "task": "T11",
165
+ "model": null,
166
+ "pred_min": null,
167
+ "actual_min": 0.0,
168
+ "delta": {
169
+ "abs": null,
170
+ "pct": null
171
+ },
172
+ "cost_usd": 0.224343,
173
+ "first_time_pass": null
174
+ },
175
+ {
176
+ "task": "T12",
177
+ "model": null,
178
+ "pred_min": null,
179
+ "actual_min": 0.0,
180
+ "delta": {
181
+ "abs": null,
182
+ "pct": null
183
+ },
184
+ "cost_usd": 0.281546,
185
+ "first_time_pass": null
186
+ },
187
+ {
188
+ "task": "T9",
189
+ "model": null,
190
+ "pred_min": null,
191
+ "actual_min": 0.0,
192
+ "delta": {
193
+ "abs": null,
194
+ "pct": null
195
+ },
196
+ "cost_usd": 0.165168,
197
+ "first_time_pass": null
198
+ }
199
+ ],
200
+ "cost_table": {
201
+ "source": "est",
202
+ "usd": 3.247,
203
+ "tokens": 8480136,
204
+ "wall_min": 568011343.9345,
205
+ "rows": [
206
+ {
207
+ "agent": "a1a960c040d23e232",
208
+ "model": "sonnet",
209
+ "wall_min": 0.924,
210
+ "tokens": 207294,
211
+ "usd": 0.142
212
+ },
213
+ {
214
+ "agent": "a27dd2c4b62114365",
215
+ "model": "sonnet",
216
+ "wall_min": 3.9581,
217
+ "tokens": 486765,
218
+ "usd": 0.2815
219
+ },
220
+ {
221
+ "agent": "a39ba404785fc1b21",
222
+ "model": "sonnet",
223
+ "wall_min": 2.3132,
224
+ "tokens": 360848,
225
+ "usd": 0.1412
226
+ },
227
+ {
228
+ "agent": "a3c01abfc2bde8c78",
229
+ "model": "sonnet",
230
+ "wall_min": 5.7162,
231
+ "tokens": 392705,
232
+ "usd": 0.1652
233
+ },
234
+ {
235
+ "agent": "a50a134039ef9862d",
236
+ "model": "sonnet",
237
+ "wall_min": 29895337.9274,
238
+ "tokens": 0,
239
+ "usd": 0.0
240
+ },
241
+ {
242
+ "agent": "a5e546c8e19deafd2",
243
+ "model": "sonnet",
244
+ "wall_min": 29895365.5444,
245
+ "tokens": 0,
246
+ "usd": 0.0
247
+ },
248
+ {
249
+ "agent": "a6ac27873b5a7d513",
250
+ "model": "sonnet",
251
+ "wall_min": 4.33,
252
+ "tokens": 574372,
253
+ "usd": 0.2243
254
+ },
255
+ {
256
+ "agent": "a78c56b4db21c27c4",
257
+ "model": "sonnet",
258
+ "wall_min": 29895325.359,
259
+ "tokens": 0,
260
+ "usd": 0.0
261
+ },
262
+ {
263
+ "agent": "a7dc4ab6ebd2288db",
264
+ "model": "sonnet",
265
+ "wall_min": 14.2417,
266
+ "tokens": 1721696,
267
+ "usd": 0.4792
268
+ },
269
+ {
270
+ "agent": "a7f4019524006638c",
271
+ "model": "sonnet",
272
+ "wall_min": 29895361.9656,
273
+ "tokens": 0,
274
+ "usd": 0.0
275
+ },
276
+ {
277
+ "agent": "a816dca9d1e31f8ac",
278
+ "model": "sonnet",
279
+ "wall_min": 15.0172,
280
+ "tokens": 1499690,
281
+ "usd": 0.5796
282
+ },
283
+ {
284
+ "agent": "a85fd2e6c24d170b0",
285
+ "model": "sonnet",
286
+ "wall_min": 1.9766,
287
+ "tokens": 745714,
288
+ "usd": 0.3511
289
+ },
290
+ {
291
+ "agent": "a897f89c2a7bcb3ba",
292
+ "model": "sonnet",
293
+ "wall_min": 29895331.2866,
294
+ "tokens": 0,
295
+ "usd": 0.0
296
+ },
297
+ {
298
+ "agent": "a8d31035bcea62a1b",
299
+ "model": "sonnet",
300
+ "wall_min": 29895304.8965,
301
+ "tokens": 0,
302
+ "usd": 0.0
303
+ },
304
+ {
305
+ "agent": "a916e3bfe065e33ca",
306
+ "model": "sonnet",
307
+ "wall_min": 29895308.043,
308
+ "tokens": 0,
309
+ "usd": 0.0
310
+ },
311
+ {
312
+ "agent": "a91f7f59b51d17204",
313
+ "model": "sonnet",
314
+ "wall_min": 29895316.4158,
315
+ "tokens": 0,
316
+ "usd": 0.0
317
+ },
318
+ {
319
+ "agent": "aa647f9c93fa5b7b1",
320
+ "model": "sonnet",
321
+ "wall_min": 29895329.8664,
322
+ "tokens": 0,
323
+ "usd": 0.0
324
+ },
325
+ {
326
+ "agent": "aa83af5df90f57224",
327
+ "model": "opus",
328
+ "wall_min": 0.5738,
329
+ "tokens": 45856,
330
+ "usd": 0.2171
331
+ },
332
+ {
333
+ "agent": "abf8945d7b644a1d5",
334
+ "model": "sonnet",
335
+ "wall_min": 0.3868,
336
+ "tokens": 111302,
337
+ "usd": 0.0839
338
+ },
339
+ {
340
+ "agent": "aca9f95e013875bcb",
341
+ "model": "sonnet",
342
+ "wall_min": 29895313.8694,
343
+ "tokens": 0,
344
+ "usd": 0.0
345
+ },
346
+ {
347
+ "agent": "ad6209113dcffa8c6",
348
+ "model": "sonnet",
349
+ "wall_min": 29895307.0034,
350
+ "tokens": 0,
351
+ "usd": 0.0
352
+ },
353
+ {
354
+ "agent": "adc378a5bab157e94",
355
+ "model": "sonnet",
356
+ "wall_min": 29895335.7,
357
+ "tokens": 0,
358
+ "usd": 0.0
359
+ },
360
+ {
361
+ "agent": "adcb4dcfc908bc785",
362
+ "model": "sonnet",
363
+ "wall_min": 29895309.2257,
364
+ "tokens": 0,
365
+ "usd": 0.0
366
+ },
367
+ {
368
+ "agent": "adf67542c96687e8a",
369
+ "model": "sonnet",
370
+ "wall_min": 29895353.2172,
371
+ "tokens": 0,
372
+ "usd": 0.0
373
+ },
374
+ {
375
+ "agent": "ae54f4cfa8b37d20b",
376
+ "model": "sonnet",
377
+ "wall_min": 29895351.7776,
378
+ "tokens": 0,
379
+ "usd": 0.0
380
+ },
381
+ {
382
+ "agent": "ae555f72c936ddec4",
383
+ "model": "sonnet",
384
+ "wall_min": 22.8393,
385
+ "tokens": 2333894,
386
+ "usd": 0.5819
387
+ },
388
+ {
389
+ "agent": "ae670ac54bcd2838c",
390
+ "model": "sonnet",
391
+ "wall_min": 29895314.8372,
392
+ "tokens": 0,
393
+ "usd": 0.0
394
+ },
395
+ {
396
+ "agent": "ae76a82a935523db2",
397
+ "model": "sonnet",
398
+ "wall_min": 29895335.1586,
399
+ "tokens": 0,
400
+ "usd": 0.0
401
+ },
402
+ {
403
+ "agent": "af8f810c7141bc774",
404
+ "model": "sonnet",
405
+ "wall_min": 29895359.1034,
406
+ "tokens": 0,
407
+ "usd": 0.0
408
+ },
409
+ {
410
+ "agent": "afc0910dfeb3a2d5d",
411
+ "model": "sonnet",
412
+ "wall_min": 29895310.4604,
413
+ "tokens": 0,
414
+ "usd": 0.0
415
+ }
416
+ ]
417
+ }
418
+ }
docs/plans/2026-11-03-pure-scene-enums.report.md ADDED
@@ -0,0 +1,59 @@
1
+ # superlite build report
2
+
3
+ projection: fixed forecast saved at lint 2026-11-03T20:41:17 (plan edited since)
4
+
5
+ Projected Actual Delta
6
+ wall-clock (min) 43.8 40.9 -3.0 (-7%)
7
+ agent cost (USD, +rework) 1.11 3.03 +1.92 (+174%)
8
+ task-minutes 58.1 6.8 -51.3 (-88%)
9
+ agents 7 30 +23 (+329%)
10
+ cost split: agents $1.16, rework $1.87, coordinator $14.92, total $17.95
11
+ concurrency: peak 2 at once (projected 2); critical path actual T3 T4 T5; projected T1 > T3 > T4 > T5 > T7 (43.8 min)
12
+ model mix: sonnet 5 tasks $1.16, unknown 5 tasks $1.87, coordinator opus $14.92
13
+ quality: tests 609 passed, 2 failed; first-time pass n/a; rework dispatches 6; open incidents 34
14
+
15
+ task model pred actual delta cost
16
+ T5 sonnet 11.4 2.0 -9.4 (-83%) $0.351
17
+ T4 sonnet 10.6 1.4 -9.3 (-87%) $0.195
18
+ T3 sonnet 10.6 1.5 -9.2 (-86%) $0.292
19
+ T2 sonnet 9.1 1.1 -7.9 (-88%) $0.176
20
+ T6 sonnet 5.2 0.9 -4.3 (-83%) $0.142
21
+ (+5 smaller misses; `superlite-cli report --json` lists all)
22
+
23
+ agent cost table:
24
+ | agent | model | wall_min | tokens | usd |
25
+ | --- | --- | --- | --- | --- |
26
+ | a1a960c040d23e232 | sonnet | 0.924 | 207294 | 0.142 |
27
+ | a27dd2c4b62114365 | sonnet | 3.9581 | 486765 | 0.2815 |
28
+ | a39ba404785fc1b21 | sonnet | 2.3132 | 360848 | 0.1412 |
29
+ | a3c01abfc2bde8c78 | sonnet | 5.7162 | 392705 | 0.1652 |
30
+ | a50a134039ef9862d | sonnet | 29895337.9274 | 0 | 0.0 |
31
+ | a5e546c8e19deafd2 | sonnet | 29895365.5444 | 0 | 0.0 |
32
+ | a6ac27873b5a7d513 | sonnet | 4.33 | 574372 | 0.2243 |
33
+ | a78c56b4db21c27c4 | sonnet | 29895325.359 | 0 | 0.0 |
34
+ | a7dc4ab6ebd2288db | sonnet | 14.2417 | 1721696 | 0.4792 |
35
+ | a7f4019524006638c | sonnet | 29895361.9656 | 0 | 0.0 |
36
+ | a816dca9d1e31f8ac | sonnet | 15.0172 | 1499690 | 0.5796 |
37
+ | a85fd2e6c24d170b0 | sonnet | 1.9766 | 745714 | 0.3511 |
38
+ | a897f89c2a7bcb3ba | sonnet | 29895331.2866 | 0 | 0.0 |
39
+ | a8d31035bcea62a1b | sonnet | 29895304.8965 | 0 | 0.0 |
40
+ | a916e3bfe065e33ca | sonnet | 29895308.043 | 0 | 0.0 |
41
+ | a91f7f59b51d17204 | sonnet | 29895316.4158 | 0 | 0.0 |
42
+ | aa647f9c93fa5b7b1 | sonnet | 29895329.8664 | 0 | 0.0 |
43
+ | aa83af5df90f57224 | opus | 0.5738 | 45856 | 0.2171 |
44
+ | abf8945d7b644a1d5 | sonnet | 0.3868 | 111302 | 0.0839 |
45
+ | aca9f95e013875bcb | sonnet | 29895313.8694 | 0 | 0.0 |
46
+ | ad6209113dcffa8c6 | sonnet | 29895307.0034 | 0 | 0.0 |
47
+ | adc378a5bab157e94 | sonnet | 29895335.7 | 0 | 0.0 |
48
+ | adcb4dcfc908bc785 | sonnet | 29895309.2257 | 0 | 0.0 |
49
+ | adf67542c96687e8a | sonnet | 29895353.2172 | 0 | 0.0 |
50
+ | ae54f4cfa8b37d20b | sonnet | 29895351.7776 | 0 | 0.0 |
51
+ | ae555f72c936ddec4 | sonnet | 22.8393 | 2333894 | 0.5819 |
52
+ | ae670ac54bcd2838c | sonnet | 29895314.8372 | 0 | 0.0 |
53
+ | ae76a82a935523db2 | sonnet | 29895335.1586 | 0 | 0.0 |
54
+ | af8f810c7141bc774 | sonnet | 29895359.1034 | 0 | 0.0 |
55
+ | afc0910dfeb3a2d5d | sonnet | 29895310.4604 | 0 | 0.0 |
56
+ | totals | | 568011343.9345 | 8480136 | 3.247 |
57
+ cost source: est
58
+
59
+ takeaway: ran 7% faster than projected; cost 174% over; biggest miss T5
docs/plans/2026-11-03-state-view-parity.review.md CHANGED
@@ -47,6 +47,11 @@ The mutable examples (`game.plum`, `game_scene.plum`, `scene.plum`, `trait_scene
47
47
  - capture is dropped when the captured id is no longer in the picture.
48
48
  - Button visuals come from the UI state: `Ui.pressed(ui, id)`, `Ui.hovered(ui, id)`. `Ui.button(id, label, rect, ui)` builds the button's `SceneNode`.
49
49
  - Events are `UiEvent(id, kind)`, as in PureScene, and the game handles them in `step`.
50
+ - **Static subtree reuse (user, 2026-11-03).** Parts of a picture that never change are built once and returned as the same value every frame. Examples: a starfield's layout, terrain tiles, HUD frames, panel backgrounds.
51
+ - `view` places them as-is (`Group(.., children: Cons(s.staticBg, ...))`), so they cost no allocation per frame. Only the moving parts are rebuilt.
52
+ - Anything that varies over time is applied around the shared subtree, not inside it: a twinkle via `Filter(Alpha(..), child: staticStar)`, a scroll via `Group(Translate(..), child: staticMap)`.
53
+ - Stored in `State` (built in `newState`), or as a module-level constant when it depends on nothing.
54
+ - Test: a wasmparser/allocation count shows the static part adds no `struct.new` per frame. `game_state_view`'s starfield becomes static (positions fixed, twinkle as a per-frame alpha wrapper).
50
55
  - **The game shell stays thin.** `update` reads input into a `Msg`, then calls `step`, then `Ui.step` against `view(state)`, then `step` again for each `UiEvent`. Sound remains the only side effect.
51
56
 
52
57
  ## What you will get
@@ -68,6 +73,7 @@ The mutable examples (`game.plum`, `game_scene.plum`, `scene.plum`, `trait_scene
68
73
  | Picture tags | `Tagged(id, child)`, `hitId`, draw/bounds pass-through; tests | S |
69
74
  | Ui | `UiState`, `Ui.step`, events, capture, `pressed`/`hovered`, `button`; PureScene-equivalent tests | M |
70
75
  | Orb Run state/view | restart via Ui, Anim for the title and hit flash; tests unchanged plus Ui/Anim cases | M |
76
+ | Static subtrees | starfield built once in `State`, twinkle as an alpha wrapper; allocation test | S |
71
77
  | Stress fixture | tags, buttons, animations in `stress_state_view.plum` | S |
72
78
  | Benchmark + README | headed table, "Functional scenes" section | S |
73
79
 
plum-checker/src/lib.rs CHANGED
@@ -3906,6 +3906,12 @@ fn liftInlineVariant(
3906
3906
  Ok(lifted)
3907
3907
  }
3908
3908
 
3909
+ thread_local! {
3910
+ /// Address of a top-level `if X := v` pattern, where a bare inline-variant name
3911
+ /// narrows `v` instead of matching a value.
3912
+ static ALLOW_BARE_TOP: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
3913
+ }
3914
+
3909
3915
  /// Checks a single case pattern against the type of the subject it matches, binding any
3910
3916
  /// new names it introduces into `env`. Constructor-payload sub-patterns (`Next(x)`) bind
3911
3917
  /// against that variant's declared field types (see `EnumVariantInfo::field_types`).
@@ -3932,6 +3938,20 @@ pub(crate) fn checkPattern(
3932
3938
  }
3933
3939
  }
3934
3940
  }
3941
+ // An inline variant with fields is never matched bare (Rust semantics): the bare
3942
+ // name would narrow to the hidden member and silently never match as written.
3943
+ if let ast::CasePattern::Name(n) = pat {
3944
+ let allowed = ALLOW_BARE_TOP.with(|c| c.get()) == pat as *const ast::CasePattern as usize;
3945
+ if let (false, Some(owner), Some(subject)) =
3946
+ (allowed, unions::inlineOwner(n), nominalName(subject_ty))
3947
+ {
3948
+ if owner == subject {
3949
+ return Err(format!(
3950
+ "{n} has fields; match it with one pattern per field, e.g. {n}(_, _)"
3951
+ ));
3952
+ }
3953
+ }
3954
+ }
3935
3955
  if let Some(r) = unions::checkMemberPattern(pat, subject_ty, env, ctx) {
3936
3956
  return r;
3937
3957
  }
@@ -4637,7 +4657,10 @@ fn bindCondEnv(cond: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<TypeEn
4637
4657
  }
4638
4658
  pat => {
4639
4659
  let narrow = unions::narrowTarget(&b.value, pat, &ty, env, ctx);
4660
+ ALLOW_BARE_TOP.with(|c| c.set(pat as *const ast::CasePattern as usize));
4640
- checkPattern(pat, &ty, &mut benv, ctx)?;
4661
+ let r = checkPattern(pat, &ty, &mut benv, ctx);
4662
+ ALLOW_BARE_TOP.with(|c| c.set(0));
4663
+ r?;
4641
4664
  unions::applyNarrow(narrow, &b.case, &mut benv, ctx);
4642
4665
  }
4643
4666
  }
plum-checker/src/ownership.rs CHANGED
@@ -85,6 +85,7 @@ pub fn checkOwnership(src: &Source) -> Vec<CheckError> {
85
85
  selfTy: None,
86
86
  tys: HashMap::new(),
87
87
  elems: HashMap::new(),
88
+ views: HashMap::new(),
88
89
  traits: HashMap::new(),
89
90
  dead: false,
90
91
  loops: Vec::new(),
@@ -159,6 +160,9 @@ struct Cx<'a> {
159
160
  tys: HashMap<String, String>,
160
161
  /// `Slice[T]` locals whose element type `T` is known.
161
162
  elems: HashMap<String, String>,
163
+ /// Borrowed projections of an owned binding (pattern binders / element views
164
+ /// holding own data), mapped to the root binding: moving the root kills them.
165
+ views: HashMap<String, String>,
162
166
  traits: HashMap<String, &'a TraitDecl>,
163
167
  /// The current path ended in return/crash/break/continue.
164
168
  dead: bool,
@@ -214,6 +218,7 @@ impl<'a> Cx<'a> {
214
218
  self.selfTy = selfTy;
215
219
  self.tys = HashMap::new();
216
220
  self.elems = HashMap::new();
221
+ self.views = HashMap::new();
217
222
  self.dead = false;
218
223
  self.loops = Vec::new();
219
224
  self.swap = None;
@@ -257,6 +262,29 @@ impl<'a> Cx<'a> {
257
262
  None
258
263
  }
259
264
 
265
+ /// Receiver type unknown (e.g. bound by a pattern): a method called `name`, but
266
+ /// only when EVERY method of that name in the program (trait methods, required
267
+ /// methods, record/enum methods) takes `self` and each param the same way, so
268
+ /// the call's modes do not depend on which type the receiver turns out to be.
269
+ /// Otherwise `None`, and the caller consumes every argument.
270
+ fn uniqueTraitMethod(&self, name: &str) -> Option<&'a Fn> {
271
+ let all = self
272
+ .traits
273
+ .values()
274
+ .flat_map(|t| t.methods.iter().chain(t.required.iter()))
275
+ .chain(self.methods.values().copied())
276
+ .filter(|f| f.name == name);
277
+ let mut found: Option<&'a Fn> = None;
278
+ for f in all {
279
+ match found {
280
+ None => found = Some(f),
281
+ Some(g) if sameModes(g, f) => {}
282
+ Some(_) => return None,
283
+ }
284
+ }
285
+ found
286
+ }
287
+
260
288
  /// `xs.at(i)` and friends on a `List[Own]`: a borrowed view of an element.
261
289
  fn elemRead(&self, e: &Expr) -> Option<(String, String)> {
262
290
  let Expr::Attribute(a) = e else { return None };
@@ -269,6 +297,61 @@ impl<'a> Cx<'a> {
269
297
  Some((format!("{}.{}", recv, c.name), el.clone()))
270
298
  }
271
299
 
300
+ /// The owned binding a `match` subject / element read is rooted in, looking
301
+ /// through views (`n.kind`, `n`, `ks.at(0)` where `ks` is a view of `n`).
302
+ fn viewRoot(&self, e: &Expr) -> Option<String> {
303
+ let r = match e {
304
+ Expr::Paren(i) => return self.viewRoot(i),
305
+ Expr::Attribute(a) if matches!(a.attr, AttrKind::Method(_)) => {
306
+ return self.elemViewRoot(e).or_else(|| self.viewRoot(&a.object));
307
+ }
308
+ Expr::Attribute(a) => return self.viewRoot(&a.object),
309
+ _ => nameOf(e)?,
310
+ };
311
+ if let Some(root) = self.views.get(&r) {
312
+ return Some(root.clone());
313
+ }
314
+ self.env.contains_key(&r).then_some(r)
315
+ }
316
+
317
+ /// `ks.at(i)` where `ks` is a view into an owned binding: that binding.
318
+ fn elemViewRoot(&self, e: &Expr) -> Option<String> {
319
+ let Expr::Attribute(a) = e else { return None };
320
+ let AttrKind::Method(c) = &a.attr else { return None };
321
+ if !matches!(c.name.as_str(), "at" | "get" | "first" | "last") {
322
+ return None;
323
+ }
324
+ self.viewRoot(&a.object)
325
+ }
326
+
327
+ /// Names a pattern binds to own data: an own-typed field or a `Slice[Own]`.
328
+ fn viewBinds(&self, p: &CasePattern, out: &mut Vec<String>) {
329
+ let CasePattern::Class { name, fields } = p else { return };
330
+ let owner = self.enums.get(name).copied().or_else(|| {
331
+ self.enums
332
+ .values()
333
+ .copied()
334
+ .find(|e| e.variants.iter().any(|v| &v.name == name))
335
+ });
336
+ let Some(v) = owner.and_then(|e| e.variants.iter().find(|v| &v.name == name)) else {
337
+ return;
338
+ };
339
+ for (i, f) in fields.iter().enumerate() {
340
+ let holdsOwn = match v.fields.get(i) {
341
+ Some(t) => {
342
+ self.own.contains(&t.name)
343
+ || (t.name == "Slice" && t.generics.iter().any(|g| self.own.contains(&g.name)))
344
+ }
345
+ None => false,
346
+ };
347
+ match f {
348
+ CasePattern::Name(n) if holdsOwn => out.push(n.clone()),
349
+ CasePattern::Class { .. } => self.viewBinds(f, out),
350
+ _ => {}
351
+ }
352
+ }
353
+ }
354
+
272
355
  fn isOwnTy(&self, ty: &Option<String>) -> bool {
273
356
  ty.as_ref().is_some_and(|t| self.own.contains(t))
274
357
  }
@@ -395,10 +478,16 @@ impl<'a> Cx<'a> {
395
478
 
396
479
  /// Names a pattern binds to a value of own type, with that type. `ty` is the
397
480
  /// type of the value the pattern matches.
481
+ fn patternBinds(
482
+ &self,
483
+ p: &CasePattern,
484
+ ty: Option<String>,
485
+ onlyOwn: bool,
398
- fn patternBinds(&self, p: &CasePattern, ty: Option<String>, out: &mut Vec<(String, String)>) {
486
+ out: &mut Vec<(String, String)>,
487
+ ) {
399
488
  match p {
400
489
  CasePattern::Name(n) => {
401
- if let Some(t) = ty.filter(|t| self.own.contains(t)) {
490
+ if let Some(t) = ty.filter(|t| !onlyOwn || self.own.contains(t)) {
402
491
  out.push((n.clone(), t));
403
492
  }
404
493
  }
@@ -419,7 +508,7 @@ impl<'a> Cx<'a> {
419
508
  (None, Some(t)) if i == 0 => Some(t.name.clone()),
420
509
  _ => None,
421
510
  };
422
- self.patternBinds(f, fty, out);
511
+ self.patternBinds(f, fty, onlyOwn, out);
423
512
  }
424
513
  }
425
514
  _ => {}
@@ -520,7 +609,7 @@ impl<'a> Cx<'a> {
520
609
  for (i, p) in c.patterns.iter().enumerate() {
521
610
  let ty = m.subjects.get(i).and_then(|e| self.typeOf(e));
522
611
  let mut found = Vec::new();
523
- self.patternBinds(p, ty, &mut found);
612
+ self.patternBinds(p, ty, true, &mut found);
524
613
  if modes.get(i) == Some(&Some(Mode::Owned)) && !found.is_empty() {
525
614
  consumes = true;
526
615
  }
@@ -548,12 +637,40 @@ impl<'a> Cx<'a> {
548
637
  for n in names {
549
638
  cx.env.remove(&n);
550
639
  cx.tys.remove(&n);
640
+ cx.views.remove(&n);
641
+ }
642
+ // Binders holding own data read out of an owned binding are
643
+ // views into it: they die when it is moved. (`match n` itself
644
+ // consumes `n`, so its binders are moved out, not views.)
645
+ let subjectRoot = m.subjects.iter().find_map(|e| match e {
646
+ Expr::Var(_) | Expr::Self_ => nameOf(e)
647
+ .and_then(|n| cx.views.get(&n).cloned()),
648
+ e => cx.viewRoot(e),
649
+ });
650
+ if let Some(root) = subjectRoot {
651
+ let mut vs = Vec::new();
652
+ for p in &c.patterns {
653
+ cx.viewBinds(p, &mut vs);
654
+ }
655
+ for n in vs {
656
+ cx.views.insert(n, root.clone());
657
+ }
658
+ }
659
+ // Every binder gets its declared type (own or not), so a method
660
+ // call on it resolves to its real type's method.
661
+ for (i, p) in c.patterns.iter().enumerate() {
662
+ let ty = m.subjects.get(i).and_then(|e| cx.typeOf(e));
663
+ let mut all = Vec::new();
664
+ cx.patternBinds(p, ty, false, &mut all);
665
+ for (n, t) in all {
666
+ cx.tys.insert(n, t);
667
+ }
551
668
  }
552
669
  for (i, p) in c.patterns.iter().enumerate() {
553
670
  let Some(Some(mode)) = modes.get(i).copied() else { continue };
554
671
  let ty = m.subjects.get(i).and_then(|e| cx.typeOf(e));
555
672
  let mut found = Vec::new();
556
- cx.patternBinds(p, ty, &mut found);
673
+ cx.patternBinds(p, ty, true, &mut found);
557
674
  for (n, t) in found {
558
675
  cx.tys.insert(n.clone(), t.clone());
559
676
  cx.env.insert(
@@ -674,6 +791,10 @@ impl<'a> Cx<'a> {
674
791
  AssignTarget::Var(x) => {
675
792
  let val = a.values.get(i).or(a.values.first());
676
793
  let view = val.and_then(|v| self.elemRead(v));
794
+ self.views.remove(x);
795
+ if let Some(root) = val.and_then(|v| self.elemViewRoot(v)) {
796
+ self.views.insert(x.clone(), root);
797
+ }
677
798
  let valTy = match &view {
678
799
  Some((_, el)) => Some(el.clone()),
679
800
  None => val.and_then(|v| self.typeOf(v)),
@@ -733,6 +854,19 @@ impl<'a> Cx<'a> {
733
854
  // ---- expressions -------------------------------------------------------
734
855
 
735
856
  fn checkLive(&mut self, name: &str) {
857
+ if let Some(root) = self.views.get(name).cloned() {
858
+ if let Some((l, c)) = self.env.get(&root).and_then(|b| b.moved) {
859
+ self.views.remove(name); // report once
860
+ let p = self.pos;
861
+ self.err(
862
+ p,
863
+ format!(
864
+ "\"{}\" used after it was moved at {}:{} (it is a view into \"{}\")",
865
+ name, l, c, root
866
+ ),
867
+ );
868
+ }
869
+ }
736
870
  let Some(b) = self.env.get_mut(name) else { return };
737
871
  if let Some((l, c)) = b.moved {
738
872
  b.moved = None; // report once
@@ -932,7 +1066,9 @@ impl<'a> Cx<'a> {
932
1066
  Expr::TypeName(t) => Some(t.clone()),
933
1067
  o => self.typeOf(o),
934
1068
  };
1069
+ let callee = recvTy
935
- let callee = recvTy.and_then(|t| self.findMethod(&t, &c.name, 0));
1070
+ .and_then(|t| self.findMethod(&t, &c.name, 0))
1071
+ .or_else(|| self.uniqueTraitMethod(&c.name));
936
1072
  if c.line != 0 {
937
1073
  self.pos = (c.line, c.column);
938
1074
  }
@@ -959,6 +1095,7 @@ impl<'a> Cx<'a> {
959
1095
  patternNames(b.pattern(), &mut names);
960
1096
  for n in names {
961
1097
  self.env.remove(&n);
1098
+ self.views.remove(&n);
962
1099
  }
963
1100
  if let Some(g) = &b.case.guard {
964
1101
  self.walk(g);
@@ -987,6 +1124,17 @@ impl<'a> Cx<'a> {
987
1124
  }
988
1125
 
989
1126
  /// Param `i`, clamped to a trailing variadic param.
1127
+ /// Two methods take `self` and every param in the same way (as a call site sees it).
1128
+ fn sameModes(a: &Fn, b: &Fn) -> bool {
1129
+ let takes = |p: &Param| {
1130
+ p.isOwn
1131
+ || matches!(&p.ty, ParamType::Type(t) | ParamType::Variadic(t) if isGeneric(&t.name))
1132
+ };
1133
+ a.ownSelf == b.ownSelf
1134
+ && a.params.len() == b.params.len()
1135
+ && a.params.iter().zip(&b.params).all(|(x, y)| takes(x) == takes(y))
1136
+ }
1137
+
990
1138
  fn paramAt(f: &Fn, i: usize) -> Option<&Param> {
991
1139
  f.params.get(i).or_else(|| {
992
1140
  f.params
plum-checker/tests/fixtures/custom_actor_probe2.plum ADDED
@@ -0,0 +1,58 @@
1
+ import std/Int
2
+ import std/Float
3
+ import std/Str
4
+ import std/Slice
5
+
6
+ own record Node(id: Int, x: Float, y: Float, kind: Kind)
7
+
8
+ enum Kind =
9
+ | Group(children: Slice[Node])
10
+ | Label(text: Str, size: Float)
11
+ | Custom(actor: Actor)
12
+
13
+ # Behaviour only: the game's records embed it, like Box<dyn Actor>.
14
+ trait Actor =
15
+ fun act(self, own n: Node, dt: Float) -> Node
16
+ fun score(self, n: Node) -> Float
17
+
18
+ record Ship(Actor, clock: Float) =
19
+ override fun act(self, own n: Node, dt: Float) -> Node =
20
+ self.clock = self.clock + dt
21
+ n
22
+ override fun score(self, n: Node) -> Float = self.clock
23
+
24
+ record Orb(Actor, vx: Float) =
25
+ override fun act(self, own n: Node, dt: Float) -> Node =
26
+ n.x = n.x + self.vx * dt
27
+ n
28
+ override fun score(self, n: Node) -> Float = n.x
29
+
30
+ fun actorOf(n: Node) -> Actor? = match n.kind
31
+ Custom(a) => a
32
+ _ => null
33
+
34
+ fun update(own n: Node, dt: Float) -> Node =
35
+ if a := actorOf(n)
36
+ return a.act(n, dt)
37
+ match n.kind
38
+ Group(ks) =>
39
+ _ := ks.mapInPlace(|k| update(k, dt))
40
+ _ => 0
41
+ n
42
+
43
+ fun total(n: Node) -> Float = match n.kind
44
+ Group(ks) =>
45
+ s := 0.0
46
+ for i := range ks.length()
47
+ s = s + total(ks.at(i))
48
+ s
49
+ Custom(a) => a.score(n)
50
+ Label(_, _) => 0.0
51
+
52
+ test "custom_actor"
53
+ ks := Slice[Node]()
54
+ ks.push(Node(id: 1, x: 1.0, y: 0.0, kind: Custom(actor: Orb(vx: 2.0))))
55
+ ks.push(Node(id: 2, x: 0.0, y: 0.0, kind: Custom(actor: Ship(clock: 0.0))))
56
+ root := Node(id: 0, x: 0.0, y: 0.0, kind: Group(children: ks))
57
+ r := update(root, 0.5)
58
+ assert total(r) == 2.0 + 0.5
plum-checker/tests/fixtures/g3_generic_funstar.plum ADDED
@@ -0,0 +1,34 @@
1
+ module g3
2
+
3
+ enum Link[T] =
4
+ | Nil
5
+ | Cons(head: T, tail: Link[T])
6
+
7
+ fun length(self) -> Int = match self
8
+ Nil => 0
9
+ Cons(_, t) => 1 + t.length()
10
+
11
+ fun* items(self) *T =
12
+ cur := self
13
+ while true
14
+ if Cons(h, t) := cur
15
+ cur = t
16
+ yield h
17
+ else
18
+ break
19
+
20
+ fun* collect(xs: Link[T]) *T -> Link[T] =
21
+ out: Link[T] := Nil
22
+ for x := range xs.items()
23
+ v := yield x
24
+ out = Cons(head: v, tail: out)
25
+ out
26
+
27
+ fun both() -> Int =
28
+ a: Link[Int] := Cons(head: 1, tail: Cons(head: 2, tail: Nil))
29
+ b: Link[Str] := Cons(head: "x", tail: Nil)
30
+ ints := for x := range collect(a)
31
+ x
32
+ strs := for s := range collect(b)
33
+ s
34
+ ints.length() + strs.length()
plum-checker/tests/optional_unpinned_tests.rs CHANGED
@@ -55,7 +55,7 @@ fn annotated_bare_tag_ok() {
55
55
 
56
56
  #[test]
57
57
  fn generic_funstar_wired() {
58
- let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../.superlite/repros/g3_generic_funstar.plum")).unwrap();
58
+ let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/g3_generic_funstar.plum")).unwrap();
59
59
  let mut parser = tree_sitter::Parser::new();
60
60
  parser.set_language(&tree_sitter_plum::LANGUAGE.into()).unwrap();
61
61
  let tree = parser.parse(&src, None).unwrap();
plum-checker/tests/ownership_moves_test.rs CHANGED
@@ -163,3 +163,70 @@ fn alias_borrowed_param_rejected() {
163
163
  let t = texts("fun f(s: Sprite) -> Float =\n t := s\n t.x\n");
164
164
  assert!(t.iter().any(|m| m.starts_with("cannot store borrowed \"s\"")), "{t:?}");
165
165
  }
166
+
167
+ const NODE_DEFS: &str = "\
168
+ import std/Float
169
+ import std/Slice
170
+
171
+ own record Node(id: Int, x: Float, kind: Kind)
172
+
173
+ enum Kind =
174
+ | Group(children: Slice[Node])
175
+ | Custom(actor: Actor)
176
+
177
+ trait Actor =
178
+ fun act(self, own n: Node, dt: Float) -> Node
179
+
180
+ fun consume(own n: Node) -> Node = n
181
+ fun look(n: Node) -> Float = n.x
182
+ fun first(n: Node) -> Node? = match n.kind
183
+ Group(ks) => ks.at(0)
184
+ _ => null
185
+ fun actorOf(n: Node) -> Actor? = match n.kind
186
+ Custom(a) => a
187
+ _ => null
188
+ ";
189
+
190
+ fn node_texts(body: &str) -> Vec<String> {
191
+ checkOwnership(&parse(&format!("{NODE_DEFS}\n{body}\n")))
192
+ .into_iter()
193
+ .map(|e| e.message)
194
+ .collect()
195
+ }
196
+
197
+ #[test]
198
+ fn own_field_used_after_move_rejected() {
199
+ let t = node_texts("fun f(own n: Node) -> Float =\n m := consume(n)\n n.x\n");
200
+ assert!(t.iter().any(|m| m.starts_with("\"n\" used after it was moved")), "{t:?}");
201
+ }
202
+
203
+ #[test]
204
+ fn own_child_projection_after_move_rejected() {
205
+ let t = node_texts(
206
+ "fun f(own n: Node) -> Float =\n match n.kind\n Group(ks) =>\n m := consume(n)\n ks.len()\n _ => 0.0\n",
207
+ );
208
+ assert!(t.iter().any(|m| m.contains("used after it was moved")), "{t:?}");
209
+ // An element read out of the child slice is a view too.
210
+ let t2 = node_texts(
211
+ "fun f(own n: Node) -> Float =\n match n.kind\n Group(ks) =>\n c := ks.at(0)\n m := consume(n)\n look(c)\n _ => 0.0\n",
212
+ );
213
+ assert!(t2.iter().any(|m| m.contains("used after it was moved")), "{t2:?}");
214
+ // An own-typed payload binder is a view into the scrutinee's root.
215
+ let defs = "own record Cell(id: Int, kind: Shape)\nenum Shape =\n | Pair(first: Cell)\n | Leaf(x: Float)\nfun eat(own n: Cell) -> Cell = n\nfun peek(n: Cell) -> Int = n.id\n";
216
+ let body = "fun f(own n: Cell) -> Int =\n match n.kind\n Pair(k) =>\n m := eat(n)\n peek(k)\n _ => 0\n";
217
+ let t3: Vec<String> = checkOwnership(&parse(&format!("{defs}\n{body}\n"))).into_iter().map(|e| e.message).collect();
218
+ assert!(t3.iter().any(|m| m.contains("used after it was moved")), "{t3:?}");
219
+ let ok = "fun f(own n: Cell) -> Int =\n match n.kind\n Pair(k) =>\n v := peek(k)\n m := eat(n)\n v\n _ => 0\n";
220
+ let t4: Vec<String> = checkOwnership(&parse(&format!("{defs}\n{ok}\n"))).into_iter().map(|e| e.message).collect();
221
+ assert!(t4.is_empty(), "{t4:?}");
222
+ }
223
+
224
+ #[test]
225
+ fn non_own_value_read_before_move_ok() {
226
+ let t = node_texts(
227
+ "fun f(own n: Node, dt: Float) -> Node =\n if a := actorOf(n)\n return a.act(n, dt)\n n\n",
228
+ );
229
+ assert!(t.is_empty(), "{t:?}");
230
+ let t2 = node_texts("fun g(own n: Node) -> Float =\n v := n.x\n m := consume(n)\n v\n");
231
+ assert!(t2.is_empty(), "{t2:?}");
232
+ }
plum-checker/tests/ownership_trait_args_test.rs ADDED
@@ -0,0 +1,73 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_checker::ownership::checkOwnership;
3
+ use plum_core::{ast::Source, AstParser};
4
+
5
+ fn parse(src: &str) -> Source {
6
+ let mut parser = tree_sitter::Parser::new();
7
+ parser
8
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
9
+ .unwrap();
10
+ let tree = parser.parse(src, None).unwrap();
11
+ assert!(
12
+ !tree.root_node().has_error(),
13
+ "embedded test source has parse errors:\n{src}"
14
+ );
15
+ AstParser::new(src).parseSource(tree.root_node())
16
+ }
17
+
18
+ const PROBE: &str = include_str!("fixtures/custom_actor_probe2.plum");
19
+
20
+ fn texts(body: &str) -> Vec<String> {
21
+ checkOwnership(&parse(&format!("{PROBE}\n{body}\n")))
22
+ .into_iter()
23
+ .map(|e| e.message)
24
+ .collect()
25
+ }
26
+
27
+ #[test]
28
+ fn trait_method_borrowed_arg_ok() {
29
+ let t = texts("");
30
+ assert!(t.is_empty(), "{t:?}");
31
+ }
32
+
33
+ #[test]
34
+ fn trait_method_own_arg_moves() {
35
+ let t = texts("fun g(own n: Node, a: Actor) -> Float =\n m := a.act(n, 0.1)\n n.x\n");
36
+ assert!(t.iter().any(|m| m.starts_with("\"n\" used after it was moved")), "{t:?}");
37
+ }
38
+
39
+ #[test]
40
+ fn borrowed_to_own_trait_param_rejected() {
41
+ let t = texts("fun h(n: Node, a: Actor) -> Node = a.act(n, 0.1)\n");
42
+ assert!(t.iter().any(|m| m.contains("cannot store borrowed \"n\"")), "{t:?}");
43
+ }
44
+
45
+ #[test]
46
+ fn plain_fn_borrowed_arg_still_ok() {
47
+ let t = texts("fun look(n: Node) -> Float = n.x\nfun k(n: Node) -> Float = look(n)\n");
48
+ assert!(t.is_empty(), "{t:?}");
49
+ }
50
+
51
+ #[test]
52
+ fn same_name_method_modes_disagree_is_conservative() {
53
+ let t = texts(
54
+ "trait Probe =\n fun f(self, n: Node) -> Float\n\nrecord Sq(Probe, w: Float) =\n override fun f(self, n: Node) -> Float = n.x\n\nrecord Other(w: Float) =\n fun f(self, own n: Node) -> Float = n.x\n\nfun u(n: Node) -> Float = match n.kind\n Custom(a) => a.f(n)\n _ => 0.0\n",
55
+ );
56
+ assert!(t.iter().any(|m| m.contains("cannot store borrowed \"n\"")), "{t:?}");
57
+ }
58
+
59
+ #[test]
60
+ fn same_name_method_modes_agree_uses_them() {
61
+ let t = texts(
62
+ "record Other(w: Float) =\n fun score(self, n: Node) -> Float = n.x\n\nfun u(n: Node) -> Float = match n.kind\n Custom(a) => a.score(n)\n _ => 0.0\n",
63
+ );
64
+ assert!(t.is_empty(), "{t:?}");
65
+ }
66
+
67
+ #[test]
68
+ fn pattern_bound_trait_receiver_resolves_despite_same_name_method() {
69
+ let t = texts(
70
+ "trait Drawer =\n fun draw(self, n: Node) -> Float\n\nrecord Pen(Drawer, w: Float) =\n override fun draw(self, n: Node) -> Float = n.x\n\nrecord Stage2(w: Float) =\n fun draw(self) -> Float = self.w\n\nenum Hold =\n | Mine(d: Drawer)\n\nown record Item(h: Hold)\n\nfun u(i: Item, n: Node) -> Float = match i.h\n Mine(d) => d.draw(n)\n",
71
+ );
72
+ assert!(t.is_empty(), "{t:?}");
73
+ }
plum-checker/tests/variant_pattern_tests.rs ADDED
@@ -0,0 +1,47 @@
1
+ #![allow(non_snake_case)]
2
+ //! A bare variant name in a pattern: valid for payload-less variants and union-enum
3
+ //! members, a positioned error for an inline variant that has fields.
4
+ use plum_checker::checkSource;
5
+ use plum_core::unions::{beginPlan, hoistInlineVariants, resolveMembers, takePlan};
6
+ use plum_core::AstParser;
7
+
8
+ const DEFS: &str = "\
9
+ enum Act = Stop | MoveBy(dx: Int, dy: Int)
10
+ record Circle(r: Float)
11
+ record Dot(x: Int)
12
+ enum Shape = Circle | Dot
13
+ ";
14
+
15
+ fn run(body: &str) -> Result<(), Vec<String>> {
16
+ let src = format!("{DEFS}\n{body}\n");
17
+ let mut parser = tree_sitter::Parser::new();
18
+ parser
19
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
20
+ .unwrap();
21
+ let tree = parser.parse(&src, None).unwrap();
22
+ assert!(!tree.root_node().has_error(), "parse errors:\n{src}");
23
+ let mut source = AstParser::new(&src).parseSource(tree.root_node());
24
+ let errs = hoistInlineVariants(&mut source);
25
+ assert!(errs.is_empty(), "{errs:?}");
26
+ let errs = resolveMembers(&mut source);
27
+ assert!(errs.is_empty(), "{errs:?}");
28
+ beginPlan();
29
+ let r = checkSource(&source).map_err(|e| e.into_iter().map(|x| x.message).collect());
30
+ takePlan();
31
+ r
32
+ }
33
+
34
+ #[test]
35
+ fn bare_payload_variant_pattern_rejected() {
36
+ let e = run("fun f(a: Act) -> Int =\n match a\n MoveBy => 1\n _ => 0")
37
+ .expect_err("bare payload variant must be rejected")
38
+ .join(" | ");
39
+ assert!(e.contains("MoveBy has fields"), "{e}");
40
+ run("fun f(a: Act) -> Int =\n match a\n MoveBy(_, _) => 1\n _ => 0").unwrap();
41
+ }
42
+
43
+ #[test]
44
+ fn bare_unit_variant_pattern_ok() {
45
+ run("fun f(a: Act) -> Int =\n match a\n Stop => 1\n _ => 0").unwrap();
46
+ run("fun f(s: Shape) -> Int =\n match s\n Circle => 1\n Dot => 2").unwrap();
47
+ }
plum-core/src/union_lower.rs CHANGED
@@ -677,7 +677,8 @@ fn synthesize(
677
677
  .params
678
678
  .iter()
679
679
  .map(|p| Param {
680
+ // The forwarder hands the value straight on, so it takes it exactly as declared.
680
- isOwn: false,
681
+ isOwn: p.isOwn,
681
682
  name: p.name.clone(),
682
683
  ty: substParam(&p.ty, &map),
683
684
  default: p.default.clone(),
@@ -715,7 +716,7 @@ isOwn: false,
715
716
  })
716
717
  .collect();
717
718
  Some(Fn {
718
- ownSelf: false,
719
+ ownSelf: decl.ownSelf,
719
720
  name: method.to_string(),
720
721
  file: decl.file.clone(),
721
722
  line: 0,
plum-core/tests/forwarder_own_test.rs ADDED
@@ -0,0 +1,51 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_core::ast::*;
3
+ use plum_core::union_lower::applyPlan;
4
+ use plum_core::unions::UnionPlan;
5
+ use plum_core::AstParser;
6
+
7
+ fn parse(src: &str) -> Source {
8
+ let mut parser = tree_sitter::Parser::new();
9
+ parser
10
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
11
+ .unwrap();
12
+ let tree = parser.parse(src, None).unwrap();
13
+ assert!(!tree.root_node().has_error(), "{}", tree.root_node().to_sexp());
14
+ AstParser::new(src).parseSource(tree.root_node())
15
+ }
16
+
17
+ const SRC: &str = "struct Circle(r: Float) =\n fun grow(self, own c: Circle, k: Float) -> Float = 1.0\nstruct Square(w: Float) =\n fun grow(self, own c: Circle, k: Float) -> Float = 2.0\nenum Shape = Circle | Square\nfun f(s: Shape, c: Circle) -> Float =\n s.grow(c, 1.0)\n";
18
+
19
+ #[test]
20
+ fn synthesized_forwarder_copies_own_flags() {
21
+ let mut s = parse(SRC);
22
+ let call = s
23
+ .items
24
+ .iter()
25
+ .find_map(|i| match i {
26
+ Item::Fn(f) if f.name == "f" => match &f.body {
27
+ FnBody::Block(b) => match &b.stmts[0] {
28
+ Stmt::Expr(e, ..) => Some(e as *const Expr as usize),
29
+ _ => None,
30
+ },
31
+ _ => None,
32
+ },
33
+ _ => None,
34
+ })
35
+ .unwrap();
36
+ let mut plan = UnionPlan::default();
37
+ plan.dispatches
38
+ .insert(call, vec!["Shape__Circle".into(), "Shape__Square".into()]);
39
+ applyPlan(&mut s, &plan);
40
+ let g = s
41
+ .items
42
+ .iter()
43
+ .find_map(|i| match i {
44
+ Item::Fn(f) if f.name == "grow" && f.type_param.as_deref() == Some("Shape") => Some(f),
45
+ _ => None,
46
+ })
47
+ .expect("no synthesized grow");
48
+ let own: Vec<bool> = g.params.iter().map(|p| p.isOwn).collect();
49
+ assert_eq!(own, vec![true, false]);
50
+ assert!(!g.ownSelf);
51
+ }
plum-examples/game_pure_scene.plum CHANGED
@@ -5,9 +5,10 @@
5
5
  # restarts after the game is over.
6
6
  #
7
7
  # What differs from game_scene.plum. There, actors are mutable objects that point at
8
- # their parent and `stage.act` changes them in place. Here every actor (`Ship`, `Orb`,
8
+ # their parent and `stage.act` changes them in place. Here the scene is made of `Node` values
9
+ # (std `Group`, `Sprite`, `Label`, `Button` kinds, and `Custom` nodes whose behaviour is a
9
- # `Mine`, `Star`, `Panel` and the std `Group`, `Sprite`, `Label`, `Button`) is a value:
10
+ # record embedding `Actor`: `Ship`, `Orb`, `Mine`, `Star`, `Panel`):
10
- # `act` returns the next actor, `Stage.frame` returns a new `Stage`, and a change from the
11
+ # `act` returns the next node, `Stage.frame` returns a new `Stage`, and a change from the
11
12
  # rules ("this orb is collected", "show the game-over panel") is a `SceneTree.modify`
12
13
  # on an actor id that returns a new tree. The `World` record below is the mutable shell the
13
14
  # host talks to: it keeps the current `Stage` and the run's state (score, lives, the ship's
@@ -141,232 +142,217 @@ fun shipStep(s: ShipState, ax: Float, ay: Float, dt: Float) -> ShipState =
141
142
  fun newShip() -> ShipState =
142
143
  ShipState(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
143
144
 
144
- # The centre of an actor drawn as a sprite-sized box.
145
+ # The centre of a node drawn as a sprite-sized box.
145
- fun cx(a: Actor) -> Float =
146
+ fun cx(n: Node) -> Float =
146
- a.x + a.width / 2.0
147
+ n.x + n.width / 2.0
147
148
 
148
- fun cy(a: Actor) -> Float =
149
+ fun cy(n: Node) -> Float =
149
- a.y + a.height / 2.0
150
+ n.y + n.height / 2.0
150
151
 
152
+ # Actor kinds of this game. Each holds only its own fields; position and size live on
151
- # Actor kinds of this game. Each is a sprite-sized actor; `create` takes the centre.
153
+ # the `Node`. Each `create` returns a sprite-sized node; it takes the centre.
152
154
 
153
- # A filled rectangle.
155
+ # A filled rectangle; its colour is the node's.
154
- own record Panel(
156
+ record Panel(Actor) =
155
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions)) =
156
157
 
157
- fun create(id: Int, x: Float, y: Float, w: Float, h: Float, c: Color) -> Panel =
158
+ fun create(id: Int, x: Float, y: Float, w: Float, h: Float, c: Color) -> Node =
158
- Panel(
159
- id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
160
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
161
- touchable: Disabled, actions: Slice[Action]())
159
+ n := PureScene.custom(id, x, y, w, h, Panel())
160
+ n.color = c
161
+ n
162
162
 
163
-
164
- override fun draw(self, m: Affine, tint: Color) =
163
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
165
164
  p := m.apply(Vec2(x: 0.0, y: 0.0))
166
- drawRect(p.x, p.y, self.width * m.scaleX(), self.height * m.scaleY(), tint)
165
+ drawRect(p.x, p.y, n.width * m.scaleX(), n.height * m.scaleY(), tint)
167
166
 
168
167
  # The player: only a picture that follows `ShipState`; `act` runs its animation clock.
169
- own record Ship(
170
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
171
- clock: Float, tex: Int) =
168
+ record Ship(Actor, clock: Float, tex: Int) =
172
-
173
- fun create(sheet: Int, s: ShipState) -> Ship =
174
- Ship(
175
- id: Id.Ship, x: s.x - Rules.Half, y: s.y - Rules.Half, width: Rules.Size, height: Rules.Size,
176
- originX: Rules.Half, originY: Rules.Half, scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
177
- color: Color.White, visible: true, touchable: Disabled, actions: Slice[Action](),
178
- clock: 0.0, tex: sheet)
179
169
 
170
+ fun create(sheet: Int, s: ShipState) -> Node =
171
+ n := PureScene.custom(Id.Ship, s.x - Rules.Half, s.y - Rules.Half, Rules.Size, Rules.Size, Ship(clock: 0.0, tex: sheet))
172
+ n.originX = Rules.Half
173
+ n.originY = Rules.Half
174
+ n
180
175
 
181
- override fun act(own self, dt: Float) -> Actor =
176
+ override fun act(self, own n: Node, dt: Float) -> Node =
182
177
  self.clock = self.clock + dt
183
- self
178
+ n
184
179
 
185
- override fun draw(self, m: Affine, tint: Color) =
180
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
186
- c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
181
+ c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
187
182
  drawCircle(c.x, c.y, 17.0 * m.scaleX(), Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12 * tint.a))
188
183
  frame := Float(frameOf(self.clock, 4, 14.0))
189
- pureDrawSprite(m, tint, self.width, self.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
184
+ pureDrawSprite(m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
190
185
 
191
186
  # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
192
- own record Orb(
193
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
194
- vx: Float, vy: Float, phase: Float, clock: Float, tex: Int) =
187
+ record Orb(Actor, vx: Float, vy: Float, phase: Float, clock: Float, tex: Int) =
195
-
196
- fun create(sheet: Int, phase: Float, id: Int, at: Motion) -> Orb =
197
- Orb(
198
- id: id, x: at.x - Rules.Half, y: at.y - Rules.Half, width: Rules.Size, height: Rules.Size,
199
- originX: Rules.Half, originY: Rules.Half, scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
200
- color: Color.White, visible: true, touchable: Disabled, actions: Slice[Action](),
201
- vx: at.vx, vy: at.vy, phase: phase, clock: 0.0, tex: sheet)
202
188
 
189
+ fun create(sheet: Int, phase: Float, id: Int, at: Motion) -> Node =
190
+ Orb.createAt(sheet, phase, id, at, 0.0)
203
191
 
192
+ # An orb that carries on an animation `clock`, so a respawn or freeze never jumps a frame.
204
- fun motion(self) -> Motion =
193
+ fun createAt(sheet: Int, phase: Float, id: Int, at: Motion, clock: Float) -> Node =
205
- Motion(x: self.x + Rules.Half, y: self.y + Rules.Half, vx: self.vx, vy: self.vy)
194
+ n := PureScene.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
195
+ Orb(vx: at.vx, vy: at.vy, phase: phase, clock: clock, tex: sheet))
196
+ n.originX = Rules.Half
197
+ n.originY = Rules.Half
198
+ n
206
199
 
207
- override fun act(own self, dt: Float) -> Actor =
200
+ override fun act(self, own n: Node, dt: Float) -> Node =
208
- m := driftOf(self.motion(), Rules.OrbRadius, dt)
201
+ m := driftOf(Motion(x: n.x + Rules.Half, y: n.y + Rules.Half, vx: self.vx, vy: self.vy), Rules.OrbRadius, dt)
209
202
  self.clock = self.clock + dt
210
- self.x = m.x - Rules.Half
203
+ n.x = m.x - Rules.Half
211
- self.y = m.y - Rules.Half
204
+ n.y = m.y - Rules.Half
212
205
  self.vx = m.vx
213
206
  self.vy = m.vy
214
- self
207
+ n
215
208
 
216
- override fun draw(self, m: Affine, tint: Color) =
209
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
217
- c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
210
+ c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
218
211
  k := m.scaleX()
219
212
  blendMode(Blend.Add)
220
213
  drawCircle(c.x, c.y, 26.0 * k, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5 * tint.a))
221
214
  drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * tint.a))
222
215
  blendMode(Blend.Normal)
223
216
  frame := Float(frameOf(self.clock + self.phase, 4, 8.0))
224
- pureDrawSprite(m, tint, self.width, self.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
217
+ pureDrawSprite(m, tint, n.width, n.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
225
218
 
226
219
  # A mine drifting about, turning `spin` radians per second (0 once the game is over).
227
- own record Mine(
228
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
229
- vx: Float, vy: Float, spin: Float, clock: Float, tex: Int) =
220
+ record Mine(Actor, vx: Float, vy: Float, spin: Float, clock: Float, tex: Int) =
230
221
 
231
- fun create(sheet: Int, id: Int, at: Motion, spin: Float) -> Mine =
222
+ fun create(sheet: Int, id: Int, at: Motion, spin: Float) -> Node =
232
- Mine(
233
- id: id, x: at.x - Rules.Half, y: at.y - Rules.Half, width: Rules.Size, height: Rules.Size,
234
- originX: Rules.Half, originY: Rules.Half, scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
235
- color: Color.White, visible: true, touchable: Disabled, actions: Slice[Action](),
236
- vx: at.vx, vy: at.vy, spin: spin, clock: 0.0, tex: sheet)
223
+ Mine.createAt(sheet, id, at, spin, 0.0)
237
224
 
225
+ fun createAt(sheet: Int, id: Int, at: Motion, spin: Float, clock: Float) -> Node =
226
+ n := PureScene.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
227
+ Mine(vx: at.vx, vy: at.vy, spin: spin, clock: clock, tex: sheet))
228
+ n.originX = Rules.Half
229
+ n.originY = Rules.Half
230
+ n
238
231
 
239
- fun motion(self) -> Motion =
240
- Motion(x: self.x + Rules.Half, y: self.y + Rules.Half, vx: self.vx, vy: self.vy)
241
-
242
- override fun act(own self, dt: Float) -> Actor =
232
+ override fun act(self, own n: Node, dt: Float) -> Node =
243
- m := driftOf(self.motion(), Rules.MineRadius, dt)
233
+ m := driftOf(Motion(x: n.x + Rules.Half, y: n.y + Rules.Half, vx: self.vx, vy: self.vy), Rules.MineRadius, dt)
244
234
  self.clock = self.clock + dt
245
- self.x = m.x - Rules.Half
235
+ n.x = m.x - Rules.Half
246
- self.y = m.y - Rules.Half
236
+ n.y = m.y - Rules.Half
247
237
  self.vx = m.vx
248
238
  self.vy = m.vy
249
- self.rotation = self.rotation + self.spin * dt
239
+ n.rotation = n.rotation + self.spin * dt
250
- self
240
+ n
251
241
 
252
- override fun draw(self, m: Affine, tint: Color) =
242
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
253
243
  frame := Float(frameOf(self.clock, 4, 4.0))
254
- pureDrawSprite(m, tint, self.width, self.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
244
+ pureDrawSprite(m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
255
245
 
256
246
  # A background star that twinkles.
257
- own record Star(
258
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
259
- seed: Float, clock: Float) =
247
+ record Star(Actor, seed: Float, clock: Float) =
260
248
 
261
- fun create(i: Int) -> Star =
249
+ fun create(i: Int) -> Node =
262
250
  r := 1.0 + Float(i % 3) * 0.5
263
- Star(
264
- id: Id.Star + i, x: Float({i * 7919} % 800) - r, y: Float({i * 104729} % 600) - r,
251
+ PureScene.custom(Id.Star + i, Float({i * 7919} % 800) - r, Float({i * 104729} % 600) - r, r * 2.0, r * 2.0,
265
- width: r * 2.0, height: r * 2.0, originX: 0.0, originY: 0.0, scaleX: 1.0, scaleY: 1.0,
266
- rotation: 0.0, color: Color.White, visible: true, touchable: Disabled, actions: Slice[Action](),
267
- seed: Float(i), clock: 0.0)
252
+ Star(seed: Float(i), clock: 0.0))
268
-
269
253
 
270
- override fun act(own self, dt: Float) -> Actor =
254
+ override fun act(self, own n: Node, dt: Float) -> Node =
271
255
  self.clock = self.clock + dt
272
- self
256
+ n
273
257
 
274
- override fun draw(self, m: Affine, tint: Color) =
258
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
275
259
  twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + self.seed)
276
- r := self.width / 2.0
260
+ r := n.width / 2.0
277
261
  c := m.apply(Vec2(x: r, y: r))
278
262
  drawCircle(c.x, c.y, r * m.scaleX(), Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle * tint.a))
279
263
 
280
264
  # --- reading and changing the tree ---
281
265
 
282
266
  # The child of `a` reached by `path` (not empty), borrowed from the tree.
283
- fun nodeAt(a: Actor, path: Slice[Int], depth: Int) -> Actor =
267
+ fun nodeAt(a: Node, path: Slice[Int], depth: Int) -> Node =
284
268
  ks := kidsOf(a)
285
269
  i := path.at(depth)
286
270
  if depth + 1 >= path.length()
287
271
  return ks.at(i)
288
272
  nodeAt(ks.at(i), path, depth + 1)
289
273
 
290
- # The actor with `id` below `root`, searched depth first, borrowed from the tree.
274
+ # The node with `id` below `root`, searched depth first, borrowed from the tree.
291
- fun find(root: Actor, id: Int) -> Actor =
275
+ fun find(root: Node, id: Int) -> Node =
292
276
  path := SceneTree.findPath(root, id) ?: crash "no such actor"
293
277
  nodeAt(root, path, 0)
294
278
 
295
- fun kidsOf(a: Actor) -> Slice[Actor] =
279
+ fun kidsOf(n: Node) -> Slice[Node] =
296
- a.kids() ?: crash "not a group"
280
+ PureScene.childrenOf(n) ?: crash "not a group"
297
281
 
298
- fun setVisible(own a: Actor, v: Bool) -> Actor =
282
+ fun setVisible(own n: Node, v: Bool) -> Node =
299
- a.visible = v
283
+ n.visible = v
300
- a
284
+ n
301
285
 
302
286
  # The group `g` with `k` added as its last child.
303
- fun addKid(own g: Actor, own k: Actor) -> Actor =
287
+ fun addKid(own g: Node, own k: Node) -> Node =
304
288
  ks := kidsOf(g)
305
289
  ks.push(k)
306
290
  g
307
291
 
308
- fun clockOf(a: Actor) -> Float =
292
+ fun actorClock(a: Actor) -> Float =
309
293
  match a
310
- Mine(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, clock, _) => clock
294
+ Mine(_, _, _, clock, _) => clock
311
- Orb(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, clock, _) => clock
295
+ Orb(_, _, _, clock, _) => clock
312
- Star(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, clock) => clock
296
+ Star(_, clock) => clock
313
297
  _ => 0.0
314
298
 
299
+ fun clockOf(n: Node) -> Float =
300
+ match n.kind
301
+ Custom(a) => actorClock(a)
302
+ _ => 0.0
303
+
315
- fun textOf(a: Actor) -> Str =
304
+ fun textOf(n: Node) -> Str =
316
- match a
305
+ match n.kind
317
- Label(_, _, _, _, _, _, _, _, _, _, _, _, _, _, text, _) => text
306
+ Label(text, _) => text
318
307
  _ => ""
319
308
 
320
- fun hudLabel(id: Int, text: Str, x: Float, y: Float, size: Float, c: Color) -> Actor =
309
+ fun hudLabel(id: Int, text: Str, x: Float, y: Float, size: Float, c: Color) -> Node =
321
310
  l := PureScene.label(id, text, x, y, size)
322
311
  l.color = c
323
312
  l
324
313
 
325
- fun icon(sheet: Int, i: Int) -> Actor =
314
+ fun icon(sheet: Int, i: Int) -> Node =
326
315
  s := PureScene.sprite(Id.Icon + i, sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0)
327
- s.srcX = 0.0
328
- s.srcY = 0.0
329
- s.srcW = Rules.Cell
316
+ s.kind = Sprite(tex: sheet, srcX: 0.0, srcY: 0.0, srcW: Rules.Cell, srcH: Rules.Cell)
330
- s.srcH = Rules.Cell
331
317
  s
332
318
 
333
319
  # The whole scene: backdrop, stars, mines, orbs, ship and HUD layers, in drawing order.
334
320
  # The layer groups are 0 x 0 so they never swallow a click; the HUD holds the
335
321
  # game-over overlay with its restart button.
336
- fun buildRoot(rng: Random, sheet: Int, best: Int) -> Actor =
322
+ fun buildRoot(rng: Random, sheet: Int, best: Int) -> Node =
337
323
  ship := newShip()
338
- stars := Slice[Actor]()
324
+ stars := Slice[Node]()
339
325
  for i := range 40
340
326
  stars.push(Star.create(i))
341
- mines := Slice[Actor]()
327
+ mines := Slice[Node]()
342
- orbs := Slice[Actor]()
328
+ orbs := Slice[Node]()
343
329
  for i := range Rules.OrbCount
344
330
  orbs.push(Orb.create(sheet, Float(i) * 0.1, Id.Orb + i, spawn(rng, ship.x, ship.y, 120.0, 40.0)))
345
331
  for i := range mineCount(0)
346
332
  mines.push(Mine.create(sheet, Id.Mine + i, spawn(rng, ship.x, ship.y, 220.0, 90.0), 2.0))
347
- over := Slice[Actor]()
348
- over.push(Panel.create(Id.OverlayPanel, 0.0, 230.0, Rules.ArenaW, 170.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)))
349
- over.push(hudLabel(Id.OverTitle, "Game over", 300.0, 244.0, 48.0, Color.Red))
350
- over.push(hudLabel(Id.OverHint, "Press Enter to play again", 270.0, 300.0, 24.0, Color.White))
351
333
  btn := PureScene.button(Id.Restart, "Again", sheet, sheet, 340.0, 342.0, 120.0, 44.0)
352
334
  btn.color = Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9)
353
335
  btn.visible = false
354
- over.push(btn)
336
+ over := Slice(
337
+ Panel.create(Id.OverlayPanel, 0.0, 230.0, Rules.ArenaW, 170.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
338
+ hudLabel(Id.OverTitle, "Game over", 300.0, 244.0, 48.0, Color.Red),
339
+ hudLabel(Id.OverHint, "Press Enter to play again", 270.0, 300.0, 24.0, Color.White),
340
+ btn)
355
341
  overlay := PureScene.group(Id.Overlay, 0.0, 0.0, 0.0, 0.0, over)
356
342
  overlay.visible = false
357
- hud := Slice[Actor]()
343
+ hud := Slice(
358
- hud.push(hudLabel(Id.Score, "Score 0", 16.0, 12.0, 28.0, Color.White))
344
+ hudLabel(Id.Score, "Score 0", 16.0, 12.0, 28.0, Color.White),
359
- hud.push(hudLabel(Id.Best, "Best {best}", 16.0, 44.0, 20.0, Color.Yellow))
345
+ hudLabel(Id.Best, "Best {best}", 16.0, 44.0, 20.0, Color.Yellow))
360
346
  for i := range Rules.StartLives
361
347
  hud.push(icon(sheet, i))
362
348
  hud.push(overlay)
363
- layers := Slice[Actor]()
349
+ layers := Slice(
364
- layers.push(Panel.create(Id.Backdrop, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)))
350
+ Panel.create(Id.Backdrop, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)),
365
- layers.push(PureScene.group(Id.StarLayer, 0.0, 0.0, 0.0, 0.0, stars))
351
+ PureScene.group(Id.StarLayer, 0.0, 0.0, 0.0, 0.0, stars),
366
- layers.push(PureScene.group(Id.MineLayer, 0.0, 0.0, 0.0, 0.0, mines))
352
+ PureScene.group(Id.MineLayer, 0.0, 0.0, 0.0, 0.0, mines),
367
- layers.push(PureScene.group(Id.OrbLayer, 0.0, 0.0, 0.0, 0.0, orbs))
353
+ PureScene.group(Id.OrbLayer, 0.0, 0.0, 0.0, 0.0, orbs),
368
- layers.push(Ship.create(sheet, ship))
354
+ Ship.create(sheet, ship),
369
- layers.push(PureScene.group(Id.Hud, 0.0, 0.0, 0.0, 0.0, hud))
355
+ PureScene.group(Id.Hud, 0.0, 0.0, 0.0, 0.0, hud))
370
356
  PureScene.group(Id.Root, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, layers)
371
357
 
372
358
  # --- the world ---
@@ -398,7 +384,7 @@ fun newWorld(seed: Int, sheet: Int) -> World =
398
384
  lives: Rules.StartLives, over: false, clock: 0.0, wasDown: false, sheet: sheet,
399
385
  pickupSound: -1, hitSound: -1, overSound: -1, musicSound: -1)
400
386
 
401
- fun swap(w: World, id: Int, f: fn(Actor) -> Actor) =
387
+ fun swap(w: World, id: Int, f: fn(Node) -> Node) =
402
388
  w.stage.root = SceneTree.modify(w.stage.root, id, f)
403
389
 
404
390
  # Starts a fresh game, keeping the best score, the camera and the random stream.
@@ -436,32 +422,29 @@ fun freeze(w: World) =
436
422
  sheet := w.sheet
437
423
  swap(w, id, |a| stillMine(sheet, id, a))
438
424
 
439
- fun orbActor(sheet: Int, phase: Float, id: Int, at: Motion) -> Actor =
425
+ fun orbActor(sheet: Int, phase: Float, id: Int, at: Motion) -> Node =
440
426
  Orb.create(sheet, phase, id, at)
441
427
 
442
428
  # A new orb that carries on `old`'s animation clock, so a respawn or freeze never jumps a frame.
443
- fun orbKeepClock(sheet: Int, phase: Float, id: Int, at: Motion, old: Actor) -> Actor =
429
+ fun orbKeepClock(sheet: Int, phase: Float, id: Int, at: Motion, old: Node) -> Node =
444
- o := Orb.create(sheet, phase, id, at)
430
+ Orb.createAt(sheet, phase, id, at, clockOf(old))
445
- o.clock = clockOf(old)
446
- o
447
431
 
448
- fun mineActor(sheet: Int, id: Int, at: Motion, spin: Float) -> Actor =
432
+ fun mineActor(sheet: Int, id: Int, at: Motion, spin: Float) -> Node =
449
433
  Mine.create(sheet, id, at, spin)
450
434
 
451
- fun stillMine(sheet: Int, id: Int, a: Actor) -> Actor =
435
+ fun stillMine(sheet: Int, id: Int, a: Node) -> Node =
452
- m := Mine.create(sheet, id, Motion(x: cx(a), y: cy(a), vx: 0.0, vy: 0.0), 0.0)
436
+ m := Mine.createAt(sheet, id, Motion(x: cx(a), y: cy(a), vx: 0.0, vy: 0.0), 0.0, clockOf(a))
453
437
  m.rotation = a.rotation
454
- m.clock = clockOf(a)
455
438
  m
456
439
 
457
- # Moves the ship actor to the ship state; it blinks while it is hurt, and is hidden once the game is over.
440
+ # Moves the ship node to the ship state; it blinks while it is hurt, and is hidden once the game is over.
458
441
  fun showShip(w: World) =
459
442
  blink := w.ship.hurt > 0.0 && frameOf(w.clock, 2, 10.0) == 0
460
443
  visible := !w.over && !blink
461
444
  s := w.ship
462
445
  swap(w, Id.Ship, |a| shipActor(a, s, visible))
463
446
 
464
- fun shipActor(own a: Actor, s: ShipState, visible: Bool) -> Actor =
447
+ fun shipActor(own a: Node, s: ShipState, visible: Bool) -> Node =
465
448
  a.x = s.x - Rules.Half
466
449
  a.y = s.y - Rules.Half
467
450
  a.rotation = s.rotation
@@ -662,12 +645,13 @@ test "drifters bounce off the walls"
662
645
  b := driftOf(Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0), Rules.OrbRadius, 0.1)
663
646
  assert b.vx == -100.0
664
647
  assert b.x == Rules.ArenaW - 10.0
665
- orb := Orb.create(-1, 0.0, Id.Orb, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0)).act(0.1)
648
+ orb := Orb.create(-1, 0.0, Id.Orb, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0))
649
+ moved := SceneTree.update(orb, 0.1)
666
- assert near(cx(orb), Rules.ArenaW - 10.0)
650
+ assert near(cx(moved), Rules.ArenaW - 10.0)
667
651
 
668
652
  test "a mine spins as it drifts"
669
653
  mine := Mine.create(-1, Id.Mine, Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0), 2.0)
670
- next := mine.act(0.5)
654
+ next := SceneTree.update(mine, 0.5)
671
655
  assert near(next.rotation, 1.0)
672
656
  assert near(cx(next), 405.0)
673
657
 
plum-game/web-test/fixtures/stress_pure_scene.plum CHANGED
@@ -1,7 +1,7 @@
1
1
  # The stress scene of stress_scene.plum on the PURE scene graph (std/game/PureScene):
2
2
  # 5000 sprites in 50 slowly rotating groups, 500 rects and 200 circles (sprites of a
3
3
  # 1x1 white texture), 50 texts with a changing number, drawn through a camera. Every
4
- # actor is an own value: each frame changes the tree in place with SceneTree.update. Group and
4
+ # node is an own value: each frame changes the tree in place with SceneTree.update. Group and
5
5
  # sprite spin comes from `forever(rotateBy(...))` actions; drifting and counting
6
6
  # actors do theirs in `act`.
7
7
  module stress_pure_scene
@@ -43,71 +43,60 @@ fun caption(m: Affine, tint: Color, s: Str, size: Float) =
43
43
  drawText(s, p.x, p.y, size * m.scaleY(), tint)
44
44
 
45
45
  # A square of the white texture that moves by (dx, dy) per frame and wraps.
46
- own record Drifter(
47
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
48
- tex: Int, dx: Float, dy: Float) =
46
+ record Drifter(Actor, tex: Int, dx: Float, dy: Float) =
49
- override fun act(own self, dt: Float) -> Actor =
47
+ override fun act(self, own n: Node, dt: Float) -> Node =
50
- self.x = self.x + self.dx
48
+ n.x = n.x + self.dx
51
- self.y = self.y + self.dy
49
+ n.y = n.y + self.dy
52
- if self.x > Float(Counts.WorldW)
50
+ if n.x > Float(Counts.WorldW)
53
- self.x = self.x - Float(Counts.WorldW)
51
+ n.x = n.x - Float(Counts.WorldW)
54
- if self.y > Float(Counts.WorldH)
52
+ if n.y > Float(Counts.WorldH)
55
- self.y = self.y - Float(Counts.WorldH)
53
+ n.y = n.y - Float(Counts.WorldH)
56
- self
54
+ n
57
-
55
+
58
- override fun draw(self, m: Affine, tint: Color) =
56
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
59
- quad(m, tint, self.width, self.height, self.tex, 0.0, 0.0, 0.0, 0.0)
57
+ quad(m, tint, n.width, n.height, self.tex, 0.0, 0.0, 0.0, 0.0)
60
58
 
61
59
  # Shows "n <count>" and counts up each frame.
62
- own record CountLabel(
63
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
64
- n: Int, text: Str) =
60
+ record CountLabel(Actor, n: Int, text: Str) =
65
- override fun act(own self, dt: Float) -> Actor =
61
+ override fun act(self, own n: Node, dt: Float) -> Node =
66
62
  self.n = self.n + 1
67
63
  self.text = "n {self.n}"
68
- self
64
+ n
69
65
 
70
- override fun draw(self, m: Affine, tint: Color) =
66
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
71
67
  caption(m, tint, self.text, 14.0)
72
68
 
73
- fun drifter(id: Int, tex: Int, c: Color, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: Float) -> Actor =
69
+ fun drifter(id: Int, tex: Int, c: Color, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: Float) -> Node =
74
- d := Drifter(
75
- id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
76
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
77
- touchable: Disabled, actions: Slice[Action](), tex: tex, dx: dx, dy: dy)
70
+ d := Drifter(tex: tex, dx: dx, dy: dy)
78
71
  me: Actor := d
72
+ n := PureScene.custom(id, x, y, w, h, me)
73
+ n.color = c
79
- me
74
+ n
80
75
 
81
- fun countLabel(id: Int, n: Int, x: Float, y: Float) -> Actor =
76
+ fun countLabel(id: Int, n: Int, x: Float, y: Float) -> Node =
82
- l := CountLabel(
83
- id: id, x: x, y: y, width: 60.0, height: 14.0, originX: 0.0, originY: 0.0,
84
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
85
- touchable: Disabled, actions: Slice[Action](), n: n, text: "n {n}")
77
+ l := CountLabel(n: n, text: "n {n}")
86
78
  me: Actor := l
87
- me
79
+ PureScene.custom(id, x, y, 60.0, 14.0, me)
88
80
 
89
81
  fun spinning(rate: Float) -> Slice[Action] =
90
82
  acts := Slice[Action]()
91
83
  acts.push(Actions.forever(Actions.rotateBy(rate, 1.0)))
92
84
  acts
93
85
 
94
- fun dancer(id: Int, sheet: Int, j: Int, i: Int) -> Actor =
86
+ fun dancer(id: Int, sheet: Int, j: Int, i: Int) -> Node =
95
87
  s := PureScene.sprite(id, sheet, Float({j * j * 13 + j * 37} % 150), Float({j * j * 29 + j * 53} % 170), 16.0, 16.0)
96
88
  s.originX = 8.0
97
89
  s.originY = 8.0
98
90
  s.rotation = Float(i)
99
- s.srcX = Float(i % 4 * 16)
91
+ s.kind = Sprite(tex: sheet, srcX: Float(i % 4 * 16), srcY: 0.0, srcW: 16.0, srcH: 16.0)
100
- s.srcW = 16.0
101
- s.srcH = 16.0
102
92
  s.actions = spinning(1.2)
103
- me: Actor := s
104
- me
93
+ s
105
94
 
106
95
  fun buildWorld(sheet: Int, white: Int) -> World =
107
- root := Slice[Actor]()
96
+ root := Slice[Node]()
108
97
  per := Counts.Sprites / Counts.Groups
109
98
  for gi := range Counts.Groups
110
- kids := Slice[Actor]()
99
+ kids := Slice[Node]()
111
100
  for j := range per
112
101
  i := gi * per + j
113
102
  kids.push(dancer(1000 + i, sheet, j, i))
@@ -115,15 +104,14 @@ fun buildWorld(sheet: Int, white: Int) -> World =
115
104
  g.originX = 75.0
116
105
  g.originY = 85.0
117
106
  g.actions = spinning(0.24)
118
- me: Actor := g
119
- root.push(me)
107
+ root.push(g)
120
108
  for i := range Counts.Rects
121
109
  root.push(drifter(10000 + i, white, hue(i), Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH), 12.0, 8.0, 2.0, 0.0))
122
110
  for i := range Counts.Circles
123
111
  root.push(drifter(20000 + i, white, hue(i + 3), Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH), 12.0, 12.0, 3.0, 1.0))
124
112
  for i := range Counts.Texts
125
113
  root.push(countLabel(30000 + i, i, Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)))
126
- top: Actor := PureScene.group(1, 0.0, 0.0, Float(Counts.WorldW), Float(Counts.WorldH), root)
114
+ top := PureScene.group(1, 0.0, 0.0, Float(Counts.WorldW), Float(Counts.WorldH), root)
127
115
  cam := Affine(a: 0.8, b: 0.0, c: 0.0, d: 0.8, e: 0.0, f: 0.0)
128
116
  World(sheet: sheet, stage: Stage(root: top, camera: cam, captured: null))
129
117
 
@@ -151,7 +139,7 @@ test "ten frames of update run"
151
139
  w := buildWorld(0, 0)
152
140
  for i := range 10
153
141
  update(w, 0.016)
154
- if k := w.stage.root.kids()
142
+ if k := PureScene.childrenOf(w.stage.root)
155
143
  assert k.length() == 50 + 500 + 200 + 50
156
144
  assert {k.at(50).x - 20.0}.abs() < 0.000001
157
145
  else
plum-std/game/Actions.plum CHANGED
@@ -14,20 +14,18 @@ import std/Float
14
14
  # The animated part of an actor's transform.
15
15
  struct Pose(x: Float, y: Float, rotation: Float, scaleX: Float, scaleY: Float)
16
16
 
17
+ # One action. `elapsed` and `current` (the running lap of a `Forever`, null
18
+ # before it starts) are the mutable state; `kind` is the immutable description.
19
+ # A `Forever`'s `template` is the pristine action restarted each lap.
20
+ record Action(elapsed: Float, current: Action?, kind: ActionKind)
21
+
22
+ enum ActionKind =
17
- record MoveBy(dx: Float, dy: Float, duration: Float, elapsed: Float)
23
+ | MoveBy(dx: Float, dy: Float, duration: Float)
18
-
19
- record RotateBy(radians: Float, duration: Float, elapsed: Float)
24
+ | RotateBy(radians: Float, duration: Float)
20
-
21
- record Delay(duration: Float, elapsed: Float)
25
+ | Delay(duration: Float)
22
-
23
- record Sequence(steps: Slice[Action])
26
+ | Sequence(steps: Slice[Action])
24
-
25
- record Parallel(steps: Slice[Action])
27
+ | Parallel(steps: Slice[Action])
26
-
27
- # `template` is the pristine action restarted each lap, `current` the lap in progress.
28
- record Forever(template: Action, current: Action)
28
+ | Forever(template: Action)
29
-
30
- enum Action = MoveBy | RotateBy | Delay | Sequence | Parallel | Forever
31
29
 
32
30
  # How much of a timed action one step covers. A zero-length action finishes at
33
31
  # once and applies all of itself (no 0/0).
@@ -39,40 +37,41 @@ struct Step(pose: Pose, spare: Float, done: Bool)
39
37
 
40
38
  primitive Actions =
41
39
  fun moveBy(dx: Float, dy: Float, secs: Float) -> Action =
42
- MoveBy(dx: dx, dy: dy, duration: secs, elapsed: 0.0)
40
+ Action(elapsed: 0.0, current: null, kind: MoveBy(dx: dx, dy: dy, duration: secs))
43
41
 
44
42
  fun rotateBy(radians: Float, secs: Float) -> Action =
45
- RotateBy(radians: radians, duration: secs, elapsed: 0.0)
43
+ Action(elapsed: 0.0, current: null, kind: RotateBy(radians: radians, duration: secs))
46
44
 
47
45
  fun delay(secs: Float) -> Action =
48
- Delay(duration: secs, elapsed: 0.0)
46
+ Action(elapsed: 0.0, current: null, kind: Delay(duration: secs))
49
47
 
50
48
  fun sequence(steps: Slice[Action]) -> Action =
51
- Sequence(steps: steps)
49
+ Action(elapsed: 0.0, current: null, kind: Sequence(steps: steps))
52
50
 
53
51
  fun parallel(steps: Slice[Action]) -> Action =
54
- Parallel(steps: steps)
52
+ Action(elapsed: 0.0, current: null, kind: Parallel(steps: steps))
55
53
 
56
54
  fun forever(a: Action) -> Action =
57
- Forever(template: Actions.fresh(a), current: a)
55
+ Action(elapsed: 0.0, current: a, kind: Forever(template: Actions.fresh(a)))
58
56
 
59
57
  # An unstarted copy of `a` (elapsed 0, all steps present).
60
58
  fun fresh(a: Action) -> Action =
61
- match a
59
+ match a.kind
62
- MoveBy => MoveBy(dx: a.dx, dy: a.dy, duration: a.duration, elapsed: 0.0)
60
+ MoveBy(dx, dy, duration) => Actions.moveBy(dx, dy, duration)
63
- RotateBy => RotateBy(radians: a.radians, duration: a.duration, elapsed: 0.0)
61
+ RotateBy(radians, duration) => Actions.rotateBy(radians, duration)
64
- Delay => Delay(duration: a.duration, elapsed: 0.0)
62
+ Delay(duration) => Actions.delay(duration)
65
- Sequence =>
63
+ Sequence(steps) =>
66
64
  out := Slice[Action]()
67
- for i := range a.steps.length()
65
+ for i := range steps.length()
68
- out.push(Actions.fresh(a.steps.at(i)))
66
+ out.push(Actions.fresh(steps.at(i)))
69
- Sequence(steps: out)
67
+ Actions.sequence(out)
70
- Parallel =>
68
+ Parallel(steps) =>
71
69
  out := Slice[Action]()
72
- for i := range a.steps.length()
70
+ for i := range steps.length()
73
- out.push(Actions.fresh(a.steps.at(i)))
71
+ out.push(Actions.fresh(steps.at(i)))
74
- Parallel(steps: out)
72
+ Actions.parallel(out)
73
+ Forever(template) =>
75
- Forever => Forever(template: a.template, current: Actions.fresh(a.template))
74
+ Action(elapsed: 0.0, current: Actions.fresh(template), kind: Forever(template: template))
76
75
 
77
76
  fun tick(duration: Float, elapsed: Float, dt: Float) -> Tick =
78
77
  if duration <= 0.0
@@ -81,43 +80,50 @@ primitive Actions =
81
80
  Tick(used: used, frac: used / duration, done: elapsed + used >= duration)
82
81
 
83
82
  fun stepAction(p: Pose, a: Action, dt: Float) -> Step =
84
- match a
83
+ match a.kind
85
- MoveBy =>
84
+ MoveBy(dx, dy, duration) =>
86
- k := Actions.tick(a.duration, a.elapsed, dt)
85
+ k := Actions.tick(duration, a.elapsed, dt)
87
86
  a.elapsed = a.elapsed + k.used
88
- q := Pose(x: p.x + a.dx * k.frac, y: p.y + a.dy * k.frac, rotation: p.rotation, scaleX: p.scaleX, scaleY: p.scaleY)
87
+ q := Pose(x: p.x + dx * k.frac, y: p.y + dy * k.frac, rotation: p.rotation, scaleX: p.scaleX, scaleY: p.scaleY)
89
88
  Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
90
- RotateBy =>
89
+ RotateBy(radians, duration) =>
91
- k := Actions.tick(a.duration, a.elapsed, dt)
90
+ k := Actions.tick(duration, a.elapsed, dt)
92
91
  a.elapsed = a.elapsed + k.used
93
- q := Pose(x: p.x, y: p.y, rotation: p.rotation + a.radians * k.frac, scaleX: p.scaleX, scaleY: p.scaleY)
92
+ q := Pose(x: p.x, y: p.y, rotation: p.rotation + radians * k.frac, scaleX: p.scaleX, scaleY: p.scaleY)
94
93
  Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
95
- Delay =>
94
+ Delay(duration) =>
96
- k := Actions.tick(a.duration, a.elapsed, dt)
95
+ k := Actions.tick(duration, a.elapsed, dt)
97
96
  a.elapsed = a.elapsed + k.used
98
97
  Step(pose: p, spare: if k.done then dt - k.used else 0.0, done: k.done)
99
- Sequence =>
98
+ Sequence(steps) =>
100
- if a.steps.length() == 0
99
+ if steps.length() == 0
101
100
  return Step(pose: p, spare: dt, done: true)
102
- s := Actions.stepAction(p, a.steps.at(0), dt)
101
+ s := Actions.stepAction(p, steps.at(0), dt)
103
102
  if !s.done
104
103
  return Step(pose: s.pose, spare: 0.0, done: false)
105
104
  # The rest starts in this same frame with the time left over; the list
106
105
  # is one shorter on every call, so this terminates.
107
- a.steps.removeAt(0)
106
+ steps.removeAt(0)
108
107
  Actions.stepAction(s.pose, a, s.spare)
109
- Parallel =>
108
+ Parallel(steps) =>
110
- Actions.stepAll(p, a.steps, dt)
109
+ Actions.stepAll(p, steps, dt)
111
- Forever =>
110
+ Forever(template) =>
112
111
  # Restarts at most once per step, with the spare time, so a zero-length
113
112
  # template cannot spin forever inside one step.
113
+ if a.current == null
114
+ a.current = Actions.fresh(template)
115
+ lap := template
116
+ if c := a.current
117
+ lap = c
114
- s := Actions.stepAction(p, a.current, dt)
118
+ s := Actions.stepAction(p, lap, dt)
115
119
  if !s.done
116
120
  return Step(pose: s.pose, spare: 0.0, done: false)
117
- a.current = Actions.fresh(a.template)
121
+ next := Actions.fresh(template)
118
- r := Actions.stepAction(s.pose, a.current, s.spare)
122
+ r := Actions.stepAction(s.pose, next, s.spare)
119
123
  if r.done
120
- a.current = Actions.fresh(a.template)
124
+ a.current = Actions.fresh(template)
125
+ else
126
+ a.current = next
121
127
  Step(pose: r.pose, spare: 0.0, done: false)
122
128
 
123
129
  # Steps every action side by side in place and drops the finished ones by
@@ -146,13 +152,13 @@ fun actNear(a: Float, b: Float) -> Bool =
146
152
  {a - b}.abs() < 0.000001
147
153
 
148
154
  fun actSeqLen(a: Action) -> Int =
149
- match a
155
+ match a.kind
150
- Sequence => a.steps.length()
156
+ Sequence(steps) => steps.length()
151
157
  _ => 0 - 1
152
158
 
153
159
  fun actElapsed(a: Action) -> Float =
154
- match a
160
+ match a.kind
155
- MoveBy => a.elapsed
161
+ MoveBy(_, _, _) => a.elapsed
156
162
  _ => 0.0 - 1.0
157
163
 
158
164
  fun actPose0() -> Pose =
plum-std/game/PureScene.plum CHANGED
@@ -10,11 +10,12 @@ import std/game/Affine
10
10
  import std/game/Game
11
11
  import std/game/Actions
12
12
 
13
- # A pure scene graph: actors are own values. `Actor` is an own trait that own
13
+ # A pure scene graph: a node is an own record (`Node`) holding a `Transform` and
14
- # records embed (`Group`, `Sprite`, `Label`, `Button` here). `onPointer` and
14
+ # an `enum Kind`. Group, Sprite, Label and Button are built in; games add their
15
+ # own behaviour with `Custom(actor: Actor)`, a behaviour-only trait their records
16
+ # embed (like Rust's `Box<dyn Actor>`). `onPointer` takes the node, changes it in
15
- # `act` take the actor, change it in place and hand it back; readers borrow.
17
+ # place and hands it back; readers borrow. Only `drawTree` talks to the host,
16
- # Only `drawTree` talks to the host, through `std/game/Game`.
17
- # `act`, the actions in `actions` and tree-wide updates live in std/game/SceneTree.
18
+ # through `std/game/Game`. Actions and tree-wide updates live in std/game/SceneTree.
18
19
 
19
20
  enum Touchable =
20
21
  | Enabled
@@ -38,104 +39,55 @@ enum UiKind =
38
39
 
39
40
  record UiEvent(id: Int, kind: UiKind)
40
41
 
41
- # The actor after handling an event, and the events it raised.
42
+ # The node's position, size, origin, scale and rotation.
42
- own record Handled(actor: Actor, events: Slice[UiEvent]) =
43
+ record Transform(x: Float, y: Float, width: Float, height: Float, originX: Float, originY: Float, scaleX: Float, scaleY: Float, rotation: Float) =
43
-
44
+
44
- # The actor, moved out; read `events` first, they stay behind.
45
+ # The transform into the parent's coordinates: translate to the position,
46
+ # rotate and scale about the origin.
45
- fun intoActor(own self) -> Actor =
47
+ fun localMatrix(self) -> Affine =
48
+ cs := Math.cos(self.rotation)
49
+ sn := Math.sin(self.rotation)
46
- match self
50
+ ma := cs * self.scaleX
47
- Handled(a, _) => a
51
+ mb := sn * self.scaleX
48
-
52
+ mc := 0.0 - sn * self.scaleY
49
- own trait Actor(
53
+ md := cs * self.scaleY
50
- id: Int,
54
+ Affine(
51
- x: Float, y: Float, width: Float, height: Float,
52
- originX: Float, originY: Float,
53
- scaleX: Float, scaleY: Float,
55
+ a: ma, b: mb, c: mc, d: md,
54
- rotation: Float,
55
- color: Color,
56
- visible: Bool,
57
- touchable: Touchable,
58
- actions: Slice[Action]) =
59
-
60
- # This actor after `dt` seconds of its own behaviour; the default is itself.
61
- fun act(own self, dt: Float) -> Actor = self
62
-
63
- # The children of a group; null for a leaf.
64
- fun kids(self) -> Slice[Actor]? = null
65
-
66
- # Paints this actor only, at its stage transform `m` with the combined `tint`.
67
- fun draw(self, m: Affine, tint: Color) =
68
- return
69
-
70
- # Whether `p`, in this actor's own coordinates, is on it.
71
- fun hit(self, p: Vec2) -> Bool =
72
- self.touchable == Enabled && p.x >= 0.0 && p.x <= self.width && p.y >= 0.0 && p.y <= self.height
73
-
74
- # Handles a pointer event and returns the new actor with the events it raised.
75
- fun onPointer(own self, e: Pointer) -> Handled =
76
- Handled(actor: self, events: Slice[UiEvent]())
77
-
78
- own record Group(
79
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
80
- children: Slice[Actor]) =
81
-
82
-
83
- override fun kids(self) -> Slice[Actor]? = self.children
56
+ e: self.x + self.originX - {ma * self.originX + mc * self.originY},
84
-
85
- own record Sprite(
86
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
87
- tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
57
+ f: self.y + self.originY - {mb * self.originX + md * self.originY})
88
-
89
58
 
59
+ # Whether `p`, in the node's own coordinates, is inside its box.
90
- override fun draw(self, m: Affine, tint: Color) =
60
+ fun contains(self, p: Vec2) -> Bool =
91
- pureDrawSprite(m, tint, self.width, self.height, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
61
+ p.x >= 0.0 && p.x <= self.width && p.y >= 0.0 && p.y <= self.height
92
62
 
93
- own record Label(
63
+ own record Node(
94
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
64
+ Transform(x, y, width, height, originX, originY, scaleX, scaleY, rotation),
95
- text: Str, size: Float) =
65
+ id: Int, color: Color, visible: Bool, touchable: Touchable, actions: Slice[Action], kind: Kind)
96
66
 
67
+ enum Kind =
68
+ | Group(children: Slice[Node])
69
+ | Sprite(tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float)
70
+ | Label(text: Str, size: Float)
71
+ | Button(text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool)
72
+ | Custom(actor: Actor)
97
73
 
98
- override fun draw(self, m: Affine, tint: Color) =
74
+ # Game behaviour for a `Custom` node. The defaults do nothing.
99
- pureDrawText(m, tint, self.text, self.size)
75
+ trait Actor =
100
76
 
101
- own record Button(
102
- Actor(id, x, y, width, height, originX, originY, scaleX, scaleY, rotation, color, visible, touchable, actions),
77
+ # The node after `dt` seconds of this actor's behaviour; the default is itself.
103
- text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool) =
78
+ fun act(self, own n: Node, dt: Float) -> Node = n
104
79
 
80
+ # Paints the node at its stage transform `m` with the combined `tint`.
81
+ fun draw(self, n: Node, m: Affine, tint: Color) =
82
+ return
105
83
 
84
+ # The node after handling an event, and the events it raised.
106
- override fun draw(self, m: Affine, tint: Color) =
85
+ own record Handled(node: Node, events: Slice[UiEvent]) =
107
- t := self.upTex
108
- if self.pressed && self.over
109
- t = self.downTex
110
- pureDrawSprite(m, tint, self.width, self.height, t, 0.0, 0.0, 0.0, 0.0)
111
- size := self.height * 0.5
112
- ox := {self.width - size * 0.6 * Float(self.text.length())} / 2.0
113
- oy := {self.height - size} / 2.0
114
- pureDrawText(m.mul(Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: ox, f: oy)), tint, self.text, size)
115
86
 
87
+ # The node, moved out; read `events` first, they stay behind.
116
- override fun onPointer(own self, e: Pointer) -> Handled =
88
+ fun intoNode(own self) -> Node =
117
- ev := Slice[UiEvent]()
118
- id := self.id
89
+ match self
119
- wasOver := self.over
120
- wasPressed := self.pressed
121
- if e.kind == Down
122
- self.pressed = true
123
- self.over = e.over
124
- ev.push(UiEvent(id: id, kind: Press))
125
- else if e.kind == Move
126
- self.over = e.over
127
- if e.over && !wasOver
128
- ev.push(UiEvent(id: id, kind: Enter))
129
- if !e.over && wasOver
130
- ev.push(UiEvent(id: id, kind: Exit))
131
- else
132
- self.over = e.over
133
- if wasPressed
134
- self.pressed = false
135
- ev.push(UiEvent(id: id, kind: Release))
136
- if e.over
137
- ev.push(UiEvent(id: id, kind: Click))
138
- Handled(actor: self, events: ev)
90
+ Handled(n, _) => n
139
91
 
140
92
  fun pureDrawSprite(m: Affine, tint: Color, w: Float, h: Float, tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
141
93
  sx := w * m.scaleX()
@@ -150,64 +102,101 @@ fun pureDrawText(m: Affine, tint: Color, s: Str, size: Float) =
150
102
  fun pureMulColor(a: Color, b: Color) -> Color =
151
103
  Color(r: a.r * b.r, g: a.g * b.g, b: a.b * b.b, a: a.a * b.a)
152
104
 
105
+ fun pureDrawButton(n: Node, m: Affine, tint: Color, text: Str, upTex: Int, downTex: Int, pressed: Bool, over: Bool) =
106
+ t := upTex
107
+ if pressed && over
108
+ t = downTex
109
+ pureDrawSprite(m, tint, n.width, n.height, t, 0.0, 0.0, 0.0, 0.0)
110
+ size := n.height * 0.5
111
+ ox := {n.width - size * 0.6 * Float(text.length())} / 2.0
112
+ oy := {n.height - size} / 2.0
113
+ pureDrawText(m.mul(Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: ox, f: oy)), tint, text, size)
114
+
115
+ fun pureNode(id: Int, x: Float, y: Float, w: Float, h: Float, touchable: Touchable, kind: Kind) -> Node =
116
+ Node(
117
+ x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
118
+ scaleX: 1.0, scaleY: 1.0, rotation: 0.0,
119
+ id: id, color: Color.White, visible: true, touchable: touchable,
120
+ actions: Slice[Action](), kind: kind)
121
+
153
122
  primitive PureScene =
154
123
 
155
- # The actor's transform into its parent's coordinates: translate to the
124
+ # The node's transform into its parent's coordinates.
156
- # position, rotate and scale about the origin.
157
- fun localMatrix(a: Actor) -> Affine =
125
+ fun localMatrix(n: Node) -> Affine =
158
- cs := Math.cos(a.rotation)
126
+ n.localMatrix()
159
- sn := Math.sin(a.rotation)
160
- ma := cs * a.scaleX
161
- mb := sn * a.scaleX
162
- mc := 0.0 - sn * a.scaleY
163
- md := cs * a.scaleY
164
- Affine(
165
- a: ma, b: mb, c: mc, d: md,
166
- e: a.x + a.originX - {ma * a.originX + mc * a.originY},
167
- f: a.y + a.originY - {mb * a.originX + md * a.originY})
168
127
 
169
- # Draws the actor and its children given the parent's matrix and tint.
128
+ # Draws the node and its children given the parent's matrix and tint.
170
- fun drawTree(a: Actor, parent: Affine, tint: Color) =
129
+ fun drawTree(n: Node, parent: Affine, tint: Color) =
171
- if !a.visible
130
+ if !n.visible
172
131
  return
173
- m := parent * PureScene.localMatrix(a)
132
+ m := parent * n.localMatrix()
174
- t := pureMulColor(tint, a.color)
133
+ t := pureMulColor(tint, n.color)
175
- a.draw(m, t)
176
- match a
134
+ match n.kind
177
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, g) =>
135
+ Group(children) =>
178
- for i := range g.length()
136
+ for i := range children.length()
179
- PureScene.drawTree(g.at(i), m, t)
137
+ PureScene.drawTree(children.at(i), m, t)
138
+ Sprite(tex, srcX, srcY, srcW, srcH) =>
139
+ pureDrawSprite(m, t, n.width, n.height, tex, srcX, srcY, srcW, srcH)
140
+ Label(text, size) =>
141
+ pureDrawText(m, t, text, size)
142
+ Button(text, upTex, downTex, pressed, over) =>
143
+ pureDrawButton(n, m, t, text, upTex, downTex, pressed, over)
144
+ Custom(actor) =>
145
+ actor.draw(n, m, t)
146
+
147
+ # The children of a group, or null for anything else.
148
+ fun childrenOf(n: Node) -> Slice[Node]? =
149
+ match n.kind
150
+ Group(children) => children
151
+ _ => null
152
+
153
+ # Whether `p`, in the node's own coordinates, is on it.
154
+ fun hit(n: Node, p: Vec2) -> Bool =
155
+ n.touchable == Enabled && n.contains(p)
156
+
157
+ # Handles a pointer event and returns the new node with the events it raised.
158
+ # Only a Button reacts.
159
+ fun onPointer(own n: Node, e: Pointer) -> Handled =
160
+ ev := Slice[UiEvent]()
161
+ match n.kind
162
+ Button(text, upTex, downTex, wasPressed, wasOver) =>
163
+ pressed := wasPressed
164
+ over := wasOver
165
+ if e.kind == Down
166
+ pressed = true
167
+ over = e.over
168
+ ev.push(UiEvent(id: n.id, kind: Press))
169
+ else if e.kind == Move
170
+ over = e.over
171
+ if e.over && !wasOver
172
+ ev.push(UiEvent(id: n.id, kind: Enter))
173
+ if !e.over && wasOver
174
+ ev.push(UiEvent(id: n.id, kind: Exit))
175
+ else
176
+ over = e.over
177
+ if wasPressed
178
+ pressed = false
179
+ ev.push(UiEvent(id: n.id, kind: Release))
180
+ if e.over
181
+ ev.push(UiEvent(id: n.id, kind: Click))
182
+ n.kind = Button(text: text, upTex: upTex, downTex: downTex, pressed: pressed, over: over)
180
183
  _ => 0
184
+ Handled(node: n, events: ev)
185
+
186
+ fun group(id: Int, x: Float, y: Float, w: Float, h: Float, children: Slice[Node]) -> Node =
187
+ pureNode(id, x, y, w, h, Enabled, Group(children: children))
188
+
189
+ fun sprite(id: Int, tex: Int, x: Float, y: Float, w: Float, h: Float) -> Node =
190
+ pureNode(id, x, y, w, h, Enabled, Sprite(tex: tex, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0))
181
191
 
182
- # The children of a group, or null for a leaf.
183
- fun childrenOf(a: Actor) -> Slice[Actor]? =
184
- a.kids()
185
-
186
- fun group(id: Int, x: Float, y: Float, w: Float, h: Float, children: Slice[Actor]) -> Group =
187
- Group(
188
- id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
189
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
190
- touchable: Enabled, actions: Slice[Action](), children: children)
191
-
192
- fun sprite(id: Int, tex: Int, x: Float, y: Float, w: Float, h: Float) -> Sprite =
193
- Sprite(
194
- id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
195
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
196
- touchable: Enabled, actions: Slice[Action](),
197
- tex: tex, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0)
198
-
199
- fun label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Label =
192
+ fun label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Node =
200
- Label(
201
- id: id, x: x, y: y, width: size * 0.6 * Float(text.length()), height: size, originX: 0.0, originY: 0.0,
193
+ pureNode(id, x, y, size * 0.6 * Float(text.length()), size, Disabled, Label(text: text, size: size))
202
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
194
+
203
- touchable: Disabled, actions: Slice[Action](), text: text, size: size)
204
-
205
- fun button(id: Int, text: Str, upTex: Int, downTex: Int, x: Float, y: Float, w: Float, h: Float) -> Button =
195
+ fun button(id: Int, text: Str, upTex: Int, downTex: Int, x: Float, y: Float, w: Float, h: Float) -> Node =
206
- Button(
207
- id: id, x: x, y: y, width: w, height: h, originX: 0.0, originY: 0.0,
208
- scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: Color.White, visible: true,
209
- touchable: Enabled, actions: Slice[Action](),
210
- text: text, upTex: upTex, downTex: downTex, pressed: false, over: false)
196
+ pureNode(id, x, y, w, h, Enabled, Button(text: text, upTex: upTex, downTex: downTex, pressed: false, over: false))
197
+
198
+ fun custom(id: Int, x: Float, y: Float, w: Float, h: Float, actor: Actor) -> Node =
199
+ pureNode(id, x, y, w, h, Disabled, Custom(actor: actor))
211
200
 
212
201
  fun psKinds(h: Handled) -> Slice[UiKind] =
213
202
  out := Slice[UiKind]()
@@ -215,9 +204,9 @@ fun psKinds(h: Handled) -> Slice[UiKind] =
215
204
  out.push(h.events.at(i).kind)
216
205
  out
217
206
 
218
- fun psIsPressed(a: Actor) -> Bool =
207
+ fun psIsPressed(n: Node) -> Bool =
219
- match a
208
+ match n.kind
220
- Button(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, pressed, _) => pressed
209
+ Button(_, _, _, pressed, _) => pressed
221
210
  _ => false
222
211
 
223
212
  fun psClicks(h: Handled) -> Int =
@@ -230,6 +219,23 @@ fun psClicks(h: Handled) -> Int =
230
219
  fun psOverPtr(kind: PointerKind, over: Bool) -> Pointer =
231
220
  Pointer(kind: kind, x: 1.0, y: 1.0, over: over)
232
221
 
222
+ # A game actor: counts its draws and moves its node right by 1 on each act.
223
+ record Beacon(Actor, draws: Int) =
224
+ override fun act(self, own n: Node, dt: Float) -> Node =
225
+ n.x = n.x + 1.0
226
+ n
227
+ override fun draw(self, n: Node, m: Affine, tint: Color) =
228
+ self.draws = self.draws + 1
229
+
230
+ fun psActorOf(n: Node) -> Actor? = match n.kind
231
+ Custom(a) => a
232
+ _ => null
233
+
234
+ fun psActed(own n: Node, dt: Float) -> Node =
235
+ if a := psActorOf(n)
236
+ return a.act(n, dt)
237
+ n
238
+
233
239
  test "local_matrix_translates"
234
240
  s := PureScene.sprite(1, 0, 10.0, 0.0, 2.0, 2.0)
235
241
  p := PureScene.localMatrix(s).apply(Vec2(x: 0.0, y: 0.0))
@@ -238,11 +244,11 @@ test "local_matrix_translates"
238
244
 
239
245
  test "button_click_needs_press_and_over"
240
246
  b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
241
- h1 := b.onPointer(psOverPtr(Down, true))
247
+ h1 := PureScene.onPointer(b, psOverPtr(Down, true))
242
248
  ks := psKinds(h1)
243
249
  n1 := h1.events.length()
244
250
  id1 := h1.events.at(0).id
245
- h2 := h1.intoActor().onPointer(psOverPtr(Up, true))
251
+ h2 := PureScene.onPointer(h1.intoNode(), psOverPtr(Up, true))
246
252
  ks.push(h2.events.at(0).kind)
247
253
  ks.push(h2.events.at(1).kind)
248
254
  assert n1 == 1
@@ -255,23 +261,68 @@ test "button_click_needs_press_and_over"
255
261
 
256
262
  test "button_release_elsewhere_no_click"
257
263
  b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
258
- h1 := b.onPointer(psOverPtr(Down, true))
264
+ h1 := PureScene.onPointer(b, psOverPtr(Down, true))
259
- h2 := h1.intoActor().onPointer(psOverPtr(Up, false))
265
+ h2 := PureScene.onPointer(h1.intoNode(), psOverPtr(Up, false))
260
266
  assert h2.events.length() == 1
261
267
  assert h2.events.at(0).kind == Release
262
- assert !psIsPressed(h2.intoActor())
268
+ assert !psIsPressed(h2.intoNode())
263
269
 
264
270
  test "button_drag_off_and_back_one_click"
265
271
  b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
266
- a1 := b.onPointer(psOverPtr(Down, true)).intoActor()
272
+ a1 := PureScene.onPointer(b, psOverPtr(Down, true)).intoNode()
267
- h2 := a1.onPointer(psOverPtr(Move, false))
273
+ h2 := PureScene.onPointer(a1, psOverPtr(Move, false))
268
274
  c2 := psClicks(h2)
269
- h3 := h2.intoActor().onPointer(psOverPtr(Move, true))
275
+ h3 := PureScene.onPointer(h2.intoNode(), psOverPtr(Move, true))
270
276
  c3 := psClicks(h3)
271
- h4 := h3.intoActor().onPointer(psOverPtr(Up, true))
277
+ h4 := PureScene.onPointer(h3.intoNode(), psOverPtr(Up, true))
272
278
  assert c2 + c3 + psClicks(h4) == 1
273
279
 
274
280
  test "on_pointer_hands_back_changed_actor"
275
281
  b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
276
- h := b.onPointer(psOverPtr(Down, true))
282
+ h := PureScene.onPointer(b, psOverPtr(Down, true))
277
- assert psIsPressed(h.intoActor())
283
+ assert psIsPressed(h.intoNode())
284
+
285
+ test "custom_actor_act_and_draw_dispatch"
286
+ beacon := Beacon(draws: 0)
287
+ n := PureScene.custom(7, 2.0, 0.0, 8.0, 8.0, beacon)
288
+ PureScene.drawTree(n, Affine.Identity, Color.White)
289
+ assert beacon.draws == 1
290
+ moved := psActed(n, 0.016)
291
+ assert moved.x == 3.0
292
+
293
+ test "draw_tree_nested_groups_reaches_custom"
294
+ beacon := Beacon(draws: 0)
295
+ inner := PureScene.group(2, 0.0, 0.0, 10.0, 10.0, Slice(PureScene.custom(3, 1.0, 1.0, 4.0, 4.0, beacon)))
296
+ root := PureScene.group(1, 0.0, 0.0, 10.0, 10.0, Slice(inner))
297
+ PureScene.drawTree(root, Affine.Identity, Color.White)
298
+ assert beacon.draws == 1
299
+
300
+ test "hit_box_and_children_of"
301
+ s := PureScene.sprite(1, 0, 0.0, 0.0, 10.0, 10.0)
302
+ assert PureScene.hit(s, Vec2(x: 5.0, y: 5.0))
303
+ assert !PureScene.hit(s, Vec2(x: 11.0, y: 5.0))
304
+ assert PureScene.childrenOf(s) == null
305
+ assert PureScene.childrenOf(PureScene.group(2, 0.0, 0.0, 1.0, 1.0, Slice(s))) != null
306
+
307
+ test "button_move_raises_enter_then_exit"
308
+ b := PureScene.button(7, "go", 1, 2, 0.0, 0.0, 10.0, 10.0)
309
+ h1 := PureScene.onPointer(b, psOverPtr(Move, true))
310
+ assert h1.events.length() == 1
311
+ assert h1.events.at(0).kind == Enter
312
+ h2 := PureScene.onPointer(h1.intoNode(), psOverPtr(Move, false))
313
+ assert h2.events.length() == 1
314
+ assert h2.events.at(0).kind == Exit
315
+ assert !psIsPressed(h2.intoNode())
316
+
317
+ test "on_pointer_non_button_raises_nothing"
318
+ s := PureScene.sprite(1, 0, 0.0, 0.0, 10.0, 10.0)
319
+ h := PureScene.onPointer(s, psOverPtr(Down, true))
320
+ assert h.events.length() == 0
321
+ assert h.intoNode().id == 1
322
+
323
+ test "draw_tree_skips_invisible_custom"
324
+ beacon := Beacon(draws: 0)
325
+ n := PureScene.custom(7, 0.0, 0.0, 8.0, 8.0, beacon)
326
+ n.visible = false
327
+ PureScene.drawTree(n, Affine.Identity, Color.White)
328
+ assert beacon.draws == 0
plum-std/game/SceneInput.plum CHANGED
@@ -35,7 +35,7 @@ own record StageHandled(stage: Stage, events: Slice[UiEvent]) =
35
35
  match self
36
36
  StageHandled(s, _) => s
37
37
 
38
- own record Stage(root: Actor, camera: Affine, captured: Capture?) =
38
+ own record Stage(root: Node, camera: Affine, captured: Capture?) =
39
39
 
40
40
  # Handles one input: routes it, returns the new stage and the events raised.
41
41
  fun input(own self, e: Input) -> StageHandled =
@@ -105,7 +105,7 @@ fun inputIsPrefix(a: Slice[Int], b: Slice[Int]) -> Bool =
105
105
  true
106
106
 
107
107
  primitive SceneInput =
108
- fun stage(own root: Actor) -> Stage =
108
+ fun stage(own root: Node) -> Stage =
109
109
  Stage(root: root, camera: Affine.Identity, captured: null)
110
110
 
111
111
  # This frame's pointer inputs from the host mouse: a press, a release, or a
@@ -122,26 +122,25 @@ primitive SceneInput =
122
122
  out.push(Input(kind: PointerMove, x: m.x, y: m.y))
123
123
  out
124
124
 
125
- fun inBtnTree() -> Actor =
125
+ fun inBtnTree() -> Node =
126
- b: Actor := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
126
+ b: Node := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
127
- kids := Slice[Actor]()
127
+ kids := Slice(b)
128
- kids.push(b)
129
- g: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
128
+ g: Node := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
130
129
  g
131
130
 
132
131
  fun inBtnPressed(s: Stage) -> Bool =
133
- if k := s.root.kids()
132
+ if k := PureScene.childrenOf(s.root)
134
133
  return psIsPressed(k.at(0))
135
134
  false
136
135
 
137
136
  fun inBtnOver(s: Stage) -> Bool =
138
- if k := s.root.kids()
137
+ if k := PureScene.childrenOf(s.root)
139
138
  return inOverOf(k.at(0))
140
139
  false
141
140
 
142
- fun inOverOf(a: Actor) -> Bool =
141
+ fun inOverOf(a: Node) -> Bool =
143
- match a
142
+ match a.kind
144
- Button(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, over) => over
143
+ Button(_, _, _, _, over) => over
145
144
  _ => false
146
145
 
147
146
  fun inDown(x: Float, y: Float) -> Input =
@@ -160,15 +159,14 @@ fun inClicks(events: Slice[UiEvent]) -> Int =
160
159
  n = n + 1
161
160
  n
162
161
 
163
- fun inXformTree() -> Actor =
162
+ fun inXformTree() -> Node =
164
- b: Actor := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
163
+ b: Node := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
165
- kids := Slice[Actor]()
164
+ kids := Slice(b)
166
- kids.push(b)
167
165
  g := PureScene.group(1, 100.0, 0.0, 800.0, 600.0, kids)
168
166
  g.rotation = 1.5707963267948966
169
167
  g.scaleX = 2.0
170
168
  g.scaleY = 2.0
171
- me: Actor := g
169
+ me: Node := g
172
170
  me
173
171
 
174
172
  test "press_sets_capture"
@@ -202,7 +200,7 @@ test "drag_off_and_back_one_click"
202
200
 
203
201
  test "stale_capture_dropped"
204
202
  h1 := SceneInput.stage(inBtnTree()).input(inDown(20.0, 20.0))
205
- empty: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, Slice[Actor]())
203
+ empty: Node := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, Slice[Node]())
206
204
  swapped := Stage(root: empty, camera: h1.stage.camera, captured: h1.stage.captured)
207
205
  h2 := swapped.input(inUp(20.0, 20.0))
208
206
  assert h2.events.length() == 0
@@ -223,24 +221,19 @@ test "camera_maps_screen_to_world"
223
221
 
224
222
  test "frame_handles_inputs_then_updates"
225
223
  spr := PureScene.sprite(6, 0, 300.0, 300.0, 10.0, 10.0)
226
- acts := Slice[Action]()
227
- acts.push(Actions.moveBy(10.0, 0.0, 1.0))
224
+ acts := Slice(Actions.moveBy(10.0, 0.0, 1.0))
228
225
  spr.actions = acts
229
- b: Actor := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
226
+ b: Node := PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0)
230
- sp: Actor := spr
227
+ sp: Node := spr
231
- kids := Slice[Actor]()
228
+ kids := Slice(b, sp)
232
- kids.push(b)
233
- kids.push(sp)
234
- root: Actor := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
229
+ root: Node := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
235
- ins := Slice[Input]()
236
- ins.push(inDown(20.0, 20.0))
230
+ ins := Slice(inDown(20.0, 20.0), inUp(20.0, 20.0))
237
- ins.push(inUp(20.0, 20.0))
238
231
  h := SceneInput.stage(root).frame(0.5, ins)
239
232
  assert h.events.length() == 3
240
233
  assert h.events.at(0).kind == Press
241
234
  assert h.events.at(1).kind == Release
242
235
  assert h.events.at(2).kind == Click
243
- if k := h.stage.root.kids()
236
+ if k := PureScene.childrenOf(h.stage.root)
244
237
  assert {k.at(1).x - 305.0}.abs() < 0.000001
245
238
  else
246
239
  assert false
plum-std/game/SceneTree.plum CHANGED
@@ -8,34 +8,44 @@ import std/game/Affine
8
8
  import std/game/Actions
9
9
  import std/game/PureScene
10
10
 
11
+ fun treeActorOf(n: Node) -> Actor? =
12
+ match n.kind
13
+ Custom(actor) => actor
14
+ _ => null
15
+
11
16
  fun treePrepend(i: Int, rest: Slice[Int]) -> Slice[Int] =
12
- out := Slice[Int]()
17
+ out := Slice(i)
13
- out.push(i)
14
18
  for j := range rest.length()
15
19
  out.push(rest.at(j))
16
20
  out
17
21
 
18
22
  primitive SceneTree =
19
23
 
24
+ # A Custom node's actor acts on it; every other node is itself.
25
+ fun act(own n: Node, dt: Float) -> Node =
26
+ if actor := treeActorOf(n)
27
+ return actor.act(n, dt)
28
+ n
29
+
20
- # The actor after `dt` seconds: its own `act`, then its actions on its pose,
30
+ # The node after `dt` seconds: a Custom actor's own `act`, then its actions on its pose,
21
31
  # then every child. The tree is changed in place and handed back.
22
- fun update(own a: Actor, dt: Float) -> Actor =
32
+ fun update(own a: Node, dt: Float) -> Node =
23
- b := a.act(dt)
33
+ b := SceneTree.act(a, dt)
24
34
  r := Actions.stepAll(Pose(x: b.x, y: b.y, rotation: b.rotation, scaleX: b.scaleX, scaleY: b.scaleY), b.actions, dt)
25
35
  b.x = r.pose.x
26
36
  b.y = r.pose.y
27
37
  b.rotation = r.pose.rotation
28
38
  b.scaleX = r.pose.scaleX
29
39
  b.scaleY = r.pose.scaleY
30
- match b
40
+ match b.kind
31
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
41
+ Group(ks) =>
32
42
  _ := ks.mapInPlace(|k| SceneTree.update(k, dt))
33
43
  _ => 0
34
44
  b
35
45
 
36
46
  # The path (child indexes from `a`) to the topmost actor under `p`, which is
37
47
  # in the coordinates of `a`'s parent; `parent` is that parent's stage matrix.
38
- fun findHit(a: Actor, parent: Affine, p: Vec2) -> Slice[Int]? =
48
+ fun findHit(a: Node, parent: Affine, p: Vec2) -> Slice[Int]? =
39
49
  if !a.visible || a.touchable == Disabled
40
50
  return null
41
51
  m := parent * PureScene.localMatrix(a)
@@ -45,8 +55,8 @@ primitive SceneTree =
45
55
  lp := Vec2(x: 0.0, y: 0.0)
46
56
  if q := inv
47
57
  lp = q.apply(p)
48
- match a
58
+ match a.kind
49
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
59
+ Group(ks) =>
50
60
  i := ks.length() - 1
51
61
  while i >= 0
52
62
  sub := SceneTree.findHit(ks.at(i), m, p)
@@ -54,16 +64,16 @@ primitive SceneTree =
54
64
  return treePrepend(i, s)
55
65
  i = i - 1
56
66
  _ => 0
57
- if a.hit(lp)
67
+ if PureScene.hit(a, lp)
58
68
  return Slice[Int]()
59
69
  null
60
70
 
61
71
  # The id of the actor at `path` below `a`, or -1 if the path leads nowhere.
62
- fun idAt(a: Actor, path: Slice[Int], depth: Int) -> Int =
72
+ fun idAt(a: Node, path: Slice[Int], depth: Int) -> Int =
63
73
  if depth >= path.length()
64
74
  return a.id
65
- match a
75
+ match a.kind
66
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
76
+ Group(ks) =>
67
77
  i := path.at(depth)
68
78
  if i >= 0 && i < ks.length()
69
79
  return SceneTree.idAt(ks.at(i), path, depth + 1)
@@ -71,11 +81,11 @@ primitive SceneTree =
71
81
  -1
72
82
 
73
83
  # The path to the first actor with `id`, depth first; null if none.
74
- fun findPath(a: Actor, id: Int) -> Slice[Int]? =
84
+ fun findPath(a: Node, id: Int) -> Slice[Int]? =
75
85
  if a.id == id
76
86
  return Slice[Int]()
77
- match a
87
+ match a.kind
78
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
88
+ Group(ks) =>
79
89
  for i := range ks.length()
80
90
  sub := SceneTree.findPath(ks.at(i), id)
81
91
  if s := sub
@@ -84,17 +94,17 @@ primitive SceneTree =
84
94
  null
85
95
 
86
96
  # The tree where the first actor with `id` is replaced by `f` of it.
87
- fun modify(own a: Actor, id: Int, f: fn(Actor) -> Actor) -> Actor =
97
+ fun modify(own a: Node, id: Int, f: fn(Node) -> Node) -> Node =
88
98
  path := SceneTree.findPath(a, id)
89
99
  if p := path
90
100
  return SceneTree.modifyAt(a, p, 0, f)
91
101
  a
92
102
 
93
- fun modifyAt(own a: Actor, path: Slice[Int], depth: Int, f: fn(Actor) -> Actor) -> Actor =
103
+ fun modifyAt(own a: Node, path: Slice[Int], depth: Int, f: fn(Node) -> Node) -> Node =
94
104
  if depth >= path.length()
95
105
  return f(a)
96
- match a
106
+ match a.kind
97
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
107
+ Group(ks) =>
98
108
  i := path.at(depth)
99
109
  if i >= 0 && i < ks.length()
100
110
  c := ks.takeAt(i)
@@ -105,39 +115,36 @@ primitive SceneTree =
105
115
  # Delivers `e` (in the coordinates of `a`'s parent) along `path`: the child on
106
116
  # the path first, then `a` itself. A path that runs out reaches the deepest
107
117
  # actor it can.
108
- fun route(own a: Actor, path: Slice[Int], depth: Int, e: Pointer) -> Handled =
118
+ fun route(own a: Node, path: Slice[Int], depth: Int, e: Pointer) -> Handled =
109
119
  events := Slice[UiEvent]()
110
120
  r := SceneTree.routeInto(a, path, depth, e, events)
111
- Handled(actor: r, events: events)
121
+ Handled(node: r, events: events)
112
122
 
113
123
  # `route`, appending the raised events to `events` and returning the actor.
114
- fun routeInto(own a: Actor, path: Slice[Int], depth: Int, e: Pointer, events: Slice[UiEvent]) -> Actor =
124
+ fun routeInto(own a: Node, path: Slice[Int], depth: Int, e: Pointer, events: Slice[UiEvent]) -> Node =
115
125
  lp := Vec2(x: e.x, y: e.y)
116
126
  inv := PureScene.localMatrix(a).invert()
117
127
  if q := inv
118
128
  lp = q.apply(lp)
119
129
  local := Pointer(kind: e.kind, x: lp.x, y: lp.y, over: e.over)
120
130
  if depth < path.length()
121
- match a
131
+ match a.kind
122
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, ks) =>
132
+ Group(ks) =>
123
133
  i := path.at(depth)
124
134
  if i >= 0 && i < ks.length()
125
135
  c := ks.takeAt(i)
126
136
  ks.putAt(i, SceneTree.routeInto(c, path, depth + 1, local, events))
127
137
  _ => 0
128
- h := a.onPointer(local)
138
+ h := PureScene.onPointer(a, local)
129
139
  for j := range h.events.length()
130
140
  events.push(h.events.at(j))
131
- h.intoActor()
141
+ h.intoNode()
132
-
142
+
133
- fun ttFixture() -> Actor =
143
+ fun ttFixture() -> Node =
134
- inner := Slice[Actor]()
135
- inner.push(PureScene.sprite(4, 0, 0.0, 0.0, 5.0, 5.0))
144
+ inner := Slice(PureScene.sprite(4, 0, 0.0, 0.0, 5.0, 5.0))
136
- kids := Slice[Actor]()
137
- kids.push(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0))
138
- kids.push(PureScene.group(3, 20.0, 0.0, 10.0, 10.0, inner))
145
+ kids := Slice(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0), PureScene.group(3, 20.0, 0.0, 10.0, 10.0, inner))
139
146
  root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
140
- me: Actor := root
147
+ me: Node := root
141
148
  me
142
149
 
143
150
  fun ttNoPath(r: Slice[Int]?) -> Bool =
@@ -154,98 +161,98 @@ fun ttPathEq(r: Slice[Int]?, want: Slice[Int]) -> Bool =
154
161
  false
155
162
 
156
163
  fun ttInts(a: Int, b: Int) -> Slice[Int] =
157
- out := Slice[Int]()
164
+ out := Slice(a, b)
158
- out.push(a)
159
- out.push(b)
160
165
  out
161
166
 
162
167
  fun ttAt(x: Float, y: Float) -> Vec2 =
163
168
  Vec2(x: x, y: y)
164
169
 
165
- fun ttMovingTree() -> Actor =
170
+ fun ttMovingTree() -> Node =
166
171
  s := PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
167
172
  s.actions = Slice(Actions.moveBy(10.0, 0.0, 1.0))
168
- kids := Slice[Actor]()
173
+ kids := Slice(s)
169
- kids.push(s)
170
174
  root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
171
- me: Actor := root
175
+ me: Node := root
172
176
  me
173
177
 
174
- fun ttFirstKidX(a: Actor) -> Float =
178
+ fun ttFirstKidX(a: Node) -> Float =
175
- match a
179
+ match a.kind
176
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
180
+ Group(k) =>
177
181
  return k.at(0).x
178
182
  _ => 0
179
183
  0.0 - 1.0
180
184
 
181
- fun ttFirstKidActions(a: Actor) -> Int =
185
+ fun ttFirstKidActions(a: Node) -> Int =
182
- match a
186
+ match a.kind
183
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
187
+ Group(k) =>
184
188
  return k.at(0).actions.length()
185
189
  _ => 0
186
190
  0 - 1
187
191
 
188
- fun ttSetTouch(own t: Actor, id: Int, tc: Touchable) -> Actor =
192
+ fun ttSetTouch(own t: Node, id: Int, tc: Touchable) -> Node =
189
193
  SceneTree.modify(t, id, |a| ttSetTouchOf(a, tc))
190
194
 
191
- fun ttSetTouchOf(own a: Actor, tc: Touchable) -> Actor =
195
+ fun ttSetTouchOf(own a: Node, tc: Touchable) -> Node =
192
196
  a.touchable = tc
193
197
  a
194
198
 
195
- fun ttSetScaleX(own a: Actor) -> Actor =
199
+ fun ttSetScaleX(own a: Node) -> Node =
196
200
  a.scaleX = 0.0
197
201
  a
198
202
 
199
- fun ttSetX9(own a: Actor) -> Actor =
203
+ fun ttSetX9(own a: Node) -> Node =
200
204
  a.x = 9.0
201
205
  a
202
206
 
203
- fun ttKidX(a: Actor, i: Int) -> Float =
207
+ fun ttKidX(a: Node, i: Int) -> Float =
204
- match a
208
+ match a.kind
205
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
209
+ Group(k) =>
206
210
  return k.at(i).x
207
211
  _ => 0
208
212
  0.0 - 1.0
209
213
 
210
- fun ttButtonRoot() -> Actor =
214
+ fun ttButtonRoot() -> Node =
211
- kids := Slice[Actor]()
212
- kids.push(PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0))
215
+ kids := Slice(PureScene.button(5, "Go", 0, 1, 10.0, 10.0, 100.0, 40.0))
213
216
  root := PureScene.group(1, 0.0, 0.0, 800.0, 600.0, kids)
214
- me: Actor := root
217
+ me: Node := root
215
218
  me
216
219
 
217
220
  fun ttZero() -> Slice[Int] =
218
- out := Slice[Int]()
221
+ out := Slice(0)
219
- out.push(0)
220
222
  out
221
223
 
222
224
  # Five actions on one sprite: the 1st, 3rd and 5th finish in the first 0.1s step,
223
225
  # the 2nd (MoveBy) and 4th (RotateBy) keep running.
224
- fun ttCompactTree() -> Actor =
226
+ fun ttCompactTree() -> Node =
225
227
  s := PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0)
226
228
  s.actions = Slice(Actions.moveBy(1.0, 0.0, 0.1), Actions.moveBy(0.0, 10.0, 10.0), Actions.rotateBy(1.0, 0.1), Actions.rotateBy(2.0, 10.0), Actions.delay(0.1))
227
- kids := Slice[Actor]()
229
+ kids := Slice(s)
228
- kids.push(s)
229
230
  root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
230
- me: Actor := root
231
+ me: Node := root
231
232
  me
232
233
 
233
- fun ttKid(a: Actor) -> Actor =
234
+ fun ttKid(a: Node) -> Node =
234
- match a
235
+ match a.kind
235
- Group(_, _, _, _, _, _, _, _, _, _, _, _, _, _, k) =>
236
+ Group(k) =>
236
237
  return k.at(0)
237
238
  _ => 0
238
- none: Actor := PureScene.sprite(0, 0, 0.0, 0.0, 0.0, 0.0)
239
+ none: Node := PureScene.sprite(0, 0, 0.0, 0.0, 0.0, 0.0)
239
240
  none
240
241
 
241
242
  # 1 MoveBy, 2 RotateBy, 3 Delay, 0 anything else.
242
243
  fun ttActKind(a: Action) -> Int =
243
- match a
244
+ match a.kind
244
- MoveBy => 1
245
+ MoveBy(_, _, _) => 1
245
- RotateBy => 2
246
+ RotateBy(_, _) => 2
246
- Delay => 3
247
+ Delay(_) => 3
247
248
  _ => 0
248
249
 
250
+ # Adds 1.0 to its node's x on every act.
251
+ record TtStepper(Actor, ticks: Int) =
252
+ override fun act(self, own n: Node, dt: Float) -> Node =
253
+ n.x = n.x + 1.0
254
+ n
255
+
249
256
  test "find_hit_nested"
250
257
  r := SceneTree.findHit(ttFixture(), Affine.Identity, ttAt(21.0, 1.0))
251
258
  assert ttPathEq(r, ttInts(1, 0))
@@ -256,8 +263,7 @@ test "find_hit_miss"
256
263
  test "id_at_paths"
257
264
  t := ttFixture()
258
265
  assert SceneTree.idAt(t, ttInts(1, 0), 0) == 4
259
- five := Slice[Int]()
266
+ five := Slice(5)
260
- five.push(5)
261
267
  assert SceneTree.idAt(t, five, 0) == -1
262
268
  assert SceneTree.idAt(t, Slice[Int](), 0) == 1
263
269
 
@@ -293,22 +299,17 @@ test "scale_zero_not_hit"
293
299
  assert ttPathEq(r, Slice[Int]())
294
300
 
295
301
  test "topmost_child_wins"
296
- kids := Slice[Actor]()
297
- kids.push(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0))
298
- kids.push(PureScene.sprite(3, 0, 0.0, 0.0, 10.0, 10.0))
302
+ kids := Slice(PureScene.sprite(2, 0, 0.0, 0.0, 10.0, 10.0), PureScene.sprite(3, 0, 0.0, 0.0, 10.0, 10.0))
299
303
  root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
300
- me: Actor := root
304
+ me: Node := root
301
305
  r := SceneTree.findHit(me, Affine.Identity, ttAt(1.0, 1.0))
302
- want := Slice[Int]()
306
+ want := Slice(1)
303
- want.push(1)
304
307
  assert ttPathEq(r, want)
305
308
 
306
309
  test "modify_changes_first_match_only"
307
- kids := Slice[Actor]()
308
- kids.push(PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0))
310
+ kids := Slice(PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0), PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0))
309
- kids.push(PureScene.sprite(7, 0, 0.0, 0.0, 10.0, 10.0))
310
311
  root := PureScene.group(1, 0.0, 0.0, 100.0, 100.0, kids)
311
- me: Actor := root
312
+ me: Node := root
312
313
  n := SceneTree.modify(me, 7, |a| ttSetX9(a))
313
314
  assert ttKidX(n, 0) == 9.0
314
315
  assert ttKidX(n, 1) == 0.0
@@ -318,7 +319,7 @@ test "route_child_first_then_group"
318
319
  h1 := SceneTree.route(g, ttZero(), 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
319
320
  n1 := h1.events.length()
320
321
  k1 := h1.events.at(0).kind
321
- h2 := SceneTree.route(h1.intoActor(), ttZero(), 0, Pointer(kind: Up, x: 20.0, y: 20.0, over: true))
322
+ h2 := SceneTree.route(h1.intoNode(), ttZero(), 0, Pointer(kind: Up, x: 20.0, y: 20.0, over: true))
322
323
  assert n1 == 1
323
324
  assert k1 == Press
324
325
  assert h2.events.length() == 2
@@ -327,8 +328,7 @@ test "route_child_first_then_group"
327
328
  assert h2.events.at(1).id == 5
328
329
 
329
330
  test "stale_path_reaches_group_only"
330
- nine := Slice[Int]()
331
+ nine := Slice(9)
331
- nine.push(9)
332
332
  h := SceneTree.route(ttButtonRoot(), nine, 0, Pointer(kind: Down, x: 20.0, y: 20.0, over: true))
333
333
  assert h.events.length() == 0
334
334
 
@@ -351,3 +351,8 @@ test "compaction_keeps_order"
351
351
  assert {ttKid(m).y - 0.6}.abs() < 1e-6
352
352
  assert {ttKid(m).rotation - 1.12}.abs() < 1e-6
353
353
  assert {ttKid(m).x - 1.0}.abs() < 1e-6
354
+
355
+ test "update_runs_custom_actor_act"
356
+ c := PureScene.custom(9, 1.0, 0.0, 4.0, 4.0, TtStepper(ticks: 0))
357
+ n := SceneTree.update(PureScene.group(1, 0.0, 0.0, 10.0, 10.0, Slice(c)), 0.016)
358
+ assert ttFirstKidX(n) == 2.0
plum-wasm-codegen/src/lib.rs CHANGED
@@ -7798,6 +7798,18 @@ fn compileExprCore(
7798
7798
  state,
7799
7799
  )?;
7800
7800
  }
7801
+ // A field-less variant or record built with call syntax (`Panel()`, e.g. a record
7802
+ // that only embeds a field-less trait): same value as the bare name.
7803
+ ast::Expr::ClassCall(call)
7804
+ if call.fields.is_empty()
7805
+ && call.spread.is_none()
7806
+ && ctx
7807
+ .enum_variants
7808
+ .get(&call.type_name)
7809
+ .is_some_and(|info| info.field_types.is_empty() && info.values.is_empty()) =>
7810
+ {
7811
+ compileExpr(&ast::Expr::TypeName(call.type_name.clone()), body, ctx, state)?;
7812
+ }
7801
7813
  // A NAMED-payload enum variant construction (`Circle(radius: 5)`, as
7802
7814
  // opposed to the positional/generic-payload form `Next(T)` — constructed
7803
7815
  // via `Expr::FnCall` instead, elsewhere) — mirrors `plum-checker`'s own
plum-wasm-codegen/tests/empty_record_embed_e2e_test.rs ADDED
@@ -0,0 +1,76 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_wasm_codegen::compileSource;
3
+
4
+ fn gcEngine() -> wasmtime::Engine {
5
+ let mut config = wasmtime::Config::new();
6
+ config.wasm_gc(true);
7
+ config.wasm_function_references(true);
8
+ wasmtime::Engine::new(&config).expect("engine with GC config should construct")
9
+ }
10
+
11
+ fn repoRoot() -> std::path::PathBuf {
12
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
13
+ }
14
+
15
+ fn load(name: &str, src: &str) -> Result<plum_core::ast::Source, String> {
16
+ let dir = std::env::temp_dir().join(format!("plum_empty_record_embed_{}_{}", name, std::process::id()));
17
+ std::fs::create_dir_all(&dir).unwrap();
18
+ let path = dir.join("main.plum");
19
+ std::fs::write(&path, src).unwrap();
20
+ let r = plum_core::loadAndMerge(&path, &repoRoot());
21
+ let _ = std::fs::remove_dir_all(&dir);
22
+ r.map_err(|e| format!("{e}"))
23
+ }
24
+
25
+ /// Compiles `src` through the real pipeline and runs its `main() -> Int`.
26
+ fn runInt(name: &str, src: &str) -> i64 {
27
+ let source = load(name, src).expect("load");
28
+ let bytes = compileSource(&source).unwrap_or_else(|e| panic!("compile failed: {e}"));
29
+ wasmparser::validate(&bytes).expect("valid wasm");
30
+ let engine = gcEngine();
31
+ let module = wasmtime::Module::new(&engine, &bytes).unwrap();
32
+ let mut store = wasmtime::Store::new(&engine, ());
33
+ let mut linker = wasmtime::Linker::new(&engine);
34
+ linker
35
+ .define_unknown_imports_as_default_values(&mut store, &module)
36
+ .unwrap();
37
+ let instance = linker.instantiate(&mut store, &module).unwrap();
38
+ let main = instance
39
+ .get_typed_func::<(), i64>(&mut store, "main")
40
+ .unwrap();
41
+ main.call(&mut store, ()).unwrap()
42
+ }
43
+
44
+
45
+ const PRE: &str = "import std/Str\nimport std/Int\nimport std/Float\nimport std/Slice\n\n";
46
+
47
+ fn prog(body: &str) -> String {
48
+ format!("{PRE}{body}")
49
+ }
50
+
51
+ #[test]
52
+ fn field_less_record_embedding_field_less_trait_dispatches_in_a_slice() {
53
+ let src = prog("\
54
+ trait Actor =
55
+ fun act(self, n: Int) -> Int = n
56
+ fun draw(self) -> Int = 1
57
+
58
+ record Panel(Actor) =
59
+ override fun draw(self) -> Int = 10
60
+
61
+ record Ship(Actor, speed: Int) =
62
+ override fun act(self, n: Int) -> Int = n + self.speed
63
+ override fun draw(self) -> Int = 100
64
+
65
+ fun main() -> Int =
66
+ xs: Slice[Actor] := Slice[Actor]()
67
+ xs.push(Panel())
68
+ xs.push(Ship(speed: 5))
69
+ total := 0
70
+ for i := range 2
71
+ total = total + xs[i].draw() + xs[i].act(1000)
72
+ total
73
+ ");
74
+ // Panel: draw 10 + act 1000; Ship: draw 100 + act 1005.
75
+ assert_eq!(runInt("slice", &src), 2115);
76
+ }