diff --git a/CLAUDE.md b/CLAUDE.md index ca1d36f..de27450 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,9 +11,9 @@ knowledge base — see § Quick links spec. |---|---| | Phase | 1 (Etch ↔ ECS) | | Current milestone | (none — between milestones) | -| Last released tag | `v0.10.9-extension-hooks` | +| Last released tag | `v0.10.10-structural-mutation` | | Active branch | `main` | -| Next planned milestone | M1.1.0 — Forge 3D foundations (zolt): types + physics math + `BodyManager` (SoA aligned ECS). M1.0 (Etch ↔ ECS interpreter, M1.0.0–M1.0.9) is complete. `override` stays the last reserved `non_s3_keywords` member (waits for a Tier-1 overridable module). | +| Next planned milestone | M1.0.11 — `async` complete (`race`/`sync`/`branch` + the remaining `await` targets). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.10 closed; M1.0.11–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), with the Etch-closure tag at M1.0.20. `override` stays the last reserved `non_s3_keywords` member (waits for a Tier-1 overridable module). | ## Tags @@ -47,6 +47,7 @@ knowledge base — see § Quick links spec. | `v0.10.7-cross-file-import` | 2026-06-29 | M1.0.7 — Cross-file `import` (resolver pass-1) | `import` graduated parser-up (lexer `kw_import` out of `non_s3_keywords`; `ImportDecl` AST + arena slabs; `parseImportDecl` — the 4 forms, items accept IDENT **and** TYPE_IDENT, D-D). `root.validateProject` builds the module dependency graph from `ProjectFile.name` (module path under `src/`), topo-sorts it (deps-first `checkProject` order), and detects cycles → **`E0108 ImportCycle`** (D-B: NOT E0101; E0101 stays DuplicateSymbol). **Per-module** byte-keyed exports index (`ExportEntry {kind, visibility, arena_index, item_id}` — NOT a flat global index; two modules exporting the same name never collide) extends the M0.9 `ProjectContext`. `bindImports` resolves each file's imports: selective items enter scope under their local name; module aliases record an `imported_alias` binding (qualified `m.Type` resolution deferred — D-F); diagnostics `E0103 NotAModule` / `E0104 UnknownExport` / `E0107 ImportPrivateItem` (wired-but-dormant until `private`, D-G). `checkComponentInstance` resolves an imported component **cross-arena** (decl fetched from its defining arena, field names compared by **bytes**) → **unblocks the E1793 false positive**: a `.prefab.etch` importing its components validates clean; E1793 fires only for a genuinely-undeclared component. Cross-arena field-TYPE check (E1795) is builtin-typed-only; named foreign field types are a documented residual. | | `v0.10.8-const-private-test` | 2026-06-29 | M1.0.8 — `const` top-level + `private` + `test` graduation | The last three `non_s3_keywords` graduate parser-up (`override` stays reserved). Lexer: `kw_const`/`kw_private`/`kw_test` added to `s3_keywords`, removed from the reserve list (identifier→keyword logic unchanged). AST: `ConstDecl`/`TestDecl` side-slabs + `Visibility {public, private}` field on the `Item` node (`itemVisibility`/`setItemVisibility`). Parser: `parseConstDecl` (`const ( IDENT \| TYPE_IDENT ) : type = const_expr`, top-level only — `parseStmt` untouched, so `const` in a block is a parse error per part1 §4.5); `parseTestDecl` (`test STRING block`, reuses `parseBlockExpr`, no execution); `private` prefix in `parseOneTopLevel` (after annotations, before dispatch; rejects `private import/const/type`; sets the item `.private`). Lockstep set extended {dispatch, `recoverToTopLevel` stop-set, the single error-message enumeration} — `private` adds no stop-set member. Resolver: `SymbolKind` += `const_`/`test_`; `pass1Collect` registers both, `checkConstValue` reuses the field-default surface (`E1101 NotConstEvaluable` + `E0200 TypeMismatch`); tests registered but not exported. `buildExports` exports `const_decl` and reads `Item.visibility` per decl → **activates the dormant `E0107 ImportPrivateItem`** check. **Cleared M1.0.7 debts**: cross-file `const` resolves; selectively importing a `private` item emits `E0107`. | | `v0.10.9-extension-hooks` | 2026-06-30 | M1.0.9 — Execute extension hooks (`on_attach`/`on_detach`) | Founds the runtime text-execution surface M1.0.6 deferred. Decision: **text re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`). `parser.parseStmtBlock` parses a cooked hook statement-run (`"; "`-separated, no braces — the shape `descriptor.renderStmtRunAlloc` emits) into a transient `AstArena` via a new `parseStmtFragment` loop (reuses `parseStmt`, skips one optional `.semicolon` between statements). `interp.execHookText` rebinds `self.ast` to the hook arena (the field is a reassignable `*const AstArena`; the executor resolves identifiers via `self.ast.strings`, so the rebind is required), binds the implicit `entity`, runs via the existing `execStmtRun`, and routes deferred structural changes through the world's shared observer-deferred buffer (mirror of `runObserverBody`). `bindToWorld` registers the real `on_attach`/`on_detach` trampolines (`ctx = *Interpreter`); the loader's `dispatchOnAttach` now reaches execution. `world.zig` gains the `on_detach` seam (`registerOnDetach`/`dispatchOnDetach` + `ExtensionDetachFn`/`detach_hook`, mirror of `on_attach`) + a per-entity active-extension side-table (`entity_extensions`: `addEntityExtension`/`removeEntityExtension`/`hasEntityExtension`/`entityExtensions`, freed in `deinit`). `dispatchMethodOnValue` entity arm gains `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions`; the `Bridge` holds an optional `ExtensionResolver` (`setExtensionResolver`); `loader.runtimeActivate`/`runtimeDeactivate` are the pub runtime entries (deactivate fires `on_detach` first, then `removeComponentDynamic`). Headline: a cooked scene activating `CombatModule` runs `on_attach` at load (`Health.max` 100→150). Conflict policy stays **reject** (`ExtensionComponentConflict`), not last-wins. **B1+B2 merge-blocker round-trip (same day):** the Etch `activate_extension`/`deactivate_extension` now ENQUEUE a deferred op (interp-side `pending_extensions`, drained at the tick boundary) instead of mutating immediately mid-`iterateArchetype` (the immediate `runtimeActivate`/`runtimeDeactivate` stay for load + direct paths); and the type-checker (`dispatchMethodOnType` entity arm) recognizes all four methods so they pass `weld check`. | +| `v0.10.10-structural-mutation` | 2026-06-30 | M1.0.10 — Structural mutation in bodies (`spawn`/`despawn`/`add(T)`/`remove(T)`) | The four structural ops are **executable from `rule`/observer/hook bodies** as DEFERRED changes, and the S4 "no structural mutation" interpreter boundary is **lifted**. Parser: `spawn` graduates `non_s3_keywords`→`kw_spawn` + new `ExprKind.spawn_struct` (`spawn (` structural vs `spawn {` = fail-loud M1.0.11 async seam); `despawn`/`add`/`remove` need no parser change (postfix methods on an `Entity` receiver). Type-checker: the four ops recognized on an `Entity` receiver; component-literal fields validated via `checkComponentInstance` reuse (`E0306` unknown field / `E0307` field-type) — no body handle (`E0304`, statement-position only, v0.6) / prefab-name spawn refused (`E0305`, gated on the prefab runtime). Interp: each op ENQUEUES a Tier-0 `CommandBuffer` command onto `world.observer_registry.deferred` (eager payload resolution), drained at the tick boundary by `flushStructural` via `applyWithObservers` (observers fire per op — `on_spawned`+`on_add` / `on_remove`+`on_despawned` / `on_replaced`), never mid-`iterateArchetype`. No sentinel/planned-pool (reserved post-v0.6); `command_buffer.zig`/`entity.zig` untouched (FROZEN). | ## Hypotheses validated by spikes @@ -79,6 +80,7 @@ knowledge base — see § Quick links spec. - **M1.0.7 scope boundary (cross-file import)**: `import` graduated parser-up (the only `non_s3_keywords` member to leave; `const`/`private`/`test`/`override` stay reserved for M1.0.8). **Validated approach**: a **per-module** byte-keyed exports index (`{name bytes → {kind, arena_index, item_id}}`) extends the M0.9 byte-keyed `ProjectContext` pattern (StringIds are per-arena) — NOT a flat global index, so two modules exporting the same name never collide; the imported-component cross-arena resolution (decl fetched from its defining arena, field names compared by bytes) **unblocks the E1793 false positive** for `.prefab.etch`. **Deferred-but-pre-wired**: module-alias qualified `m.Type` resolution (D-F — the `imported_alias` binding is recorded at E5, so the descending `Path` walk is purely additive later); `E0107 ImportPrivateItem` (D-G — wired through the exports `visibility` flag, dormant until `private` graduates M1.0.8). **Not debt — moot**: the cross-arena field-TYPE check (E1795) resolves builtin types; this is COMPLETE for components because `validateFieldsInDecl(.component_like)` admits only builtin-POD field types (named struct/enum/string rejected on components) — the named-type branch is unreachable for a valid component (forward-compat headroom only). The cross-file `const`-import acceptance test is deferred to M1.0.8 (`const` is not parseable until it graduates) — cross-file resolution is covered by the imported-component type test + the prefab unblock. - **M1.0.8 scope boundary (`const`/`private`/`test` graduation)**: the last three `non_s3_keywords` graduate parser-up; `override` stays reserved (waits for a Tier-1 overridable module). **Top-level `const` only** — `parseStmt` is deliberately NOT extended, so a block-level `const` is a parse error; the tri-document drift (`const_stmt` under `etch-grammar.md §4.1` statements vs §4.5 "top-level only" vs `local_const` in `etch-resolver-types.md §2.1`) is a PREEXISTING cross-doc inconsistency deferred to a KB-audit conversation (NOT resolved here). **`private` is direct export-visibility + `E0107` only** — visibility inheritance (`etch-resolver-types.md §10.2`) and `W0902 PrivateTypeInPublicImpl` stay additive/deferred; `private` is parsed as a prefix on a `declaration_body` (rejects `private import/const/type`) and adds no `recoverToTopLevel` stop-set member. **`test` is parse + validate + symbol registration only** — no execution surface exists (same blocker family as M1.0.9); tests register a `test_` symbol but are never exported. **Residual**: a string-named `test "X"` registers under the byte sequence `X` via `registerSymbol`, so it shares the name namespace with identifier-named symbols (a `test "Foo"` collides with `component Foo` → `E0101`); acceptable for M1.0.8, revisit when the M1.0.9 test-runner formalizes test identity. **Cross-file tests** live in `tests/etch/import_resolve_test.zig` (the `validateProject` harness), not inline in `types.zig` (which cannot reach `validateProject` — a tier-up dependency). - **M1.0.9 scope boundary (execute extension hooks)** — **decided: TEXT re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`; `etch-visual-scripting.md §4`). The Tier-0 seam (`register`/`dispatch` `On{Attach,Detach}`) is backend-agnostic — a Phase-2 migration swaps the bridge callback + the cook side + a format bump. `execHookText` reuses the SAME `execStmtRun` that runs all Phase-1 gameplay (not hook-specific scaffolding). **Surface findings vs the brief's premise** (the brief's NOTE pre-authorizes adapting to the actual surface): (1) the interpreter has **no `entity.add(T)`/`spawn`/`despawn`** structural mutation in bodies (S4 boundary, `interp.zig` header) and the only deferred-structural producer (tag mutation) is **not in the cookable hook subset** `{let,emit,expr,assign,return}` — so a cooked Etch hook **cannot** itself issue a deferred structural change; the "drained before `on_spawned`" test is realized with a Tier-0 stand-in attach callback that enqueues into the same observer-deferred channel (Recorded deviation). (2) **[B1 round-trip 2026-06-30]** the Etch `activate_extension`/`deactivate_extension` ENQUEUE a deferred command (interp-side `pending_extensions`, mirror of `pending_tags`, drained at the tick boundary by `flushPendingExtensions`) instead of an immediate `add`/`removeComponentDynamic` — fixing the archetype-migration-mid-`iterateArchetype` corruption. The immediate `runtimeActivate`/`runtimeDeactivate` (and the new bytes-taking `pub activateExtension`/`deactivateExtension`) stay for the load + direct-programmatic paths (outside iteration). (3) The activate/deactivate/has/active tests live in `tests/scene/extensions_test.zig`, not inline in `interp.zig`, because they need the cook pipeline (`scene_cook` → circular import otherwise) — the M1.0.8 tier-dependency precedent. (4) **[B2 round-trip 2026-06-30]** the four entity methods are recognized by the **type-checker** (`dispatchMethodOnType` entity arm, `types.zig`: `activate`/`deactivate_extension → unit`/unknown, `has_extension → bool`, `active_extensions → [string]`, arg-validated) — a real rule body calling them passes `weld check`; tests no longer skip the checker. **Deferred (not M1.0.9)**: the §30.5 compile-time additive-conflict warning (out of brief scope); a `test`-runner (the `ast.zig` `TestDecl` comment "execution surface is M1.0.9" is a pre-existing M1.0.8 imprecision — M1.0.9 delivers HOOK execution, not `test` execution). +- **M1.0.10 scope boundary (structural mutation in bodies)** — the four ops (`spawn`/`despawn`/`add(T)`/`remove(T)`) are DEFERRED structural changes routed through the Tier-0 `CommandBuffer` (`world.observer_registry.deferred`), drained at the tick boundary via `applyWithObservers` (NOT a parallel `pending_*` queue). **In-body `spawn` is statement-only, NO body handle** (v0.6, `etch-grammar.md §4.5`): binding/using a spawn result is refused (`E0304`); the sentinel/planned-pool that would back a body handle is reserved post-v0.6 (`etch-bytecode.md §11.2`) — NOT built. **Prefab-name `spawn("X")`** parses + is recognized but refused (`E0305`), gating on the prefab runtime (post-Etch). **Recorded deviation (Claude.ai round-trip, VERDICT E2 — STOP):** E2 was completed to statically validate component-literal fields (`E0306`/`E0307`) by reusing the scene/prefab `checkComponentInstance` path — "type the expression" implies field validation for `weld check` / C1.6. **Design note (E3):** the 4 ops route via a `structuralDeferred` helper (prefers `observer_deferred` when bound, else the world's shared buffer) rather than binding `observer_deferred` in `execBody` — equivalent routing, leaves the tag/extension `pending_*` paths untouched. **Out (later milestones):** async `spawn { }` task (M1.0.11); the §30.5 additive-conflict cook warning (later cook milestone). ## Non-negotiable rules diff --git a/briefs/M1.0.10-structural-mutation.md b/briefs/M1.0.10-structural-mutation.md new file mode 100644 index 0000000..7f3e662 --- /dev/null +++ b/briefs/M1.0.10-structural-mutation.md @@ -0,0 +1,180 @@ +# M1.0.10 — Structural mutation in bodies (`spawn` / `despawn` / `add(T)` / `remove(T)`) + +> **Status:** CLOSED +> **Phase:** 1 +> **Branch:** `phase-1/etch/structural-mutation` +> **Planned tag:** `v0.10.10-structural-mutation` +> **Dependencies:** M1.0.2 (observers + deferred command buffer routing), M1.0.9 (entity-receiver method dispatch, deferred-at-tick-boundary pattern) +> **Opened:** 2026-06-30 +> **Closed:** 2026-06-30 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +This is the first of the M1.0.10–M1.0.20 series closing the remaining execution gaps of EBNF v0.6 (criterion C1.6). The `interp.zig` header explicitly forbids structural mutation in bodies (`No structural mutation (spawn, despawn, add(T), remove(T))`) — these statements are in the grammar but have NO execution path; the S4 boundary was never lifted. Structural mutation is the substrate of all gameplay (the M1.7 demo needs spawn for projectiles, despawn for death, add/remove for status), so this milestone makes the four operations executable from `rule` / observer / hook bodies, deferred to the tick boundary so they never corrupt live iteration. The deferral primitive ALREADY exists (the Tier-0 `CommandBuffer`, the M1.0.2 observer-deferred routing) and is reused, not reinvented. + +## Scope + +- **E1 — Parser: structural `spawn(...)` production.** `src/etch/parser.zig` learns the `spawn` keyword, disambiguated on the following token: `spawn (` → STRUCTURAL spawn (this milestone), `spawn {` → ASYNC task (`spawn_stmt`, M1.0.11). The structural form parses to a NEW AST node (proposed `ExprKind.spawn_struct` in `src/etch/ast.zig`, holding either a component-literal argument range — `TYPE_IDENT { … }` varargs — or a single `STRING_LITERAL` prefab name), per `etch-grammar.md §3.2 structural_spawn`. The `{` (async) branch — unparsed today, owned by M1.0.11 — emits a clear "async spawn not yet executable (M1.0.11)" diagnostic rather than mis-parsing; this is the seam M1.0.11 fills. `despawn` / `add` / `remove` need NO parser or AST change: they are postfix method calls on an `Entity` receiver (existing `postfix_op` production), like `get` / `get_mut` / `has` / `add_tag` / `remove_tag`. +- **E2 — Type-checker: recognize the four ops + refuse the body handle + refuse prefab spawn.** `src/etch/types.zig` recognizes, on an `Entity` receiver, the structural methods `entity.add(T { … })` (T a declared `component`; add-on-present is a replace, no separate construct), `entity.remove(T)` (T a declared `component`), `entity.despawn()` (no args) — mirroring the existing `add_tag` / `remove_tag` recognition path — and the structural `spawn(...)` node (each component-literal argument a declared `component`). Three refusals, each a DEDICATED diagnostic (new codes in the structural-ECS family per `etch-diagnostics.md` conventions — do NOT reuse `get`/`get_mut`/`E0200` codes): (a) **binding or using a structural-spawn result** in a body — `let e = spawn(…)`, `spawn(…).field`, `spawn(…)` passed as an argument — is rejected ("spawn handle unavailable in body — v0.6"); the structural spawn is statement-position only. (b) **`spawn("PrefabName")`** (the `STRING_LITERAL` form) is rejected ("prefab spawn not executable in Phase 1 — gating on the prefab runtime"); it parses and is recognized but is NOT executed this milestone (explicit refusal). Each of the four executable surfaces type-checks to no value (statement effect). +- **E3 — Interpreter: execute the four ops via the Tier-0 `CommandBuffer`; lift the S4 boundary.** `src/etch/interp.zig` dispatches each operation by enqueuing the matching FROZEN `CommandBuffer` command (`.spawn` / `.despawn` / `.add_component` / `.remove_component`) onto the observer-dispatch-correct deferred buffer — the SAME route M1.0.2 established (`self.observer_deferred` bound to `world.observer_registry.deferred` during body execution). `add` / `remove` / `despawn` dispatch in the entity-receiver method site (the M1.0.9 `activate_extension` / `has_extension` site). `spawn(C1 { … }, C2 { … })` resolves each component literal's bytes EAGERLY at call time (mirror of extensions B1 eager-byte resolution) and enqueues a `.spawn` command (component ids + payload bytes); no body handle is produced (statement position). NO operation mutates the world immediately — never mid-`iterateArchetype` (which walks live `arch.chunks`); all apply at the tick-boundary drain via the existing observer-aware flush (`applyWithObservers`), firing observers per op. The `interp.zig` header S4-boundary line is removed and replaced with a one-line note that the four ops are deferred structural changes. +- **E4 — CLAUDE.md update (§3.4).** On the milestone branch, within the closing PR: **FIX the stale current-state line** that reads "Next planned milestone = M1.1.0 … M1.0 (M1.0.0–M1.0.9) is complete" → next planned = **M1.0.11**, M1.0 = **21 sub-milestones** with **M1.0.10 closed** (M1.0 is NOT complete; M1.0.10–M1.0.20 close C1.6, Etch-closure tag at M1.0.20). Add one Tags row (`v0.10.10-structural-mutation`). Record that the four structural ops are executable from bodies and the S4 structural-mutation boundary is lifted. Update the "Last updated" date. No narrative prose. + +## Out of scope + +- **Body-usable spawn handle (sentinel / planned-id model).** Explicit v0.6 refusal (E2). In-body `spawn(…)` carries its full initial payload and yields NO handle in the body; referencing a freshly-spawned entity in the same body (parent→child at creation) is not expressible in v0.6. Do NOT implement a sentinel range, a planned-id pool, or a planned→real remap. The model is reserved post-v0.6 (`etch-bytecode.md §11.2` note); the Phase-2 VM stays language-identical (no body handle either). +- **Prefab-name spawn EXECUTION.** `spawn("X")` parses (E1) and is recognized + refused (E2); it is NOT executed. Gating on the prefab runtime (post-Etch). +- **Async `spawn { }` / `race` / `sync` / `branch`.** M1.0.11. E1 only adds the disambiguation seam (the `{` branch emits the M1.0.11 diagnostic). +- **Unfreezing Tier-0.** `src/core/ecs/command_buffer.zig` and `src/core/ecs/entity.zig` are FROZEN (C0.5) and used AS-IS. The `CommandBuffer` already carries `.spawn` / `.despawn` / `.add_component` / `.remove_component` + observer dispatch; reuse it. Do NOT touch these files. +- **A new `pending_*` queue for structural ops.** NOT needed — route through the existing `CommandBuffer` (which fires observers). Do NOT build a parallel structural queue beside `pending_tags` / `pending_extensions`. +- **`world.spawn()` as an Etch surface.** The direct, out-of-dispatch handle-returning path is a Tier-0 mechanism (`World.spawn` / `spawnDynamic`); whether Etch exposes a `world` receiver is not introduced here. This milestone is the IN-BODY deferred path only. +- **Bytecode / IR.** Phase 2. The dispatch reuses the Phase-1 tree-walker; introduce no lowering, no `.etchc`. +- **Re-type-checking at runtime / hot-reload identity for spawned entities.** Phase-2 (`etch-ast-ir.md`); untouched. + +## Specs to read first + +1. `etch-grammar.md` — §3.2 `structural_spawn` (the new production; `spawn (` vs `spawn {` disambiguation), §4.5 *Mutations structurelles ECS* (the four surfaces + the v0.6 handle decision), §4.2 (the async `spawn_stmt` is M1.0.11 — NOT this milestone) +2. `etch-reference-part1.md` — §5.3 (deferred structural mutations: refs stay valid through the body, generation checks at `get`/`get_mut`), §5.2 (value semantics; `world.spawn()` is the out-of-dispatch path, not in-body) +3. `etch-memory-model.md` — §6.6 (command buffer / deferred ECS: spawn payload in the frame arena, single-threaded consume + apply at the tick boundary) +4. `engine-ecs-internals.md` — §8 (observers: the `applyWithObservers` FLUSH order and exactly which observers fire per op — `spawn` → `on_spawned` then `on_add` per component; `add_component` → `on_replaced` if present else `on_add`; `remove_component` → `on_remove`; `despawn` → `on_remove` per component then `on_despawned`), and the deferred command buffer / flush point +5. `etch-bytecode.md` — §11.2 (`SPAWN_DEFERRED` is statement-only in v0.6, no sentinel; the sentinel/planned-pool note is reserved post-v0.6 — this CONFIRMS no body handle; do NOT implement the sentinel) +6. `etch-visual-scripting.md` — §4 (Phase-1 backend = tree-walking interpreter, no VM) +7. `etch-diagnostics.md` — the structural-ECS diagnostic family (allocate the three new codes here; do not reuse component-access codes) +8. `engine-phase-1-plan.md` — the M1.0.10 line and the M1.0 series framing + +## Files to create or modify + +(Tests live inline in the source `.zig` files per repo convention. A cross-cutting interp/observer integration test belongs in the canonical interpreter test location — reconfirm at clone; record in *Recorded deviations* if it lands elsewhere for a tier-dependency reason, as in M1.0.8.) + +- `src/etch/ast.zig` — modify — new `ExprKind.spawn_struct` (structural spawn) + its data table entry (component-literal arg range OR prefab-name `StringId`); distinct from the existing async `spawn_stmt` +- `src/etch/parser.zig` — modify — `spawn` keyword parsing with `(`-vs-`{` lookahead; the `(` branch builds `spawn_struct`; the `{` branch emits the M1.0.11 diagnostic; inline parse tests +- `src/etch/types.zig` — modify — recognize `entity.add(T {…})` / `entity.remove(T)` / `entity.despawn()` (entity-receiver structural methods, RTTI: T a declared `component`) and `spawn_struct` (component-literal args); three dedicated diagnostics (bound/used spawn result; prefab-name spawn refusal); inline tests +- `src/etch/interp.zig` — modify — dispatch the four ops to the `CommandBuffer` via `observer_deferred` (eager byte resolution for `spawn`/`add` payloads); remove the S4-boundary header line; inline tests +- `CLAUDE.md` — modify — §3.4 update: FIX the "next = M1.1.0 / M1.0 complete" line (→ next = M1.0.11, M1.0 = 21 sub-milestones, M1.0.10 closed), +1 Tags row, record the four ops executable + S4 lifted, "Last updated" date + +## Acceptance criteria + +### Tests + +- `src/etch/parser.zig` — `test "structural spawn parses component-literal varargs"` — `spawn(Projectile { speed: 20.0 }, Velocity { value: [0,0,1] })` → a `spawn_struct` node with two component-literal arguments +- `src/etch/parser.zig` — `test "structural spawn parses a prefab name"` — `spawn("Goblin")` → a `spawn_struct` node carrying the string-literal argument +- `src/etch/parser.zig` — `test "spawn brace form is the async seam"` — `spawn { … }` → a clear "async spawn not executable (M1.0.11)" diagnostic, NOT a structural mis-parse +- `src/etch/types.zig` — `test "entity structural methods type-check on an Entity receiver"` — a type-checked program (no checker skip) whose rule body calls `entity.add(Shield {…})`, `entity.remove(Poisoned)`, `entity.despawn()` passes `TypeChecker.check` with zero diagnostics +- `src/etch/types.zig` — `test "structural spawn of component literals type-checks"` — `spawn(C1 {…}, C2 {…})` with declared components passes with zero diagnostics +- `src/etch/types.zig` — `test "binding a structural spawn result is rejected"` — `let e = spawn(C {…})` emits the dedicated "spawn handle unavailable in body" diagnostic +- `src/etch/types.zig` — `test "prefab-name spawn is refused in Phase 1"` — `spawn("X")` emits the dedicated "prefab spawn not executable" diagnostic +- `src/etch/interp.zig` — `test "spawn defers — entity materializes at flush with full payload"` — a rule body `spawn(Health { max: 10 })`; world entity count unchanged during the body; after the tick flush, a new entity carries `Health { max: 10 }` +- `src/etch/interp.zig` — `test "despawn defers — entity removed at flush"` — `entity.despawn()` in a body; the entity is still live during the walk; gone after the flush +- `src/etch/interp.zig` — `test "add defers — component present at flush"` — `entity.add(Shield {…})`; `Shield` absent during the body, present after the flush +- `src/etch/interp.zig` — `test "remove defers — component gone at flush"` — `entity.remove(Poisoned)`; present during the body, gone after the flush +- `src/etch/interp.zig` — `test "spawn fires on_spawned then on_add per component at flush"` — observer rules registered for `@on_spawned` and `@on_added(T)` fire on the flush, in the documented order +- `src/etch/interp.zig` — `test "despawn fires on_remove per component then on_despawned"` — observers fire on the flush, components readable in `on_remove`/`on_despawned` before destruction +- `src/etch/interp.zig` — `test "add-on-present fires on_replaced"` — `entity.add(T)` on an entity already carrying `T` fires `@on_replaced(T)` (old then new), not `@on_added` +- **B1 (headline)** — canonical interp test — `test "multi-entity rule structural mutation defers without corrupting iteration"` — a rule matching N (>1) entities, each body issuing a structural op (`entity.add(T {…})`, an `entity.despawn()`, and a `spawn(…)`), runs the FULL live archetype walk with NO corruption; after the tick's flush every effect is applied (mirror of the M1.0.9 B1 acceptance) +- `src/etch/interp.zig` — `test "S4 structural-mutation boundary lifted"` — a guard/doc check that the interp header no longer claims the four ops are unsupported (and a smoke test that a body issuing all four runs without `UnsupportedExpr`/`UnsupportedStmt`) + +### Benchmarks + +- N/A. Correctness milestone. Deferred ops apply once at the tick-boundary drain through the existing `CommandBuffer` path; no new per-tick hot path is introduced. + +### Observable behavior + +- An Etch rule that, on an `EnemyKilled` event, `spawn(Pickup {…}, Position {…})` → after the tick, a new pickup entity exists and is iterated by other rules. Demonstrable via the interp/observer integration test or a small driver. +- An Etch round-trip on a live entity: a rule `entity.add(Poisoned {…})`, a later tick `entity.remove(Poisoned)`, and a death rule `entity.despawn()` → the status appears then is removed, the entity is destroyed, and the matching observers (`on_added` / `on_removed` / `on_despawned`) fired on the respective flushes. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe) +- `zig fmt --check` green +- `zig build lint` green +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/etch/structural-mutation` +- **Final tag:** `v0.10.10-structural-mutation` +- **PR title:** `Phase 1 / Etch / Structural mutation in bodies` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Decision frozen at scoping — in-body spawn is a STATEMENT, no body handle (v0.6).** The structural spawn carries its full initial payload (the component literals) and materializes at the tick boundary; its result is not a usable value in the body. This is a grammar/resolver decision (`etch-grammar.md §4.5`), not a Phase-2 report — both the tree-walker (this milestone) and the future bytecode VM accept the same language. The sentinel/planned-pool that WOULD back a body handle is reserved post-v0.6 (`etch-bytecode.md §11.2` note); do NOT build it. +- **Reuse the deferral primitive — do NOT reinvent it.** Eager-resolve payload bytes at call time, defer the structural apply to the tick boundary — the SAME shape as tag mutations (`pending_tags` / `flushPendingTags`) and extensions B1 (`pending_extensions` / `flushPendingExtensions`). But for the four structural ops, route through the Tier-0 `CommandBuffer` (which ALSO fires observers via `applyWithObservers`), NOT a new parallel queue. `world.observer_registry.deferred` bound to `self.observer_deferred` during a body is the route M1.0.2 set up for rule/observer bodies (`interp.zig` ~l.1219–1228; reconfirm at clone). +- **The Tier-0 `CommandBuffer` already has everything.** `command_buffer.zig` (FROZEN, C0.5) exposes a `Command` union with `.spawn` (component ids + payload byte slices), `.despawn`, `.add_component`, `.remove_component` (+ `.set_tag` / `.clear_tag`), and `applyOne` → `spawnDynamicWithValues` / `despawn` / `addComponentDynamic` / `removeComponentDynamic`. The four Etch ops map one-to-one. The raw spawn return id is discarded today — which is exactly correct for the no-body-handle decision. Do NOT unfreeze this file; do NOT add sentinel handling. +- **`entity.zig` is FROZEN and `allocate` marks the slot alive immediately** — there is no "reserved, not-yet-materialized" state. This is a second reason the sentinel/reserved-id model is out of scope: it would require unfreezing Tier-0. +- **Drain point.** The deferred commands apply at the tick boundary alongside `flushPendingTags` / `flushPendingExtensions` (`interp.zig` ~l.1345–1350; `flushPendingTags` ~l.1770, `flushPendingExtensions` ~l.1796). Reconfirm that `world.observer_registry.deferred` is flushed via `applyWithObservers` at that boundary so observers fire; if the existing flush ordering needs the structural drain interleaved with the tag/extension drains, preserve submission order and document it. +- **`iterateArchetype` walks LIVE `arch.chunks`** (`interp.zig` ~l.1461) — verified at M1.0.9. Any immediate structural apply would corrupt the walk; this is why all four ops MUST be deferred. +- **Dispatch sites.** `add` / `remove` / `despawn` are entity-receiver method calls — add their branches at the M1.0.9 method-dispatch site (`dispatchMethodOnValue`; the `activate_extension` / `has_extension` arm; reconfirm at clone). `spawn_struct` is dispatched on its own expr arm (statement position); the `method_call` / `method_get` arms are ~l.3193 / ~l.2858. +- **`add` is the only mutation that can be a replace.** `add_component` on an entity already carrying `T` is the add-on-present = `@on_replaced` path (capture old, overwrite, fire `on_replaced(old, new)`) — already implemented in `applyWithObservers` (ecs-internals §8). No separate `replace` construct. +- **Keep async and structural `spawn` distinct.** `spawn_struct` (this milestone, an expression, `etch-grammar.md §3.2`) ≠ `spawn_stmt` (async task, `§4.2`, M1.0.11). The `ast.zig` async `spawn_stmt` enum member (~l.201) stays; the new node is separate. +- **KB spec reconciliation already done by Claude.ai.** The five files (`etch-grammar.md` §3.2 + §4.5, `etch-reference-part1.md` §5.2/§5.3, `etch-memory-model.md` §6.6, `engine-gameplay-systems.md`, `etch-bytecode.md` §11.2) are re-uploaded. Spec files are NOT in the repo — do NOT edit them. The canonical surface is `entity.despawn()` (not `destroy` / not a free `despawn(e)`), `entity.add(T {…})`, `entity.remove(T)`, `spawn(C1 {…}, …)` varargs. +- **§3.6.1 closing audit (REPO, not `/mnt/project/`):** `grep -rn` over `src/` for the modified terms — the structural-spawn node name, the `add`/`remove`/`despawn` method branches, the three new diagnostics, and the old S4-boundary header line — and patch orphan references in-session or record as residual debt. Language audit on the diff + brief (no French in code/comments). +- **Surface verified @ `v0.10.9` during scoping; reconfirm at clone** (the surface is the source of truth): `interp.zig` S4 header (l.11), `observer_deferred` field (~l.682) + rule-body routing (~l.1219–1228), tick-boundary flush (~l.1345–1350), `flushPendingTags` (~l.1770) / `flushPendingExtensions` (~l.1796), `iterateArchetype` (~l.1461), `method_get`/`method_get_mut` (~l.2858) / `method_call` (~l.3193); `command_buffer.zig` `Command` union + `applyOne` + `spawn`/`despawn`/`addComponent`/`removeComponent`; `entity.zig` `EntityIdentityStore.allocate`; `ast.zig` `StmtKind` (l.177, `spawn_stmt` l.201) / `ExprKind` (l.209); `types.zig` `add_tag`/`remove_tag` recognition (~l.4284). + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +*Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* + +- [x] `etch-grammar.md` (§3.2, §4.5, §4.2) — read 2026-06-30 14:20 +- [x] `etch-reference-part1.md` (§5.2, §5.3) — read 2026-06-30 14:20 +- [x] `etch-memory-model.md` (§6.6) — read 2026-06-30 14:20 +- [x] `engine-ecs-internals.md` (§8) — read 2026-06-30 14:20 +- [x] `etch-bytecode.md` (§11.2) — read 2026-06-30 14:20 +- [x] `etch-visual-scripting.md` (§4) — read 2026-06-30 14:20 +- [x] `etch-diagnostics.md` (structural-ECS family) — read 2026-06-30 14:20 +- [x] `engine-phase-1-plan.md` (M1.0.10 line) — read 2026-06-30 14:20 + +## Execution log + +*One entry per logical unit of work (typically: an objective met, a green test, a blocker). Chronological. Short — 1 to 3 lines per entry.* + +- **E1 — parser + AST `spawn_struct` (2026-06-30).** `spawn` graduated `non_s3_keywords` → `s3_keywords`/`kw_spawn` so the parser can dispatch it (precedent: import/const/test). New `ast.ExprKind.spawn_struct` + `SpawnStructExpr` slab (component-literal `NodeId` run in `arena.extra`, OR prefab `StringId`) + `addSpawnStruct{Components,Prefab}` + deinit. `parsePrimary` `.kw_spawn` arm → new `parseStructuralSpawn`: `spawn (` parses a prefab string or `TYPE_IDENT {…}` varargs; `spawn {` is fail-loud (the M1.0.11 async seam). 3 inline parse tests + a token graduation test. +- **Out-of-list file touched (E1):** `src/etch/token.zig` (not in "Files to create or modify"). Justification: graduating `spawn` to a real `kw_spawn` lexeme is the lexer prerequisite for the parser to disambiguate `spawn (` vs `spawn {`; the brief's E1 text ("`parser.zig` learns the `spawn` keyword") presumes it. `lexer.zig` is unchanged (keyword resolution is data-driven over `token.s3_keywords`). `types.zig`/`interp.zig` are NOT touched in E1 — the new `spawn_struct` variant falls into their existing `else` arms (`.unknown` / `RuntimeFailure`) until E2/E3. +- **E1 validation (green):** `zig build`, `zig build test` (debug + ReleaseSafe), `zig fmt --check`, `zig build lint`, standalone `zig test src/etch/token.zig` (3/3). +- **Workflow (Guy, 2026-06-30):** push the branch at the end of each gate sub-step (E1…E4), not only at Étape 5. E1 pushed after green validation. +- **E2 — type-checker (2026-06-30).** `dispatchMethodOnType` Entity arm recognizes `entity.despawn()` (0 args) / `entity.add(T {…})` / `entity.remove(T)` (mirror of the M1.0.9 extension-method site); all three are statement-effect (`unknown` return, the `array.push` convention). New `checkSpawnStruct` handles the `spawn_struct` node: `synthExpr` reaches it only in a VALUE position → E0304; `checkStmt`'s `expr_stmt` calls it with `value_position=false` for the legal statement position. Prefab form → E0305. Component-literal args validated as declared `component` via `checkStructuralComponentLiteral`/`checkStructuralComponentName` (symbol lookup, mirror of `when has`). 4 inline tests. +- **Two new diagnostics (E2):** `E0304 SpawnHandleUnavailable` + `E0305 PrefabSpawnNotExecutable`, allocated in the ECS-access family (E03XX, adjacent to E0301/E0302/E0303) in `diagnostics.zig` (enum + `code()` + `name()`). +- **Out-of-list file touched (E2):** `src/etch/diagnostics.zig` (not in "Files to create or modify"). Justification: E2 explicitly mandates "new codes in the structural-ECS family"; allocating diagnostic codes necessarily edits the `DiagnosticCode` enum + `code()`/`name()` mappings. (Same omission class as E1's `token.zig`.) +- **Brief prose imprecision flagged (E2):** the FROZEN E2 text says "Three refusals" but enumerates only (a) spawn-handle-unavailable and (b) prefab-spawn-not-executable, and the acceptance criteria test exactly those two. Implemented the 2 enumerated + tested refusals (1 code each); the 3 value-use sub-cases of (a) — `let e = spawn(…)`, `spawn(…).field`, `spawn(…)` as an argument — are all covered by the single `synthExpr` value-position arm. No third refusal was invented (none tested/enumerated). Surfaced for Claude.ai review; NOT a Cas-2 blocker (the implementable contract is unambiguous). +- **Surface finding (E2):** `synthStructLit`/`checkStructLitAgainst` require a `struct_` symbol ("X is not a struct type"), so a *component* literal cannot be routed through them. Component-literal args are validated by direct symbol lookup on the `struct_lit.type_name` (component kind), NOT synthesized. Component *field-value* validation is out of E2 scope (the brief mandates only "T a declared component"); additive if wanted later. +- **E2 validation (green):** `zig build`, `zig build test` (debug + ReleaseSafe), `zig fmt --check`, `zig build lint`. The 4 E2 test names are compiled into the test binaries (collection confirmed). +- **E2 completion — static component-literal field validation (2026-06-30, commit `3ea6adc`).** Per VERDICT E2 — STOP: the "Surface finding (E2)" above ("field-value validation out of E2 scope") is **superseded**. `spawn(...)` / `entity.add(...)` component-literal fields are now validated (unknown field → `E0306`, mistyped → `E0307`) by reusing `checkComponentInstance` (a `ComponentInstance` built from the `struct_lit`; local + imported foreign paths). +2 inline tests (unknown field via spawn AND add → E0306; mistyped field → E0307); the two "accepts" tests stay at 0 diagnostics. Re-validated green (build, debug + ReleaseSafe, fmt, lint). See Recorded deviations. +- **E3 — interpreter (2026-06-30).** `dispatchMethodOnValue` Entity arm gains `despawn` / `add(T {…})` / `remove(T)` (mirror of the M1.0.9 `activate_extension` site); `evalExpr` gains the `.spawn_struct` arm. All four ENQUEUE a Tier-0 `CommandBuffer` command onto `world.observer_registry.deferred` (helper `structuralDeferred` — NOT a parallel `pending_*` queue, per §4.5); component payloads are resolved EAGERLY (`buildComponentPayload`: component defaults + field overrides via `bridge.writeValueAsBytes`, into the deferred buffer's arena). New tick-boundary `flushStructural` drains the buffer via `applyWithObservers` (drain-until-empty for observer cascades), after tags + extensions. S4 header line lifted → deferred-structural note. `execBody` / tags / extensions UNCHANGED; `command_buffer.zig` / `entity.zig` untouched (used via their public surface). 9 inline tests (spawn/despawn/add/remove defer; on_spawned+on_add; on_remove+on_despawned; on_replaced; B1 multi-entity; S4-lifted smoke) + the E3 carry-over types.zig test (non-component literal → E0200). +- **Surface findings (E3):** `world.Archetype` == `archetype_dynamic.DynamicArchetype` (same `archetype_mod.Archetype`); `world.archetypes.items` is `[]*Archetype`. Component payloads built from `componentDefaultBytes` + per-field `writeValueAsBytes` (components are POD-strict — no heap-field interning). `Value.unit` is the spawn expression's runtime result (statement effect). +- **E3 validation (green):** `zig build`, `zig build test` (debug + ReleaseSafe), `zig fmt --check`, `zig build lint`. E3 test names compiled into the test binaries (collection confirmed). +- **E4 — CLAUDE.md §3.4 (2026-06-30).** Current-state: `Last released tag` → `v0.10.10-structural-mutation`; `Next planned milestone` → M1.0.11, M1.0 = **21 sub-milestones** (M1.0.0–M1.0.20), NOT complete, M1.0.0–M1.0.10 closed, M1.0.11–M1.0.20 close C1.6 (Etch-closure tag at M1.0.20). +1 Tags row (`v0.10.10-structural-mutation`) recording the four ops executable from bodies + the S4 boundary lifted. +1 open-decision "M1.0.10 scope boundary". `Last updated` already 2026-06-30 (same day). No narrative prose. + +## Recorded deviations + +*Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* + +- **E2 scope clarification (Claude.ai round-trip, VERDICT E2 — STOP, 2026-06-30) — commit `3ea6adc`.** The FROZEN E2 "recognize … type the expression" implies STATIC validation of `spawn(...)` / `entity.add(...)` component-literal FIELDS (unknown field + field-type mismatch), matching what scene/prefab already do (`checkComponentInstance` → `checkInstanceField` / `checkInstanceFieldForeign`) so `weld check` (gate C1.6) catches them. My first E2 pass scoped field validation out — corrected here: 2 new codes `E0306 StructuralComponentFieldUnknown` / `E0307 StructuralComponentFieldTypeInvalid` (ECS-access family) + `checkStructuralComponentLiteral` now builds a `ComponentInstance` from the `struct_lit` and reuses the local+imported field-validation path. `remove(T)` / `despawn()` (field-less) unchanged. + +## Blockers encountered + +*Blocking points that required a return to Claude.ai (cf. `engine-development-workflow.md §2.4`). If 2+ distinct blockers: re-scope signal.* + +- + +## Closing notes + +*Fill in at Status → CLOSED, just before opening the PR.* + +- **What worked:** The deferral primitive was reused verbatim — the four ops route through the Tier-0 `CommandBuffer` (`world.observer_registry.deferred`) and drain at the tick boundary via `applyWithObservers`, so observers fire per op (`on_spawned`+`on_add` / `on_remove`+`on_despawned` / `on_replaced`) with zero new Tier-0 surface. The M1.0.9 precedents (entity-method dispatch site, eager-resolve-then-defer pattern) and the scene/prefab field-validation path (`checkComponentInstance`) mirrored cleanly. The gate-by-gate E1→E4 cadence kept each diff small and reviewable. +- **What deviated from the original spec:** (1) **E2 completion** (Claude.ai round-trip, VERDICT E2 — STOP): static component-literal field validation added (`E0306`/`E0307`) — Recorded deviation, commit `3ea6adc`. (2) The FROZEN E2 prose says "Three refusals" but enumerates + tests two (`E0304` handle, `E0305` prefab); the three value-use sub-cases of (a) are covered by the single `E0304` value-position arm — no third refusal invented. (3) Out-of-list files touched (justified in Execution log): `token.zig` (E1 — lexer graduation of `spawn`), `diagnostics.zig` (E2 — new codes). (4) E3 routes via a `structuralDeferred` helper rather than binding `observer_deferred` in `execBody` — equivalent, leaves the tag/extension `pending_*` paths untouched. +- **What to flag explicitly in review:** `Last released tag` bumped to `v0.10.10-structural-mutation` (anticipates the post-merge tag; keeps the invariant "Last released tag == newest Tags row"). The `structuralDeferred` routing choice (E3 design note). The drain order at the tick boundary: `flushPendingTags` → `flushPendingExtensions` → `flushStructural` (drain-until-empty for observer cascades). +- **Final measurements:** N/A — correctness milestone (brief: no benchmark). Deferred ops apply once at the tick-boundary drain through the existing `CommandBuffer` path; no new per-tick hot path. All acceptance tests green in `debug` + `ReleaseSafe`; `zig build` / `zig fmt --check` / `zig build lint` clean. +- **Residual risks / tech debt left intentionally:** async `spawn { }` task — M1.0.11 (E1 emits the fail-loud seam diagnostic). Prefab-name `spawn("X")` execution — gated on the prefab runtime (`E0305`, post-Etch). No body handle / sentinel / planned-pool — reserved post-v0.6 (`etch-bytecode.md §11.2`), NOT built. The §30.5 additive-conflict cook warning — later cook milestone. Component-literal field values are materialized via `evalExpr` + `bridge.writeValueAsBytes` (POD-strict components; POD covered by tests, `entity`/`enum` field kinds ride the existing Value→bytes machinery, untested here). diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 2b029c2..8bbd7cc 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -258,6 +258,13 @@ pub const ExprKind = enum { /// in both backends (the negative-tag-op precedent — flagged bound). /// Data indexes `tag_query_exprs`. tag_query, + /// Structural spawn `spawn(C1{…}, …)` / `spawn("Prefab")` (§3.2 + /// `structural_spawn`, M1.0.10). A statement-position expression: the v0.6 + /// no-body-handle decision (§4.5) means the type-checker rejects binding or + /// using its result (M1.0.10 E2). Data indexes `spawn_structs`. Distinct + /// from the async `spawn { }` task form (§4.2 `spawn_stmt`, M1.0.11) which + /// is fail-loud at parse — it is NOT this node. + spawn_struct, }; /// Closed enum of type-node kinds the parser can produce. @@ -1228,6 +1235,25 @@ pub const StructLitExpr = struct { fields_len: u32, }; +/// `structural_spawn` (§3.2 l.552, M1.0.10): `spawn(C1 {…}, …)` (component +/// literals) or `spawn("Prefab")` (prefab name). Two forms, discriminated by +/// `is_prefab`: +/// • component-literal varargs — `is_prefab == false`; `args_start` / +/// `args_len` index a contiguous run of struct-lit `Expr` `NodeId.raw()` +/// values in `arena.extra` (each a `.struct_lit`), the `addArrayLit` run +/// convention. +/// • prefab name — `is_prefab == true`; `prefab_name` is the interned string +/// literal, `args_len == 0`. The prefab form parses + is recognized but is +/// REFUSED at type-check in Phase 1 (gating on the prefab runtime, E2). +/// No body handle is produced (v0.6 statement-only, §4.5) — the result is not a +/// usable value, which the type-checker enforces (E2). +pub const SpawnStructExpr = struct { + is_prefab: bool, + prefab_name: StringId = 0, // valid iff is_prefab + args_start: u32 = 0, // index into `arena.extra` (struct-lit NodeIds); valid iff !is_prefab + args_len: u32 = 0, +}; + /// One entry of a `data` table (M0.8 E4, `etch-grammar.md` §14: /// `data_entry = IDENT ":" struct_literal_body [","]`). The entry body is a /// `(start, len)` run of `arena.struct_lit_fields` (spread fields carry @@ -2456,6 +2482,10 @@ pub const AstArena = struct { method_calls: std.ArrayListUnmanaged(MethodCall) = .empty, struct_lits: std.ArrayListUnmanaged(StructLitExpr) = .empty, struct_lit_fields: std.ArrayListUnmanaged(StructLitField) = .empty, + /// `structural_spawn` nodes (M1.0.10). Component-literal arg runs live in + /// `arena.extra` (struct-lit NodeIds); the prefab form carries an interned + /// name. See `SpawnStructExpr`. + spawn_structs: std.ArrayListUnmanaged(SpawnStructExpr) = .empty, /// Named-argument labels (M0.8 E4, §3.3): runs parallel to call arg /// runs, `0` = positional slot. Referenced by `CallExpr.names_start` / /// `MethodCall.names_start` (`no_arg_names` = all-positional call). @@ -2671,6 +2701,7 @@ pub const AstArena = struct { self.method_calls.deinit(gpa); self.struct_lits.deinit(gpa); self.struct_lit_fields.deinit(gpa); + self.spawn_structs.deinit(gpa); self.call_arg_names.deinit(gpa); self.loop_exprs.deinit(gpa); self.string_interps.deinit(gpa); @@ -3288,6 +3319,30 @@ pub const AstArena = struct { return try self.addExpr(gpa, .struct_lit, idx, span); } + /// `spawn(C1 {…}, …)` — component-literal varargs (M1.0.10, §3.2). `components` + /// is a slice of struct-lit `NodeId.raw()` values, bulk-appended to + /// `arena.extra` as a contiguous run (the `addArrayLit` convention). + pub fn addSpawnStructComponents(self: *AstArena, gpa: std.mem.Allocator, components: []const u32, span: SourceSpan) !NodeId { + const start: u32 = @intCast(self.extra.items.len); + try self.extra.appendSlice(gpa, components); + const idx: u32 = @intCast(self.spawn_structs.items.len); + try self.spawn_structs.append(gpa, .{ + .is_prefab = false, + .args_start = start, + .args_len = @intCast(components.len), + }); + return try self.addExpr(gpa, .spawn_struct, idx, span); + } + + /// `spawn("Prefab")` — prefab-name form (M1.0.10, §3.2). `prefab_name` is the + /// interned string literal. Parses + is recognized; REFUSED at type-check in + /// Phase 1 (E2). + pub fn addSpawnStructPrefab(self: *AstArena, gpa: std.mem.Allocator, prefab_name: StringId, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.spawn_structs.items.len); + try self.spawn_structs.append(gpa, .{ .is_prefab = true, .prefab_name = prefab_name }); + return try self.addExpr(gpa, .spawn_struct, idx, span); + } + /// `return [expr]` (M0.8 E2). The value `NodeId` is stored directly in the /// statement's `data` (`NodeId.none` for a bare `return`), no side slab — /// same encoding as `expr_stmt`. diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 579e2d6..4f1d73b 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -55,6 +55,11 @@ pub const DiagnosticCode = enum { resource_expected_component_given, // M0.8 — E0301 ResourceExpectedComponentGiven component_expected_resource_given, // M0.8 — E0302 ComponentExpectedResourceGiven resource_field_unknown, // M0.8 E7 — E0303 ResourceFieldUnknown (scene `resources` block field check) + // M1.0.10 E2 — structural-mutation refusals (structural-ECS family, §4.5). + spawn_handle_unavailable, // M1.0.10 E2 — E0304 SpawnHandleUnavailable (structural spawn result bound/used in a body; statement-position only, no body handle v0.6) + prefab_spawn_not_executable, // M1.0.10 E2 — E0305 PrefabSpawnNotExecutable (spawn("Name") recognized but gated on the prefab runtime; not executable Phase 1) + structural_component_field_unknown, // M1.0.10 E2 (completion) — E0306 StructuralComponentFieldUnknown (spawn/add component-literal field absent from the component decl) + structural_component_field_type_invalid, // M1.0.10 E2 (completion) — E0307 StructuralComponentFieldTypeInvalid (spawn/add component-literal field value type mismatch) // ── Annotation errors (E0500-E0599) ── annotation_misapplied, // M0.8 — E0502 AnnotationMisapplied @@ -380,6 +385,10 @@ pub const DiagnosticCode = enum { .resource_expected_component_given => "E0301", .component_expected_resource_given => "E0302", .resource_field_unknown => "E0303", + .spawn_handle_unavailable => "E0304", + .prefab_spawn_not_executable => "E0305", + .structural_component_field_unknown => "E0306", + .structural_component_field_type_invalid => "E0307", .annotation_misapplied => "E0502", .bound_not_satisfied => "E0601", .generic_type_annotation_required => "E0603", @@ -564,6 +573,10 @@ pub const DiagnosticCode = enum { .resource_expected_component_given => "ResourceExpectedComponentGiven", .component_expected_resource_given => "ComponentExpectedResourceGiven", .resource_field_unknown => "ResourceFieldUnknown", + .spawn_handle_unavailable => "SpawnHandleUnavailable", + .prefab_spawn_not_executable => "PrefabSpawnNotExecutable", + .structural_component_field_unknown => "StructuralComponentFieldUnknown", + .structural_component_field_type_invalid => "StructuralComponentFieldTypeInvalid", .annotation_misapplied => "AnnotationMisapplied", .bound_not_satisfied => "BoundNotSatisfied", .generic_type_annotation_required => "GenericTypeAnnotationRequired", diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 75d956c..f58ad8e 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -8,7 +8,11 @@ //! Boundaries (cf. `briefs/S4-etch-tree-walking-interpreter.md` Out-of-scope): //! - No HIR — walks the AST directly. //! - No bytecode VM. -//! - No structural mutation (`spawn`, `despawn`, `add(T)`, `remove(T)`). +//! - Structural mutation (`spawn`, `despawn`, `add(T)`, `remove(T)`) is a +//! DEFERRED change (M1.0.10): a rule / observer / hook body enqueues a Tier-0 +//! `CommandBuffer` command onto `world.observer_registry.deferred`; it applies +//! at the tick boundary via `applyWithObservers` (firing observers per op), +//! never mid-`iterateArchetype`. //! - No job system use; rules run sequentially on the calling thread. //! - `ExprKind.path` and `ExprKind.tag_path` produce `RuntimeError.UnsupportedExpr`. @@ -1348,6 +1352,10 @@ pub const Interpreter = struct { // Apply deferred extension activate/deactivate at the same boundary // (M1.0.9 B1) — same never-mid-walk discipline. try self.flushPendingExtensions(world); + // Apply deferred structural mutations (spawn/despawn/add/remove) last, so + // any extension hook's structural change (enqueued just above) drains in + // the same boundary, with observers firing per op (M1.0.10 E3). + try self.flushStructural(world); } fn runRule(self: *Interpreter, world: *World, rd: *RuleDesc, report: *RuntimeReport) !void { @@ -2468,6 +2476,67 @@ pub const Interpreter = struct { try self.pending_extensions.append(self.gpa, .{ .entity = entity, .name = name_dup, .bytes = bytes, .op = op }); } + /// M1.0.10 E3 — the Tier-0 `CommandBuffer` that a body's structural + /// mutations (`spawn` / `despawn` / `add` / `remove`) enqueue onto. Inside an + /// observer / hook body `observer_deferred` is already bound to it; in a + /// plain rule body it is the world's shared observer-deferred buffer (lazily + /// created, owned + freed by the `ObserverRegistry`). The four ops route here + /// — NOT through a parallel `pending_*` queue (cf. `etch-grammar.md` §4.5) — + /// and the tick-boundary `flushStructural` drains it via `applyWithObservers`. + fn structuralDeferred(self: *Interpreter, world: *World) StmtError!*CommandBuffer { + if (self.observer_deferred) |d| return d; + if (world.observer_registry.deferred == null) { + world.observer_registry.deferred = CommandBuffer.init(self.gpa, world); + } + return &world.observer_registry.deferred.?; + } + + /// M1.0.10 E3 — resolve a component literal `T { f: v, … }` to its registry id + /// + a freshly built payload (component defaults overwritten by the provided + /// fields, evaluated EAGERLY now). Bytes are allocated in `alloc` (the + /// deferred buffer's arena) so they survive until the tick-boundary drain. + /// Components are POD-strict (no heap fields), so `writeValueAsBytes` covers + /// every valid field kind. The type-checker (E2) has already validated the + /// type is a declared component and the fields exist + type-match. + fn buildComponentPayload(self: *Interpreter, world: *World, locals: *Locals, struct_lit_arg: NodeId, alloc: std.mem.Allocator) StmtError!struct { cid: ComponentId, bytes: []u8 } { + if (self.ast.exprKind(struct_lit_arg) != .struct_lit) return error.RuntimeFailure; + const sl = self.ast.struct_lits.items[self.ast.exprData(struct_lit_arg)]; + const name = self.ast.strings.slice(sl.type_name); + const cid = world.registry.idOf(name) orelse return error.RuntimeFailure; + const size = world.registry.componentSize(cid); + const buf = try alloc.alloc(u8, size); + @memcpy(buf, world.registry.componentDefaultBytes(cid)); + var i: u32 = 0; + while (i < sl.fields_len) : (i += 1) { + const flit = self.ast.struct_lit_fields.items[sl.fields_start + i]; + const fd = world.registry.findField(cid, self.ast.strings.slice(flit.name)) orelse return error.RuntimeFailure; + const v = try self.evalExpr(world, locals, flit.value); + const fsize: u16 = @intCast(fd.kind.sizeBytes()); + const field_bytes = buf[fd.offset .. fd.offset + fsize]; + bridge_mod.writeValueAsBytes(fd.kind, field_bytes, v) catch return error.RuntimeFailure; + } + return .{ .cid = cid, .bytes = buf }; + } + + /// M1.0.10 E3 — drain the deferred structural commands at the tick boundary, + /// applying each via `applyWithObservers` so observers fire per op (spawn → + /// `on_spawned` + `on_add`; despawn → `on_remove` + `on_despawned`; + /// add-on-present → `on_replaced`; add → `on_add`; remove → `on_remove`). + /// Drains until empty: an observer body may itself enqueue more structural + /// commands (routed back to the same buffer), and those apply in a later + /// round of this same boundary. Never runs mid-`iterateArchetype`. + fn flushStructural(self: *Interpreter, world: *World) !void { + const reg = &world.observer_registry; + if (reg.deferred == null) return; + while (reg.deferred.?.commands.items.len > 0) { + const batch = try reg.deferred.?.commands.toOwnedSlice(reg.deferred.?.gpa); + defer reg.deferred.?.gpa.free(batch); + for (batch) |c| try observers_mod.applyWithObservers(c, reg, world, self.gpa); + } + // All commands applied — reclaim the payload arena. + reg.deferred.?.reset(); + } + /// Dispatch an instance method call on an already-evaluated receiver /// value — §5.5 order: inherent / trait on user types, then the builtin /// string / collection subsets. Split from the `.method_call` arm so the @@ -2514,6 +2583,44 @@ pub const Interpreter = struct { } return Value{ .array_ref = handle }; } + // M1.0.10 — structural mutation methods on an Entity receiver + // (`etch-grammar.md` §4.5). Each ENQUEUES a Tier-0 `CommandBuffer` + // command onto the deferred buffer (never an immediate mutation — + // we may be mid-`iterateArchetype`); the tick-boundary + // `flushStructural` applies it with observers. All return `unit`. + if (std.mem.eql(u8, mname, "despawn")) { + if (mc.args_len != 0) return error.RuntimeFailure; + const dbuf = try self.structuralDeferred(world); + try dbuf.commands.append(dbuf.gpa, .{ .despawn = .{ .entity = @bitCast(eid) } }); + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, mname, "add")) { + if (mc.args_len != 1) return error.RuntimeFailure; + const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); + const dbuf = try self.structuralDeferred(world); + const payload = try self.buildComponentPayload(world, locals, arg, dbuf.arena.allocator()); + try dbuf.commands.append(dbuf.gpa, .{ .add_component = .{ + .entity = @bitCast(eid), + .component_id = payload.cid, + .bytes = payload.bytes, + } }); + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, mname, "remove")) { + if (mc.args_len != 1) return error.RuntimeFailure; + // The argument is a bare component TYPE name (`.path`), not a + // value — resolve the id directly (do not `evalExpr` a path). + const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); + if (self.ast.exprKind(arg) != .path) return error.RuntimeFailure; + const cname = self.ast.strings.slice(self.ast.exprData(arg)); + const cid = world.registry.idOf(cname) orelse return error.RuntimeFailure; + const dbuf = try self.structuralDeferred(world); + try dbuf.commands.append(dbuf.gpa, .{ .remove_component = .{ + .entity = @bitCast(eid), + .component_id = cid, + } }); + return Value{ .unit = {} }; + } // Trait method on an Entity (`impl Trait for Entity`). The // type key is the interned `Entity`; self is the handle. const entity_name = self.ast.strings.find("Entity") orelse return error.RuntimeFailure; @@ -3298,6 +3405,29 @@ pub const Interpreter = struct { if (ife.else_branch.isNone()) return Value{ .unit = {} }; return try self.evalExpr(world, locals, ife.else_branch); }, + .spawn_struct => { + // Structural `spawn(C1 {…}, …)` (M1.0.10, `etch-grammar.md` §3.2 / + // §4.5) — a statement-position expression (the type-checker rejects + // value use, E0304). Resolve each component literal's bytes EAGERLY + // now, enqueue a single deferred `.spawn` command, and yield `unit` + // (no body handle, v0.6). The prefab form is refused at type-check + // (E0305) — never executed. + const ss = self.ast.spawn_structs.items[data]; + if (ss.is_prefab) return error.RuntimeFailure; + const dbuf = try self.structuralDeferred(world); + const arena_alloc = dbuf.arena.allocator(); + const ids = try arena_alloc.alloc(ComponentId, ss.args_len); + const payloads = try arena_alloc.alloc([]const u8, ss.args_len); + var i: u32 = 0; + while (i < ss.args_len) : (i += 1) { + const arg: NodeId = @bitCast(self.ast.extra.items[ss.args_start + i]); + const payload = try self.buildComponentPayload(world, locals, arg, arena_alloc); + ids[i] = payload.cid; + payloads[i] = payload.bytes; + } + try dbuf.commands.append(dbuf.gpa, .{ .spawn = .{ .component_ids = ids, .payloads = payloads } }); + return Value{ .unit = {} }; + }, else => return error.RuntimeFailure, // path / tag_path / unsupported variants } } @@ -7745,3 +7875,361 @@ test "execHookText emit enqueues into the dynamic event store (M1.0.9 E2)" { // The hook's `emit` landed in the per-tick dynamic event store. try std.testing.expectEqual(@as(usize, 1), interp.events.list.items.len); } + +// ── M1.0.10 E3 — structural mutation in bodies ──────────────────────────── + +/// E3 test helper — parse + type-check a program, asserting both clean, and +/// return the `ParseResult` (caller owns; `deinit` after the interp). +fn checkCleanProgram(gpa: std.mem.Allocator, source: []const u8) !parser_mod.ParseResult { + var pr = try parser_mod.parse(gpa, source); + errdefer pr.deinit(gpa); + if (pr.diagnostics.len != 0) { + std.debug.print("unexpected parse diag: {s}\n", .{pr.diagnostics[0].primary_message}); + return error.UnexpectedParseDiagnostic; + } + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + if (diags.items.len != 0) { + std.debug.print("unexpected typecheck diag: {s}\n", .{diags.items[0].primary_message}); + return error.UnexpectedTypecheckDiagnostic; + } + return pr; +} + +/// Count live entities whose archetype carries `cid`. +fn countEntitiesWith(world: *World, cid: ComponentId) usize { + var total: usize = 0; + for (world.archetypes.items) |arch| { + if (arch.componentIndex(cid) == null) continue; + for (arch.chunks.items) |chunk| total += chunk.header().entity_count; + } + return total; +} + +/// The first live entity carrying `cid`, or `null` if none. +fn firstEntityWith(world: *World, cid: ComponentId) ?CoreEntityId { + for (world.archetypes.items) |arch| { + if (arch.componentIndex(cid) == null) continue; + for (arch.chunks.items) |chunk| { + if (chunk.header().entity_count > 0) return arch.entityIdsConst(chunk)[0]; + } + } + return null; +} + +fn readI32(world: *World, entity: CoreEntityId, cid: ComponentId, field: []const u8) ?i32 { + const fd = world.registry.findField(cid, field) orelse return null; + const bytes = world.componentBytes(entity, cid) orelse return null; + var v: i32 = 0; + @memcpy(std.mem.asBytes(&v), bytes[fd.offset .. fd.offset + @sizeOf(i32)]); + return v; +} + +test "spawn defers — entity materializes at flush with full payload (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Trigger { t: i32 = 0 } + \\component Health { max: i32 = 0 } + \\rule r(entity: Entity) when entity has Trigger { + \\ spawn(Health { max: 10 }) + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const trigger = world.registry.idOf("Trigger").?; + const health = world.registry.idOf("Health").?; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{trigger}); + try std.testing.expectEqual(@as(usize, 1), world.entityCount()); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + + // Materialized at the flush: a new entity carries Health { max: 10 }. + try std.testing.expectEqual(@as(usize, 2), world.entityCount()); + try std.testing.expectEqual(@as(usize, 1), countEntitiesWith(&world, health)); + const spawned = firstEntityWith(&world, health).?; + try std.testing.expectEqual(@as(i32, 10), readI32(&world, spawned, health, "max").?); +} + +test "despawn defers — entity removed at flush (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Doomed { d: i32 = 0 } + \\rule r(entity: Entity) when entity has Doomed { + \\ entity.despawn() + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const doomed = world.registry.idOf("Doomed").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{doomed}); + try std.testing.expect(world.dynamicLocation(e) != null); // live before the tick + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expect(world.dynamicLocation(e) == null); // gone after the flush + try std.testing.expectEqual(@as(usize, 0), world.entityCount()); +} + +test "add defers — component present at flush (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Marker { m: i32 = 0 } + \\component Shield { amount: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.add(Shield { amount: 7 }) + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const marker = world.registry.idOf("Marker").?; + const shield = world.registry.idOf("Shield").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{marker}); + try std.testing.expect(world.componentBytes(e, shield) == null); // absent before + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expect(world.componentBytes(e, shield) != null); // present after + try std.testing.expectEqual(@as(i32, 7), readI32(&world, e, shield, "amount").?); +} + +test "remove defers — component gone at flush (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Marker { m: i32 = 0 } + \\component Poison { dmg: i32 = 0 } + \\rule r(entity: Entity) when entity has Poison { + \\ entity.remove(Poison) + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const marker = world.registry.idOf("Marker").?; + const poison = world.registry.idOf("Poison").?; + const e = try world.spawnDynamic(gpa, &[_]ComponentId{ marker, poison }); + try std.testing.expect(world.componentBytes(e, poison) != null); // present before + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expect(world.componentBytes(e, poison) == null); // gone after + try std.testing.expect(world.componentBytes(e, marker) != null); // Marker kept +} + +test "spawn fires on_spawned then on_add per component at flush (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Trigger { t: i32 = 0 } + \\component Health { max: i32 = 0 } + \\event Sp { x: i32 = 0 } + \\event Ad { x: i32 = 0 } + \\@on_spawned + \\rule on_sp(entity: Entity) { emit Sp { x: 1 } } + \\@on_added(Health) + \\rule on_ad(entity: Entity, value: Health) { emit Ad { x: value.max } } + \\rule spawner(entity: Entity) when entity has Trigger { + \\ spawn(Health { max: 10 }) + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const trigger = world.registry.idOf("Trigger").?; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{trigger}); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + + const sp_id = pr.ast.strings.find("Sp").?; + const ad_id = pr.ast.strings.find("Ad").?; + try std.testing.expectEqual(@as(usize, 1), interp.events.count(sp_id)); + try std.testing.expectEqual(@as(usize, 1), interp.events.count(ad_id)); + // Documented order: on_spawned BEFORE on_add (Tier-0 `applyWithObservers`). + try std.testing.expectEqual(@as(usize, 2), interp.events.list.items.len); + try std.testing.expectEqual(sp_id, interp.events.list.items[0].type_name); + try std.testing.expectEqual(ad_id, interp.events.list.items[1].type_name); +} + +test "despawn fires on_remove per component then on_despawned (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Health { current: i32 = 0 } + \\event Rm { v: i32 = 0 } + \\event Ds { v: i32 = 0 } + \\@on_removed(Health) + \\rule on_rm(entity: Entity, old: Health) { emit Rm { v: old.current } } + \\@on_despawned + \\rule on_ds(entity: Entity) { emit Ds { v: 1 } } + \\rule killer(entity: Entity) when entity has Health { + \\ entity.despawn() + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const health = world.registry.idOf("Health").?; + var hv: i32 = 42; + _ = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{health}, &[_][]const u8{std.mem.asBytes(&hv)}); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + + const rm_id = pr.ast.strings.find("Rm").?; + const ds_id = pr.ast.strings.find("Ds").?; + try std.testing.expectEqual(@as(usize, 2), interp.events.list.items.len); + // Documented order: on_remove (per component) BEFORE on_despawned. + try std.testing.expectEqual(rm_id, interp.events.list.items[0].type_name); + try std.testing.expectEqual(ds_id, interp.events.list.items[1].type_name); + // The component is readable in on_remove, pre-destruction (current = 42). + const v_id = pr.ast.strings.find("v").?; + var seen: i64 = -1; + for (interp.events.list.items[0].fields.items) |f| { + if (f.name == v_id) seen = f.value.int_; + } + try std.testing.expectEqual(@as(i64, 42), seen); +} + +test "add-on-present fires on_replaced not on_added (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Health { current: i32 = 0 } + \\event Rp { o: i32 = 0, n: i32 = 0 } + \\event Ad { x: i32 = 0 } + \\@on_replaced(Health) + \\rule on_rp(entity: Entity, old: Health, new: Health) { emit Rp { o: old.current, n: new.current } } + \\@on_added(Health) + \\rule on_ad(entity: Entity, value: Health) { emit Ad { x: 1 } } + \\rule replacer(entity: Entity) when entity has Health { + \\ entity.add(Health { current: 9 }) + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const health = world.registry.idOf("Health").?; + var hv: i32 = 5; + _ = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{health}, &[_][]const u8{std.mem.asBytes(&hv)}); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + + // add-on-present is a replace: on_replaced fires (old=5, new=9), on_added does not. + try std.testing.expectEqual(@as(usize, 1), interp.events.count(pr.ast.strings.find("Rp").?)); + try std.testing.expectEqual(@as(usize, 0), interp.events.count(pr.ast.strings.find("Ad").?)); + const o_id = pr.ast.strings.find("o").?; + const n_id = pr.ast.strings.find("n").?; + var o_seen: i64 = -1; + var n_seen: i64 = -1; + for (interp.events.list.items[0].fields.items) |f| { + if (f.name == o_id) o_seen = f.value.int_; + if (f.name == n_id) n_seen = f.value.int_; + } + try std.testing.expectEqual(@as(i64, 5), o_seen); + try std.testing.expectEqual(@as(i64, 9), n_seen); +} + +test "B1 — multi-entity rule structural mutation defers without corrupting iteration (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Marker { m: i32 = 0 } + \\component Shield { x: i32 = 0 } + \\component Spawned { id: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.add(Shield { x: 1 }) + \\ spawn(Spawned { id: 7 }) + \\ entity.despawn() + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const marker = world.registry.idOf("Marker").?; + const spawned = world.registry.idOf("Spawned").?; + const shield = world.registry.idOf("Shield").?; + var i: usize = 0; + while (i < 3) : (i += 1) _ = try world.spawnDynamic(gpa, &[_]ComponentId{marker}); + try std.testing.expectEqual(@as(usize, 3), world.entityCount()); + + // The full live archetype walk (3 entities) runs with no corruption; every + // effect applies at the tick's flush. + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + + // The 3 Marker entities were despawned (with Shield added first, then gone); + // 3 new Spawned entities exist. Net entity count unchanged. + try std.testing.expectEqual(@as(usize, 0), countEntitiesWith(&world, marker)); + try std.testing.expectEqual(@as(usize, 0), countEntitiesWith(&world, shield)); + try std.testing.expectEqual(@as(usize, 3), countEntitiesWith(&world, spawned)); + try std.testing.expectEqual(@as(usize, 3), world.entityCount()); +} + +test "S4 structural-mutation boundary lifted — a body issuing all four ops runs (M1.0.10 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // The S4 header no longer claims spawn/despawn/add/remove are unsupported; + // a body issuing all four runs with zero runtime errors (no UnsupportedExpr). + var pr = try checkCleanProgram(gpa, + \\component Marker { m: i32 = 0 } + \\component Poison { dmg: i32 = 0 } + \\component Shield { x: i32 = 0 } + \\component Spawned { id: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.add(Shield { x: 1 }) + \\ entity.remove(Poison) + \\ spawn(Spawned { id: 1 }) + \\ entity.despawn() + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const marker = world.registry.idOf("Marker").?; + const poison = world.registry.idOf("Poison").?; + _ = try world.spawnDynamic(gpa, &[_]ComponentId{ marker, poison }); + + const report = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), report.runtime_errors); + try std.testing.expectEqual(@as(usize, 1), countEntitiesWith(&world, world.registry.idOf("Spawned").?)); +} diff --git a/src/etch/parser.zig b/src/etch/parser.zig index d3c5d09..410625b 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -6387,6 +6387,62 @@ pub const Parser = struct { }, .{ .byte_start = kw_span.byte_start, .byte_end = fut_span.byte_end }); } + /// Parse the structural spawn expression `spawn(...)` (§3.2 + /// `structural_spawn`, M1.0.10). The token after `spawn` disambiguates: + /// `spawn (` → STRUCTURAL spawn — `spawn(C1 {…}, …)` component-literal + /// varargs, or `spawn("Prefab")` prefab name. + /// `spawn {` → the async task form (§4.2 `spawn_stmt`), owned by M1.0.11 — + /// emit a clear fail-loud diagnostic rather than mis-parsing. + /// This is the seam M1.0.11 fills. + /// Statement-position only (no body handle, §4.5) is enforced by the + /// type-checker (M1.0.10 E2); the parser produces the node in any expression + /// position. The prefab form parses + is recognized but is refused at + /// type-check in Phase 1 (E2). + fn parseStructuralSpawn(self: *Parser) ParseError!NodeId { + const kw_span = (try self.advance()).span; // 'spawn' + if (self.peek() == .lbrace) { + // The `{` (async) branch — owned by M1.0.11. Fail loud with a precise + // message instead of mis-parsing it as a structural spawn. + return self.parseErr(self.peekSpan(), "the async 'spawn { ... }' task is not yet executable (M1.0.11); only structural 'spawn(...)' is supported in M1.0.10"); + } + if (self.peek() != .lparen) { + return self.parseErrFmt(self.peekSpan(), "expected '(' to open a structural spawn (or '{{' for an async task), got '{s}'", .{self.sliceOf(self.peekSpan())}); + } + _ = try self.advance(); // '(' + // Prefab-name form `spawn("Name")` (§3.2 `spawn_arg = … | STRING_LITERAL`). + if (self.peek() == .string_literal) { + const str_tok = try self.advance(); + const name = try self.internStringLiteral(str_tok.span); + const closing = try self.expect(.rparen, "expected ')' to close spawn(\"...\")"); + return try self.arena.addSpawnStructPrefab(self.gpa, name, .{ + .byte_start = kw_span.byte_start, + .byte_end = closing.span.byte_end, + }); + } + // Component-literal varargs `spawn(C1 {…}, C2 {…}, …)` — each arg is a + // `struct_literal` (`TYPE_IDENT struct_literal_body`); an anonymous + // `.{…}` carries no component type and is rejected here. + var components: std.ArrayListUnmanaged(u32) = .empty; + defer components.deinit(self.gpa); + while (true) { + const ty = try self.expect(.type_ident, "expected a component literal 'T { ... }' or a prefab name string in spawn(...)"); + const type_name = try self.internSlice(ty.span); + if (self.peek() != .lbrace) { + return self.parseErrFmt(self.peekSpan(), "expected '{{' to open the component literal body after '{s}' in spawn(...)", .{self.sliceOf(ty.span)}); + } + const lit = try self.parseStructLiteral(type_name, ty.span); + try components.append(self.gpa, lit.raw()); + if (!try self.match(.comma)) break; + // Trailing comma allowed: `spawn(A {}, )`. + if (self.peek() == .rparen) break; + } + const closing = try self.expect(.rparen, "expected ')' to close spawn(...)"); + return try self.arena.addSpawnStructComponents(self.gpa, components.items, .{ + .byte_start = kw_span.byte_start, + .byte_end = closing.span.byte_end, + }); + } + fn parsePrimary(self: *Parser) ParseError!NodeId { try self.surfaceTokenErrors(); switch (self.peek()) { @@ -6394,6 +6450,7 @@ pub const Parser = struct { .kw_loop => return try self.parseLoop(0), .kw_if => return try self.parseIf(), .kw_await => return try self.parseAwaitExpr(), + .kw_spawn => return try self.parseStructuralSpawn(), .lbrace => return try self.parseBlockExpr(), .lbracket => return try self.parseArrayOrMapLiteral(), .pipe => return try self.parseClosure(), @@ -7069,6 +7126,82 @@ test "parser builds method-call postfix into the reserved method_call kind (M0.8 try std.testing.expectEqual(@as(usize, 2), calculate_args); // (target, 5) } +test "structural spawn parses component-literal varargs (M1.0.10)" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\rule r() { + \\ spawn(Projectile { speed: 20.0 }, Velocity { value: [0, 0, 1] }) + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + var spawns: usize = 0; + var ss: ast_mod.SpawnStructExpr = undefined; + for (result.ast.exprs.items(.kind), 0..) |k, i| { + if (k == .spawn_struct) { + spawns += 1; + ss = result.ast.spawn_structs.items[result.ast.exprs.items(.data)[i]]; + } + } + try std.testing.expectEqual(@as(usize, 1), spawns); + try std.testing.expect(!ss.is_prefab); + try std.testing.expectEqual(@as(u32, 2), ss.args_len); + // Each component arg is a `struct_lit` reachable from `arena.extra`. + const arg0: ast_mod.NodeId = @bitCast(result.ast.extra.items[ss.args_start]); + const arg1: ast_mod.NodeId = @bitCast(result.ast.extra.items[ss.args_start + 1]); + try std.testing.expectEqual(ast_mod.ExprKind.struct_lit, result.ast.exprKind(arg0)); + try std.testing.expectEqual(ast_mod.ExprKind.struct_lit, result.ast.exprKind(arg1)); +} + +test "structural spawn parses a prefab name (M1.0.10)" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\rule r() { + \\ spawn("Goblin") + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + var found = false; + var ss: ast_mod.SpawnStructExpr = undefined; + for (result.ast.exprs.items(.kind), 0..) |k, i| { + if (k == .spawn_struct) { + found = true; + ss = result.ast.spawn_structs.items[result.ast.exprs.items(.data)[i]]; + } + } + try std.testing.expect(found); + try std.testing.expect(ss.is_prefab); + try std.testing.expectEqualStrings("Goblin", result.ast.strings.slice(ss.prefab_name)); + try std.testing.expectEqual(@as(u32, 0), ss.args_len); +} + +test "spawn brace form is the async seam (M1.0.11 diagnostic, M1.0.10)" { + const gpa = std.testing.allocator; + // The async `spawn { }` task form (§4.2) is owned by M1.0.11 — the parser + // emits a clear fail-loud diagnostic rather than mis-parsing it. + var result = try parse(gpa, + \\rule r() { + \\ spawn { } + \\} + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len > 0); + try std.testing.expectEqual(diag_mod.DiagnosticCode.parse_error, result.diagnostics[0].code); + // No structural spawn node was produced (the `{` branch errors before build). + var spawns: usize = 0; + for (result.ast.exprs.items(.kind)) |k| { + if (k == .spawn_struct) spawns += 1; + } + try std.testing.expectEqual(@as(usize, 0), spawns); +} + test "parser builds loops, labels, break value, and continue (M0.8 loop/break)" { const gpa = std.testing.allocator; var result = try parse(gpa, diff --git a/src/etch/token.zig b/src/etch/token.zig index f458d7d..5a99b48 100644 --- a/src/etch/token.zig +++ b/src/etch/token.zig @@ -110,6 +110,7 @@ pub const TokenKind = enum { kw_const, // top-level `const` declaration (M1.0.8 — graduated from non_s3_keywords; top-level only per part1 §4.5) kw_private, // `private` visibility modifier prefix on a declaration_body (M1.0.8 — graduated from non_s3_keywords; grammar §5.1) kw_test, // top-level `test "name" { ... }` block (M1.0.8 — graduated from non_s3_keywords; parse + validate only, no execution) + kw_spawn, // structural spawn expr `spawn(C{…})` (M1.0.10 — graduated from non_s3_keywords; §3.2 structural_spawn. The async `spawn { }` task form §4.2 stays M1.0.11, fail-loud at parse) // ── Primitive type keywords (lexed as kw_type_*) ── kw_int, @@ -277,6 +278,7 @@ pub const s3_keywords = [_]KeywordEntry{ .{ .lexeme = "const", .kind = .kw_const }, .{ .lexeme = "private", .kind = .kw_private }, .{ .lexeme = "test", .kind = .kw_test }, + .{ .lexeme = "spawn", .kind = .kw_spawn }, .{ .lexeme = "true", .kind = .bool_literal }, .{ .lexeme = "false", .kind = .bool_literal }, .{ .lexeme = "int", .kind = .kw_int }, @@ -315,13 +317,15 @@ pub const non_s3_keywords = [_][]const u8{ // ── Async machinery: `async` graduated with M0.8 E2 (`async fn` parsed; // interp E3, codegen Phase 2); `await` graduated with M0.8 E3 sub-slice B - // (`async rule`/`async fn` + `await` interpreted, codegen Phase 2). The - // concurrency algebra (`race`/`sync`/`spawn`) stays reserved (T2/T3, - // flagged for Review E3); `branch` graduated with the E4 quest slice - // (its async statement form keeps an explicit fail-loud parse error) ── + // (`async rule`/`async fn` + `await` interpreted, codegen Phase 2); + // `spawn` graduated with M1.0.10 — the STRUCTURAL `spawn(C{…})` expr + // (§3.2 structural_spawn) now lexes as `kw_spawn` (the async `spawn { }` + // task form §4.2 stays M1.0.11, fail-loud at parse). The remaining + // concurrency algebra (`race`/`sync`) stays reserved (T2/T3, flagged + // for Review E3); `branch` graduated with the E4 quest slice (its async + // statement form keeps an explicit fail-loud parse error) ── "race", "sync", - "spawn", // ── Timers / lifecycle (out of S3; `emit` graduated with E3 ECS layer; // `after` graduated with E4 routine triggers — the §4.3 timer @@ -381,3 +385,34 @@ test "const/private/test graduate to s3 keywords" { try std.testing.expect(isKeywordToken(.kw_private)); try std.testing.expect(isKeywordToken(.kw_test)); } + +test "spawn graduates to s3 keyword; race/sync stay reserved" { + // M1.0.10: `spawn` moves from the reserve list into `s3_keywords`, mapped + // to `kw_spawn`. The structural `spawn(C{…})` expr now lexes to a real + // keyword so the parser can dispatch it (the async `spawn { }` task form + // is fail-loud at parse, M1.0.11). `race` / `sync` stay reserved. + const T = struct { + fn s3Kind(lexeme: []const u8) ?TokenKind { + for (s3_keywords) |kw| { + if (std.mem.eql(u8, kw.lexeme, lexeme)) return kw.kind; + } + return null; + } + fn reserved(lexeme: []const u8) bool { + for (non_s3_keywords) |kw| { + if (std.mem.eql(u8, kw, lexeme)) return true; + } + return false; + } + }; + try std.testing.expectEqual(TokenKind.kw_spawn, T.s3Kind("spawn").?); + try std.testing.expect(!T.reserved("spawn")); + // The rest of the concurrency algebra stays reserved. + try std.testing.expect(T.s3Kind("race") == null); + try std.testing.expect(T.reserved("race")); + try std.testing.expect(T.s3Kind("sync") == null); + try std.testing.expect(T.reserved("sync")); + // `kw_spawn` sits inside the contiguous keyword range (tag-path contextual + // acceptance via `isKeywordToken`). + try std.testing.expect(isKeywordToken(.kw_spawn)); +} diff --git a/src/etch/types.zig b/src/etch/types.zig index e9ef0ef..c325518 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -4116,7 +4116,15 @@ pub const TypeChecker = struct { }, .expr_stmt => { const expr_id: NodeId = @bitCast(data); - _ = self.synthExpr(expr_id, ctx); + // A structural `spawn(...)` is statement-position only (§4.5, no + // body handle): check it here so the value-position refusal + // (E0304) in `synthExpr` does NOT fire on a legal statement use. + // Every other expression types normally. + if (self.arena.exprKind(expr_id) == .spawn_struct) { + _ = try self.checkSpawnStruct(expr_id, self.arena.exprData(expr_id), ctx, false); + } else { + _ = self.synthExpr(expr_id, ctx); + } }, .assert_stmt => { // `assert(cond[, msg])` — the condition must be bool (M0.8 @@ -4538,6 +4546,12 @@ pub const TypeChecker = struct { .loop_expr => return try self.synthLoop(data, ctx_opt), .block_expr => return try self.synthBlock(data, ctx_opt), .if_expr => return try self.synthIf(id, data, ctx_opt), + // Reaching a structural spawn through `synthExpr` means it is being + // used as a VALUE (let binding, field receiver, call argument) — the + // v0.6 refusal (E0304): structural spawn is statement-position only, + // no body handle (§4.5). `checkStmt` handles the legal statement + // position before this arm is reached. + .spawn_struct => return try self.checkSpawnStruct(id, data, ctx_opt, true), .paren => unreachable, // parser doesn't emit a paren node — it returns the inner expr else => return ResolvedType.unknown, } @@ -5301,6 +5315,78 @@ pub const TypeChecker = struct { } } + /// M1.0.10 E2 — type-check a structural `spawn(...)` node (`etch-grammar.md` + /// §3.2 / §4.5). The spawn is statement-position only (no body handle, v0.6): + /// `value_position` is `true` when reached through `synthExpr` (a value use: + /// `let e = spawn(…)`, `spawn(…).field`, `spawn(…)` as an argument) → E0304; + /// `checkStmt` passes `false` for the legal `expr_stmt` position. The + /// prefab-name form is recognized but refused (E0305, gated on the prefab + /// runtime). The component-literal form validates each argument names a + /// declared `component`. Returns `unknown` (statement effect, no value). + fn checkSpawnStruct(self: *TypeChecker, id: NodeId, data: u32, ctx_opt: ?*RuleCtx, value_position: bool) TypeError!ResolvedType { + _ = ctx_opt; + const ss = self.arena.spawn_structs.items[data]; + if (value_position) { + try self.emit(.spawn_handle_unavailable, .error_, self.arena.exprSpan(id), "structural spawn produces no usable handle in a body (v0.6) — call spawn(...) as a statement, do not bind or use its result", .{}); + } + if (ss.is_prefab) { + try self.emit(.prefab_spawn_not_executable, .error_, self.arena.exprSpan(id), "prefab spawn 'spawn(\"...\")' is not executable in Phase 1 (gating on the prefab runtime)", .{}); + return ResolvedType.unknown; + } + var i: u32 = 0; + while (i < ss.args_len) : (i += 1) { + const arg: NodeId = @bitCast(self.arena.extra.items[ss.args_start + i]); + try self.checkStructuralComponentLiteral(arg); + } + return ResolvedType.unknown; + } + + /// M1.0.10 E2 — validate one component-literal argument of `spawn(...)` / + /// `entity.add(...)`: it must be a `TYPE_IDENT { ... }` struct literal naming + /// a declared `component`, and its field set + field-value types must match + /// the component declaration. The `struct_lit` shares the `struct_lit_fields` + /// storage with a `ComponentInstance`, so the scene/prefab validation path + /// (`checkComponentInstance` → `checkInstanceField` / `checkInstanceFieldForeign`, + /// local + imported) is reused rather than reinvented — `weld check` (C1.6) + /// must catch the same field errors as scene/prefab. The node is NOT routed + /// through `synthStructLit` (which requires a `struct`, not a `component`). + fn checkStructuralComponentLiteral(self: *TypeChecker, arg: NodeId) TypeError!void { + if (self.arena.exprKind(arg) != .struct_lit) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "expected a component literal 'T {{ ... }}'", .{}); + return; + } + const sl = self.arena.struct_lits.items[self.arena.exprData(arg)]; + if (sl.type_name == 0) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "a component literal needs an explicit component type ('T {{ ... }}')", .{}); + return; + } + // A non-component type name surfaces through `code_type` (E0200 — the + // structural "not a component" path); declared component fields are + // checked with the dedicated E0306/E0307 codes. + const ci: ast_mod.ComponentInstance = .{ + .type_name = sl.type_name, + .fields_start = sl.fields_start, + .fields_len = sl.fields_len, + .span = self.arena.exprSpan(arg), + }; + try self.checkComponentInstance(ci, .type_mismatch, .structural_component_field_unknown, .structural_component_field_type_invalid); + } + + /// M1.0.10 E2 — validate a type name used in a structural mutation + /// (`entity.remove(T)` and the component-literal types of `spawn` / `add`) + /// resolves to a declared `component`. Mirrors the `when has` component + /// lookup (`self.symbols.get` + `SymbolKind.component`). + fn checkStructuralComponentName(self: *TypeChecker, name: StringId, span: SourceSpan) TypeError!void { + const tname = self.arena.strings.slice(name); + if (self.symbols.get(name)) |sym| { + if (sym.kind != .component) { + try self.emit(.type_mismatch, .error_, span, "structural mutation requires a component, '{s}' is a {s}", .{ tname, @tagName(sym.kind) }); + } + } else { + try self.emit(.type_mismatch, .error_, span, "unknown component '{s}' in structural mutation", .{tname}); + } + } + /// Dispatch an instance method call against an already-typed receiver /// (`etch-resolver-types.md §5.5` strict order: inherent → trait → /// builtin → service). Split from `synthMethodCall` so the optional @@ -5457,6 +5543,37 @@ pub const TypeChecker = struct { if (mc.args_len != 0) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "Entity method 'active_extensions' takes no arguments", .{}); return ResolvedType{ .array_dyn = .string_ }; } + // M1.0.10 — structural mutation methods on an `Entity` receiver + // (`etch-grammar.md` §4.5). All three are statement-effect (`unknown` + // return, like `array.push` / `activate_extension`); they enqueue a + // deferred command at run (E3). `add` on a present component is a + // replace (`@on_replaced`), no separate construct. + if (std.mem.eql(u8, method_slice, "despawn")) { + if (mc.args_len != 0) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "Entity method 'despawn' takes no arguments", .{}); + return ResolvedType.unknown; + } + if (std.mem.eql(u8, method_slice, "add")) { + if (mc.args_len != 1) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "Entity method 'add' takes exactly one component literal 'T {{ ... }}'", .{}); + } else { + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); + try self.checkStructuralComponentLiteral(arg); + } + return ResolvedType.unknown; + } + if (std.mem.eql(u8, method_slice, "remove")) { + if (mc.args_len != 1) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "Entity method 'remove' takes exactly one component type 'T'", .{}); + } else { + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); + if (self.arena.exprKind(arg) != .path) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "Entity method 'remove' expects a component type 'T'", .{}); + } else { + try self.checkStructuralComponentName(self.arena.exprData(arg), self.arena.exprSpan(arg)); + } + } + return ResolvedType.unknown; + } } const type_name: StringId = typeNameOfResolved(recv_t) orelse blk: { @@ -7549,6 +7666,130 @@ test "type-checker accepts `has T changed` on a component, E1210 on a non-compon try expectNoCode(cat.diagnostics.items, .unknown_tag); } +test "entity structural methods type-check on an Entity receiver (M1.0.10 E2)" { + const gpa = std.testing.allocator; + // `entity.add(T { … })` / `entity.remove(T)` / `entity.despawn()` on an + // Entity receiver with declared components → zero diagnostics. + var ok = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\component Shield { amount: i32 = 0 } + \\component Poisoned { stacks: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.add(Shield { amount: 5 }) + \\ entity.remove(Poisoned) + \\ entity.despawn() + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.diagnostics.items.len); +} + +test "structural spawn of component literals type-checks (M1.0.10 E2)" { + const gpa = std.testing.allocator; + var ok = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\component Projectile { speed: f32 = 0.0 } + \\component Velocity { vx: f32 = 0.0 } + \\rule r(entity: Entity) when entity has Marker { + \\ spawn(Projectile { speed: 20.0 }, Velocity { vx: 1.0 }) + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.diagnostics.items.len); +} + +test "binding a structural spawn result is rejected (M1.0.10 E2)" { + const gpa = std.testing.allocator; + // `let e = spawn(…)` uses the spawn result as a value → E0304 (no body + // handle, statement-position only, v0.6). + var bad = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\component Health { max: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ let e = spawn(Health { max: 10 }) + \\} + ); + defer bad.deinit(gpa); + try expectAnyCode(bad.diagnostics.items, .spawn_handle_unavailable); +} + +test "prefab-name spawn is refused in Phase 1 (M1.0.10 E2)" { + const gpa = std.testing.allocator; + // `spawn("Name")` parses + is recognized but is refused → E0305 (gating on + // the prefab runtime). + var bad = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ spawn("Goblin") + \\} + ); + defer bad.deinit(gpa); + try expectAnyCode(bad.diagnostics.items, .prefab_spawn_not_executable); +} + +test "structural component-literal unknown field is rejected (M1.0.10 E2 completion)" { + const gpa = std.testing.allocator; + // via spawn(...) + var s = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\component Health { max: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ spawn(Health { maxx: 10 }) + \\} + ); + defer s.deinit(gpa); + try expectAnyCode(s.diagnostics.items, .structural_component_field_unknown); + // via entity.add(...) + var a = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\component Health { max: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.add(Health { maxx: 10 }) + \\} + ); + defer a.deinit(gpa); + try expectAnyCode(a.diagnostics.items, .structural_component_field_unknown); +} + +test "structural component-literal mistyped field is rejected (M1.0.10 E2 completion)" { + const gpa = std.testing.allocator; + // `max: i32` given a float literal → E0307 (same field-type path as scene/prefab). + var bad = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\component Health { max: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.add(Health { max: 1.5 }) + \\} + ); + defer bad.deinit(gpa); + try expectAnyCode(bad.diagnostics.items, .structural_component_field_type_invalid); +} + +test "structural mutation of a non-component type is rejected (M1.0.10 E3 carry-over)" { + const gpa = std.testing.allocator; + // A resource (not a component) given to `entity.add(...)` → E0200 (the + // "not a declared component" path; scene/prefab collapse the same way). + var a = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\resource Conf { v: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.add(Conf { v: 1 }) + \\} + ); + defer a.deinit(gpa); + try expectAnyCode(a.diagnostics.items, .type_mismatch); + // Same for `entity.remove(T)` on a non-component type. + var rm = try parseAndCheck(gpa, + \\component Marker { x: i32 = 0 } + \\resource Conf { v: i32 = 0 } + \\rule r(entity: Entity) when entity has Marker { + \\ entity.remove(Conf) + \\} + ); + defer rm.deinit(gpa); + try expectAnyCode(rm.diagnostics.items, .type_mismatch); +} + test "type-checker validates tag mutations (M0.8 E3)" { const gpa = std.testing.allocator;