plum

#treesitter#compiler#wasm

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

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


775bbeb — Peter John 2026-11-03T15:44:05+05:30
Recursive state/view; fix param reassignment codegen; own record docs
README.md CHANGED
@@ -631,6 +631,58 @@ w.draw() # World.draw: prints "stage" then "world"
631
631
 
632
632
  Every method that implements a trait's required method, replaces a trait default or replaces an embedded record's method must be `override fun`; defining one without it is an error (`World.draw hides Stage.draw; mark it override`), and so is an `override fun` that overrides nothing. Inside a record, `self.Owner.method(args)` calls the original from the embedded record or trait `Owner`.
633
633
 
634
+ ### Own records
635
+
636
+ An `own record` (or `own trait`) is a value with a single owner, so the compiler can update it in place instead of copying. Ordinary records are unchanged; ownership is a property of the type. A plain parameter only borrows (read-only); an `own` parameter or `own self` takes the value, and the caller can't use it afterwards:
637
+
638
+ ```plum
639
+ import std/Int
640
+ import std/Float
641
+ import std/List
642
+
643
+ own record Cell(x: Float) =
644
+ fun step(own self) -> Cell = Cell(..self, x: self.x + 1.0)
645
+ fun peek(self) -> Float = self.x
646
+
647
+ fun bump(own s: Cell) -> Cell =
648
+ t := Cell(..s, x: s.x + 1.0)
649
+ t
650
+
651
+ fun read(c: Cell) -> Float = c.peek()
652
+
653
+ record World(cell: Cell)
654
+
655
+ fun advance(w: World) =
656
+ w.cell = w.cell.step()
657
+
658
+ test "own"
659
+ c := Cell(x: 1.0)
660
+ d := bump(c)
661
+ assert {read(d) - 2.0}.abs() < 1e-6
662
+ w := World(cell: Cell(x: 0.0))
663
+ advance(w)
664
+ assert {w.cell.x - 1.0}.abs() < 1e-6
665
+ xs := List(Cell(x: 1.0), Cell(x: 2.0))
666
+ ys := xs.mapInPlace(|k| Cell(..k, x: k.x + 1.0))
667
+ assert {ys.at(1).x - 3.0}.abs() < 1e-6
668
+ ```
669
+
670
+ - **What moves:** passing a value to an `own` parameter, calling an `own self` method, returning it, storing it in a field or list, and `match`ing on an owned value (the match consumes it). **What borrows:** plain parameters and plain `self`, and reads such as `s.x`.
671
+ - **Spread writes in place:** `Cell(..s, x: s.x + 1.0)` where `s` is owned reuses `s`'s storage and writes the changed fields into it, so no new record is allocated. Spreading a borrowed or ordinary record still copies.
672
+ - **Swap form:** you can't move a field out of a record, so replace it with a method call that takes and returns it: `w.cell = w.cell.step()` (in a game, `w.stage = w.stage.frame(dt, inputs).intoStage()`).
673
+ - **Lists:** `List.mapInPlace` rewrites every element, and `takeAt(i)` / `putAt(i, v)` move one element out and back. See [`plum-examples/own_list.plum`](plum-examples/own_list.plum).
674
+
675
+ The ownership checker reports mistakes at compile time:
676
+
677
+ ```
678
+ "s" used after it was moved at 12:3
679
+ cannot assign field "x" of borrowed "s"
680
+ cannot store borrowed "s"; pass it with own or move it
681
+ cannot move out of field "stage" of "w"; use the swap form
682
+ ```
683
+
684
+ Embedding an own record or trait makes the embedding record own.
685
+
634
686
  ### Methods, `self` and field access
635
687
 
636
688
  A `fun` declared indented directly inside an `enum`, `struct` or `record` body whose first parameter is `self` is a method, called on a value (`c.getAge()`). One without `self` is a static function, called on the type (`Cat.named("Tom")`), and can't use `self`. Calling a static on a value, or a method on the type, is an error:
@@ -1246,7 +1298,7 @@ The page calls `init` once after the assets are loaded, then `update` and `draw`
1246
1298
 
1247
1299
  ### Pure scene graph
1248
1300
 
1249
- The pure scene graph is the same kind of tree as `std/game/Scene`, but every function returns a new value and nothing is changed in place. It is split into four modules: `std/game/Actions` (timed actions such as `forever(rotateBy(...))`), `std/game/PureScene` (the `Actor` trait and its leaves, `group`, `sprite`, `button`, and the draw walk), `std/game/SceneTree` (update, hit testing, paths and event routing) and `std/game/SceneInput` (`Stage`, pointer capture and `UiEvent`). A game imports them by name:
1301
+ The pure scene graph is the same kind of tree as `std/game/Scene`, but a function never changes a value it was only given: updates take an owned actor (`own self`, `own` parameters, see [Own records](#own-records)), change it in place and hand it back, so no `clone()` is needed. `Actor` is an `own trait` and `Group`, `Sprite`, `Label`, `Button` and `Stage` are `own record`s. The graph is split into four modules: `std/game/Actions` (timed actions such as `forever(rotateBy(...))`), `std/game/PureScene` (the `Actor` trait and its leaves, `group`, `sprite`, `button`, and the draw walk), `std/game/SceneTree` (update, hit testing, paths and event routing) and `std/game/SceneInput` (`Stage`, pointer capture and `UiEvent`). A game imports them by name:
1250
1302
 
1251
1303
  ```
1252
1304
  import std/game/Actions
@@ -1272,8 +1324,7 @@ export fun init() -> World =
1272
1324
  export fun update(w: World, dt: Float) =
1273
1325
  inputs := SceneInput.pollPointer(w.wasDown)
1274
1326
  w.wasDown = isMouseDown(0)
1275
- h := w.stage.frame(dt, inputs)
1327
+ w.stage = w.stage.frame(dt, inputs).intoStage()
1276
- w.stage = h.stage
1277
1328
 
1278
1329
  export fun draw(w: World) =
1279
1330
  w.stage.draw()
@@ -1281,7 +1332,8 @@ export fun draw(w: World) =
1281
1332
 
1282
1333
  - **Immediate mode** ([`game.plum`](plum-examples/game.plum)): one state record and a `draw` that issues the calls every frame. Use it for flat arcade games with a few kinds of thing.
1283
1334
  - **Scene** ([`game_scene.plum`](plum-examples/game_scene.plum)): a retained tree of mutable actors. Use it when you want objects that change in place and a familiar `act`/`draw` object model.
1284
- - **Pure scene** ([`game_pure_scene.plum`](plum-examples/game_pure_scene.plum)): every update returns new values, pointer clicks arrive as `UiEvent`s, and the rules can be tested without a host. Use it when you want pure updates and tests that run without a game host.
1335
+ - **Pure scene** ([`game_pure_scene.plum`](plum-examples/game_pure_scene.plum)): every update takes an owned value and hands it back, pointer clicks arrive as `UiEvent`s, and the rules can be tested without a host. Use it when you want pure updates and tests that run without a game host.
1336
+ - **State/view** ([`game_state_view.plum`](plum-examples/game_state_view.plum)): the game state is plain data and a `view` turns it into `std/game/Picture`, a recursive picture type drawn by recursive functions. Use it when you want to draw from state instead of keeping a tree of actors.
1285
1337
 
1286
1338
  **Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU; `--browser webkit` drives Playwright's WebKit, the Safari engine, instead) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)), `--expect-unsupported-webgpu` (hides `navigator.gpu` and requires the page's "WebGPU is not supported" message while plum.wasm still loads) and `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw, as Safari does). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
1287
1339
 
docs/plans/2026-11-03-own-record.benchmark.md CHANGED
@@ -12,6 +12,17 @@ with the current debug `plum`, so phase B includes the in-place spread codegen a
12
12
  | stress_pure_scene, phase B (own record) | 2.107 / 1.931 | 2.00 / 1.90 | 2.70 / 2.30 | 4.10 / 2.50 | 0.343 / 0.316 | 1.26 |
13
13
  | stress_state_view (Picture, tree rebuilt per frame) | 2.132 / 2.066 | 2.10 / 2.00 | 2.30 / 2.20 | 4.30 / 4.20 | 0.08 | 1.61 |
14
14
 
15
+ After the rebase onto master 05f8c38, recursive state/view (T14/T15: inline-variant `SceneNode`, `Nodes = End | More`, recursive `draw`/`step`/`view`, state in `Link[T]` lists of at most 700), same session:
16
+
17
+ | fixture | avg ms | p50 | p95 | p99 | update ms | draw ms |
18
+ |---|---|---|---|---|---|---|
19
+ | stress_scene (mutable) | 1.729 | 1.70 | 1.90 | 2.00 | 0.040 | 1.28 |
20
+ | stress_state_view, recursive | 1.905 / 1.908 | 1.90 | 2.30 | 2.40 | 0.05 | 1.49 |
21
+
22
+ The recursive version is faster than the List-based state/view (avg 1.91 vs 2.07–2.13), and its p99 tail is gone (2.4 vs 4.2 ms).
23
+ Against the mutable scene: avg +10%, p95 +21%. The remaining gap is the per-frame tree rebuild in draw (+0.2 ms).
24
+ Tail calls and, later, a supercompiler (fusing `view` into `draw`, so no tree is built) are the next levers.
25
+
15
26
  Bar (review plan): average within 10% and p95 within 15% of the mutable scene, same session.
16
27
 
17
28
  - Phase B: avg +11% to +21%, p95 +21% to +42%: **missed**. Update time is unchanged from phase A (about 0.32 ms).
docs/plans/2026-11-03-own-record.incidents.jsonl ADDED
@@ -0,0 +1,84 @@
1
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; ./target/debug/plum compile plum-game/web-test/fixtur"}
2
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-game --test host_test pureScene 2"}
3
+ {"file": "docs/", "line": 0, "context": "T1: change outside Owns blocked by checkpoint; nothing saved: 1 files under docs/, first 1: docs/plans/2026-11-02-own-record.review.md", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "14:40:41"}
4
+ {"file": "plum-core/", "line": 0, "context": "T1: change outside Owns blocked by checkpoint; nothing saved: 5 files under plum-core/, first 5: plum-core/src/ctors.rs, plum-core/src/embeds.rs, plum-core/src/forms.rs, plum-core/src/traits.rs, plum…", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "14:40:52"}
5
+ {"file": "-", "line": 0, "context": "exit code 101: error: could not compile `plum-wasm-codegen` (lib) due to 2 previous errors", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:41:14"}
6
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-core 21 | grep -E test result|FA"}
7
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-core --test own_parse_test own_rec"}
8
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-core --test own_parse_test 21 | g"}
9
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-core --test own_parse_test > .supe"}
10
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-core --test own_parse_test 21 | g"}
11
+ {"file": "-", "line": 0, "context": "exit code 1: For more information, try '--help'.", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:42:19"}
12
+ {"file": "", "line": 0, "context": "verified: seam T1↔T2", "agent": "pt_review", "kind": "verified", "time": "14:43:13"}
13
+ {"file": "docs/plans/2026-11-03-own-record.md", "line": 177, "context": "seam T10↔T11: T10 Produces: Picture.draw: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "14:45:30"}
14
+ {"file": "docs/plans/2026-11-03-own-record.md", "line": 177, "context": "seam T10↔T11: T10 Produces: Picture.bounds: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "14:45:30"}
15
+ {"file": "docs/plans/2026-11-03-own-record.md", "line": 177, "context": "seam T10↔T11: T10 Produces: Picture.hitPath: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "14:45:30"}
16
+ {"file": "plum-core/", "line": 0, "context": "T12: change outside Owns blocked by checkpoint; nothing saved: 2 files under plum-core/, first 2: plum-core/src/embeds.rs, plum-core/src/traits.rs", "agent": "checkpoint", "kind": "exception", "resolution": "open", "time": "14:47:49"}
17
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-checker --test ownership_moves_tes"}
18
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-checker --test ownership_moves_tes"}
19
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: touch plum-wasm-codegen/src/lib.rs && cargo test -q -p plum-wasm-codegen --test own_inplace_e2e_test"}
20
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values/plum-wasm-codegen && cargo test -q -p plum-wasm-codege"}
21
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values/plum-wasm-codegen && cargo test -q -p plum-wasm-codege"}
22
+ {"file": "plum-checker/src/ownership.rs", "line": 1, "context": "Assign and ClassCall carry no source position; move/use errors use the nearest FnCall or statement position, columns approximate", "agent": "T4", "kind": "workaround", "time": "14:50:38"}
23
+ {"file": "", "line": 0, "context": "verified: seam T3↔T4", "agent": "pt_review", "kind": "verified", "time": "14:50:51"}
24
+ {"file": "", "line": 0, "context": "verified: seam T11↔T12", "agent": "pt_review", "kind": "verified", "time": "14:50:51"}
25
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cargo test -q -p plum-checker --test ownership_moves_test 21 | tail -8"}
26
+ {"file": "-", "line": 0, "context": "own_spread_reuses_object: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:52:35"}
27
+ {"file": "-", "line": 0, "context": "exit code 1: For more information, try '--help'.", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:52:40"}
28
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cargo test -p plum-checker --test ownership_flow_test 21 | grep -E ^test |panicked|error | head -"}
29
+ {"file": "-", "line": 0, "context": "own_spread_reuses_object: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:53:03"}
30
+ {"file": "-", "line": 0, "context": "own_self_field_write: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:53:03"}
31
+ {"file": "-", "line": 0, "context": "own_spread_sets_fields: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:53:03"}
32
+ {"file": "-", "line": 0, "context": "own_spread_swap_reads_old_values: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:53:03"}
33
+ {"file": "-", "line": 0, "context": "own_trait_record_spread_in_place: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:53:03"}
34
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cargo test -q -p plum-wasm-codegen --test own_inplace_e2e_test own_spread_sets_fields 21 | tail -20"}
35
+ {"file": "-", "line": 0, "context": "exit code 101: warning: [email protected]: plum-tooling/vscode-plum/out/extension.js not found (run `npm install && npm run build` there); `plum editor vscode` wil", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:55:03"}
36
+ {"file": "-", "line": 0, "context": "exit code 101: warning: [email protected]: plum-tooling/vscode-plum/out/extension.js not found (run `npm install && npm run build` there); `plum editor vscode` wil", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:55:10"}
37
+ {"file": "-", "line": 0, "context": "variadic_own_param_takes_every_arg: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:56:31"}
38
+ {"file": "-", "line": 0, "context": "own_spread_reuses_object: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:56:44"}
39
+ {"file": "-", "line": 0, "context": "own_spread_sets_fields: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:56:44"}
40
+ {"file": "-", "line": 0, "context": "own_spread_swap_reads_old_values: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:56:44"}
41
+ {"file": "-", "line": 0, "context": "own_spread_reuses_object: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:57:15"}
42
+ {"file": "-", "line": 0, "context": "own_spread_swap_reads_old_values: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:57:15"}
43
+ {"file": "-", "line": 0, "context": "own_spread_sets_fields: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "14:57:15"}
44
+ {"file": "", "line": 0, "context": "verified: seam T3↔T4", "agent": "pt_review", "kind": "verified", "time": "14:58:35"}
45
+ {"file": "", "line": 0, "context": "verified: seam T4↔T5", "agent": "pt_review", "kind": "verified", "time": "14:58:35"}
46
+ {"file": "plum-checker/src/ownership.rs", "line": 700, "context": "Any program containing an own record makes checker flag Int/Float params stored into locals in std/Str.plum etc: cannot store borrowed charIndex (k := charIndex). Store check must apply only to own-t…", "agent": "T6", "kind": "exception", "time": "15:04:21"}
47
+ {"file": "-", "line": 0, "context": "own_spread_reuses_object: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:06:00"}
48
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cargo test -p plum-checker --test ownership_flow_test --test ownership_moves_test 21 | grep -E tes"}
49
+ {"file": "-", "line": 0, "context": "exit code 1: For more information, try '--help'.", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:06:18"}
50
+ {"file": "-", "line": 0, "context": "own_spread_reuses_object: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:06:20"}
51
+ {"file": "-", "line": 0, "context": "own_spread_reuses_object: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:06:47"}
52
+ {"file": "-", "line": 0, "context": "std_str_checks_with_own_present: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:07:09"}
53
+ {"file": "-", "line": 0, "context": "own_trait_record_spread_in_place: failed", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:07:46"}
54
+ {"file": "", "line": 0, "context": "verified: seam T4↔T5", "agent": "pt_review", "kind": "verified", "time": "15:07:59"}
55
+ {"file": "-", "line": 0, "context": "exit code 1: For more information, try '--help'.", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:08:25"}
56
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-wasm-codegen --test own_inplace_e2"}
57
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-wasm-codegen --no-run 21 | grep E"}
58
+ {"file": "", "line": 0, "context": "verified: seam T3↔T6", "agent": "pt_review", "kind": "verified", "time": "15:11:59"}
59
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: sed -i 's/if let Some((true, field)) = accessorField(&c.name) {/if let Some((true, field)) = accesso"}
60
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cargo test -p plum-wasm-codegen --test own_inplace_e2e_test 21 | grep -E ^test |test result|panick"}
61
+ {"file": "-", "line": 0, "context": "exit code 1: For more information, try '--help'.", "agent": "run_tests", "kind": "test-failure", "resolution": "open", "time": "15:15:26"}
62
+ {"file": "", "line": 0, "context": "verified: seam T6↔T7", "agent": "pt_review", "kind": "verified", "time": "15:15:42"}
63
+ {"file": "", "line": 0, "context": "verified: seam T5↔T7", "agent": "pt_review", "kind": "verified", "time": "15:15:42"}
64
+ {"file": "", "line": 0, "context": "verified: seam T4↔T5", "agent": "pt_review", "kind": "verified", "time": "15:16:54"}
65
+ {"file": "", "line": 0, "context": "verified: seam T5↔T7", "agent": "pt_review", "kind": "verified", "time": "15:16:54"}
66
+ {"file": "", "line": 0, "context": "verified: seam T3↔T6", "agent": "pt_review", "kind": "verified", "time": "15:16:54"}
67
+ {"file": "", "line": 0, "context": "verified: seam T6↔T7", "agent": "pt_review", "kind": "verified", "time": "15:16:54"}
68
+ {"file": "", "line": 0, "context": "verified: seam T1↔T8", "agent": "pt_review", "kind": "verified", "time": "15:20:37"}
69
+ {"file": "docs/plans/2026-11-03-own-record.md", "line": 140, "context": "seam T7↔T8: T7 Produces: List.take: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "15:20:37"}
70
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; cargo test -p plum-checker 21 | grep -E test result"}
71
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cd /Users/pyrossh/Code/plum-pure-scene-values; ./target/debug/plum test plum-std/game/PureScene.plum"}
72
+ {"file": "", "line": 0, "context": "verified: seam T4↔T5", "agent": "pt_review", "kind": "verified", "time": "15:24:18"}
73
+ {"file": "", "line": 0, "context": "verified: seam T5↔T7", "agent": "pt_review", "kind": "verified", "time": "15:24:18"}
74
+ {"file": "", "line": 0, "context": "verified: seam T8↔T9", "agent": "pt_review", "kind": "verified", "time": "15:24:40"}
75
+ {"kind":"rule-block","agent":"subagent","tool":"Bash","target":"full suite: cargo test -p plum-core -p plum-checker -p plum-wasm-codegen 21 | grep -E '^test result|FAILED|pani"}
76
+ {"file": "", "line": 0, "context": "verified: seam T12↔T14", "agent": "pt_review", "kind": "verified", "time": "15:35:01"}
77
+ {"file": "", "line": 0, "context": "verified: seam T9↔T13", "agent": "pt_review", "kind": "verified", "time": "15:35:36"}
78
+ {"file": "", "line": 0, "context": "verified: seam T11↔T15", "agent": "pt_review", "kind": "verified", "time": "15:40:19"}
79
+ {"file": "", "line": 0, "context": "verified: seam T12↔T15", "agent": "pt_review", "kind": "verified", "time": "15:40:19"}
80
+ {"file": "docs/plans/2026-11-03-own-record.md", "line": 221, "context": "seam T14↔T15: T14 Produces: Picture.draw: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "15:40:19"}
81
+ {"file": "docs/plans/2026-11-03-own-record.md", "line": 221, "context": "seam T14↔T15: T14 Produces: Picture.bounds: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "15:40:19"}
82
+ {"file": "docs/plans/2026-11-03-own-record.md", "line": 221, "context": "seam T14↔T15: T14 Produces: Picture.hitPath: no module found (file missing)", "agent": "pt_review", "kind": "exception", "confidence": "high", "resolution": "open", "time": "15:40:19"}
83
+ {"file": "", "line": 0, "context": "verified: seam T6↔T7", "agent": "pt_review", "kind": "verified", "time": "15:41:35"}
84
+ {"file": "", "line": 0, "context": "verified: seam T3↔T6", "agent": "pt_review", "kind": "verified", "time": "15:41:35"}
docs/plans/2026-11-03-own-record.md CHANGED
@@ -47,6 +47,8 @@ flowchart LR
47
47
  T11[state/view game]
48
48
  T12[state/view fixture]
49
49
  T13[docs]
50
+ T14[recursive Picture]
51
+ T15[recursive state/view]
50
52
  %% taskgraph
51
53
  T1 --> T2
52
54
  T3 --> T4
@@ -60,6 +62,10 @@ flowchart LR
60
62
  T10 --> T11
61
63
  T11 --> T12
62
64
  T9 --> T13
65
+ T12 --> T14
66
+ T11 --> T15
67
+ T12 --> T15
68
+ T14 --> T15
63
69
  ```
64
70
 
65
71
  ## Migration
@@ -210,6 +216,31 @@ e.g. `Picture.hitPath(Group([], [RectShape(0.0, 0.0, 10.0, 10.0, c)]), 50.0, 50.
210
216
  **Review Focus:** numeric: same actor count (about 5,750) and visual density as stress_pure_scene.
211
217
  **Test code:** web_check.rs `state_view_renders_and_animates` (mirrors pure_scene_renders_and_animates) passes in Chromium.
212
218
 
219
+ ## Recursive state/view (added after rebase; user request)
220
+
221
+ ### T14 recursive Picture · Complexity 4 · Est. context 40k
222
+ **Owns:** plum-std/game/Picture.plum
223
+ **Components:** 1
224
+ **LOC owned:** 200
225
+ **Tests:** fast
226
+ **Model:** sonnet
227
+ **Consumes:** none
228
+ **Produces:** Picture.draw(node: SceneNode) · Picture.bounds(node: SceneNode) -> Rect · Picture.hitPath(node: SceneNode, x: Float, y: Float) -> List[Int]
229
+ e.g. `Picture.hitPath(Group(Translate(10.0, 0.0), More(RectShape(0.0, 0.0, 10.0, 10.0, c), End)), 15.0, 5.0) -> [0]`
230
+ **Review Focus:** deep: structural recursion only (no List indexing, no matrix stack list); parent matrix and alpha passed as args. numeric: same bounds/hit results as before.
231
+ **Test code:** inline-variant enums: `enum SceneNode = Group(transform: Transform, children: Nodes) | Filter(effect: Effect, target: Int, child: SceneNode) | Sprite(..) | Text(..) | RectShape(..) | CircleShape(..) | LineShape(..)`, `enum Nodes = End | More(head: SceneNode, tail: Nodes)`, `enum Transform = Translate(..) | Rotate(..) | Scale(..) | Compose(first: Transform, then: Transform)`. draw/bounds/hitPath are recursive `= match` fns. Existing 5 test names keep their asserted values.
232
+
233
+ ### T15 recursive state/view game and fixture · Complexity 5 · Est. context 50k
234
+ **Owns:** plum-examples/game_state_view.plum, plum-game/web-test/fixtures/stress_state_view.plum
235
+ **Components:** 2
236
+ **LOC owned:** 300
237
+ **Tests:** slow
238
+ **Model:** sonnet
239
+ **Consumes:** Picture.hitPath(node: SceneNode, x: Float, y: Float) -> List[Int]
240
+ **Produces:** none
241
+ **Review Focus:** state: `Msg` inline-variant enum; `step(state, msg)` and `view(state)` recursive `= match`; id-keyed state folded recursively. Same gameplay and test names.
242
+ **Test code:** game_state_view.plum keeps its 22 test names and values; stress_state_view.plum keeps 2 tests and 5,750 actors; web_check `state_view_renders_and_animates` passes.
243
+
213
244
  ## Docs
214
245
 
215
246
  ### T13 docs · Complexity 2 · Est. context 30k
@@ -229,3 +260,4 @@ e.g. `Picture.hitPath(Group([], [RectShape(0.0, 0.0, 10.0, 10.0, c)]), 50.0, 50.
229
260
  - std/game + examples: state, numeric. Risks: behaviour drift from phase A; Picture matrix order.
230
261
  **Probe entry:** run=./target/debug/plum test
231
262
  R4 gate: coordinator benchmarks T2; if it misses the bar, stop and report.
263
+ Follow-ups (user, not in this plan): tail calls for the recursive Picture/state/view; a supercompiler later.
docs/plans/2026-11-03-own-record.progress.md ADDED
@@ -0,0 +1,117 @@
1
+ # Phase B own-record run
2
+ - start ea04e6cc77a758820e11f3544ec36d10ce77c6f2
3
+ - Ruling: skip T0 Wave 0 skeleton - stubs cannot be generated for Plum/Rust compiler interfaces; consumers test real code behind taskgraph edges - cost if wrong: a consumer waits on its producer
4
+ - Ruling: archived phase A run state to .superlite/archive/pure-scene-phase-a - lint froze T1-T9 against phase A's ledger - cost if wrong: none, files kept
5
+ - Ruling: plan 16.7k chars over the 15k cap, accepted - 13 tasks of contracts; prime stays slim - cost if wrong: larger prime
6
+ - unblocked: dispatched T1 T3 T10 at 14:38:12; waiting on fixes: none
7
+ - Ruling: T3 may add isOwn/ownSelf: false at AST construction sites outside its Owns (plum-core ctors/embeds/forms/traits/union_lower, others) - adding struct fields breaks those literals - cost if wrong: checkpoint needs a manual docs-free commit of those sites
8
+ - Ruling: T2 sent to T1's agent before T1's checkpoint - checkpoint blocked by T3's in-flight construction-site edits; T1 tests green - cost if wrong: T1 re-checkpointed later
9
+ - T1 DONE_WITH_CONCERNS: host_test pureScene not run by agent (hook); coordinator runs it
10
+ - tests fast passed=0 failed=1 skipped=0 seconds=2.4
11
+ - Ruling: T10 Filter carries target: Int (game-created render target) - per-draw textureCreate leaked a texture per filter per frame, no destroy extern - cost if wrong: games manage one texture per filter
12
+ - Ruling: T10 SceneNode flattened (RectShape|CircleShape|LineShape|Sprite|Text|Group|Filter), BlendFx name - nested Shape enum fails to type-check; Blend collides with Game - cost if wrong: none, same expressiveness
13
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.0
14
+ - checkpoint T1 a0d9f9fe12 migrate phase A to master syntax
15
+ - checkpoint T3 1b68156d83 own syntax
16
+ - checkpoint T10 4b3d53c2cb std/game/Picture
17
+ - unblocked: dispatched T4 T6 at 14:43:02 (T2 T11 already sent); waiting on fixes: none
18
+ - Ruling: committed T3's 13 one-line construction-site defaults separately (1 commit) - checkpoint blocks files outside Owns - cost if wrong: one extra commit
19
+ - checkpoint T2 03d3d3fb2a upper-bound probe fixture
20
+ - checkpoint T11 c9368a7cda state/view Orb Run
21
+ - R4 GATE TRIPPED 14:45: headed M3 Pro, measure 10. mutable avg 1.737/1.689 p95 1.9/1.8 upd 0.037; phase A avg 2.012 p95 2.5 upd 0.321; in-place upper bound avg 1.862/1.883 p95 2.3/2.3 upd 0.275/0.277. Bar avg<=+10% p95<=+15%: upper bound avg +7..11%, p95 +21..28% MISS; in-place removes only ~14% of update cost -> copies are not the main cost.
22
+ - Ruling: per R4 stop compiler chain (T4 stopped, T6 not sent, T5 T7 T8 T9 held); continue state/view T11-T12 (independent) for the 5-row table; report to user - cost if wrong: compiler work delayed one round
23
+ - T4 stopped: plum-checker/src/ownership.rs draft (uncompiled, unwired, untested); lowerings still drop isOwn; Assign/ClassCall lack positions (move error positions approximate)
24
+ - Ruling: [scope] user chose 'build own record' after R4 gate tripped - resume T4, dispatch T6; continue full compiler chain - cost if wrong: phase B may still miss the bar (upper bound p95 +21..28%)
25
+ - T12 DONE (checkpoint waits on T4 in-flight plum-core edits). state/view headed: avg 2.132/2.066 p95 2.3/2.2 p99 4.3/4.2 upd 0.081/0.074 draw 1.621/1.595 (view rebuild counted in draw)
26
+ - tests fast passed=1 failed=0 skipped=0 seconds=54.4
27
+ - checkpoint T4 d692a4a4f7 ownership checker: bindings and moves
28
+ - checkpoint T12 f0933bd6ff state/view fixture and web check
29
+ - tests fast passed=1 failed=0 skipped=0 seconds=42.3
30
+ - unblocked: dispatched T5 (fresh) + package-reviewer on T4 at 14:51:21; waiting on fixes: none
31
+ - Ruling: T4 reviewer is findings-only for ownership.rs (T5 edits it in parallel); fixes route to T5 - cost if wrong: one extra T5 round
32
+ - tests fast passed=1 failed=0 skipped=0 seconds=55.7
33
+ - tests fast passed=0 failed=1 skipped=0 seconds=4.3
34
+ - tests fast passed=1 failed=0 skipped=0 seconds=5.4
35
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.0
36
+ - T4 review DONE_WITH_CONCERNS: no straight-line bugs; plum-std+examples 579/0; 6 holes (field move-out, yield, ternary tails, variadic own, Bang/Catch/Try, inherited own self) routed to T5
37
+ - Ruling: moving an own value out of a field (non-swap) errors 'cannot move out of field "<f>" of "<name>"; use the swap form' - partial moves untracked otherwise - cost if wrong: one more message text
38
+ - tests fast passed=19 failed=5 skipped=0 seconds=15.6
39
+ - tests fast passed=1 failed=0 skipped=0 seconds=15.6
40
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.3
41
+ - tests fast passed=0 failed=1 skipped=0 seconds=1.6
42
+ - tests fast passed=0 failed=1 skipped=0 seconds=1.2
43
+ - tests fast passed=8 failed=1 skipped=0 seconds=0.4
44
+ - tests fast passed=0 failed=3 skipped=0 seconds=9.8
45
+ - tests fast passed=1 failed=0 skipped=0 seconds=52.4
46
+ - tests fast passed=0 failed=3 skipped=0 seconds=5.1
47
+ - tests fast passed=1 failed=0 skipped=0 seconds=4.0
48
+ - checkpoint T4 95d5739e16 ownership checker: bindings and moves
49
+ - checkpoint T5 5e9fcc30d1 ownership checker: branches, loops, closures, fields
50
+ - T5 DONE: flow 16/16, moves 13/13. Ruling: accept deviations (loop error at move site; closures borrow-only incl. returning captures; field moves except exact swap; R2 only at/get/first/last on List[Own] params/annotated locals; R5 wasm test moved to T6 scope) - cost if wrong: narrower R2 coverage
51
+ - T6 BLOCKED: checker false positive 'cannot store borrowed' on Int bindings whenever an own type exists -> F-T5-1 to fixer-sonnet (tier 1, known symptom, one module)
52
+ - NOTE: single-letter uppercase type names (S) parse as generics; tests use Cell
53
+ - tests fast passed=2 failed=1 skipped=0 seconds=6.8
54
+ - tests fast passed=1 failed=0 skipped=0 seconds=40.1
55
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
56
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.0
57
+ - tests fast passed=0 failed=1 skipped=0 seconds=2.0
58
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
59
+ - tests fast passed=0 failed=1 skipped=0 seconds=2.7
60
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
61
+ - tests fast passed=10 failed=1 skipped=0 seconds=15.0
62
+ - tests fast passed=3 failed=0 skipped=0 seconds=24.0
63
+ - tests fast passed=0 failed=1 skipped=0 seconds=2.0
64
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.5
65
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.4
66
+ - checkpoint T5 86efd073f5 ownership checker: branches, loops, closures, fields
67
+ - F-T5-1 FIXED (params of non-own type no longer tracked), T5 re-checkpointed 86efd07
68
+ - F-T6-1 to fixer-sonnet-high: finish/verify T6 (implementer over 80k context); replace Debug-text hazard check with AST walk; add R5 match-arm test
69
+ - tests fast passed=1 failed=0 skipped=0 seconds=0.3
70
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.1
71
+ - tests fast passed=1 failed=0 skipped=0 seconds=19.5
72
+ - checkpoint T6 575a969416 in-place spread codegen
73
+ - T6 checkpointed 575a969 (own_inplace 8 pass 1 ignored; codegen suite green)
74
+ - F-T6-2 to fixer-sonnet-high: own trait field setters (__set_) flagged as borrowed self; __set_ calls must count as field writes on receiver
75
+ - unblocked: dispatched T7 to a53be28 at 15:12:21; waiting on fixes: F-T6-2
76
+ - Ruling: [scope] List.take/put renamed takeAt/putAt - List.take shadowed the lazy Seq take adapter (forms.rs methodStar), breaking seq.plum - cost if wrong: naming only; T8 consumes takeAt/putAt
77
+ - T7 sweep: at/get/first/last/head/findValue/sample alias; set old slot not invalidated; slice shares storage; removeAt/pop/takeAt leave stale slots; push/add/init/remove take plain T (follow-up: own-aware List API)
78
+ - tests fast passed=0 failed=1 skipped=0 seconds=0.0
79
+ - checkpoint T7 a167991594 List for owned elements
80
+ - checkpoint T5 5b664337f4 ownership checker: branches, loops, closures, fields
81
+ - checkpoint T6 58a9182174 in-place spread codegen
82
+ - F-T6-2 FIXED, T5/T6 re-checkpointed
83
+ - Ruling: T8 Consumes cleared (T7 block frozen with take/put names); T8 notes name takeAt/putAt; edge T7->T8 kept - cost if wrong: none
84
+ - unblocked: dispatched T8 (fresh) at 15:17:17; waiting on fixes: none
85
+ - checkpoint T8 968ef98c72 scene modules on own
86
+ - T8 checkpointed 968ef98 (38 tests; clone test removed; 3 leaves_input_unchanged tests replaced by in-place equivalents; Handled/StageHandled/Stage own records)
87
+ - F-T8-1 to fixer-sonnet-high: match pattern bindings untracked (move-out hole)
88
+ - unblocked: dispatched T9 to a7cd687 at 15:20:50; waiting on fixes: F-T8-1
89
+ - tests fast passed=1 failed=0 skipped=0 seconds=37.4
90
+ - checkpoint T5 b8ee44d8b1 ownership checker: branches, loops, closures, fields
91
+ - F-T8-1 FIXED: match on owned scrutinee moves it, pattern names owned; borrowed -> names borrowed; synthesized union forwarders skipped. flow 27/27
92
+ - checkpoint T9 d692c712f3 example and fixture on own
93
+ - T9 checkpointed d692c71. Benchmark phase B: avg 2.107/1.931 p95 2.7/2.3 upd 0.343/0.316 vs mutable 1.736/1.738 p95 1.9 -> MISSED; written docs/plans/2026-11-03-own-record.benchmark.md
94
+ - Rebase onto master 9117837 (inline record variants + = match bodies): phase B committed 6525e4c; conflicts ast.rs parser.rs parser.c -> fixer-sonnet-high. Reverted superlite's .gitignore additions (ignored committed design docs)
95
+ - Ruling: [scope] user approved T14: Picture SceneNode + state/view Msg on inline-variant enums with = match bodies, after the rebase - cost if wrong: one task
96
+ - Rebase DONE onto master 05f8c38 (512 Rust / 593 Plum / 118 corpus green); hidden inline-variant records inherit isOwn
97
+ - Ruling: [scope] T14 recursive Picture + T15 recursive state/view added (user: recursive type + recursive fns; tail calls / supercompiler later, out of scope); children as recursive Nodes = End | More - cost if wrong: deep recursion slower until tail calls
98
+ - unblocked: dispatched T13 T14 (fresh) at 15:32:51; T15 after T14
99
+ - checkpoint T14 e54f685579 recursive Picture
100
+ - T14 checkpointed e54f685 (Picture recursive: inline-variant SceneNode/Transform(+Identity)/Effect, Nodes=End|More; 6/6)
101
+ - unblocked: dispatched T15 to a7ed9fc at 15:35:12
102
+ - checkpoint T13 88103bd15c docs
103
+ - T13 checkpointed 88103bd (README Own records; example uses t := Cell(..s, ...))
104
+ - F-T6-3 to fixer-opus: s = Cell(..s, ...) on own param traps (wasm cast failure), cause unknown
105
+ - tests fast passed=1 failed=0 skipped=0 seconds=57.4
106
+ - checkpoint T15 b689125069 recursive state/view game and fixture
107
+ - checkpoint T6 017bd757c0 in-place spread codegen
108
+ - F-T6-3 FIXED (pre-existing): assigning to any param created a shadow local read for the whole body (Int params silently read 0, records trapped); now reuses the param slot. 594 Plum tests green
109
+ - T15 checkpointed b689125. Recursive state/view headed: avg 1.905/1.908 p95 2.3 p99 2.4 upd 0.05 draw 1.49 vs mutable 1.729 p95 1.9
110
+ - tests full passed=1765 failed=0 skipped=0 seconds=110.6
111
+ - Final review CHANGES_REQUESTED; ruled after direct checks: full suite 1765/0 (run_tests --full --wait).
112
+ - Ruling: finding "codegen red / own_spread_reuses_object" - stale incident [1]; suite green, own_inplace_e2e 12 pass - cost if wrong: none
113
+ - Ruling: finding "Int false positive" - fixed by F-T5-1, regression int_param_alias_not_tracked_with_own_present - cost if wrong: none
114
+ - Ruling: finding "approximate move positions" - real; deferred follow-up (add line/col to Stmt::Assign, Expr::ClassCall) - cost if wrong: error columns approximate (lines correct)
115
+ - Ruling: finding "state not probed" - flow tests cover joins/loops/closures/early return (27 pass) - cost if wrong: none
116
+ - Ruling: finding "Picture fns missing" - tooling cannot parse Plum; Picture.plum:217-227 present, 6 tests - cost if wrong: none
117
+ - Ruling: finding "T7 Produces take/put" - block frozen; rename recorded in T8 notes and rulings - cost if wrong: doc drift only
docs/plans/2026-11-03-own-record.report.json ADDED
@@ -0,0 +1,342 @@
1
+ {
2
+ "projection_note": "projection: fixed forecast saved at lint 2026-11-03T14:37:44 (plan edited since)",
3
+ "headline": {
4
+ "wall_min": {
5
+ "projected": 13.605,
6
+ "actual": 61.825
7
+ },
8
+ "cost_usd_agents": {
9
+ "projected": 1.742609,
10
+ "actual": 9.027237
11
+ },
12
+ "task_min": {
13
+ "projected": 25.758,
14
+ "actual": 83.348
15
+ },
16
+ "agents": {
17
+ "projected": 13,
18
+ "actual": 20
19
+ }
20
+ },
21
+ "feature": null,
22
+ "cost_split": {
23
+ "agents_usd": 6.859187,
24
+ "rework_usd": 2.16805,
25
+ "coordinator_usd": 25.293098,
26
+ "total_usd": 34.320335
27
+ },
28
+ "concurrency": {
29
+ "peak": 4,
30
+ "projected_peak": 4
31
+ },
32
+ "critical_path": {
33
+ "actual": [
34
+ "T3",
35
+ "T6"
36
+ ],
37
+ "projected": [
38
+ "T3",
39
+ "T4",
40
+ "T5",
41
+ "T7",
42
+ "T8",
43
+ "T9",
44
+ "T13"
45
+ ],
46
+ "projected_min": 13.605
47
+ },
48
+ "model_mix": {
49
+ "sonnet": {
50
+ "tasks": 10,
51
+ "cost_usd": 9.027237
52
+ }
53
+ },
54
+ "coordinator_cost_by_model": {
55
+ "opus": 25.293098
56
+ },
57
+ "quality": {
58
+ "tests": {
59
+ "passed": 1765,
60
+ "failed": 0,
61
+ "skipped": 0,
62
+ "seconds": 110.6,
63
+ "mode": "full",
64
+ "failures": [],
65
+ "skipped_tests": []
66
+ },
67
+ "first_time_pass": 0,
68
+ "first_time_n": 1,
69
+ "rework_dispatches": 5,
70
+ "open_incidents": 38
71
+ },
72
+ "tasks": [
73
+ {
74
+ "task": "T6",
75
+ "model": "sonnet",
76
+ "pred_min": 1.612,
77
+ "actual_min": 32.548,
78
+ "delta": {
79
+ "abs": 30.9363,
80
+ "pct": 1919.1
81
+ },
82
+ "cost_usd": 3.216926,
83
+ "first_time_pass": false
84
+ },
85
+ {
86
+ "task": "T5",
87
+ "model": "sonnet",
88
+ "pred_min": 1.769,
89
+ "actual_min": 7.252,
90
+ "delta": {
91
+ "abs": 5.4827,
92
+ "pct": 309.9
93
+ },
94
+ "cost_usd": 1.200695,
95
+ "first_time_pass": null
96
+ },
97
+ {
98
+ "task": "T3",
99
+ "model": "sonnet",
100
+ "pred_min": 2.094,
101
+ "actual_min": 7.223,
102
+ "delta": {
103
+ "abs": 5.1293,
104
+ "pct": 245.0
105
+ },
106
+ "cost_usd": 0.755352,
107
+ "first_time_pass": null
108
+ },
109
+ {
110
+ "task": "T8",
111
+ "model": "sonnet",
112
+ "pred_min": 2.459,
113
+ "actual_min": 7.138,
114
+ "delta": {
115
+ "abs": 4.6793,
116
+ "pct": 190.3
117
+ },
118
+ "cost_usd": 1.148973,
119
+ "first_time_pass": null
120
+ },
121
+ {
122
+ "task": "T2",
123
+ "model": "sonnet",
124
+ "pred_min": 2.178,
125
+ "actual_min": 6.643,
126
+ "delta": {
127
+ "abs": 4.4653,
128
+ "pct": 205.0
129
+ },
130
+ "cost_usd": 0.290503,
131
+ "first_time_pass": null
132
+ },
133
+ {
134
+ "task": "T10",
135
+ "model": "sonnet",
136
+ "pred_min": 1.691,
137
+ "actual_min": 5.927,
138
+ "delta": {
139
+ "abs": 4.2357,
140
+ "pct": 250.5
141
+ },
142
+ "cost_usd": 0.715954,
143
+ "first_time_pass": null
144
+ },
145
+ {
146
+ "task": "T4",
147
+ "model": "sonnet",
148
+ "pred_min": 1.847,
149
+ "actual_min": 4.74,
150
+ "delta": {
151
+ "abs": 2.893,
152
+ "pct": 156.6
153
+ },
154
+ "cost_usd": 0.393466,
155
+ "first_time_pass": null
156
+ },
157
+ {
158
+ "task": "T13",
159
+ "model": "sonnet",
160
+ "pred_min": 1.4,
161
+ "actual_min": 2.523,
162
+ "delta": {
163
+ "abs": 1.1233,
164
+ "pct": 80.2
165
+ },
166
+ "cost_usd": 0.24823,
167
+ "first_time_pass": null
168
+ },
169
+ {
170
+ "task": "T1",
171
+ "model": "sonnet",
172
+ "pred_min": 1.937,
173
+ "actual_min": 2.073,
174
+ "delta": {
175
+ "abs": 0.1363,
176
+ "pct": 7.0
177
+ },
178
+ "cost_usd": 0.211794,
179
+ "first_time_pass": null
180
+ },
181
+ {
182
+ "task": "T14",
183
+ "model": "sonnet",
184
+ "pred_min": null,
185
+ "actual_min": 7.28,
186
+ "delta": {
187
+ "abs": null,
188
+ "pct": null
189
+ },
190
+ "cost_usd": 0.845344,
191
+ "first_time_pass": null
192
+ }
193
+ ],
194
+ "cost_table": {
195
+ "source": "est",
196
+ "usd": 9.3617,
197
+ "tokens": 30849464,
198
+ "wall_min": 119580025.0231,
199
+ "rows": [
200
+ {
201
+ "agent": "a01a9e03e2acb835b",
202
+ "model": "sonnet",
203
+ "wall_min": 1.5649,
204
+ "tokens": 231841,
205
+ "usd": 0.1368
206
+ },
207
+ {
208
+ "agent": "a0e8dea8f85f13dbf",
209
+ "model": "sonnet",
210
+ "wall_min": 9.2805,
211
+ "tokens": 1529221,
212
+ "usd": 0.5023
213
+ },
214
+ {
215
+ "agent": "a1f4933624de7b5ef",
216
+ "model": "sonnet",
217
+ "wall_min": 6.0213,
218
+ "tokens": 1085351,
219
+ "usd": 0.716
220
+ },
221
+ {
222
+ "agent": "a3e35069781629003",
223
+ "model": "sonnet",
224
+ "wall_min": 4.4801,
225
+ "tokens": 1579541,
226
+ "usd": 0.447
227
+ },
228
+ {
229
+ "agent": "a440ab2702c72dcc7",
230
+ "model": "sonnet",
231
+ "wall_min": 3.066,
232
+ "tokens": 1243873,
233
+ "usd": 0.3122
234
+ },
235
+ {
236
+ "agent": "a53be28dbfee6f9f8",
237
+ "model": "sonnet",
238
+ "wall_min": 32.6157,
239
+ "tokens": 9405486,
240
+ "usd": 1.6496
241
+ },
242
+ {
243
+ "agent": "a645d85b6578c5875",
244
+ "model": "sonnet",
245
+ "wall_min": 29894955.8928,
246
+ "tokens": 0,
247
+ "usd": 0.0
248
+ },
249
+ {
250
+ "agent": "a68b5d7f23754c57c",
251
+ "model": "sonnet",
252
+ "wall_min": 12.2574,
253
+ "tokens": 3610245,
254
+ "usd": 1.012
255
+ },
256
+ {
257
+ "agent": "a7cd68767266f8c93",
258
+ "model": "sonnet",
259
+ "wall_min": 7.1753,
260
+ "tokens": 2932728,
261
+ "usd": 0.8604
262
+ },
263
+ {
264
+ "agent": "a7ed9fc0c1c782ec6",
265
+ "model": "sonnet",
266
+ "wall_min": 7.3337,
267
+ "tokens": 1950255,
268
+ "usd": 0.8453
269
+ },
270
+ {
271
+ "agent": "a83ddb1893bfc4ac1",
272
+ "model": "sonnet",
273
+ "wall_min": 29894955.9872,
274
+ "tokens": 0,
275
+ "usd": 0.0
276
+ },
277
+ {
278
+ "agent": "a8aad43f5eeb9f328",
279
+ "model": "sonnet",
280
+ "wall_min": 2.6288,
281
+ "tokens": 733083,
282
+ "usd": 0.2482
283
+ },
284
+ {
285
+ "agent": "a95910dd785a9fb35",
286
+ "model": "sonnet",
287
+ "wall_min": 7.2874,
288
+ "tokens": 3048672,
289
+ "usd": 0.8885
290
+ },
291
+ {
292
+ "agent": "a969fabbbf56da79e",
293
+ "model": "opus",
294
+ "wall_min": 0.4953,
295
+ "tokens": 44207,
296
+ "usd": 0.2016
297
+ },
298
+ {
299
+ "agent": "a9cb67589cc0e1ed2",
300
+ "model": "sonnet",
301
+ "wall_min": 3.6224,
302
+ "tokens": 1278155,
303
+ "usd": 0.3731
304
+ },
305
+ {
306
+ "agent": "ac171c85cba4b85f2",
307
+ "model": "opus",
308
+ "wall_min": 4.4896,
309
+ "tokens": 1108958,
310
+ "usd": 0.7473
311
+ },
312
+ {
313
+ "agent": "acd825d12312cafcd",
314
+ "model": "sonnet",
315
+ "wall_min": 4.1557,
316
+ "tokens": 319327,
317
+ "usd": 0.1328
318
+ },
319
+ {
320
+ "agent": "ad18889d1756b4c8d",
321
+ "model": "sonnet",
322
+ "wall_min": 29894997.312,
323
+ "tokens": 0,
324
+ "usd": 0.0
325
+ },
326
+ {
327
+ "agent": "ad338bf16e18c83e2",
328
+ "model": "sonnet",
329
+ "wall_min": 29895006.0052,
330
+ "tokens": 0,
331
+ "usd": 0.0
332
+ },
333
+ {
334
+ "agent": "af3bca1f1f48b0c08",
335
+ "model": "sonnet",
336
+ "wall_min": 3.3518,
337
+ "tokens": 748521,
338
+ "usd": 0.2886
339
+ }
340
+ ]
341
+ }
342
+ }
docs/plans/2026-11-03-own-record.report.md ADDED
@@ -0,0 +1,49 @@
1
+ # superlite build report
2
+
3
+ projection: fixed forecast saved at lint 2026-11-03T14:37:44 (plan edited since)
4
+
5
+ Projected Actual Delta
6
+ wall-clock (min) 13.6 61.8 +48.2 (+354%)
7
+ agent cost (USD, +rework) 1.74 9.03 +7.28 (+418%)
8
+ task-minutes 25.8 83.3 +57.6 (+224%)
9
+ agents 13 20 +7 (+54%)
10
+ cost split: agents $6.86, rework $2.17, coordinator $25.29, total $34.32
11
+ concurrency: peak 4 at once (projected 4); critical path actual T3 T6; projected T3 > T4 > T5 > T7 > T8 > T9 > T13 (13.6 min)
12
+ model mix: sonnet 10 tasks $9.03, coordinator opus $25.29
13
+ quality: tests 1765 passed, 0 failed; first-time pass 0/1 (0%); rework dispatches 5; open incidents 38
14
+
15
+ task model pred actual delta cost
16
+ T6 sonnet 1.6 32.5 +30.9 (+1919%) $3.217 first-time miss
17
+ T5 sonnet 1.8 7.3 +5.5 (+310%) $1.201
18
+ T3 sonnet 2.1 7.2 +5.1 (+245%) $0.755
19
+ T8 sonnet 2.5 7.1 +4.7 (+190%) $1.149
20
+ T2 sonnet 2.2 6.6 +4.5 (+205%) $0.291
21
+ (+5 smaller misses; `superlite-cli report --json` lists all)
22
+
23
+ agent cost table:
24
+ | agent | model | wall_min | tokens | usd |
25
+ | --- | --- | --- | --- | --- |
26
+ | a01a9e03e2acb835b | sonnet | 1.5649 | 231841 | 0.1368 |
27
+ | a0e8dea8f85f13dbf | sonnet | 9.2805 | 1529221 | 0.5023 |
28
+ | a1f4933624de7b5ef | sonnet | 6.0213 | 1085351 | 0.716 |
29
+ | a3e35069781629003 | sonnet | 4.4801 | 1579541 | 0.447 |
30
+ | a440ab2702c72dcc7 | sonnet | 3.066 | 1243873 | 0.3122 |
31
+ | a53be28dbfee6f9f8 | sonnet | 32.6157 | 9405486 | 1.6496 |
32
+ | a645d85b6578c5875 | sonnet | 29894955.8928 | 0 | 0.0 |
33
+ | a68b5d7f23754c57c | sonnet | 12.2574 | 3610245 | 1.012 |
34
+ | a7cd68767266f8c93 | sonnet | 7.1753 | 2932728 | 0.8604 |
35
+ | a7ed9fc0c1c782ec6 | sonnet | 7.3337 | 1950255 | 0.8453 |
36
+ | a83ddb1893bfc4ac1 | sonnet | 29894955.9872 | 0 | 0.0 |
37
+ | a8aad43f5eeb9f328 | sonnet | 2.6288 | 733083 | 0.2482 |
38
+ | a95910dd785a9fb35 | sonnet | 7.2874 | 3048672 | 0.8885 |
39
+ | a969fabbbf56da79e | opus | 0.4953 | 44207 | 0.2016 |
40
+ | a9cb67589cc0e1ed2 | sonnet | 3.6224 | 1278155 | 0.3731 |
41
+ | ac171c85cba4b85f2 | opus | 4.4896 | 1108958 | 0.7473 |
42
+ | acd825d12312cafcd | sonnet | 4.1557 | 319327 | 0.1328 |
43
+ | ad18889d1756b4c8d | sonnet | 29894997.312 | 0 | 0.0 |
44
+ | ad338bf16e18c83e2 | sonnet | 29895006.0052 | 0 | 0.0 |
45
+ | af3bca1f1f48b0c08 | sonnet | 3.3518 | 748521 | 0.2886 |
46
+ | totals | | 119580025.0231 | 30849464 | 9.3617 |
47
+ cost source: est
48
+
49
+ takeaway: ran 354% slower than projected; cost 418% over; biggest miss T6
plum-examples/game_state_view.plum CHANGED
@@ -4,11 +4,13 @@
4
4
  # WASD, collect the drifting orbs and avoid the mines. Three lives; Enter, R or the
5
5
  # "Again" button restarts after the game is over.
6
6
  #
7
- # The game is three plain pieces. `State` is flat data: the ship, the orbs and the
7
+ # The game is three plain pieces. `State` is plain data: the ship, the orbs and the
8
- # mines keyed by id, the score, the random seed and the screen size. `Msg` is what
8
+ # mines (recursive `Link` lists keyed by id), the score, the random seed and the screen
9
- # can happen to it (a tick of time, a resize, a restart). `step(state, msg)` is pure
9
+ # size. `Msg` is what can happen to it (a tick of time, a resize, a restart), an enum
10
- # and returns the next `State`; `view(state)` is pure too and builds a fresh
10
+ # with inline variants. `step(state, msg)` is pure and returns the next `State`;
11
- # `SceneNode` picture every frame, which `Picture.draw` interprets. There is no
11
+ # `view(state)` is pure too and builds a fresh `SceneNode` picture every frame, which
12
+ # `Picture.draw` interprets. Both are structural recursion over `Link` and `Nodes`:
13
+ # no loop runs in `step` or `view`. There is no
12
14
  # retained scene: the restart button is found by `Picture.hitPath` on the picture
13
15
  # the player is looking at. The `World` record is the host shell: it holds the
14
16
  # current `State`, reads the keyboard and mouse into messages, and plays sounds.
@@ -70,8 +72,8 @@ struct Mine(id: Int, x: Float, y: Float, vx: Float, vy: Float, rotation: Float,
70
72
  # the view needs: the sprite sheet and the render target of the game-over filter.
71
73
  record State(
72
74
  ship: ShipState,
73
- orbs: List[Orb],
75
+ orbs: Link[Orb],
74
- mines: List[Mine],
76
+ mines: Link[Mine],
75
77
  seed: Int,
76
78
  score: Int,
77
79
  best: Int,
@@ -85,14 +87,41 @@ record State(
85
87
  target: Int)
86
88
 
87
89
  # What can happen to the state.
90
+ enum Msg =
88
- struct Tick(dt: Float, ax: Float, ay: Float)
91
+ | Tick(dt: Float, ax: Float, ay: Float)
89
-
90
- struct Resize(w: Float, h: Float)
92
+ | Resize(w: Float, h: Float)
91
-
92
- # A new run with `seed`, keeping the best score.
93
- struct Restart(seed: Int)
93
+ | Restart(seed: Int)
94
+
94
-
95
+ # A recursive list: the empty `Nil`, or a head in front of another list.
95
- enum Msg = Tick | Resize | Restart
96
+ enum Link[T] = Nil | Cons(head: T, tail: Link[T])
97
+
98
+ fun mapLink(xs: Link[A], f: fn(A) -> B) -> Link[B] = match xs
99
+ Nil => Nil
100
+ Cons(h, t) => Cons(head: f(h), tail: mapLink(t, f))
101
+
102
+ fun lenLink(xs: Link[T]) -> Int = match xs
103
+ Nil => 0
104
+ Cons(_, t) => 1 + lenLink(t)
105
+
106
+ # `xs` with `x` added at the end.
107
+ fun snoc(xs: Link[T], x: T) -> Link[T] = match xs
108
+ Nil => Cons(head: x, tail: Nil)
109
+ Cons(h, t) => Cons(head: h, tail: snoc(t, x))
110
+
111
+ # `xs` with the element at `i` replaced by `x`.
112
+ fun replaceNth(xs: Link[T], i: Int, x: T) -> Link[T] = match xs
113
+ Nil => Nil
114
+ Cons(h, t) => if i <= 0 then Cons(head: x, tail: t) else Cons(head: h, tail: replaceNth(t, i - 1, x))
115
+
116
+ # The element at `i`, or `dflt` past the end.
117
+ fun nthOr(xs: Link[T], i: Int, dflt: T) -> T = match xs
118
+ Nil => dflt
119
+ Cons(h, t) => if i <= 0 then h else nthOr(t, i - 1, dflt)
120
+
121
+ # The nodes f(x) for every x of `xs`, followed by `rest`.
122
+ fun toNodes(xs: Link[T], f: fn(T) -> SceneNode, rest: Nodes) -> Nodes = match xs
123
+ Nil => rest
124
+ Cons(h, t) => More(head: f(h), tail: toNodes(t, f, rest))
96
125
 
97
126
  # --- small pure helpers ---
98
127
 
@@ -193,88 +222,108 @@ fun mineMove(k: Mine, dt: Float) -> Mine =
193
222
 
194
223
  # --- the state ---
195
224
 
225
+ # A built list of orbs and the seed to continue from.
226
+ record OrbsMade(orbs: Link[Orb], seed: Int)
227
+
228
+ record MinesMade(mines: Link[Mine], seed: Int)
229
+
230
+ # Orbs `i` and up to OrbCount, each at a random spot away from the ship.
231
+ fun makeOrbs(i: Int, seed: Int, ship: ShipState) -> OrbsMade =
232
+ if i >= Rules.OrbCount
233
+ return OrbsMade(orbs: Nil, seed: seed)
234
+ sp := spawn(seed, ship.x, ship.y, 120.0, 40.0)
235
+ rest := makeOrbs(i + 1, sp.seed, ship)
236
+ OrbsMade(orbs: Cons(head: orbAt(Id.Orb + i, Float(i) * 0.1, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)), tail: rest.orbs), seed: rest.seed)
237
+
238
+ # Mines `i` and up to `count`.
239
+ fun makeMines(i: Int, count: Int, seed: Int, ship: ShipState) -> MinesMade =
240
+ if i >= count
241
+ return MinesMade(mines: Nil, seed: seed)
242
+ sp := spawn(seed, ship.x, ship.y, 220.0, 90.0)
243
+ rest := makeMines(i + 1, count, sp.seed, ship)
244
+ MinesMade(mines: Cons(head: mineAt(Id.Mine + i, 0.0, 2.0, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)), tail: rest.mines), seed: rest.seed)
245
+
196
246
  fun newState(seed: Int, best: Int, sheet: Int, target: Int, sw: Float, sh: Float) -> State =
197
247
  ship := newShip()
198
- sd := seed
199
- orbs := List[Orb]()
200
- for i := range Rules.OrbCount
248
+ made := makeOrbs(0, seed, ship)
201
- sp := spawn(sd, ship.x, ship.y, 120.0, 40.0)
249
+ mm := makeMines(0, mineCount(0), made.seed, ship)
202
- sd = sp.seed
203
- orbs.push(orbAt(Id.Orb + i, Float(i) * 0.1, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)))
204
- mines := List[Mine]()
205
- for i := range mineCount(0)
206
- sp := spawn(sd, ship.x, ship.y, 220.0, 90.0)
207
- sd = sp.seed
208
- mines.push(mineAt(Id.Mine + i, 0.0, 2.0, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)))
209
250
  State(
210
- ship: ship, orbs: orbs, mines: mines, seed: sd, score: 0, best: best, lives: Rules.StartLives,
251
+ ship: ship, orbs: made.orbs, mines: mm.mines, seed: mm.seed, score: 0, best: best, lives: Rules.StartLives,
211
252
  over: false, clock: 0.0, overClock: 0.0, sw: sw, sh: sh, sheet: sheet, target: target)
212
253
 
254
+ fun orbStill(o: Orb) -> Orb =
255
+ orbAt(o.id, o.phase, o.clock, Motion(x: o.x, y: o.y, vx: 0.0, vy: 0.0))
256
+
257
+ fun mineStill(k: Mine) -> Mine =
258
+ mineAt(k.id, k.rotation, 0.0, k.clock, Motion(x: k.x, y: k.y, vx: 0.0, vy: 0.0))
259
+
213
260
  # Stops every orb and mine: after game over they only animate.
214
261
  fun freeze(s: State) -> State =
215
262
  n := State(..s)
216
- orbs := List[Orb]()
217
- for i := range s.orbs.length()
263
+ n.orbs = mapLink(s.orbs, |o| orbStill(o))
218
- o := s.orbs.at(i)
219
- orbs.push(orbAt(o.id, o.phase, o.clock, Motion(x: o.x, y: o.y, vx: 0.0, vy: 0.0)))
220
- mines := List[Mine]()
221
- for i := range s.mines.length()
264
+ n.mines = mapLink(s.mines, |k| mineStill(k))
222
- k := s.mines.at(i)
223
- mines.push(mineAt(k.id, k.rotation, 0.0, k.clock, Motion(x: k.x, y: k.y, vx: 0.0, vy: 0.0)))
224
- n.orbs = orbs
225
- n.mines = mines
226
265
  n
227
266
 
267
+ # The orbs after the ship collected the ones it touches, with the score and seed.
268
+ record Collected(orbs: Link[Orb], seed: Int, score: Int)
269
+
270
+ fun collectOrbs(orbs: Link[Orb], ship: ShipState, seed: Int, score: Int) -> Collected = match orbs
271
+ Nil => Collected(orbs: Nil, seed: seed, score: score)
272
+ Cons(o, rest) =>
273
+ if overlaps(ship.x, ship.y, Rules.ShipRadius, o.x, o.y, Rules.OrbRadius)
274
+ sp := spawn(seed, ship.x, ship.y, 120.0, 40.0)
275
+ later := collectOrbs(rest, ship, sp.seed, score + Rules.OrbPoints)
276
+ moved := orbAt(o.id, o.phase, o.clock, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy))
277
+ Collected(orbs: Cons(head: moved, tail: later.orbs), seed: later.seed, score: later.score)
278
+ else
279
+ kept := collectOrbs(rest, ship, seed, score)
280
+ Collected(orbs: Cons(head: o, tail: kept.orbs), seed: kept.seed, score: kept.score)
281
+
282
+ # `mines` grown (new mines go at the end) until there are `want`.
283
+ fun growMines(mines: Link[Mine], have: Int, want: Int, seed: Int, ship: ShipState) -> MinesMade =
284
+ if have >= want
285
+ return MinesMade(mines: mines, seed: seed)
286
+ sp := spawn(seed, ship.x, ship.y, 220.0, 90.0)
287
+ added := snoc(mines, mineAt(Id.Mine + have, 0.0, 2.0, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)))
288
+ growMines(added, have + 1, want, sp.seed, ship)
289
+
290
+ fun touchesMine(mines: Link[Mine], ship: ShipState) -> Bool = match mines
291
+ Nil => false
292
+ Cons(k, rest) => overlaps(ship.x, ship.y, Rules.ShipRadius, k.x, k.y, Rules.MineRadius) || touchesMine(rest, ship)
293
+
228
294
  # One frame of time. Once the game is over the actors only animate.
229
295
  fun tick(s: State, dt: Float, ax: Float, ay: Float) -> State =
230
296
  n := State(..s)
231
297
  n.clock = s.clock + dt
232
- orbs := List[Orb]()
233
- for i := range s.orbs.length()
234
- orbs.push(orbMove(s.orbs.at(i), dt))
298
+ n.orbs = mapLink(s.orbs, |o| orbMove(o, dt))
235
- mines := List[Mine]()
236
- for i := range s.mines.length()
237
- mines.push(mineMove(s.mines.at(i), dt))
299
+ n.mines = mapLink(s.mines, |k| mineMove(k, dt))
238
- n.orbs = orbs
239
- n.mines = mines
240
300
  if s.over
241
301
  n.overClock = s.overClock + dt
242
302
  return n
243
303
  ship := shipStep(s.ship, ax, ay, dt)
244
304
  n.ship = ship
245
- collected := List[Orb]()
246
- for i := range orbs.length()
247
- o := orbs.at(i)
248
- if overlaps(ship.x, ship.y, Rules.ShipRadius, o.x, o.y, Rules.OrbRadius)
249
- n.score = n.score + Rules.OrbPoints
250
- sp := spawn(n.seed, ship.x, ship.y, 120.0, 40.0)
305
+ got := collectOrbs(n.orbs, ship, n.seed, n.score)
306
+ n.orbs = got.orbs
251
- n.seed = sp.seed
307
+ n.seed = got.seed
252
- collected.push(orbAt(o.id, o.phase, o.clock, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)))
253
- else
254
- collected.push(o)
255
- n.orbs = collected
308
+ n.score = got.score
256
- while n.mines.length() < mineCount(n.score)
309
+ grown := growMines(n.mines, lenLink(n.mines), mineCount(n.score), n.seed, ship)
257
- sp := spawn(n.seed, ship.x, ship.y, 220.0, 90.0)
310
+ n.mines = grown.mines
258
- n.seed = sp.seed
311
+ n.seed = grown.seed
259
- n.mines.push(mineAt(Id.Mine + n.mines.length(), 0.0, 2.0, 0.0, Motion(x: sp.x, y: sp.y, vx: sp.vx, vy: sp.vy)))
260
- for i := range n.mines.length()
261
- k := n.mines.at(i)
262
- if n.ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, k.x, k.y, Rules.MineRadius)
312
+ if n.ship.hurt <= 0.0 && touchesMine(n.mines, ship)
263
- n.lives = n.lives - 1
313
+ n.lives = n.lives - 1
264
- n.ship = ShipState(x: ship.x, y: ship.y, vx: ship.vx, vy: ship.vy, rotation: ship.rotation, hurt: Rules.HurtTime)
314
+ n.ship = ShipState(x: ship.x, y: ship.y, vx: ship.vx, vy: ship.vy, rotation: ship.rotation, hurt: Rules.HurtTime)
265
- if n.lives <= 0
315
+ if n.lives <= 0
266
- n.over = true
316
+ n.over = true
267
- n.best = n.best.max(n.score)
317
+ n.best = n.best.max(n.score)
268
318
  if n.over
269
319
  return freeze(n)
270
320
  n
271
321
 
272
322
  # The next state: pure, nothing that is passed in is changed.
273
- fun step(s: State, msg: Msg) -> State =
323
+ fun step(s: State, msg: Msg) -> State = match msg
274
- match msg
275
- Tick => tick(s, msg.dt, msg.ax, msg.ay)
324
+ Tick(dt, ax, ay) => tick(s, dt, ax, ay)
276
- Resize => resized(s, msg.w, msg.h)
325
+ Resize(w, h) => resized(s, w, h)
277
- Restart => newState(msg.seed, s.best.max(s.score), s.sheet, s.target, s.sw, s.sh)
326
+ Restart(seed) => newState(seed, s.best.max(s.score), s.sheet, s.target, s.sw, s.sh)
278
327
 
279
328
  fun resized(s: State, w: Float, h: Float) -> State =
280
329
  n := State(..s)
@@ -304,86 +353,90 @@ fun starView(i: Int, clock: Float) -> SceneNode =
304
353
  twinkle := 0.25 + 0.2 * Math.sin(clock * 2.0 + Float(i))
305
354
  CircleShape(cx: Float({i * 7919} % 800), cy: Float({i * 104729} % 600), r: r, color: Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
306
355
 
356
+ # The stars `i` and up to 40.
357
+ fun starNodes(i: Int, clock: Float) -> Nodes =
358
+ if i >= 40 then End else More(head: starView(i, clock), tail: starNodes(i + 1, clock))
359
+
307
360
  fun starsView(clock: Float) -> SceneNode =
308
- kids := List[SceneNode]()
309
- for i := range 40
310
- kids.push(starView(i, clock))
311
- Group(transform: List[Transform](), children: kids)
361
+ Group(transform: Identity, children: starNodes(0, clock))
312
362
 
313
363
  fun mineView(sheet: Int, k: Mine) -> SceneNode =
314
364
  Group(
315
- transform: List(Translate(dx: k.x, dy: k.y), Rotate(radians: k.rotation)),
365
+ transform: Compose(first: Translate(dx: k.x, dy: k.y), then: Rotate(radians: k.rotation)),
316
- children: List(cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(k.clock, 4, 4.0), 2)))
366
+ children: More(head: cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(k.clock, 4, 4.0), 2), tail: End))
317
367
 
318
368
  # The glow is drawn additively, then the sprite on top.
319
369
  fun orbView(sheet: Int, o: Orb) -> SceneNode =
320
370
  glow := Group(
321
- transform: List[Transform](),
371
+ transform: Identity,
322
- children: List(
372
+ children: More(
323
- CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
373
+ head: CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
324
- CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))))
374
+ tail: More(head: CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5)), tail: End)))
325
375
  Group(
326
- transform: List(Translate(dx: o.x, dy: o.y)),
376
+ transform: Translate(dx: o.x, dy: o.y),
327
- children: List(
377
+ children: More(
328
- Filter(effect: BlendFx(mode: Blend.Add), child: glow, target: 0 - 1),
378
+ head: Filter(effect: BlendFx(mode: Blend.Add), child: glow, target: 0 - 1),
329
- cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(o.clock + o.phase, 4, 8.0), 1)))
379
+ tail: More(head: cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(o.clock + o.phase, 4, 8.0), 1), tail: End)))
330
380
 
331
381
  # The ship blinks while hurt and is gone once the game is over (an empty group keeps the slots).
332
382
  fun shipView(s: State) -> SceneNode =
333
383
  blink := s.ship.hurt > 0.0 && frameOf(s.clock, 2, 10.0) == 0
334
384
  if s.over || blink
335
- return Group(transform: List[Transform](), children: List[SceneNode]())
385
+ return Group(transform: Identity, children: End)
336
386
  Group(
337
- transform: List(Translate(dx: s.ship.x, dy: s.ship.y), Rotate(radians: s.ship.rotation)),
387
+ transform: Compose(first: Translate(dx: s.ship.x, dy: s.ship.y), then: Rotate(radians: s.ship.rotation)),
338
- children: List(
388
+ children: More(
339
- CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12)),
389
+ head: CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12)),
340
- cell(s.sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(s.clock, 4, 14.0), 0)))
390
+ tail: More(head: cell(s.sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(s.clock, 4, 14.0), 0), tail: End)))
391
+
392
+ # One life icon per remaining life, from the right edge going left.
393
+ fun lifeNodes(i: Int, lives: Int, sheet: Int) -> Nodes =
394
+ if i >= lives
395
+ End
396
+ else
397
+ More(head: cell(sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 0, 0), tail: lifeNodes(i + 1, lives, sheet))
341
398
 
342
399
  fun hudView(s: State) -> SceneNode =
400
+ Group(
401
+ transform: Identity,
343
- kids := List[SceneNode]()
402
+ children: More(
344
- kids.push(Text(text: "Score {s.score}", x: 16.0, y: 12.0, size: 28.0, color: Color.White))
403
+ head: Text(text: "Score {s.score}", x: 16.0, y: 12.0, size: 28.0, color: Color.White),
345
- kids.push(Text(text: "Best {s.best.max(s.score)}", x: 16.0, y: 44.0, size: 20.0, color: Color.Yellow))
404
+ tail: More(head: Text(text: "Best {s.best.max(s.score)}", x: 16.0, y: 44.0, size: 20.0, color: Color.Yellow), tail: lifeNodes(0, s.lives, s.sheet))))
346
- for i := range s.lives
347
- kids.push(cell(s.sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 0, 0))
348
- Group(transform: List[Transform](), children: kids)
349
405
 
350
406
  # The game-over panel with the restart button, faded in through a render-target filter.
351
407
  # While playing it is an empty group, so nothing in it can be hit.
352
408
  fun overlayView(s: State) -> SceneNode =
353
409
  if !s.over
354
- return Group(transform: List[Transform](), children: List[SceneNode]())
410
+ return Group(transform: Identity, children: End)
355
411
  button := Group(
356
- transform: List[Transform](),
412
+ transform: Identity,
357
- children: List(
413
+ children: More(
358
- RectShape(x: 340.0, y: 342.0, w: 120.0, h: 44.0, color: Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9)),
414
+ head: RectShape(x: 340.0, y: 342.0, w: 120.0, h: 44.0, color: Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9)),
359
- Text(text: "Again", x: 372.0, y: 352.0, size: 24.0, color: Color.White)))
415
+ tail: More(head: Text(text: "Again", x: 372.0, y: 352.0, size: 24.0, color: Color.White), tail: End)))
360
416
  panel := Group(
361
- transform: List[Transform](),
417
+ transform: Identity,
362
- children: List(
418
+ children: More(
363
- RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
419
+ head: RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
420
+ tail: More(
364
- Text(text: "Game over", x: 300.0, y: 244.0, size: 48.0, color: Color.Red),
421
+ head: Text(text: "Game over", x: 300.0, y: 244.0, size: 48.0, color: Color.Red),
422
+ tail: More(
365
- Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
423
+ head: Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
366
- button))
424
+ tail: More(head: button, tail: End)))))
367
425
  Filter(effect: Alpha(alpha: {s.overClock * 2.0}.min(1.0)), child: panel, target: s.target)
368
426
 
369
427
  # The whole picture, in drawing order, under the camera that fits the arena on screen.
370
428
  fun view(s: State) -> SceneNode =
371
- mines := List[SceneNode]()
372
- for i := range s.mines.length()
373
- mines.push(mineView(s.sheet, s.mines.at(i)))
374
- orbs := List[SceneNode]()
375
- for i := range s.orbs.length()
376
- orbs.push(orbView(s.sheet, s.orbs.at(i)))
377
- layers := List[SceneNode]()
378
- layers.push(RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: Rules.ArenaH, color: Color.rgb(0.07, 0.08, 0.16)))
379
- layers.push(starsView(s.clock))
380
- layers.push(Group(transform: List[Transform](), children: mines))
381
- layers.push(Group(transform: List[Transform](), children: orbs))
382
- layers.push(shipView(s))
383
- layers.push(hudView(s))
384
- layers.push(overlayView(s))
385
429
  z := zoomOf(s)
430
+ layers := More(
431
+ head: RectShape(x: 0.0, y: 0.0, w: Rules.ArenaW, h: Rules.ArenaH, color: Color.rgb(0.07, 0.08, 0.16)),
432
+ tail: More(
433
+ head: starsView(s.clock),
434
+ tail: More(
435
+ head: Group(transform: Identity, children: toNodes(s.mines, |k| mineView(s.sheet, k), End)),
436
+ tail: More(
437
+ head: Group(transform: Identity, children: toNodes(s.orbs, |o| orbView(s.sheet, o), End)),
438
+ tail: More(head: shipView(s), tail: More(head: hudView(s), tail: More(head: overlayView(s), tail: End)))))))
386
- Group(transform: List(Translate(dx: offsetX(s), dy: offsetY(s)), Scale(sx: z, sy: z)), children: layers)
439
+ Group(transform: Compose(first: Translate(dx: offsetX(s), dy: offsetY(s)), then: Scale(sx: z, sy: z)), children: layers)
387
440
 
388
441
  # True when (x, y) in screen pixels is on the restart button of the picture `view` builds.
389
442
  fun onRestart(s: State, x: Float, y: Float) -> Bool =
@@ -461,24 +514,12 @@ fun away() -> Motion =
461
514
 
462
515
  fun withOrb(s: State, i: Int, m: Motion) -> State =
463
516
  n := State(..s)
464
- orbs := List[Orb]()
465
- for j := range s.orbs.length()
466
- if j == i
467
- orbs.push(orbAt(Id.Orb + i, Float(i) * 0.1, 0.0, m))
517
+ n.orbs = replaceNth(s.orbs, i, orbAt(Id.Orb + i, Float(i) * 0.1, 0.0, m))
468
- else
469
- orbs.push(s.orbs.at(j))
470
- n.orbs = orbs
471
518
  n
472
519
 
473
520
  fun withMine(s: State, i: Int, m: Motion) -> State =
474
521
  n := State(..s)
475
- mines := List[Mine]()
476
- for j := range s.mines.length()
477
- if j == i
478
- mines.push(mineAt(Id.Mine + i, 0.0, 2.0, 0.0, m))
522
+ n.mines = replaceNth(s.mines, i, mineAt(Id.Mine + i, 0.0, 2.0, 0.0, m))
479
- else
480
- mines.push(s.mines.at(j))
481
- n.mines = mines
482
523
  n
483
524
 
484
525
  # A state with the orbs and mines moved out of the way.
@@ -486,10 +527,16 @@ fun emptyState() -> State =
486
527
  s := newState(7, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
487
528
  for i := range Rules.OrbCount
488
529
  s = withOrb(s, i, away())
489
- for i := range s.mines.length()
530
+ for i := range lenLink(s.mines)
490
531
  s = withMine(s, i, away())
491
532
  s
492
533
 
534
+ fun orbN(s: State, i: Int) -> Orb =
535
+ nthOr(s.orbs, i, orbAt(0, 0.0, 0.0, away()))
536
+
537
+ fun mineN(s: State, i: Int) -> Mine =
538
+ nthOr(s.mines, i, mineAt(0, 0.0, 0.0, 0.0, away()))
539
+
493
540
  fun onShip(s: State) -> Motion =
494
541
  Motion(x: s.ship.x, y: s.ship.y, vx: 0.0, vy: 0.0)
495
542
 
@@ -500,15 +547,14 @@ fun forceOver(s: State) -> State =
500
547
  n.best = s.best.max(s.score)
501
548
  n
502
549
 
503
- fun countNodes(n: SceneNode) -> Int =
550
+ fun countNodes(n: SceneNode) -> Int = match n
504
- match n
505
- Group =>
506
- total := 1
507
- for i := range n.children.length()
508
- total = total + countNodes(n.children.at(i))
551
+ Group(_, kids) => 1 + countKids(kids)
509
- total
510
- Filter => 1 + countNodes(n.child)
552
+ Filter(_, child, _) => 1 + countNodes(child)
511
- _ => 1
553
+ _ => 1
554
+
555
+ fun countKids(ks: Nodes) -> Int = match ks
556
+ End => 0
557
+ More(h, t) => countNodes(h) + countKids(t)
512
558
 
513
559
  test "steering accelerates and drag slows the ship"
514
560
  s := shipStep(newShip(), 1.0, 0.0, 0.1)
@@ -551,11 +597,11 @@ test "a mine spins as it drifts"
551
597
  test "collecting an orb scores and respawns it elsewhere"
552
598
  s := withOrb(emptyState(), 0, onShip(emptyState()))
553
599
  t := tickOf(s, 0.0, 0.0, 0.016)
554
- orb := t.orbs.at(0)
600
+ orb := orbN(t, 0)
555
601
  assert t.score == Rules.OrbPoints
556
602
  assert Math.hypot(orb.x - t.ship.x, orb.y - t.ship.y) >= 120.0
557
603
  assert orb.x < 5000.0
558
- assert t.orbs.length() == Rules.OrbCount
604
+ assert lenLink(t.orbs) == Rules.OrbCount
559
605
 
560
606
  test "a mine costs one life and makes the ship flash"
561
607
  e := emptyState()
@@ -604,15 +650,15 @@ test "after game over nothing moves but the animations keep running"
604
650
  s.lives = 1
605
651
  t := tickOf(withMine(s, 0, Motion(x: s.ship.x, y: s.ship.y, vx: 10.0, vy: 0.0)), 0.0, 0.0, 0.016)
606
652
  assert t.over
607
- mine := t.mines.at(0)
653
+ mine := mineN(t, 0)
608
654
  for i := range 10
609
655
  t = tickOf(t, 1.0, 0.0, 0.1)
610
- after := t.mines.at(0)
656
+ after := mineN(t, 0)
611
657
  assert after.x == mine.x
612
658
  assert after.rotation == mine.rotation
613
659
  assert t.ship.x == Rules.ArenaW / 2.0
614
660
  assert after.clock > mine.clock + 0.9
615
- assert t.orbs.at(0).clock > 0.9
661
+ assert orbN(t, 0).clock > 0.9
616
662
  assert t.clock > 0.9
617
663
  assert t.score == 0
618
664
 
@@ -627,7 +673,7 @@ test "restart resets the run and keeps the best score"
627
673
  assert r.best == 90
628
674
  assert r.lives == Rules.StartLives
629
675
  assert r.ship.x == Rules.ArenaW / 2.0
630
- assert r.mines.length() == mineCount(0)
676
+ assert lenLink(r.mines) == mineCount(0)
631
677
  assert countNodes(shipView(r)) == 3
632
678
  assert countNodes(overlayView(r)) == 1
633
679
 
@@ -639,9 +685,9 @@ test "more mines appear as the score grows and restart removes them"
639
685
  s := State(..e)
640
686
  s.score = 100
641
687
  t := tickOf(s, 0.0, 0.0, 0.016)
642
- assert t.mines.length() == 5
688
+ assert lenLink(t.mines) == 5
643
689
  r := step(t, Restart(seed: 3))
644
- assert r.mines.length() == 3
690
+ assert lenLink(r.mines) == 3
645
691
 
646
692
  test "the same seed plays out the same way"
647
693
  a := newState(11, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
@@ -651,9 +697,9 @@ test "the same seed plays out the same way"
651
697
  b = tickOf(b, 1.0, -1.0, 0.016)
652
698
  assert a.ship.x == b.ship.x
653
699
  assert a.score == b.score
654
- assert a.mines.at(0).x == b.mines.at(0).x
700
+ assert mineN(a, 0).x == mineN(b, 0).x
655
701
  other := newState(12, 0, -1, -1, Rules.ArenaW, Rules.ArenaH)
656
- assert other.orbs.at(0).x != a.orbs.at(0).x
702
+ assert orbN(other, 0).x != orbN(a, 0).x
657
703
 
658
704
  test "fit centres the arena on the screen"
659
705
  s := step(emptyState(), Resize(w: 1600.0, h: 600.0))
@@ -697,14 +743,14 @@ test "freezing at game over keeps the orb and mine animation clocks"
697
743
  t := emptyState()
698
744
  for i := range 5
699
745
  t = tickOf(t, 0.0, 0.0, 0.1)
700
- orbClock := t.orbs.at(0).clock
746
+ orbClock := orbN(t, 0).clock
701
- mineClock := t.mines.at(0).clock
747
+ mineClock := mineN(t, 0).clock
702
748
  assert orbClock > 0.4
703
749
  t.lives = 1
704
750
  t = tickOf(withMine(t, 1, onShip(t)), 0.0, 0.0, 0.1)
705
751
  assert t.over
706
- assert t.orbs.at(0).clock > orbClock
752
+ assert orbN(t, 0).clock > orbClock
707
- assert t.mines.at(0).clock > mineClock
753
+ assert mineN(t, 0).clock > mineClock
708
754
 
709
755
  test "view_is_pure"
710
756
  s := emptyState()
plum-game/web-test/fixtures/stress_state_view.plum CHANGED
@@ -1,9 +1,11 @@
1
- # The stress scene of stress_pure_scene.plum written as state and view: 5000 sprites in
1
+ # The stress scene of stress_pure_scene.plum written as state and view, now recursive:
2
- # 50 slowly rotating groups, 500 rects and 200 circles (sprites of a 1x1 white texture),
2
+ # 5000 sprites in 50 slowly rotating groups, 500 rects and 200 circles (sprites of a
3
- # 50 texts with a changing number, drawn through a 0.8 camera. `State` is flat structs in
3
+ # 1x1 white texture), 50 texts with a changing number, drawn through a 0.8 camera.
4
+ # `State` is recursive `Link` lists (each spinner owns the `Link` of its 100 dancers);
4
- # lists (keyed by position, which is the id order); `step` is pure and returns the next
5
+ # `step` is pure, a structural recursion that returns the next state; `view` is pure
5
- # state; `view` is pure and rebuilds the whole SceneNode tree every frame, which
6
+ # and rebuilds the whole SceneNode tree every frame with recursive functions, which
6
- # `Picture.draw` interprets. Nothing is retained between frames but `State`.
7
+ # `Picture.draw` interprets. Nothing is retained between frames but `State`. The
8
+ # deepest recursion is the 700 drifters; no loops run in step or view.
7
9
  module stress_state_view
8
10
  import std/Str
9
11
  import std/Int
@@ -23,21 +25,47 @@ primitive Counts =
23
25
  WorldW = 1600
24
26
  WorldH = 900
25
27
 
28
+ enum Link[T] = Nil | Cons(head: T, tail: Link[T])
29
+
30
+ fun mapLink(xs: Link[A], f: fn(A) -> B) -> Link[B] = match xs
31
+ Nil => Nil
26
- # A rotating group of dancers: top-left (x, y), pivot (75, 85).
32
+ Cons(h, t) => Cons(head: f(h), tail: mapLink(t, f))
33
+
34
+ # The list f(i), f(i + 1) ... f(n - 1).
27
- struct Spinner(id: Int, x: Float, y: Float, rotation: Float)
35
+ fun rangeLink(i: Int, n: Int, f: fn(Int) -> T) -> Link[T] =
36
+ if i >= n then Nil else Cons(head: f(i), tail: rangeLink(i + 1, n, f))
37
+
38
+ fun appendLink(xs: Link[T], ys: Link[T]) -> Link[T] = match xs
39
+ Nil => ys
40
+ Cons(h, t) => Cons(head: h, tail: appendLink(t, ys))
41
+
42
+ # The element at `i`, or `dflt` past the end.
43
+ fun nthOr(xs: Link[T], i: Int, dflt: T) -> T = match xs
44
+ Nil => dflt
45
+ Cons(h, t) => if i <= 0 then h else nthOr(t, i - 1, dflt)
46
+
47
+ # The nodes f(x) for every x of `xs`, followed by `rest`.
48
+ fun toNodes(xs: Link[T], f: fn(T) -> SceneNode, rest: Nodes) -> Nodes = match xs
49
+ Nil => rest
50
+ Cons(h, t) => More(head: f(h), tail: toNodes(t, f, rest))
51
+
52
+ fun countNodes(ns: Nodes) -> Int = match ns
53
+ End => 0
54
+ More(_, t) => 1 + countNodes(t)
28
55
 
29
56
  # A sprite of the sheet inside its group, spinning about its centre.
30
57
  struct Dancer(id: Int, x: Float, y: Float, rotation: Float, srcX: Float)
31
58
 
59
+ # A rotating group of dancers: top-left (x, y), pivot (75, 85).
60
+ record Spinner(id: Int, x: Float, y: Float, rotation: Float, dancers: Link[Dancer])
61
+
32
62
  # A square of the white texture that moves by (dx, dy) per frame and wraps.
33
63
  struct Drifter(id: Int, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: Float, color: Color)
34
64
 
35
65
  # Shows "n <count>" and counts up each frame.
36
66
  struct Counter(id: Int, x: Float, y: Float, n: Int)
37
67
 
38
- record State(
39
- sheet: Int, white: Int,
40
- spinners: List[Spinner], dancers: List[Dancer], drifters: List[Drifter], counters: List[Counter])
68
+ record State(sheet: Int, white: Int, spinners: Link[Spinner], drifters: Link[Drifter], counters: Link[Counter])
41
69
 
42
70
  fun hue(i: Int) -> Color =
43
71
  Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
@@ -45,26 +73,20 @@ fun hue(i: Int) -> Color =
45
73
  fun newDrifter(id: Int, c: Color, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: Float) -> Drifter =
46
74
  Drifter(id: id, x: x, y: y, w: w, h: h, dx: dx, dy: dy, color: c)
47
75
 
76
+ fun newDancer(gi: Int, j: Int) -> Dancer =
77
+ i := gi * {Counts.Sprites / Counts.Groups} + j
78
+ Dancer(id: 1000 + i, x: Float({j * j * 13 + j * 37} % 150), y: Float({j * j * 29 + j * 53} % 170), rotation: Float(i), srcX: Float(i % 4 * 16))
79
+
80
+ fun newSpinner(gi: Int) -> Spinner =
81
+ Spinner(id: 100 + gi, x: Float(gi % 10 * 160), y: Float(gi / 10 * 180), rotation: 0.0, dancers: rangeLink(0, Counts.Sprites / Counts.Groups, |j| newDancer(gi, j)))
82
+
48
83
  fun newState(sheet: Int, white: Int) -> State =
49
- per := Counts.Sprites / Counts.Groups
50
- spinners := List[Spinner]()
51
- dancers := List[Dancer]()
52
- for gi := range Counts.Groups
53
- spinners.push(Spinner(id: 100 + gi, x: Float(gi % 10 * 160), y: Float(gi / 10 * 180), rotation: 0.0))
54
- for j := range per
55
- i := gi * per + j
56
- dancers.push(Dancer(
57
- id: 1000 + i, x: Float({j * j * 13 + j * 37} % 150), y: Float({j * j * 29 + j * 53} % 170),
58
- rotation: Float(i), srcX: Float(i % 4 * 16)))
59
- drifters := List[Drifter]()
60
- for i := range Counts.Rects
61
- drifters.push(newDrifter(10000 + i, hue(i), Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH), 12.0, 8.0, 2.0, 0.0))
84
+ rects := rangeLink(0, Counts.Rects, |i| newDrifter(10000 + i, hue(i), Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH), 12.0, 8.0, 2.0, 0.0))
62
- for i := range Counts.Circles
63
- drifters.push(newDrifter(20000 + i, hue(i + 3), Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH), 12.0, 12.0, 3.0, 1.0))
85
+ circles := rangeLink(0, Counts.Circles, |i| newDrifter(20000 + i, hue(i + 3), Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH), 12.0, 12.0, 3.0, 1.0))
86
+ State(
87
+ sheet: sheet, white: white, spinners: rangeLink(0, Counts.Groups, |gi| newSpinner(gi)),
64
- counters := List[Counter]()
88
+ drifters: appendLink(rects, circles),
65
- for i := range Counts.Texts
66
- counters.push(Counter(id: 30000 + i, x: Float(i * 30 % Counts.WorldW), y: Float(i * 17 % Counts.WorldH), n: i))
89
+ counters: rangeLink(0, Counts.Texts, |i| Counter(id: 30000 + i, x: Float(i * 30 % Counts.WorldW), y: Float(i * 17 % Counts.WorldH), n: i)))
67
- State(sheet: sheet, white: white, spinners: spinners, dancers: dancers, drifters: drifters, counters: counters)
68
90
 
69
91
  # --- step: pure ---
70
92
 
@@ -77,56 +99,46 @@ fun stepDrifter(d: Drifter) -> Drifter =
77
99
  ny = ny - Float(Counts.WorldH)
78
100
  Drifter(id: d.id, x: nx, y: ny, w: d.w, h: d.h, dx: d.dx, dy: d.dy, color: d.color)
79
101
 
102
+ fun stepDancer(d: Dancer, dt: Float) -> Dancer =
103
+ Dancer(id: d.id, x: d.x, y: d.y, rotation: d.rotation + 1.2 * dt, srcX: d.srcX)
104
+
105
+ fun stepSpinner(p: Spinner, dt: Float) -> Spinner =
106
+ Spinner(id: p.id, x: p.x, y: p.y, rotation: p.rotation + 0.24 * dt, dancers: mapLink(p.dancers, |d| stepDancer(d, dt)))
107
+
108
+ fun stepCounter(c: Counter) -> Counter =
109
+ Counter(id: c.id, x: c.x, y: c.y, n: c.n + 1)
110
+
80
111
  fun step(s: State, dt: Float) -> State =
81
- spinners := List[Spinner]()
82
- for i := range s.spinners.length()
83
- p := s.spinners.at(i)
84
- spinners.push(Spinner(id: p.id, x: p.x, y: p.y, rotation: p.rotation + 0.24 * dt))
85
- dancers := List[Dancer]()
86
- for i := range s.dancers.length()
87
- d := s.dancers.at(i)
112
+ State(
88
- dancers.push(Dancer(id: d.id, x: d.x, y: d.y, rotation: d.rotation + 1.2 * dt, srcX: d.srcX))
89
- drifters := List[Drifter]()
90
- for i := range s.drifters.length()
91
- drifters.push(stepDrifter(s.drifters.at(i)))
92
- counters := List[Counter]()
93
- for i := range s.counters.length()
94
- c := s.counters.at(i)
95
- counters.push(Counter(id: c.id, x: c.x, y: c.y, n: c.n + 1))
96
- State(sheet: s.sheet, white: s.white, spinners: spinners, dancers: dancers, drifters: drifters, counters: counters)
113
+ sheet: s.sheet, white: s.white, spinners: mapLink(s.spinners, |p| stepSpinner(p, dt)),
114
+ drifters: mapLink(s.drifters, |d| stepDrifter(d)), counters: mapLink(s.counters, |c| stepCounter(c)))
97
115
 
98
116
  # --- view: pure, the whole tree every frame ---
99
117
 
100
118
  fun dancerView(sheet: Int, d: Dancer) -> SceneNode =
101
119
  Group(
102
- transform: List(Translate(dx: d.x + 8.0, dy: d.y + 8.0), Rotate(radians: d.rotation), Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0)),
120
+ transform: Compose(first: Translate(dx: d.x + 8.0, dy: d.y + 8.0), then: Compose(first: Rotate(radians: d.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0))),
103
- children: List(Sprite(
121
+ children: More(head: Sprite(
104
122
  tex: sheet, x: 0.0, y: 0.0, w: 16.0, h: 16.0, srcX: d.srcX, srcY: 0.0, srcW: 16.0, srcH: 16.0,
105
- tint: Color.White)))
123
+ tint: Color.White), tail: End))
106
-
124
+
107
- fun spinnerView(s: State, gi: Int) -> SceneNode =
125
+ fun spinnerView(sheet: Int, p: Spinner) -> SceneNode =
108
- per := Counts.Sprites / Counts.Groups
109
- p := s.spinners.at(gi)
110
- kids := List[SceneNode]()
111
- for j := range per
112
- kids.push(dancerView(s.sheet, s.dancers.at(gi * per + j)))
113
126
  Group(
114
- transform: List(Translate(dx: p.x + 75.0, dy: p.y + 85.0), Rotate(radians: p.rotation), Translate(dx: 0.0 - 75.0, dy: 0.0 - 85.0)),
127
+ transform: Compose(first: Translate(dx: p.x + 75.0, dy: p.y + 85.0), then: Compose(first: Rotate(radians: p.rotation), then: Translate(dx: 0.0 - 75.0, dy: 0.0 - 85.0))),
128
+ children: toNodes(p.dancers, |d| dancerView(sheet, d), End))
129
+
130
+ fun drifterView(white: Int, d: Drifter) -> SceneNode =
131
+ Sprite(
132
+ tex: white, x: d.x, y: d.y, w: d.w, h: d.h, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0,
115
- children: kids)
133
+ tint: d.color)
134
+
135
+ fun counterView(c: Counter) -> SceneNode =
136
+ Text(text: "n {c.n}", x: c.x, y: c.y, size: 14.0, color: Color.White)
116
137
 
117
138
  fun view(s: State) -> SceneNode =
118
- kids := List[SceneNode]()
119
- for gi := range Counts.Groups
120
- kids.push(spinnerView(s, gi))
121
- for i := range s.drifters.length()
122
- d := s.drifters.at(i)
123
- kids.push(Sprite(
124
- tex: s.white, x: d.x, y: d.y, w: d.w, h: d.h, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0,
125
- tint: d.color))
126
- for i := range s.counters.length()
139
+ texts := toNodes(s.counters, |c| counterView(c), End)
127
- c := s.counters.at(i)
128
- kids.push(Text(text: "n {c.n}", x: c.x, y: c.y, size: 14.0, color: Color.White))
140
+ drifts := toNodes(s.drifters, |d| drifterView(s.white, d), texts)
129
- Group(transform: List(Scale(sx: 0.8, sy: 0.8)), children: kids)
141
+ Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), drifts))
130
142
 
131
143
  fun whiteTexture() -> Int =
132
144
  t := textureCreate(1, 1)
@@ -151,15 +163,16 @@ test "ten frames of step run"
151
163
  s := newState(0, 0)
152
164
  for i := range 10
153
165
  s = step(s, 0.016)
154
- assert s.counters.at(3).n == 13
166
+ assert nthOr(s.counters, 3, Counter(id: 0, x: 0.0, y: 0.0, n: 0)).n == 13
155
- assert {s.drifters.at(0).x - 20.0}.abs() < 0.000001
167
+ assert {nthOr(s.drifters, 0, newDrifter(0, Color.White, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)).x - 20.0}.abs() < 0.000001
168
+ first := nthOr(s.spinners, 0, newSpinner(0))
156
- assert {s.dancers.at(0).rotation - 0.192}.abs() < 0.000001
169
+ assert {nthOr(first.dancers, 0, newDancer(0, 0)).rotation - 0.192}.abs() < 0.000001
157
170
 
158
171
  test "view has one node per actor"
159
172
  s := newState(0, 0)
160
173
  v := view(s)
161
174
  match v
162
- Group =>
175
+ Group(_, kids) =>
163
- assert v.children.length() == 50 + 500 + 200 + 50
176
+ assert countNodes(kids) == 50 + 500 + 200 + 50
164
177
  _ =>
165
178
  assert false
plum-std/game/Picture.plum CHANGED
@@ -9,63 +9,51 @@ import std/game/Color
9
9
  import std/game/Affine
10
10
  import std/game/Game
11
11
 
12
- # An ML-style picture: a SceneNode is plain data (shapes, sprites, text, groups
12
+ # A recursive picture: a SceneNode is plain data (shapes, sprites, text, groups
13
- # with transforms, filters) and `Picture` is the interpreter that draws it with a
13
+ # with a transform, filters) and `Picture` is a set of structurally recursive
14
+ # functions that draw it, measure it and hit-test it. Children are a recursive
15
+ # `Nodes` list, so every operation recurses over SceneNode and Nodes, passing
14
- # matrix stack, measures it and hit-tests it. Nothing here mutates the picture.
16
+ # the parent matrix and alpha down as arguments. Nothing here mutates the picture.
15
- # Transforms in a group's list compose in order: [Translate, Scale] scales first,
16
- # then translates (the first entry is the outermost).
17
+ # `Compose(first, then)` applies `then` first, then `first` (`first` is the outermost).
18
+
17
-
19
+ enum Transform =
20
+ | Identity
18
- struct Translate(dx: Float, dy: Float)
21
+ | Translate(dx: Float, dy: Float)
19
-
20
- struct Rotate(radians: Float)
22
+ | Rotate(radians: Float)
21
-
22
- struct Scale(sx: Float, sy: Float)
23
+ | Scale(sx: Float, sy: Float)
23
-
24
- enum Transform = Translate | Rotate | Scale
24
+ | Compose(first: Transform, then: Transform)
25
-
26
- struct RectShape(x: Float, y: Float, w: Float, h: Float, color: Color)
27
-
28
- struct CircleShape(cx: Float, cy: Float, r: Float, color: Color)
29
-
30
- struct LineShape(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Color)
31
-
32
- # A texture region `w` by `h` with its top-left at (x, y).
33
- struct Sprite(tex: Int, x: Float, y: Float, w: Float, h: Float, srcX: Float, srcY: Float, srcW: Float, srcH: Float, tint: Color)
34
-
35
- record Text(text: Str, x: Float, y: Float, size: Float, color: Color)
36
-
37
- struct Tint(color: Color)
38
-
39
- struct Alpha(alpha: Float)
40
25
 
41
26
  # `mode` is a `Blend` constant from std/game/Game.
27
+ enum Effect =
28
+ | Tint(color: Color)
29
+ | Alpha(alpha: Float)
42
- struct BlendFx(mode: Int)
30
+ | BlendFx(mode: Int)
43
-
44
- enum Effect = Tint | Alpha | BlendFx
31
+
45
-
46
- record Group(transform: List[Transform], children: List[SceneNode])
32
+ # A Sprite is a texture region `w` by `h` with its top-left at (x, y).
47
-
48
- # The child is drawn into `target`, then composited with the effect. `target` is a
33
+ # A Filter draws its child into `target`, then composites it with the effect.
49
- # texture the game creates ONCE at load with `textureCreate`; draw creates nothing,
34
+ # `target` is a texture the game creates ONCE at load with `textureCreate`; draw
50
- # so a view does not leak a texture per frame. The size of `target` bounds what the
35
+ # creates nothing, so a view does not leak a texture per frame. The size of
51
- # filter can show: child pixels beyond it are cut off. A negative target draws nothing.
36
+ # `target` bounds what the filter can show: child pixels beyond it are cut off.
37
+ # A negative target draws nothing.
38
+ enum SceneNode =
39
+ | Group(transform: Transform, children: Nodes)
52
- record Filter(effect: Effect, child: SceneNode, target: Int)
40
+ | Filter(effect: Effect, child: SceneNode, target: Int)
53
-
41
+ | Sprite(tex: Int, x: Float, y: Float, w: Float, h: Float, srcX: Float, srcY: Float, srcW: Float, srcH: Float, tint: Color)
42
+ | Text(text: Str, x: Float, y: Float, size: Float, color: Color)
54
- enum SceneNode = RectShape | CircleShape | LineShape | Sprite | Text | Group | Filter
43
+ | RectShape(x: Float, y: Float, w: Float, h: Float, color: Color)
44
+ | CircleShape(cx: Float, cy: Float, r: Float, color: Color)
45
+ | LineShape(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Color)
46
+
47
+ enum Nodes = End | More(head: SceneNode, tail: Nodes)
55
48
 
56
49
  struct Rect(x: Float, y: Float, w: Float, h: Float)
57
50
 
58
- fun picMatrixOf(t: Transform) -> Affine =
51
+ fun picMatrix(t: Transform) -> Affine = match t
59
- match t
52
+ Identity => Affine.Identity
60
- Translate => Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: t.dx, f: t.dy)
53
+ Translate(dx, dy) => Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: dx, f: dy)
61
- Rotate => Affine(a: Math.cos(t.radians), b: Math.sin(t.radians), c: 0.0 - Math.sin(t.radians), d: Math.cos(t.radians), e: 0.0, f: 0.0)
54
+ Rotate(rad) => Affine(a: Math.cos(rad), b: Math.sin(rad), c: 0.0 - Math.sin(rad), d: Math.cos(rad), e: 0.0, f: 0.0)
62
- Scale => Affine(a: t.sx, b: 0.0, c: 0.0, d: t.sy, e: 0.0, f: 0.0)
55
+ Scale(sx, sy) => Affine(a: sx, b: 0.0, c: 0.0, d: sy, e: 0.0, f: 0.0)
63
-
64
- fun picCompose(m: Affine, ts: List[Transform]) -> Affine =
56
+ Compose(first, then) => picMatrix(first).mul(picMatrix(then))
65
- out := m
66
- for i := range ts.length()
67
- out = out.mul(picMatrixOf(ts.at(i)))
68
- out
69
57
 
70
58
  # The bounding box, in the outer space, of the box (x, y, w, h) seen through `m`.
71
59
  fun picBox(x: Float, y: Float, w: Float, h: Float, m: Affine) -> Rect =
@@ -88,138 +76,144 @@ fun picUnion(a: Rect?, b: Rect) -> Rect =
88
76
  return Rect(x: x0, y: y0, w: x1 - x0, h: y1 - y0)
89
77
  b
90
78
 
79
+ fun picBoxLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, m: Affine) -> Rect =
80
+ lx := x1.min(x2) - thickness / 2.0
81
+ ly := y1.min(y2) - thickness / 2.0
82
+ picBox(lx, ly, {x1 - x2}.abs() + thickness, {y1 - y2}.abs() + thickness, m)
83
+
91
84
  # The bounds of `node` drawn under `m`, or null when it has no extent.
92
- fun picBounds(node: SceneNode, m: Affine) -> Rect? =
85
+ fun picBounds(node: SceneNode, m: Affine) -> Rect? = match node
93
- match node
94
- RectShape => picBox(node.x, node.y, node.w, node.h, m)
86
+ RectShape(x, y, w, h, _) => picBox(x, y, w, h, m)
95
- CircleShape => picBox(node.cx - node.r, node.cy - node.r, node.r * 2.0, node.r * 2.0, m)
87
+ CircleShape(cx, cy, r, _) => picBox(cx - r, cy - r, r * 2.0, r * 2.0, m)
96
- LineShape =>
97
- lx := node.x1.min(node.x2) - node.thickness / 2.0
98
- ly := node.y1.min(node.y2) - node.thickness / 2.0
99
- picBox(lx, ly, {node.x1 - node.x2}.abs() + node.thickness, {node.y1 - node.y2}.abs() + node.thickness, m)
100
- Sprite => picBox(node.x, node.y, node.w, node.h, m)
88
+ LineShape(x1, y1, x2, y2, th, _) => picBoxLine(x1, y1, x2, y2, th, m)
89
+ Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => picBox(sx, sy, sw, sh, m)
101
- Text => picBox(node.x, node.y, node.size * 0.6 * Float(node.text.length()), node.size, m)
90
+ Text(txt, tx, ty, size, _) => picBox(tx, ty, size * 0.6 * Float(txt.length()), size, m)
102
- Group =>
103
- gm := picCompose(m, node.transform)
104
- acc: Rect? := null
105
- for i := range node.children.length()
106
- cb := picBounds(node.children.at(i), gm)
91
+ Group(tr, kids) => picBoundsKids(kids, m.mul(picMatrix(tr)), null)
107
- if cr := cb
108
- acc = picUnion(acc, cr)
109
- acc
110
- Filter => picBounds(node.child, m)
92
+ Filter(_, child, _) => picBounds(child, m)
93
+
94
+ fun picBoundsKids(kids: Nodes, m: Affine, acc: Rect?) -> Rect? = match kids
95
+ End => acc
96
+ More(head, tail) =>
97
+ if cr := picBounds(head, m)
98
+ picBoundsKids(tail, m, picUnion(acc, cr))
99
+ else
100
+ picBoundsKids(tail, m, acc)
111
101
 
112
102
  fun picInBox(x: Float, y: Float, w: Float, h: Float, px: Float, py: Float) -> Bool =
113
103
  px >= x && px <= x + w && py >= y && py <= y + h
114
104
 
115
- fun picNearLine(l: LineShape, px: Float, py: Float) -> Bool =
105
+ fun picNearLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, px: Float, py: Float) -> Bool =
116
- dx := l.x2 - l.x1
106
+ dx := x2 - x1
117
- dy := l.y2 - l.y1
107
+ dy := y2 - y1
118
108
  len2 := dx * dx + dy * dy
119
- t := if len2 == 0.0 then 0.0 else {{px - l.x1} * dx + {py - l.y1} * dy} / len2
109
+ t := if len2 == 0.0 then 0.0 else {{px - x1} * dx + {py - y1} * dy} / len2
120
110
  tc := t.max(0.0).min(1.0)
121
- qx := l.x1 + dx * tc
111
+ qx := x1 + dx * tc
122
- qy := l.y1 + dy * tc
112
+ qy := y1 + dy * tc
123
- Math.hypot(px - qx, py - qy) <= l.thickness.max(1.0) / 2.0
113
+ Math.hypot(px - qx, py - qy) <= thickness.max(1.0) / 2.0
124
114
 
125
115
  fun picLeaf(hit: Bool) -> List[Int]? =
126
116
  if hit
127
117
  return List[Int]()
128
118
  null
129
119
 
120
+ # `i` followed by the path `sub`.
121
+ fun picPrepend(i: Int, sub: List[Int]) -> List[Int] =
122
+ path := List(i)
123
+ for j := range sub.length()
124
+ path.push(sub.at(j))
125
+ path
126
+
130
127
  # The child indices from `node` down to the hit leaf, or null on a miss. A leaf
131
128
  # that is hit is an empty path. (px, py) is in `node`'s own space.
132
- fun picHit(node: SceneNode, px: Float, py: Float) -> List[Int]? =
129
+ fun picHit(node: SceneNode, px: Float, py: Float) -> List[Int]? = match node
133
- match node
134
- RectShape => picLeaf(picInBox(node.x, node.y, node.w, node.h, px, py))
130
+ RectShape(x, y, w, h, _) => picLeaf(picInBox(x, y, w, h, px, py))
135
- CircleShape => picLeaf(Math.hypot(px - node.cx, py - node.cy) <= node.r)
131
+ CircleShape(cx, cy, r, _) => picLeaf(Math.hypot(px - cx, py - cy) <= r)
136
- LineShape => picLeaf(picNearLine(node, px, py))
132
+ LineShape(x1, y1, x2, y2, th, _) => picLeaf(picNearLine(x1, y1, x2, y2, th, px, py))
137
- Sprite => picLeaf(picInBox(node.x, node.y, node.w, node.h, px, py))
133
+ Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => picLeaf(picInBox(sx, sy, sw, sh, px, py))
138
- Text => picLeaf(picInBox(node.x, node.y, node.size * 0.6 * Float(node.text.length()), node.size, px, py))
134
+ Text(txt, tx, ty, size, _) => picLeaf(picInBox(tx, ty, size * 0.6 * Float(txt.length()), size, px, py))
139
- Group =>
135
+ Group(tr, kids) =>
140
- inv := picCompose(Affine.Identity, node.transform).invert()
141
- if gi := inv
136
+ if gi := picMatrix(tr).invert()
142
- lp := gi.apply(Vec2(x: px, y: py))
137
+ lp := gi.apply(Vec2(x: px, y: py))
143
- i := node.children.length() - 1
144
- while i >= 0
145
- sub := picHit(node.children.at(i), lp.x, lp.y)
138
+ picHitKids(kids, 0, lp.x, lp.y)
146
- if sp := sub
147
- path := List(i)
148
- for j := range sp.length()
149
- path.push(sp.at(j))
150
- return path
139
+ else
151
- i = i - 1
152
140
  null
153
- Filter =>
141
+ Filter(_, child, _) =>
154
- fsub := picHit(node.child, px, py)
142
+ if fp := picHit(child, px, py)
155
- if fp := fsub
156
- fpath := List(0)
157
- for k := range fp.length()
158
- fpath.push(fp.at(k))
143
+ picPrepend(0, fp)
159
- return fpath
144
+ else
160
145
  null
161
146
 
147
+ # Tries the later children first (they are drawn on top); `i` is the index of
148
+ # `kids`' head. The first hit found from the back wins.
149
+ fun picHitKids(kids: Nodes, i: Int, px: Float, py: Float) -> List[Int]? = match kids
150
+ End => null
151
+ More(head, tail) =>
152
+ if later := picHitKids(tail, i + 1, px, py)
153
+ later
154
+ else
155
+ if sub := picHit(head, px, py)
156
+ picPrepend(i, sub)
157
+ else
158
+ null
159
+
162
- fun picDraw(node: SceneNode, m: Affine, alpha: Float, target: Int) =
160
+ fun picDraw(node: SceneNode, m: Affine, alpha: Float, target: Int) = match node
163
- match node
164
- RectShape =>
161
+ RectShape(x, y, w, h, color) =>
165
- sx := node.w * m.scaleX()
162
+ sx := w * m.scaleX()
166
- sy := node.h * m.scaleY()
163
+ sy := h * m.scaleY()
167
- c := m.apply(Vec2(x: node.x + node.w / 2.0, y: node.y + node.h / 2.0))
164
+ c := m.apply(Vec2(x: x + w / 2.0, y: y + h / 2.0))
168
- drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, node.color.withAlpha(node.color.a * alpha))
165
+ drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, color.withAlpha(color.a * alpha))
169
- CircleShape =>
166
+ CircleShape(cx, cy, r, ccolor) =>
170
- cc := m.apply(Vec2(x: node.cx, y: node.cy))
167
+ cc := m.apply(Vec2(x: cx, y: cy))
171
- drawCircle(cc.x, cc.y, node.r * m.scaleX(), node.color.withAlpha(node.color.a * alpha))
168
+ drawCircle(cc.x, cc.y, r * m.scaleX(), ccolor.withAlpha(ccolor.a * alpha))
172
- LineShape =>
169
+ LineShape(x1, y1, x2, y2, th, lcolor) =>
173
- p := m.apply(Vec2(x: node.x1, y: node.y1))
170
+ p := m.apply(Vec2(x: x1, y: y1))
174
- q := m.apply(Vec2(x: node.x2, y: node.y2))
171
+ q := m.apply(Vec2(x: x2, y: y2))
175
- drawLine(p.x, p.y, q.x, q.y, node.thickness * m.scaleX(), node.color.withAlpha(node.color.a * alpha))
172
+ drawLine(p.x, p.y, q.x, q.y, th * m.scaleX(), lcolor.withAlpha(lcolor.a * alpha))
176
- Sprite =>
173
+ Sprite(tex, sx0, sy0, sw0, sh0, srcX, srcY, srcW, srcH, tint) =>
177
- sw := node.w * m.scaleX()
174
+ sw := sw0 * m.scaleX()
178
- sh := node.h * m.scaleY()
175
+ sh := sh0 * m.scaleY()
179
- sc := m.apply(Vec2(x: node.x + node.w / 2.0, y: node.y + node.h / 2.0))
176
+ sc := m.apply(Vec2(x: sx0 + sw0 / 2.0, y: sy0 + sh0 / 2.0))
180
- drawTextureEx(node.tex, node.srcX, node.srcY, node.srcW, node.srcH, sc.x - sw / 2.0, sc.y - sh / 2.0, sw, sh, m.rotation(), false, node.tint.withAlpha(node.tint.a * alpha))
177
+ drawTextureEx(tex, srcX, srcY, srcW, srcH, sc.x - sw / 2.0, sc.y - sh / 2.0, sw, sh, m.rotation(), false, tint.withAlpha(tint.a * alpha))
181
- Text =>
178
+ Text(txt, tx, ty, size, tcolor) =>
182
- tp := m.apply(Vec2(x: node.x, y: node.y))
179
+ tp := m.apply(Vec2(x: tx, y: ty))
183
- drawText(node.text, tp.x, tp.y, node.size * m.scaleY(), node.color.withAlpha(node.color.a * alpha))
180
+ drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
184
- Group =>
185
- gm := picCompose(m, node.transform)
186
- for i := range node.children.length()
187
- picDraw(node.children.at(i), gm, alpha, target)
181
+ Group(tr, kids) => picDrawKids(kids, m.mul(picMatrix(tr)), alpha, target)
188
- Filter => picFilter(node, m, alpha, target)
182
+ Filter(effect, child, tex) => picFilter(effect, child, tex, m, alpha, target)
189
-
183
+
190
- fun picFilter(f: Filter, m: Affine, alpha: Float, target: Int) =
184
+ fun picDrawKids(kids: Nodes, m: Affine, alpha: Float, target: Int) = match kids
191
- fx := f.effect
185
+ End => 0
192
- match fx
186
+ More(head, tail) =>
187
+ picDraw(head, m, alpha, target)
188
+ picDrawKids(tail, m, alpha, target)
189
+
190
+ fun picFilter(effect: Effect, child: SceneNode, tex: Int, m: Affine, alpha: Float, target: Int) = match effect
193
- BlendFx =>
191
+ BlendFx(mode) =>
194
- blendMode(fx.mode)
192
+ blendMode(mode)
195
- picDraw(f.child, m, alpha, target)
193
+ picDraw(child, m, alpha, target)
196
- blendMode(Blend.Normal)
194
+ blendMode(Blend.Normal)
197
- _ =>
195
+ _ =>
198
- fb := picBounds(f.child, m)
196
+ if b := picBounds(child, m)
199
- if b := fb
200
- tex := f.target
201
- if tex < 0
197
+ if tex >= 0
202
- return
203
198
  w := {Int(b.w) + 1}.min(textureWidth(tex))
204
199
  h := {Int(b.h) + 1}.min(textureHeight(tex))
205
200
  renderTarget(tex)
206
201
  clear(Color.Transparent)
207
202
  shift := Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0 - b.x, f: 0.0 - b.y)
208
- picDraw(f.child, shift.mul(m), 1.0, tex)
203
+ picDraw(child, shift.mul(m), 1.0, tex)
209
204
  renderTarget(target)
210
- picComposite(f.effect, tex, b, alpha, w, h)
205
+ picComposite(effect, tex, b, alpha, w, h)
211
206
 
212
- fun picTint(e: Effect, alpha: Float) -> Color =
207
+ fun picTint(e: Effect, alpha: Float) -> Color = match e
213
- match e
214
- Tint => e.color.withAlpha(e.color.a * alpha)
208
+ Tint(color) => color.withAlpha(color.a * alpha)
215
- Alpha => Color.White.withAlpha(e.alpha * alpha)
209
+ Alpha(a) => Color.White.withAlpha(a * alpha)
216
- _ => Color.White.withAlpha(alpha)
210
+ _ => Color.White.withAlpha(alpha)
217
211
 
218
212
  fun picComposite(e: Effect, tex: Int, b: Rect, alpha: Float, w: Int, h: Int) =
219
213
  drawTextureEx(tex, 0.0, 0.0, Float(w), Float(h), b.x, b.y, Float(w), Float(h), 0.0, false, picTint(e, alpha))
220
214
 
221
215
  primitive Picture =
222
- # Draws the picture to the current target with a matrix stack.
216
+ # Draws the picture to the current target, recursing with the parent matrix and alpha.
223
217
  fun draw(node: SceneNode) =
224
218
  picDraw(node, Affine.Identity, 1.0, 0 - 1)
225
219
 
@@ -238,8 +232,11 @@ primitive Picture =
238
232
  fun picNear(a: Float, b: Float) -> Bool =
239
233
  {a - b}.abs() < 1e-6
240
234
 
235
+ fun picRect(x: Float, y: Float, w: Float, h: Float, c: Color) -> SceneNode =
236
+ RectShape(x: x, y: y, w: w, h: h, color: c)
237
+
241
238
  test "bounds_translate_scale"
242
- g := Group(transform: List(Translate(dx: 5.0, dy: 5.0), Scale(sx: 2.0, sy: 2.0)), children: List(RectShape(x: 0.0, y: 0.0, w: 10.0, h: 10.0, color: Color.Red)))
239
+ g := Group(transform: Compose(first: Translate(dx: 5.0, dy: 5.0), then: Scale(sx: 2.0, sy: 2.0)), children: More(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: End))
243
240
  r := Picture.bounds(g)
244
241
  assert picNear(r.x, 5.0)
245
242
  assert picNear(r.y, 5.0)
@@ -247,7 +244,7 @@ test "bounds_translate_scale"
247
244
  assert picNear(r.h, 20.0)
248
245
 
249
246
  test "bounds_rotate_quarter"
250
- g := Group(transform: List(Rotate(radians: 1.5707963267948966)), children: List(RectShape(x: 0.0, y: 0.0, w: 10.0, h: 4.0, color: Color.Red)))
247
+ g := Group(transform: Rotate(radians: 1.5707963267948966), children: More(head: picRect(0.0, 0.0, 10.0, 4.0, Color.Red), tail: End))
251
248
  r := Picture.bounds(g)
252
249
  assert picNear(r.x, 0.0 - 4.0)
253
250
  assert picNear(r.y, 0.0)
@@ -255,7 +252,8 @@ test "bounds_rotate_quarter"
255
252
  assert picNear(r.h, 10.0)
256
253
 
257
254
  test "hit_path_nested"
258
- g := Group(transform: List[Transform](), children: List(RectShape(x: 0.0, y: 0.0, w: 10.0, h: 10.0, color: Color.Red), Group(transform: List(Translate(dx: 10.0, dy: 0.0)), children: List(RectShape(x: 0.0, y: 0.0, w: 10.0, h: 10.0, color: Color.Blue)))))
255
+ inner := Group(transform: Translate(dx: 10.0, dy: 0.0), children: More(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue), tail: End))
256
+ g := Group(transform: Identity, children: More(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: More(head: inner, tail: End)))
259
257
  p := Picture.hitPath(g, 15.0, 5.0)
260
258
  assert p.length() == 2
261
259
  assert p.at(0) == 1
@@ -264,12 +262,18 @@ test "hit_path_nested"
264
262
  assert q.length() == 1
265
263
  assert q.at(0) == 0
266
264
 
265
+ test "hit_path_last_child_wins"
266
+ g := Group(transform: Identity, children: More(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: More(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue), tail: End)))
267
+ p := Picture.hitPath(g, 5.0, 5.0)
268
+ assert p.length() == 1
269
+ assert p.at(0) == 1
270
+
267
271
  test "hit_miss_empty"
268
- g := Group(transform: List[Transform](), children: List(RectShape(x: 0.0, y: 0.0, w: 10.0, h: 10.0, color: Color.Red)))
272
+ g := Group(transform: Identity, children: More(head: picRect(0.0, 0.0, 10.0, 10.0, Color.Red), tail: End))
269
273
  assert Picture.hitPath(g, 50.0, 50.0).length() == 0
270
274
 
271
275
  test "filter_bounds_is_child_bounds"
272
- g := Filter(effect: Alpha(alpha: 0.5), child: RectShape(x: 1.0, y: 2.0, w: 10.0, h: 4.0, color: Color.Red), target: 0 - 1)
276
+ g := Filter(effect: Alpha(alpha: 0.5), child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red), target: 0 - 1)
273
277
  r := Picture.bounds(g)
274
278
  assert picNear(r.x, 1.0)
275
279
  assert picNear(r.y, 2.0)
plum-wasm-codegen/src/lib.rs CHANGED
@@ -4745,8 +4745,16 @@ fn compileFnBodyImpl(
4745
4745
  idx += leafCount(ty);
4746
4746
  }
4747
4747
  for (name, ty) in &collector.named {
4748
- locals.insert(name.clone(), idx);
4749
4748
  let vts = withGcTypes(|g| flatValtypes(ty, g));
4749
+ // `s = T(..s, ...)` on a param reassigns the param's own slot (F-T6-3): a
4750
+ // fresh local would shadow the param for the whole body, so every read of
4751
+ // `s` (the right-hand side included) would see the unset local.
4752
+ if let Some(pty) = decl_types.get(name).filter(|_| locals.contains_key(name)) {
4753
+ if withGcTypes(|g| flatValtypes(pty, g)) == vts {
4754
+ continue;
4755
+ }
4756
+ }
4757
+ locals.insert(name.clone(), idx);
4750
4758
  idx += vts.len() as u32;
4751
4759
  groups.extend(vts);
4752
4760
  }
plum-wasm-codegen/tests/own_inplace_e2e_test.rs CHANGED
@@ -204,3 +204,53 @@ fun main() -> Int =
204
204
  ");
205
205
  assert_eq!(runInt("match_rename", &src), 400 + 56);
206
206
  }
207
+
208
+ /// F-T6-3: `s = Cell(..s, ...)` on a param must reassign the param's own slot, not
209
+ /// a fresh shadow local that every read of `s` (the spread source included) sees unset.
210
+ #[test]
211
+ fn own_spread_reassign_same_var() {
212
+ let body = "own record Cell(x: Int, y: Int)
213
+
214
+ fun bump(own s: Cell) -> Cell =
215
+ s = Cell(..s, x: s.x + 1)
216
+ s
217
+
218
+ fun inc(n: Int) -> Int =
219
+ n = n + 1
220
+ n
221
+
222
+ fun main() -> Int =
223
+ t := bump(Cell(x: 1, y: 7))
224
+ t.x * 100 + t.y + inc(40) * 1000
225
+ ";
226
+ let own = prog(body);
227
+ assert_eq!(runInt("reassign_param", &own), 41000 + 207);
228
+ let plain = prog(&body.replace("own record", "record").replace("own s", "s"));
229
+ assert_eq!(runInt("reassign_param_plain", &plain), 41000 + 207);
230
+ assert_eq!(structNewCount("reassign_param_p", &plain), structNewCount("reassign_param_o", &own) + 1);
231
+ }
232
+
233
+ #[test]
234
+ fn own_spread_reassign_local() {
235
+ let src = prog("own record Cell(x: Int, y: Int)
236
+
237
+ fun main() -> Int =
238
+ s := Cell(x: 1, y: 7)
239
+ s = Cell(..s, x: s.x + 1)
240
+ s = Cell(..s, y: s.y + s.x)
241
+ s.x * 100 + s.y
242
+ ");
243
+ assert_eq!(runInt("reassign_local", &src), 200 + 9);
244
+ }
245
+
246
+ #[test]
247
+ fn own_self_spread_return() {
248
+ let src = prog("own record Cell(x: Int, y: Int) =
249
+ fun step(own self) -> Cell = Cell(..self, x: self.x + 1)
250
+
251
+ fun main() -> Int =
252
+ t := Cell(x: 1, y: 7).step().step()
253
+ t.x * 100 + t.y
254
+ ");
255
+ assert_eq!(runInt("self_spread", &src), 300 + 7);
256
+ }