From 4a78221ccfc06b138d216584f3764904577cfc02 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 9 Nov 2026 14:52:19 -0700 Subject: [PATCH] feat(engine-mcp): a capability search finds a symbol by name and answers in one call (PRD-865) The index read only `situations` and `aliases`, so an exact symbol name answered `verdict: none`: `godrays`, `traa` and `mergeParts` are exported by the manifest and were unfindable, while `engine_capability_detail` cost a call per hit for a row that lacked only `signature`, `overrides` and `deprecated`. - The tokenizer splits camelCase, and a request that spells a name as a run of whole words leads with that symbol: whole-name equality is case- and separator-insensitive (`mergeparts`, `characterbody3d`), a node name inside a sentence counts (`GPUParticles3D sparks`, `Area3D trigger`), and the lead scales with how much of the request the name accounts for, with a longer name winning a tie. A name mentioned in passing cannot outrank the mechanic the request is about. - Names carry their own index (digits stripped, `GPUParticles3D` reads as `gpu particles`) and the summary is scored over it at low weight, which is what makes a thinly described entry like `traa` findable. Keeping it apart from the prose index is deliberate: folding it in cost `SnowField` its row in the recall budget. - A row equals the detail minus `situations`/`aliases`. `AGENTS.md`, `agent-docs/kit-router.md`, the shared `threenative-capabilities` skill and all sixteen kit `AGENTS.md`/`CLAUDE.md` now say read every row and call the detail only for a symbol the search did not return. - MCP text is compact JSON; indentation cost 11.8% of every response and the shell command re-indents its own copy. - A frozen gold set and a two-arm A/B: 150 real queries from 26 `.threenative/engine-mcp.log` files, 100 dev / 50 held out, held-out sha256 in the fixture and the PRD, 20 exact-symbol. Labels came from a fresh opus process that read only the logs and the manifest. `search-gold.spec.ts` locks the held-out floor; `scripts/capability-search-ab.ts` extracts the base source with `git archive`, measures both arms and runs a live `claude -p` arm. Against ecd2a1a56 on the frozen held-out half: recall@5 0.580 -> 0.780, recall@1 0.400 -> 0.620, 0 rows lost from the top 5, modelled tokens -47%. Live: MCP calls -56..62%, MCP response bytes -30..36%, correct-symbol rate unchanged, 0 failed runs. Two independent opus reviews; every finding fixed, including the sixteen scaffold trees re-measured on the merged tree. Also lands the develop red this PR was queued behind: the platformer kit gets the worker entry pair the starter shipped in #574 (`index.html` -> `src/worker-page.ts`, `main.html` for the page-thread arm, `src/worker.ts`, and the two-entry build). Its golden-path leg boots `index.html` and asserts threads on, so without the worker page the scaffolded game never booted and the leg reported `TN_PLAYTEST_BRIDGE_MISSING`. The platformer's game code is worker-safe (`render/loading.ts` guards its canvas behind `typeof document`). --- AGENTS.md | 2 +- CLAUDE.md | 2 +- ...h-finds-by-name-and-answers-in-one-call.md | 100 ++ packages/core/mcp/engine-server.mjs | 104 +- .../__tests__/scaffold.spec.ts | 37 +- .../agent-docs/kit-router.md | 2 +- .../skills/threenative-capabilities/SKILL.md | 11 +- .../create-threenative/engine-mcp-tools.json | 2 +- .../templates/action-rpg/AGENTS.md | 2 +- .../templates/action-rpg/CLAUDE.md | 2 +- .../templates/adventure/AGENTS.md | 2 +- .../templates/adventure/CLAUDE.md | 2 +- .../templates/collectathon/AGENTS.md | 2 +- .../templates/collectathon/CLAUDE.md | 2 +- .../templates/kart/AGENTS.md | 2 +- .../templates/kart/CLAUDE.md | 2 +- .../templates/minimal/AGENTS.md | 2 +- .../templates/minimal/CLAUDE.md | 2 +- .../templates/platformer/AGENTS.md | 4 +- .../templates/platformer/CLAUDE.md | 4 +- .../templates/platformer/index.html | 2 +- .../templates/platformer/main.html | 12 + .../templates/platformer/src/worker-page.ts | 29 + .../templates/platformer/src/worker.ts | 5 + .../templates/platformer/vite.config.ts | 2 + .../templates/puzzle/AGENTS.md | 2 +- .../templates/puzzle/CLAUDE.md | 2 +- .../templates/racing/AGENTS.md | 2 +- .../templates/racing/CLAUDE.md | 2 +- .../templates/rain/AGENTS.md | 2 +- .../templates/rain/CLAUDE.md | 2 +- .../templates/rts/AGENTS.md | 2 +- .../templates/rts/CLAUDE.md | 2 +- .../templates/runner/AGENTS.md | 2 +- .../templates/runner/CLAUDE.md | 2 +- .../templates/sailing/AGENTS.md | 2 +- .../templates/sailing/CLAUDE.md | 2 +- .../templates/shooter/AGENTS.md | 2 +- .../templates/shooter/CLAUDE.md | 2 +- .../templates/snow/AGENTS.md | 2 +- .../templates/snow/CLAUDE.md | 2 +- .../templates/starter/AGENTS.md | 2 +- .../templates/starter/CLAUDE.md | 2 +- .../templates/tower-defense/AGENTS.md | 2 +- .../templates/tower-defense/CLAUDE.md | 2 +- packages/engine-mcp/README.md | 10 +- .../__tests__/fixtures/search-gold.json | 1356 +++++++++++++++++ .../engine-mcp/__tests__/search-gold.spec.ts | 78 + packages/engine-mcp/__tests__/search.spec.ts | 74 + packages/engine-mcp/src/index.ts | 220 ++- scripts/__tests__/capability-manifest.spec.ts | 33 +- scripts/__tests__/capability-recall.spec.ts | 1 + scripts/capability-search-ab.ts | 818 ++++++++++ 53 files changed, 2890 insertions(+), 78 deletions(-) create mode 100644 docs/PRDs/done/PRD-865-capability-search-finds-by-name-and-answers-in-one-call.md create mode 100644 packages/create-threenative/templates/platformer/main.html create mode 100644 packages/create-threenative/templates/platformer/src/worker-page.ts create mode 100644 packages/create-threenative/templates/platformer/src/worker.ts create mode 100644 packages/engine-mcp/__tests__/fixtures/search-gold.json create mode 100644 packages/engine-mcp/__tests__/search-gold.spec.ts create mode 100644 scripts/capability-search-ab.ts diff --git a/AGENTS.md b/AGENTS.md index 85a6585afe..d08eb0f054 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro ## How you work -1. **Search the manifest before you write — by rule, not judgement.** Run `engine_search_capabilities`, then `engine_capability_detail` on every hit, before any new file under `src/` or `packages/`, any helper longer than a screen, and any render stage you toggle. Skipping it has shipped 446 hand-written lines of an installed system at 9 FPS. **Search the symptom too, before you debug it** (owner, 2026-10-24: "why this capability research wasnt done BEFORE having these issues"): a slow frame, limbs out of step with motion, a blurred or wrong image. Before you report a game done, play it from the spawn and read `TN_FRAME_BUDGET`, `TN_SCENE_WARNING` and `TN_RENDER_PROJECTION`. The manifest (`packages/create-threenative/capabilities.json`, rebuilt by `pnpm build`) is the only complete public surface, including subpath exports like `@threenative/physics/navigation`. Both tools ship in `threenative-engine-mcp`, wired by the `.mcp.json` that installing `@threenative/core` writes; launch from the game-project root or the server is missing. +1. **Search the manifest before you write — by rule, not judgement.** Run `engine_search_capabilities` and read every row — it carries the import path, signature, example, constraints, overrides and deprecations; call `engine_capability_detail` only for a symbol the search did not return — before any new file under `src/` or `packages/`, any helper longer than a screen, and any render stage you toggle. Skipping it has shipped 446 hand-written lines of an installed system at 9 FPS. **Search the symptom too, before you debug it** (owner, 2026-10-24: "why this capability research wasnt done BEFORE having these issues"): a slow frame, limbs out of step with motion, a blurred or wrong image. Before you report a game done, play it from the spawn and read `TN_FRAME_BUDGET`, `TN_SCENE_WARNING` and `TN_RENDER_PROJECTION`. The manifest (`packages/create-threenative/capabilities.json`, rebuilt by `pnpm build`) is the only complete public surface, including subpath exports like `@threenative/physics/navigation`. Both tools ship in `threenative-engine-mcp`, wired by the `.mcp.json` that installing `@threenative/core` writes; launch from the game-project root or the server is missing. For game behavior references, use `engine_search_references`, then `engine_reference_detail` for pinned source; `engine_save_reference` saves a local reference and returns a contribution command; `engine_watch_reference` returns a contact sheet of real gameplay footage. Then be lazy (the `ponytail` skill, re-injected by the project hooks; `PONYTAIL=off` opts out): YAGNI, installed system, stdlib/platform, one line. Never simplify away validation, error handling, security, accessibility or requested behaviour. 2. **Name the layer before fixing a bug**: engine (`packages/`) or game (example/template), and why. An engine bug fixed in game code leaves every other game broken. diff --git a/CLAUDE.md b/CLAUDE.md index 0e3807d653..ddb55b79cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro ## How you work -1. **Search the manifest before you write — by rule, not judgement.** Run `engine_search_capabilities`, then `engine_capability_detail` on every hit, before any new file under `src/` or `packages/`, any helper longer than a screen, and any render stage you toggle. Skipping it has shipped 446 hand-written lines of an installed system at 9 FPS. **Search the symptom too, before you debug it** (owner, 2026-10-24: "why this capability research wasnt done BEFORE having these issues"): a slow frame, limbs out of step with motion, a blurred or wrong image. Before you report a game done, play it from the spawn and read `TN_FRAME_BUDGET`, `TN_SCENE_WARNING` and `TN_RENDER_PROJECTION`. The manifest (`packages/create-threenative/capabilities.json`, rebuilt by `pnpm build`) is the only complete public surface, including subpath exports like `@threenative/physics/navigation`. Both tools ship in `threenative-engine-mcp`, wired by the `.mcp.json` that installing `@threenative/core` writes; launch from the game-project root or the server is missing. +1. **Search the manifest before you write — by rule, not judgement.** Run `engine_search_capabilities` and read every row — it carries the import path, signature, example, constraints, overrides and deprecations; call `engine_capability_detail` only for a symbol the search did not return — before any new file under `src/` or `packages/`, any helper longer than a screen, and any render stage you toggle. Skipping it has shipped 446 hand-written lines of an installed system at 9 FPS. **Search the symptom too, before you debug it** (owner, 2026-10-24: "why this capability research wasnt done BEFORE having these issues"): a slow frame, limbs out of step with motion, a blurred or wrong image. Before you report a game done, play it from the spawn and read `TN_FRAME_BUDGET`, `TN_SCENE_WARNING` and `TN_RENDER_PROJECTION`. The manifest (`packages/create-threenative/capabilities.json`, rebuilt by `pnpm build`) is the only complete public surface, including subpath exports like `@threenative/physics/navigation`. Both tools ship in `threenative-engine-mcp`, wired by the `.mcp.json` that installing `@threenative/core` writes; launch from the game-project root or the server is missing. For game behavior references, use `engine_search_references`, then `engine_reference_detail` for pinned source; `engine_save_reference` saves a local reference and returns a contribution command; `engine_watch_reference` returns a contact sheet of real gameplay footage. Then be lazy (the `ponytail` skill, re-injected by the project hooks; `PONYTAIL=off` opts out): YAGNI, installed system, stdlib/platform, one line. Never simplify away validation, error handling, security, accessibility or requested behaviour. 2. **Name the layer before fixing a bug**: engine (`packages/`) or game (example/template), and why. An engine bug fixed in game code leaves every other game broken. diff --git a/docs/PRDs/done/PRD-865-capability-search-finds-by-name-and-answers-in-one-call.md b/docs/PRDs/done/PRD-865-capability-search-finds-by-name-and-answers-in-one-call.md new file mode 100644 index 0000000000..bb3f5b32c0 --- /dev/null +++ b/docs/PRDs/done/PRD-865-capability-search-finds-by-name-and-answers-in-one-call.md @@ -0,0 +1,100 @@ +--- +prd_contract: v1 +--- + +# PRD-865 — Capability search finds a symbol by name and answers in one call + +**Status:** DONE — all three phases and all four acceptance criteria measured on the frozen held-out set and in the live run (see Decisions for how the live bytes split between the row change and the response serialization). +**Priority:** P1 — Every agent build starts with `engine_search_capabilities`; AC-3 and AC-4 are open while an exact symbol name (`godrays`, `traa`, `mergeParts`) returns `verdict: none` and each search costs 3.4 detail calls. +**Complexity:** 4 (MEDIUM) — 1–5 implementation files (1), new A/B harness module (+2), external CLI integration for the live arm (`claude -p`, +1). Risk override: none. +**Owner:** João +**Depends on:** None + +## Context + +Measured 2026-11-09 from 29 real `.threenative/engine-mcp.log` files on this machine (2 861 searches, 9 678 detail calls), replayed against `packages/engine-mcp/dist` at `ecd2a1a56`: + +- ✅ Only 4% of searches return no result. The tuned scorer (`packages/engine-mcp/src/index.ts:461-627`) works on situation text. +- ❌ The index reads only `situations` and `aliases`. An exact symbol name finds nothing: `godrays`, `traa`, `temporal anti-aliasing TRAA`, `mergeParts` and `sword slash trail arc effect` all return `verdict: none`, although `godrays`, `traa` and `mergeParts` are entries. +- ❌ Agents make 3.4 detail calls per search. The rule "`engine_capability_detail` on every hit" (`AGENTS.md:13`, `packages/create-threenative/agent-docs/kit-router.md:7`, rendered into every kit `AGENTS.md`) forces them. A row (`ICapabilitySearchResult`, `index.ts:62`) already carries `importPath`, `summary`, `example` and `constraints`, and lacks only `signature`, `overrides` and `deprecated`. A row is about 416 bytes at p50. A detail is about 798 bytes at p50, and `signature` is 75 bytes at p50. +- ⚠️ There is no clean test set. Log-derived labels favour the current ranker (62% recall@5), and the labels from searches whose follow-up detail was outside the results are mostly noise. A naive BM25 prototype scored 35% recall@5 on the same labels, so a rewrite of the ranker is not the plan. The plan is to add the missing fields to the index. + +Inspected: `packages/engine-mcp/src/index.ts` (scorer, tool definitions, call log), `packages/engine-mcp/__tests__/search.spec.ts` (66 cases), `packages/create-threenative/capabilities.json` (531 entries, 558 KB), `scripts/make-sandbox.ts` and `scripts/sweep-pair.ts` (framework-against-vanilla arms only, so they cannot compare two engine-mcp versions). + +## Solution + +1. **Gold set and A/B harness first.** `packages/engine-mcp/__tests__/fixtures/search-gold.json` holds about 150 real queries, each labelled with the correct symbols. 100 are `dev` (for tuning) and 50 are `heldout` (frozen by sha256 before Phase 2, and the implementer does not read them). It includes 20 exact-symbol queries. A fresh subagent that has not read the scorer writes the labels from the logs and the manifest. + `scripts/capability-search-ab.ts` loads arm A (engine-mcp `src/` at the pinned base, through `git archive` into a temp dir, imported with tsx; `src/` imports only `node:` modules) and arm B (the working tree). Both arms use the same manifest and the same gold set. The harness prints both source hashes and fails when they match (self-comparison guard). +2. **Index symbol names (option 1).** Split camelCase symbols into tokens, give an exact symbol match a lead score, and read `summary` at a low weight. Keep the IDF and agreement scorer. Do not rewrite it. +3. **Rows that answer (option 2).** Add `signature`, `overrides` and `deprecated` to each result row, so a row equals the detail minus `situations`/`aliases`. Change the rule to "read every hit's row; call `engine_capability_detail` only for a symbol that the search did not return". This keeps the owner's intent (no write before the detail is read) and removes the forced calls. The tool's text payload is compact JSON rather than indented: indentation cost 11.8% of every response and answered nothing, and `threenative engine_* …` from a shell re-indents its own copy, so the human-facing command stays readable. + +```mermaid +flowchart LR + G[search-gold.json
dev + heldout] --> AB[capability-search-ab.ts] + A["arm A: src at ecd2a1a56
(git archive)"] --> AB + B[arm B: working tree src] --> AB + AB --> O[offline: recall@1/@5, MRR,
tokens to answer] + AB --> L["live: claude -p per arm,
tool calls + MCP bytes"] + O --> R[artifacts/capability-search-ab.json] + L --> R +``` + +**Frozen 2026-11-09.** `packages/engine-mcp/__tests__/fixtures/search-gold.json` holds 150 queries: 100 `dev` / 50 `heldout`, 20 exact-symbol, 12 request-scope. A fresh opus process that read only the logs and the manifest wrote the labels; 58 candidate rows it judged engine-unowned were dropped before selection. Held-out sha256 `0cb517f250f2965a6c37fbb487a8d95b98277893dfe8389f7e57f52c199aa78c`, asserted by `search-gold.spec.ts` against both the fixture and this line. + +**Metrics (all printed per arm and as B minus A):** +- `recall@1`, `recall@5` and MRR on `heldout`, split by query kind (`exact-symbol`, `mechanic`, `request`). +- *Tokens to answer* (offline, modelled): the bytes of the real search response, plus the bytes of the real detail response for each top-5 row that the arm's rule makes the agent open, divided by 4. Arm A's rule opens every hit. Arm B's rule opens none when the gold symbol is in the rows. +- *Live*: `claude -p --model sonnet --strict-mcp-config --mcp-config .json --output-format stream-json` on 10 fixed briefs ("find the engine capability for X; print its import line and signature; write no code"), 2 runs per arm, from an empty temp dir with the arm's rule text as the prompt prefix. Counted: MCP tool calls, MCP response bytes, and whether the final answer names a gold symbol. + +**Risks.** (a) Overfitting to the gold set: the held-out split, frozen hash and blind labeller control it. (b) Larger rows make the search response grow: AC-4 counts the total bytes, not only the call count. (c) Symbol tokens such as `create` or `read` would match everything: they go through the same IDF weights, so a word common across symbols weighs almost nothing. + +## Acceptance Criteria + +- [x] AC-1 [local]: The A/B harness reports arm A against arm B on the frozen held-out set, with two different source hashes. proof: `pnpm tsx scripts/capability-search-ab.ts --base ecd2a1a56` — Evidence: exit 0, artifact `artifacts/capability-search-ab.json`; arm A `7189d6ef9600977a33e15a21d43903371d2856da5486b2d70d6050daa12e5b12` at ecd2a1a56, arm B `5aba15838af33b1f712daf03781804c1018715e187ec37bc374ad72790890d5e`, gold `0cb517f2`. +- [x] AC-2 [local]: A search for an exact symbol name returns that symbol first: `godrays`, `traa`, `mergeParts`, and all 20 exact-symbol gold queries. proof: `pnpm exec vitest run packages/engine-mcp/__tests__/search-gold.spec.ts` — Evidence: 20/20 return their symbol at rank 1 (the same case run against arm A's `index.ts` reports 18 misses), and the whole-name match is case- and separator-insensitive, so `mergeparts`, `characterbody3d` and `joint3d` answer too. +- [x] AC-3 [local]: On `heldout`, arm B recall@5 is at least arm A + 10 points, recall@1 is at least arm A + 5 points, and at most 1 query that arm A had in its top 5 drops out of arm B's top 5. proof: AC-1 command — Evidence: recall@5 0.580 → 0.780 (+0.200), recall@1 0.400 → 0.620 (+0.220), MRR 0.475 → 0.689, and 0 of arm A's top-5 rows dropped. `dev`, tuning only: recall@5 0.500 → 0.650, recall@1 0.360 → 0.500. +- [x] AC-4 [local]: Arm B lowers modelled tokens to answer by at least 30%. In the live run it lowers MCP tool calls and MCP response bytes by at least 30%, with an equal or higher correct-symbol rate. proof: AC-1 command plus `--live --runs 2` — Evidence: modelled tokens 2 884 → 1 537 on the 29 rows both arms answer (-47%; the candidate's own mean over the 39 rows it answers is 1 325). Live (10 briefs × 2 arms × 2 runs; three runs reported because the base arm varies with how many details the model opens): MCP tool calls 51 / 45 / 53 → 20 (-61%, -56%, -62%), MCP response bytes 182 555 / 167 620 / 177 322 → 116 496 / 116 496 / 114 660 (-36%, -30%, -35%), correct-symbol rate 1.00 every time, 0 failed runs. Arm B is the stable side: one search per brief. The stricter payload-only measure (reference stubs excluded, which every search also returns) is -25%, -19% and -26%; Decisions records how the live bytes split between the row change and the response serialization. The last run is on the merged tree (533-entry manifest). + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Symbol-name search | Agent → MCP `tools/call engine_search_capabilities` → `handleToolCall` → `searchCapabilities` (`index.ts:790`); fill final `file:line` | Same scorer, plus a symbol-name channel and a low-weight summary channel over their own index | AC-2 (20/20 at rank 1), AC-3 (+0.180 recall@5) | +| Rows that answer | Same path; row built in `searchMechanics` (`index.ts:864`); kit rule in `kit-router.md:7` → rendered kit `AGENTS.md` | Rule "detail on every hit" becomes "detail for symbols not in the rows" | AC-4 (calls -61%, modelled tokens -54%, MCP bytes -36%) | + +## Decisions + +- 2026-11-09 (João): Do options 1, 2 and 3 in one PRD, with a dedicated before/after A/B that proves the gains. +- 2026-11-09 (agent): Keep the JSON manifest and the current scorer. No database and no embeddings. A BM25 prototype lost to the current scorer (35% against 62% recall@5 on log labels), and 531 entries load in milliseconds. Semantic search waits until the gold set shows synonym misses that this PRD does not fix. +- 2026-11-09 (agent): No batch `symbols[]` input on `engine_capability_detail`. Rows that carry the detail remove most calls. Add a batch input only if AC-4's live run still shows several detail calls per search. +- 2026-11-09 (agent): The base arm is pinned at `ecd2a1a56`, the develop HEAD when this PRD was filed. `packages/engine-mcp/src` there equals its last change, `86d753d9b`. +- 2026-11-09 (agent): The label writer ran as a fresh `claude -p --model opus` process in an empty directory with only the logs and the manifest, not as a `subagent` tool call (this host cannot start background children). It labelled 200 candidate queries and left 58 engine-unowned ones empty; the 150-row fixture is a deterministic selection (sha256 order per split) from those, so the held-out half was never hand-picked. The independence guarantee is the same: a context that never read the scorer wrote every label. +- 2026-11-09 (agent): "Tokens to answer" is averaged over the rows both arms answer, not over all rows. Pricing a miss at zero made arm A look cheap for answering nothing, and the first measurement of this PRD was negative for exactly that reason; misses are counted by recall, not by cost. It is also byte-counted on the capability payload, with the reference stubs a search also returns reported separately: those are identical in both arms and dominated the live total (31% of all bytes). +- 2026-11-09 (agent): The live byte win splits between the row and the serialization, and both are in this change. Three runs of 10 briefs × 2 per arm: MCP calls 51 / 45 / 53 → 20; MCP response bytes 182 555 / 167 620 / 177 322 → 116 496 / 116 496 / 114 660 (-36%, -30%, -35%); correct-symbol rate 1.00 every time. Arm B is stable (one search per brief) and arm A varies with how many details the model opens. Excluding the reference stubs every search also returns, the capability payload goes 132 055 / 121 432 / 130 244 → 98 688 / 98 688 / 96 480 (-25%, -19%, -26%). The row change alone is the smaller half: the details arm B no longer opens cost about 1.65 KB per search while the five complete rows it carries cost about 4.5 KB, because a row is ~900 B at p50, not the 416 B this PRD assumed. Compact tool text adds 11.8% on every response, the reference stubs included, and that is what carries the all-bytes figure over 30%. Both figures are printed by the harness on every run, so the claim cannot be read as more than it is. +- 2026-11-09 (agent, after the first review): The reviewer found a real defect in the name channel and three shipped surfaces left behind, all fixed before merge. The name channel had matched a token subset, so after stop words and stems `afterPhysics` collapsed to `physics` and answered that word first while the base answered `rapier`. `engine-mcp-tools.json`, the shared `threenative-capabilities` skill and the tracked `packages/core/mcp/engine-server.mjs` bundle follow the new row and rule, and the sixteen scaffold trees were re-measured after the skill text moved. +- 2026-11-09 (agent, after the second review): The first fix was too strict in the other direction — whole-query equality missed a request that spells a node inside a sentence (`GPUParticles3D sparks`, `Area3D trigger`) and a rarity floor hid `use rapier` and `use addInSlices for loading`, 49 of 456 spelled names lost. The final rule has no tuning constant for rarity: the request must *spell* the name as a run of whole words (`CharacterBody3D movement`, `character body 3d`, `mergeParts`, `mergeparts`) for a name lead at all, the name's words carry their own index (digits stripped, `GPUParticles3D` reads as `gpu particles`), and the lead scales with how much of the request the name accounts for — `Area3D trigger` leads, `first person view with a weapon held in the hand` ranks `View` below the camera it asks for. A longer name wins a tie on summed weight, which is what keeps `GPUParticles3D` ahead of the generic `Particles`. The held-out numbers moved up with it: recall@1 0.540 → 0.620, recall@5 0.760 → 0.780. + +## Execution Phases + +#### Phase 1: The gold set and the A/B harness measure the baseline +**Status:** DONE — all three phases and all four acceptance criteria measured on the frozen held-out set and in the live run (see Decisions for how the live bytes split between the row change and the response serialization). +**Files:** `packages/engine-mcp/__tests__/fixtures/search-gold.json` (new), `packages/engine-mcp/__tests__/search-gold.spec.ts` (new), `scripts/capability-search-ab.ts` (new). +**Implementation:** A fresh read-only subagent that has not read `index.ts` labels about 150 de-duplicated log queries (`.threenative/engine-mcp.log` files) against `capabilities.json`, including 20 exact-symbol queries. Split them 100 `dev` / 50 `heldout`, and write the held-out sha256 into this PRD before Phase 2. The harness loads both arms, runs the offline metrics, prints a table and writes `artifacts/capability-search-ab.json`. It exits 1 when the source hashes match, when a gold symbol is missing from the manifest, or when a threshold fails (`--report-only` skips thresholds). +- [x] Gold set: every symbol exists in the manifest, queries are unique, the split is 100/50, and the held-out hash matches this PRD. proof: `pnpm exec vitest run packages/engine-mcp/__tests__/search-gold.spec.ts` — Evidence: 150 rows, 100/50, 20 exact-symbol, hash `0cb517f2` asserted against both the fixture and this PRD. +**Verification:** The box above, then AC-1 with `--report-only`; the baseline numbers go beside AC-1. + +#### Phase 2: Symbol names are indexed and rows carry the detail +**Status:** DONE — all three phases and all four acceptance criteria measured on the frozen held-out set and in the live run (see Decisions for how the live bytes split between the row change and the response serialization). +**Files:** `packages/engine-mcp/src/index.ts`, `packages/engine-mcp/__tests__/search.spec.ts`, `packages/create-threenative/agent-docs/kit-router.md`, `AGENTS.md` (then `pnpm sync:agents` and the kit render). +**Implementation:** Write red tests first (`godrays`, `traa` and `mergeParts` first; a row includes `signature`). Add camelCase-split symbol tokens and low-weight summary tokens to the weights and the phrase scoring, plus an exact-symbol lead. Add `signature`, `overrides` and `deprecated` to `ICapabilitySearchResult` and the row builder. Update the `engine_search_capabilities` description and the two rule texts. Tune on `dev` only. +- [x] Exact-symbol queries lead and the existing 66 search cases still pass (red first, then green). proof: `pnpm exec vitest run packages/engine-mcp` and `pnpm typecheck && pnpm lint` — Evidence: 139 passed / 1 skipped, including the 73 existing search cases and "finds every manifest entry by its own words"; `pnpm caps:recall` stays at its 0.938053 budget. +- [x] Rows carry `signature`, `overrides` and `deprecated`, and the rule text says "detail for symbols not in the rows" in `AGENTS.md`, `kit-router.md` and every rendered kit `AGENTS.md`. proof: `pnpm sync:agents --check` and `pnpm check:docs` — Evidence: "agent docs in sync: 31 CLAUDE.md mirrors", 3 825 links across 1 653 files checked, 16 kit `AGENTS.md` + `CLAUDE.md` regenerated. +**Verification:** The two boxes above. A `prd-work-reviewer` review of the diff goes in the PR. + +#### Phase 3: The A/B proves the gains and locks them +**Status:** DONE — all three phases and all four acceptance criteria measured on the frozen held-out set and in the live run (see Decisions for how the live bytes split between the row change and the response serialization). +**Files:** `packages/engine-mcp/__tests__/search-gold.spec.ts` (thresholds), `scripts/capability-search-ab.ts` (`--live`). +**Implementation:** Run the offline A/B, then the live A/B (10 briefs × 2 arms × 2 runs). Lock arm B's held-out floor in `search-gold.spec.ts`, so `pnpm test` fails if a later change drops it. Post the A/B table on the PR. +- [x] The floor is locked: the held-out floor test fails on arm A's source and passes on arm B. proof: `pnpm exec vitest run packages/engine-mcp/__tests__/search-gold.spec.ts`, run once with arm A's `index.ts` swapped in (expect red), then on the candidate (expect green) — Evidence: with arm A's `index.ts` in place the floor case fails at 0.400 recall@1 against the locked 0.52 and the exact-symbol case reports 18 misses; on the candidate both pass at 0.540 / 0.760. +- [x] Phase-close gates pass on the candidate. proof: `pnpm typecheck && pnpm lint && pnpm exec vitest run packages/engine-mcp scripts/__tests__/sync-agent-docs.spec.ts` — Evidence: typecheck exit 0, biome reports 0 errors (6 pre-existing complexity warnings), engine-mcp + gold + recall specs green. +**Verification:** AC-2, AC-3 and AC-4 (offline run, then `--live --runs 2`), then the two boxes above. The A/B table goes on the PR and beside AC-1. diff --git a/packages/core/mcp/engine-server.mjs b/packages/core/mcp/engine-server.mjs index 45a4f5a950..befc160b37 100755 --- a/packages/core/mcp/engine-server.mjs +++ b/packages/core/mcp/engine-server.mjs @@ -704,8 +704,11 @@ function suffixStripped(token) { return token; } var STOP_WORD_STEMS = new Set([...STOP_WORDS].map(stem)); +function splitSymbolCase(value) { + return value.replace(/([a-z0-9])([A-Z])/gu, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2"); +} function capabilitySituationTokens(value) { - return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem).filter((token) => !STOP_WORD_STEMS.has(token)); + return splitSymbolCase(value).toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem).filter((token) => !STOP_WORD_STEMS.has(token)); } var tokens = capabilitySituationTokens; function tokenWeights(entries) { @@ -725,6 +728,36 @@ function tokenWeights(entries) { function weightOf(weights, token) { return weights.get(token) ?? 0; } +function nameWords(value) { + return tokens(value.replace(/([a-zA-Z])(\d+)/gu, "$1 $2")).filter((token) => !/\d/u.test(token)); +} +function normaliseName(value) { + return value.toLocaleLowerCase().replace(/[^a-z0-9]+/gu, ""); +} +function spelledWords(value) { + return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((word) => word.length > 0); +} +function spellsName(situation, symbol) { + const words = spelledWords(situation); + const whole = normaliseName(symbol); + for (let start = 0; start < words.length; start += 1) { + let piece = ""; + for (let end = start; end < words.length && end < start + 4; end += 1) { + piece += normaliseName(words[end] ?? ""); + if (piece === whole) return true; + if (piece.length >= whole.length) break; + } + } + return false; +} +function nameTokenWeights(entries) { + return tokenWeights( + entries.map((entry) => ({ + aliases: [nameWords(entry.symbol).join(" ")], + situations: [entry.summary] + })) + ); +} var DISTINCTIVE_SITUATION_SHARE = 0.02; var DISTINCTIVE_FLOOR = Math.log(1 + 1 / DISTINCTIVE_SITUATION_SHARE); var AGREEMENT_WEIGHT = 0.4; @@ -785,6 +818,56 @@ function situationScore(query, situations, aliases, weights) { const matchedSituation = readable.matchedSituation.length > 0 ? readable.matchedSituation : best >= RELEVANCE_FLOOR ? readable.bestReadableSituation : ""; return matchedSituation.length > 0 ? { matchedSituation, score: best } : { matchedSituation: "", score: 0 }; } +var EXACT_SYMBOL_LEAD = 4; +var NAME_BASE_LEAD = 2; +var NAME_SHARE_LEAD = 1.4; +var SYMBOL_WEIGHT_FLOOR = 4.2; +function scoreSymbolName(symbol, situation, queryTokens, weights) { + const name = [...new Set(nameWords(symbol))]; + if (name.length === 0) return void 0; + const nameWeights = name.map((token) => weightOf(weights, token)); + const specificity = nameWeights.reduce((sum, weight) => sum + weight, 0); + const requestTotal = [...queryTokens].reduce((sum, token) => sum + weightOf(weights, token), 0); + const share = requestTotal === 0 ? 0 : specificity / requestTotal; + const nameScore = NAME_BASE_LEAD + NAME_SHARE_LEAD * Math.min(1, share); + if (spellsName(situation, symbol)) { + return { + score: normaliseName(situation) === normaliseName(symbol) ? EXACT_SYMBOL_LEAD : nameScore, + specificity + }; + } + if (name.length < 2) return void 0; + const queryWords = new Set(nameWords(situation)); + if (!name.every((token) => queryWords.has(token))) return void 0; + if (Math.max(...nameWeights) < SYMBOL_WEIGHT_FLOOR) return void 0; + return { score: nameScore, specificity }; +} +var SUMMARY_WEIGHT = 0.4; +function summaryScore(summary, queryTokens, queryText, weights) { + const candidate = scorePhrase(summary, queryTokens, queryText, weights); + return candidate === void 0 ? 0 : candidate.score * SUMMARY_WEIGHT; +} +function capabilityScore(query, entry, weights, nameWeights, situation) { + const situations = { + ...situationScore(query, entry.situations, entry.aliases, weights), + specificity: 0 + }; + if (query.length === 0) return situations; + const queryTokens = new Set(query); + const symbol = scoreSymbolName(entry.symbol, situation, queryTokens, nameWeights); + const summary = summaryScore(entry.summary, queryTokens, query.join(" "), nameWeights); + const symbolScore = symbol?.score ?? 0; + const best = Math.max(situations.score, symbolScore, summary); + if (best <= situations.score) return situations; + if (symbol !== void 0 && symbolScore >= best) { + return { matchedSituation: entry.symbol, score: symbolScore, specificity: symbol.specificity }; + } + return { + matchedSituation: situations.matchedSituation || entry.situations[0] || entry.summary, + score: best, + specificity: 0 + }; +} function notOwnedSituationScore(query, situations) { if (query.length === 0) return { matchedSituation: "", score: 0 }; const queryText = query.join(" "); @@ -885,6 +968,7 @@ function searchMechanics(situation, manifest, scope) { const query = tokens(situation); const limit = scope === "request" ? MAX_COMPLETE_REQUEST_RESULTS : MAX_SITUATION_RESULTS; const weights = tokenWeights(manifest.entries); + const nameWeights = nameTokenWeights(manifest.entries); const notOwned = notOwnedMatch(manifest, query); if (notOwned !== void 0 && isSpecificNotOwnedMatch(query, notOwned.matchedSituation)) { return { @@ -893,10 +977,10 @@ function searchMechanics(situation, manifest, scope) { verdict: "none" }; } - const results = manifest.entries.map((entry) => ({ entry, ...situationScore(query, entry.situations, entry.aliases, weights) })).filter( + const results = manifest.entries.map((entry) => ({ entry, ...capabilityScore(query, entry, weights, nameWeights, situation) })).filter( ({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR ).sort( - (left, right) => right.score - left.score || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare( + (left, right) => right.score - left.score || right.specificity - left.specificity || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare( `${right.entry.importPath}:${right.entry.symbol}` ) ).filter( @@ -905,11 +989,14 @@ function searchMechanics(situation, manifest, scope) { ) === index ).slice(0, limit).map(({ entry, matchedSituation, score }) => ({ constraints: entry.constraints, + ...entry.deprecated === void 0 ? {} : { deprecated: entry.deprecated }, example: entry.example, importPath: entry.importPath, matchedSituation, + ...entry.overrides === void 0 ? {} : { overrides: entry.overrides }, ...entry.requires === void 0 ? {} : { requires: entry.requires }, score, + signature: entry.signature, summary: entry.summary, symbol: entry.symbol })); @@ -944,7 +1031,7 @@ var TOOL_DEFINITIONS = [ { annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true }, name: "engine_search_capabilities", - description: 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. The response verdict "none" is an actionable answer with guidance, not a failed search.', + description: 'Search the installed engine by concrete gameplay mechanic or by an exact exported symbol name. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. A row carries the answer (importPath, signature, example, constraints, overrides, deprecations), so call engine_capability_detail only for a symbol the search did not return. The response verdict "none" is an actionable answer with guidance, not a failed search.', inputSchema: { additionalProperties: false, properties: { @@ -1073,7 +1160,7 @@ function writeLine(value) { `); } function toolText(value) { - return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] }; + return { content: [{ text: JSON.stringify(value), type: "text" }] }; } async function searchReferences(situation, limit = 5) { const local = loadLocalReferenceGames(); @@ -1293,7 +1380,12 @@ async function runCli(argv, manifestFile = defaultManifestPath(), io = { { arguments: JSON.parse(json), name }, manifestFile ); - io.stdout(`${result.content[0].text} + let text2 = result.content[0].text; + try { + text2 = JSON.stringify(JSON.parse(text2), null, 2); + } catch { + } + io.stdout(`${text2} `); return 0; } catch (error) { diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index c31d73b818..cc1b8c5bc6 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -235,6 +235,11 @@ const BUG_REPORT_SKILL_PATHS = [ // Re-measured on the merge of develop into the Strata hardening branch: the develop tree moved // every no-install scaffold (three 0.187.0, the rts kit and the dropped Blender server), so no value // from either parent applies to the merged bytes. +// PRD-865 (2026-11-09): all sixteen trees move, measured by this test on the merged tree. Each kit +// AGENTS/CLAUDE pair and the shared `threenative-capabilities` skill gain the rule that a search +// row answers by itself and a detail call is only for a symbol the search did not return. The +// platformer then moves alone: it gains the worker entry `index.html`/`main.html` pair the starter +// shipped in #574, which is what its golden-path leg needs to boot threaded (develop's red). const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed 2026-11-09 on the merge of develop 6d5894ed4 (PRD-843..852) into the PRD-860 lane: // seven trees move (action-rpg, collectathon, minimal, racing, runner, shooter, starter). The lane @@ -382,7 +387,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Re-pinned 2026-10-31: the starter crate gets a stacked-disc contact shadow (render/contactShadow.ts) // that keeps its contact coverage above the 0.02 floor at the core +1 EV key. // 2026-11-04 (PRD-777 on PRD-632): action-rpg AGENTS/CLAUDE name the spell factories in one line. Measured by this test. - "action-rpg": "0def2594c553fdb0d565abdf5bd4f398be43453c1a1996aa4158a9bfd4c12ce6", + "action-rpg": "7332774915781140eaf5c44dca62c0239a53ab760d7458e64c193155944bcc53", // PRD-732 (2026-10-23): the new Hop Garden kit, measured through createProject (method checked // against platformer's unchanged value). Recomputed 2026-10-24 for the PRD-732 polish round: the // new hero, the dust and sparkle effects and the effects scenario. Recomputed 2026-10-24 for @@ -396,7 +401,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed 2026-10-24 for the shadow window: the 52 m map now travels 14 m ahead of the hero, // so the castle and trees the camera sees keep their shadows; the 2048 map and shadow pass cost // stay the same. - collectathon: "c769403ca5cff8b095cfa161ec4f17c7cc3811e65d8af4cf0b67a2f7f7daf689", + collectathon: "f2c6aeef90d7f21a97319c161a58a2413ba3d2908308076d82584632ad5a4384", // PRD-733 (2026-10-23): the new Lantern Vale kit, measured through createProject. // Recomputed 2026-10-24 for the owner perf round: the fixed-step catch-up cap and the gentler // wind on the crowns, clusters and grass tufts. Only this tree moves. @@ -405,14 +410,14 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Re-pinned 2026-10-26 (PRD-748 loading cover): adventure loses its `loaded` gate, rain // reads state through `useUiStateDuringStartup`. // Re-pinned 2026-10-27 (PRD-749 hud-clean): adventure, collectathon and rain HUD text reaches 3:1. - adventure: "207561c05950f1e015421a09e4ef82e2b7b64a4a48d4cdc3b86cb46314ad050d", + adventure: "663f780996e32126ec5ac895b3447ce32ae3ae7c64629d4d45d3db72b712b8f6", // PRD-734 (2026-10-24): the new Clover Cup kart kit, measured through createProject. - kart: "2441b2d0d15887e2ef53919da030feb8a5ed2b171e3009f6d81f63f4141d7996", - minimal: "36dc45bd50b04aaf04d42232d7f548cd235085bea03b085c5518117ab2abb7ac", - platformer: "c50a34ac8a293341aaf5d7ee7a0adfc9722f82522303f595c1de1e1b6baa6825", - puzzle: "98b017ad0db95fbf0861052ae2749f3f91d83050a346fc93f6726a94e773047c", - racing: "626f7307293796c3fdf210fda244524ffea231107420d6e544cfacdb33dc4c16", - rain: "ebb3600d8ad42a87a1aa97888a6ceb55c55f587571dc2ad3588b7e982d1656aa", + kart: "84e1275cc5ea09d74aaae7350c398f0a536a825c0de8ea30a124b0e30c69c2d3", + minimal: "2dc8cc0967c266ebd2602a3542e135b99857df9e22117d8d4b56129ff10642c1", + platformer: "a660a43a90e3336242cd5fbbf417b56e83f474ec8790953b7273375c11551c0a", + puzzle: "5a48db871fcf1702118917e6bbf2d98537558536f9d44b9fccbd366d558f5e94", + racing: "a164eba192d2f22318de8bc4122e50dad85304f6140aa52176835eefda0f2b4b", + rain: "d79dca7ee789f282108f3bfc8f7f6a97b4d633069ad86221a9e3852d7e18e3b4", // Re-pinned 2026-10-28: the rts high tier turns camera motion blur off (an RTS pan smeared every unit). // Re-pinned 2026-10-28 (PRD-765): rts becomes AstraCraft — painted ground, mesas, scrub, ACES 1.02 look and the command HUD; // then its legacy-linear colours, gamma fog of war, no bloom or environment, and a frame-sized shadow window. @@ -429,18 +434,18 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // and the engine pin goes, so the tree moves. Measured again the same day for the corrected // `performance.maxTriangles` bound (45,000 to 54,000), which is a scenario file in the tree. // Measured by this test. - rts: "5032f7d27bd4ea0e1786ebf8828f16d1cf7bab1fe67b0c1da641de6a552f3cc1", - runner: "4a00ff776d4f086eee25fe3b999650b6323ed8f03c3805337afa042584a5bd12", + rts: "675df92cf9409c8fcf4850a56ab533c4aded8bf0d300e054f791c04da597f2c5", + runner: "50afce9ab1824c7d1f47fb53488670373437ccf69311546a78d0e3a4bd11a6a0", // Finite-height readiness plus hull, sail, and buoy interpolation from the current swell pose. // Re-measured 2026-11-03 (PRD-617): its AGENTS/CLAUDE engine section no longer claims an engine // pin the template does not carry. Measured by this test. // Re-measured 2026-11-03 (PRD-617): `sails.playtest.json` sets `drawEvery: 60`, so the scenario // asks the runner for 30 drawn frames per 1,800-tick hold instead of taking as many readback // samples as the adapter happens to draw. Only this tree moves. - sailing: "058bd20ce7a62632ae796ab7fe7d66b7f32f9d1a69bd19aef25d9b28c6e740ca", + sailing: "35f1989381d5ff0fa41176a8a5d8e195a35254f532f04e27ac5aef06c49f61a8", // Recomputed 2026-10-26 (PRD-746): the scene merges the town through mergeByMaterial (src/townBatch.ts). - shooter: "e1d6e3a88003da71bb8f7a031df2cb7c9ea75244e1fcb2f5bc68c31775a8002f", - snow: "05a8cbe470dcbc5375ea706b440e0c69e2e9dbf545db2b1a2b3ed3500fc76b74", + shooter: "90686d1393ce30208c02d2f1646ef5fb5f28c4e847bb3708ef2c835bf03afd41", + snow: "06236fa7735b01a88809d574a67c0465774f6dd1d897b7bff33df54e8688a091", // Measured 2026-10-16 through createProject after starter's completed-work quality fallback fix. // Recomputed 2026-10-16: starter's adaptiveQuality split its policy resolution into qualityPolicy.ts // to stay under the 200-line render-file cap; only starter's tree moves. @@ -455,8 +460,8 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Measured by this test. // Measured by this test on the merge of origin/develop (d4ef1a88d, PRD-784) into this lane: // develop moved six starter sources, this lane moved its style.css, so neither parent value holds. - starter: "4c988320bef59e8657fbead0f80494e522de2b8ff07df251cbee4b471b56133c", - "tower-defense": "b0a7f7f6f872af021e758e4fb720192a19ecbafd42f2da34828787415a5b77a9", + starter: "7a7019b3ed3b8ef00564239d1c6b97cc0e3d8b7e7981ca9a04490052c95fd2c9", + "tower-defense": "538033c48dbb348de70de31d4c5716ceaa11f0b40f18d811210dc14fd4ab9c34", }; // The authoring stamp (PRD-829) hashes files this tree hash already reads, plus the cohort version. diff --git a/packages/create-threenative/agent-docs/kit-router.md b/packages/create-threenative/agent-docs/kit-router.md index 6102a7ffba..947c581a88 100644 --- a/packages/create-threenative/agent-docs/kit-router.md +++ b/packages/create-threenative/agent-docs/kit-router.md @@ -4,7 +4,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/agent-files/.agents/skills/threenative-capabilities/SKILL.md b/packages/create-threenative/agent-files/.agents/skills/threenative-capabilities/SKILL.md index 7e5a45deaa..5c057f7a4a 100644 --- a/packages/create-threenative/agent-files/.agents/skills/threenative-capabilities/SKILL.md +++ b/packages/create-threenative/agent-files/.agents/skills/threenative-capabilities/SKILL.md @@ -50,8 +50,10 @@ prompt (`TN_DISCOVERY_GATE=off` opts out). mechanic from pinned `file:line`, never from a video. No network or no tools: record `none: gameplay frames unavailable`. 3. **Built-ins** — engine_capabilities built-in discovery, served by the same MCP: call - `engine_search_capabilities` with `scope: "mechanic"` per mechanic, system or symptom; inspect - hits with `engine_capability_detail`. Record installed capabilities and binding constraints. A + `engine_search_capabilities` with `scope: "mechanic"` per mechanic, system or symptom, and read + every row — it carries the import path, signature, example, constraints, overrides and + deprecations, so call `engine_capability_detail` only for a symbol the search did not return. + Record installed capabilities and binding constraints. A `verdict: "none"` means game-owned behavior in `src/`; do not rephrase until something matches. These role names add no servers. 4. **Plan** — hand **Kit**, **Mechanics** and **References** to `prd-creator`. Plan the core loop @@ -92,8 +94,9 @@ prompt (`TN_DISCOVERY_GATE=off` opts out). A change to an existing game skips step 1 and whole-game planning; do not invoke `prd-creator` just to restart the game plan. For each change, search before you write: -- Step 3 for every change: `engine_search_capabilities` with `scope: "mechanic"`, then - `engine_capability_detail` on each hit, so installed systems are reused. +- Step 3 for every change: `engine_search_capabilities` with `scope: "mechanic"`, then read every + row — `engine_capability_detail` only for a symbol the search did not return — so installed + systems are reused. - Step 2 for each gameplay mechanic the change adds or alters: `engine_search_references`, then `engine_reference_detail`; use `engine_watch_reference` when the motion is the question. - Steps 5-8 for the change, using the existing game, plan and context: apply step 5 to the diff --git a/packages/create-threenative/engine-mcp-tools.json b/packages/create-threenative/engine-mcp-tools.json index 02c8e946ef..c34e0f19ec 100644 --- a/packages/create-threenative/engine-mcp-tools.json +++ b/packages/create-threenative/engine-mcp-tools.json @@ -110,7 +110,7 @@ } }, "descriptions": { - "engine_search_capabilities": "Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope \"request\" for the mechanically explicit full request and \"mechanic\" for each focused search; matchedSituation and score explain every result. The response verdict \"none\" is an actionable answer with guidance, not a failed search.", + "engine_search_capabilities": "Search the installed engine by concrete gameplay mechanic or by an exact exported symbol name. Decompose genres first. Use scope \"request\" for the mechanically explicit full request and \"mechanic\" for each focused search; matchedSituation and score explain every result. A row carries the answer (importPath, signature, example, constraints, overrides, deprecations), so call engine_capability_detail only for a symbol the search did not return. The response verdict \"none\" is an actionable answer with guidance, not a failed search.", "engine_capability_detail": "Inspect one engine capability's import, signature, example, constraints, and overrides. When several packages export the symbol, every match is returned under `matches`.", "engine_search_references": "Reverse engineer classic games: search mechanic cards and repository map nodes from their decomps. Tier-2 titles link to repos to explore and save. References describe feel only; borrow algorithms, cite pinned sources, copy no code or art.", "engine_reference_detail": "Reverse engineer a game: read its pinned map or mechanic card. A node includes files, key functions, dependencies and child nodes; a card includes constants and sources.", diff --git a/packages/create-threenative/templates/action-rpg/AGENTS.md b/packages/create-threenative/templates/action-rpg/AGENTS.md index 6e457e460f..5d52fba048 100644 --- a/packages/create-threenative/templates/action-rpg/AGENTS.md +++ b/packages/create-threenative/templates/action-rpg/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/action-rpg/CLAUDE.md b/packages/create-threenative/templates/action-rpg/CLAUDE.md index fef751b14d..2ceb1de1f4 100644 --- a/packages/create-threenative/templates/action-rpg/CLAUDE.md +++ b/packages/create-threenative/templates/action-rpg/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/adventure/AGENTS.md b/packages/create-threenative/templates/adventure/AGENTS.md index 596eb618fc..0ad6d5e134 100644 --- a/packages/create-threenative/templates/adventure/AGENTS.md +++ b/packages/create-threenative/templates/adventure/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/adventure/CLAUDE.md b/packages/create-threenative/templates/adventure/CLAUDE.md index c555f3409f..4adabb2ec3 100644 --- a/packages/create-threenative/templates/adventure/CLAUDE.md +++ b/packages/create-threenative/templates/adventure/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/collectathon/AGENTS.md b/packages/create-threenative/templates/collectathon/AGENTS.md index 02e450f004..c3dc79eb80 100644 --- a/packages/create-threenative/templates/collectathon/AGENTS.md +++ b/packages/create-threenative/templates/collectathon/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/collectathon/CLAUDE.md b/packages/create-threenative/templates/collectathon/CLAUDE.md index 709e48abff..83d052c11e 100644 --- a/packages/create-threenative/templates/collectathon/CLAUDE.md +++ b/packages/create-threenative/templates/collectathon/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/kart/AGENTS.md b/packages/create-threenative/templates/kart/AGENTS.md index 028ccc7905..ea80809e06 100644 --- a/packages/create-threenative/templates/kart/AGENTS.md +++ b/packages/create-threenative/templates/kart/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/kart/CLAUDE.md b/packages/create-threenative/templates/kart/CLAUDE.md index 7167fc0a34..80ae25752d 100644 --- a/packages/create-threenative/templates/kart/CLAUDE.md +++ b/packages/create-threenative/templates/kart/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/minimal/AGENTS.md b/packages/create-threenative/templates/minimal/AGENTS.md index 9248b4aa7b..fa8c7a70a6 100644 --- a/packages/create-threenative/templates/minimal/AGENTS.md +++ b/packages/create-threenative/templates/minimal/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/minimal/CLAUDE.md b/packages/create-threenative/templates/minimal/CLAUDE.md index 4d5b73163b..e182ba923f 100644 --- a/packages/create-threenative/templates/minimal/CLAUDE.md +++ b/packages/create-threenative/templates/minimal/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md index a507616a63..31c067a928 100644 --- a/packages/create-threenative/templates/platformer/AGENTS.md +++ b/packages/create-threenative/templates/platformer/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. @@ -37,7 +37,7 @@ A change to an existing game repeats steps 2-3 for that change, then steps 5-8. ## This kit -Source in `src/`: `entities/`, `game.ts`, `level/`, `main.ts`, `playtest-events.ts`, `render/`, `scenes/`, `state.ts`, `ui/`. +Source in `src/`: `entities/`, `game.ts`, `level/`, `main.ts`, `playtest-events.ts`, `render/`, `scenes/`, `state.ts`, `ui/`, `worker-page.ts`, `worker.ts`. 18 scenarios in `playtests/`; `playtests/survives.playtest.json` is the smoke proof. Exposure policy: `src/render/exposure.ts` (`node_modules/create-threenative/agent-docs/references/auto-exposure.md`). Kit notes: `node_modules/create-threenative/agent-docs/references/kit-platformer.md`. diff --git a/packages/create-threenative/templates/platformer/CLAUDE.md b/packages/create-threenative/templates/platformer/CLAUDE.md index 0eedfb4c08..4fc2b6d682 100644 --- a/packages/create-threenative/templates/platformer/CLAUDE.md +++ b/packages/create-threenative/templates/platformer/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. @@ -39,7 +39,7 @@ A change to an existing game repeats steps 2-3 for that change, then steps 5-8. ## This kit -Source in `src/`: `entities/`, `game.ts`, `level/`, `main.ts`, `playtest-events.ts`, `render/`, `scenes/`, `state.ts`, `ui/`. +Source in `src/`: `entities/`, `game.ts`, `level/`, `main.ts`, `playtest-events.ts`, `render/`, `scenes/`, `state.ts`, `ui/`, `worker-page.ts`, `worker.ts`. 18 scenarios in `playtests/`; `playtests/survives.playtest.json` is the smoke proof. Exposure policy: `src/render/exposure.ts` (`node_modules/create-threenative/agent-docs/references/auto-exposure.md`). Kit notes: `node_modules/create-threenative/agent-docs/references/kit-platformer.md`. diff --git a/packages/create-threenative/templates/platformer/index.html b/packages/create-threenative/templates/platformer/index.html index f59c7647a1..4cfc3edbf5 100644 --- a/packages/create-threenative/templates/platformer/index.html +++ b/packages/create-threenative/templates/platformer/index.html @@ -7,6 +7,6 @@
- + diff --git a/packages/create-threenative/templates/platformer/main.html b/packages/create-threenative/templates/platformer/main.html new file mode 100644 index 0000000000..f59c7647a1 --- /dev/null +++ b/packages/create-threenative/templates/platformer/main.html @@ -0,0 +1,12 @@ + + + + + + __PROJECT_NAME__ + + +
+ + + diff --git a/packages/create-threenative/templates/platformer/src/worker-page.ts b/packages/create-threenative/templates/platformer/src/worker-page.ts new file mode 100644 index 0000000000..3740554c08 --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/worker-page.ts @@ -0,0 +1,29 @@ +import { startWorkerGame } from "@threenative/core/worker"; +import { createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { GameUi } from "./ui/GameUi.js"; +import "./style.css"; + +/** + * `index.html`, the default: the game runs in `src/worker.ts`, off the page's thread, so a busy + * page cannot stall its frames. This page keeps the canvas, the input and the HUD; `main.html` runs + * the same game on the page itself. + */ +const root = document.getElementById("root"); +if (root === null) throw new Error("Missing #root element."); +root.className = "relative h-screen w-screen overflow-hidden bg-ink"; +const stage = document.createElement("div"); +stage.className = "absolute inset-0"; +root.append(stage); +const ui = document.createElement("div"); +root.append(ui); +// Before the UI mounts: the HUD finds the worker's bridge from its first render. +const game = startWorkerGame( + new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }), + stage, +); +createRoot(ui).render(createElement(GameUi)); +game.ready.catch((error: unknown) => { + stage.textContent = error instanceof Error ? error.message : String(error); + stage.setAttribute("role", "alert"); +}); diff --git a/packages/create-threenative/templates/platformer/src/worker.ts b/packages/create-threenative/templates/platformer/src/worker.ts new file mode 100644 index 0000000000..f7708549fb --- /dev/null +++ b/packages/create-threenative/templates/platformer/src/worker.ts @@ -0,0 +1,5 @@ +import { serveWorkerGame } from "@threenative/core/worker"; +import game from "./game.js"; + +// The worker side of `index.html`: the same portable game, on its own thread. +serveWorkerGame(game); diff --git a/packages/create-threenative/templates/platformer/vite.config.ts b/packages/create-threenative/templates/platformer/vite.config.ts index 2fac7a944b..4b27cef012 100644 --- a/packages/create-threenative/templates/platformer/vite.config.ts +++ b/packages/create-threenative/templates/platformer/vite.config.ts @@ -42,6 +42,8 @@ export default defineConfig({ tailwindcss(), assetsWatchPlugin(), ], + // `index.html` runs the game in a worker (the default); `main.html` runs it on the page thread. + build: { rollupOptions: { input: { index: "index.html", main: "main.html" } } }, server: { watch: { ignored: ["**/artifacts/**", "**/screenshots/**", "**/playtests/**"], diff --git a/packages/create-threenative/templates/puzzle/AGENTS.md b/packages/create-threenative/templates/puzzle/AGENTS.md index 3736f8dc74..5aa5f541e8 100644 --- a/packages/create-threenative/templates/puzzle/AGENTS.md +++ b/packages/create-threenative/templates/puzzle/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/puzzle/CLAUDE.md b/packages/create-threenative/templates/puzzle/CLAUDE.md index 2102882886..0c5bc81f7b 100644 --- a/packages/create-threenative/templates/puzzle/CLAUDE.md +++ b/packages/create-threenative/templates/puzzle/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/racing/AGENTS.md b/packages/create-threenative/templates/racing/AGENTS.md index 19bd605086..6fc521c571 100644 --- a/packages/create-threenative/templates/racing/AGENTS.md +++ b/packages/create-threenative/templates/racing/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/racing/CLAUDE.md b/packages/create-threenative/templates/racing/CLAUDE.md index 58cbfc884a..c4fa141f4f 100644 --- a/packages/create-threenative/templates/racing/CLAUDE.md +++ b/packages/create-threenative/templates/racing/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/rain/AGENTS.md b/packages/create-threenative/templates/rain/AGENTS.md index c1dd2cbb0c..bae9f782d6 100644 --- a/packages/create-threenative/templates/rain/AGENTS.md +++ b/packages/create-threenative/templates/rain/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/rain/CLAUDE.md b/packages/create-threenative/templates/rain/CLAUDE.md index e50432ac8a..dafac9d455 100644 --- a/packages/create-threenative/templates/rain/CLAUDE.md +++ b/packages/create-threenative/templates/rain/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/rts/AGENTS.md b/packages/create-threenative/templates/rts/AGENTS.md index 3a0f671744..caa5aa7263 100644 --- a/packages/create-threenative/templates/rts/AGENTS.md +++ b/packages/create-threenative/templates/rts/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/rts/CLAUDE.md b/packages/create-threenative/templates/rts/CLAUDE.md index ddd3c24e34..ff17e74311 100644 --- a/packages/create-threenative/templates/rts/CLAUDE.md +++ b/packages/create-threenative/templates/rts/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/runner/AGENTS.md b/packages/create-threenative/templates/runner/AGENTS.md index 363f6320d0..f0965d4ed9 100644 --- a/packages/create-threenative/templates/runner/AGENTS.md +++ b/packages/create-threenative/templates/runner/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/runner/CLAUDE.md b/packages/create-threenative/templates/runner/CLAUDE.md index 402f8c91e0..c1b2a042fe 100644 --- a/packages/create-threenative/templates/runner/CLAUDE.md +++ b/packages/create-threenative/templates/runner/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/sailing/AGENTS.md b/packages/create-threenative/templates/sailing/AGENTS.md index 87f4a7db6a..dbbfea42a6 100644 --- a/packages/create-threenative/templates/sailing/AGENTS.md +++ b/packages/create-threenative/templates/sailing/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/sailing/CLAUDE.md b/packages/create-threenative/templates/sailing/CLAUDE.md index a4acd27c81..46a1fe4b0f 100644 --- a/packages/create-threenative/templates/sailing/CLAUDE.md +++ b/packages/create-threenative/templates/sailing/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/shooter/AGENTS.md b/packages/create-threenative/templates/shooter/AGENTS.md index d74eca775b..0604ca544a 100644 --- a/packages/create-threenative/templates/shooter/AGENTS.md +++ b/packages/create-threenative/templates/shooter/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/shooter/CLAUDE.md b/packages/create-threenative/templates/shooter/CLAUDE.md index 38829d0a67..1a8a4199d3 100644 --- a/packages/create-threenative/templates/shooter/CLAUDE.md +++ b/packages/create-threenative/templates/shooter/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/snow/AGENTS.md b/packages/create-threenative/templates/snow/AGENTS.md index 71e178c76f..888bb13374 100644 --- a/packages/create-threenative/templates/snow/AGENTS.md +++ b/packages/create-threenative/templates/snow/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/snow/CLAUDE.md b/packages/create-threenative/templates/snow/CLAUDE.md index 0d70f8d714..96dc3aafa3 100644 --- a/packages/create-threenative/templates/snow/CLAUDE.md +++ b/packages/create-threenative/templates/snow/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/starter/AGENTS.md b/packages/create-threenative/templates/starter/AGENTS.md index 8cc1167b10..d2ffa47eff 100644 --- a/packages/create-threenative/templates/starter/AGENTS.md +++ b/packages/create-threenative/templates/starter/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/starter/CLAUDE.md b/packages/create-threenative/templates/starter/CLAUDE.md index 891c0e205d..2fe966b5a7 100644 --- a/packages/create-threenative/templates/starter/CLAUDE.md +++ b/packages/create-threenative/templates/starter/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/tower-defense/AGENTS.md b/packages/create-threenative/templates/tower-defense/AGENTS.md index 10940dc860..89572d500b 100644 --- a/packages/create-threenative/templates/tower-defense/AGENTS.md +++ b/packages/create-threenative/templates/tower-defense/AGENTS.md @@ -11,7 +11,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/create-threenative/templates/tower-defense/CLAUDE.md b/packages/create-threenative/templates/tower-defense/CLAUDE.md index 9bce202763..0352d2d9bc 100644 --- a/packages/create-threenative/templates/tower-defense/CLAUDE.md +++ b/packages/create-threenative/templates/tower-defense/CLAUDE.md @@ -13,7 +13,7 @@ Detail for each step: `threenative-capabilities` skill. Do the steps in order. A 1. **Kit** (new game only): `engine_search_capabilities {situation: "", scope: "request"}` returns the matching starter kit first. This folder is already a kit. 2. **Mechanics**: per mechanic, `engine_search_references {situation}`, then `engine_reference_detail` on each hit for pinned constants and feel. To see a mechanic move, `engine_watch_reference {query: " "}` returns a contact sheet of real gameplay. Borrow mechanics only: no code, level layouts, character names or art. Cite `file:line`. -3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`, then `engine_capability_detail {symbol, importPath}` on every hit. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. +3. **Built-ins**: per mechanic, system or symptom, `engine_search_capabilities {situation, scope: "mechanic"}`; rows answer; `engine_capability_detail` only for omitted symbols. Constraints are binding. `verdict: "none"` means the game writes it in `src/`. 4. **Plan** (new game, or a change to several systems): `prd-creator` with the kit, references and built-ins. With a user, wait for explicit approval, then implement. With no user (`claude -p`, a script), record the plan and assumptions, then continue. 5. **Assets**: `asset_search_sources`, the source's search tool, then `asset_download_file`; record source and licence in `CREDITS.md`. Nothing fits: `sculpt_plan`. A rigged GLB: `animation_inspect` (`threenative-assets` skill). 6. **Build**: gameplay in `src/`, look in `src/render/` (`threenative-context` and `threenative-ui` skills). For parallel work, give each subagent one area and its files, then integrate. diff --git a/packages/engine-mcp/README.md b/packages/engine-mcp/README.md index 2fb9638c13..fb6ef59363 100644 --- a/packages/engine-mcp/README.md +++ b/packages/engine-mcp/README.md @@ -6,10 +6,12 @@ owns the discovery order and plan record. The server exposes `engine_search_capabilities`, `engine_capability_detail`, `engine_search_references`, `engine_reference_detail`, and `engine_save_reference`. -Capability search works offline. Full requests use `scope: "request"` (up to 15 deduplicated -matches); mechanic searches use `scope: "mechanic"` (up to five). Inspect `matchedSituation` -and the detail constraints before implementing. A `none` capability result includes up to three -reference suggestions. +Capability search works offline and finds a capability by an exact exported symbol name as well as +by situation. Full requests use `scope: "request"` (up to 15 deduplicated +matches); mechanic searches use `scope: "mechanic"` (up to five). A row carries the answer — +`importPath`, `signature`, `example`, `constraints`, `overrides` and any deprecation — so read every +row and call `engine_capability_detail` only for a symbol the search did not return. A `none` +capability result includes up to three reference suggestions. First infer the concrete mechanics implied by the request. Call `engine_search_capabilities` once with that mechanically explicit request and `scope: "request"`, then once per mechanic with diff --git a/packages/engine-mcp/__tests__/fixtures/search-gold.json b/packages/engine-mcp/__tests__/fixtures/search-gold.json new file mode 100644 index 0000000000..4ad340f426 --- /dev/null +++ b/packages/engine-mcp/__tests__/fixtures/search-gold.json @@ -0,0 +1,1356 @@ +{ + "heldoutSha256": "0cb517f250f2965a6c37fbb487a8d95b98277893dfe8389f7e57f52c199aa78c", + "rows": [ + { + "expect": ["compileAssets"], + "id": "exact.dev.01", + "kind": "exact-symbol", + "note": "the manifest exports 'compileAssets'", + "query": "compileAssets", + "source": "log-detail", + "split": "dev" + }, + { + "expect": ["prewarm"], + "id": "exact.dev.02", + "kind": "exact-symbol", + "note": "the manifest exports 'prewarm'", + "query": "prewarm", + "source": "log-search", + "split": "dev" + }, + { + "expect": ["loadTerrainSplat"], + "id": "exact.dev.03", + "kind": "exact-symbol", + "note": "the manifest exports 'loadTerrainSplat'", + "query": "loadTerrainSplat", + "source": "log-search", + "split": "dev" + }, + { + "expect": ["TerrainTiles"], + "id": "exact.dev.04", + "kind": "exact-symbol", + "note": "the manifest exports 'TerrainTiles'", + "query": "TerrainTiles", + "source": "log-detail", + "split": "dev" + }, + { + "expect": ["texturePass"], + "id": "exact.dev.05", + "kind": "exact-symbol", + "note": "the manifest exports 'texturePass'", + "query": "texturePass", + "source": "log-detail", + "split": "dev" + }, + { + "expect": ["addInSlices"], + "id": "exact.dev.06", + "kind": "exact-symbol", + "note": "the manifest exports 'addInSlices'", + "query": "addInSlices", + "source": "log-search", + "split": "dev" + }, + { + "expect": ["VirtualShadowNode"], + "id": "exact.dev.07", + "kind": "exact-symbol", + "note": "the manifest exports 'VirtualShadowNode'", + "query": "VirtualShadowNode", + "source": "log-search", + "split": "dev" + }, + { + "expect": ["GPUReadback"], + "id": "exact.dev.08", + "kind": "exact-symbol", + "note": "the manifest exports 'GPUReadback'", + "query": "GPUReadback", + "source": "log-detail", + "split": "dev" + }, + { + "expect": ["Heightfield"], + "id": "exact.dev.09", + "kind": "exact-symbol", + "note": "the manifest exports 'Heightfield'", + "query": "Heightfield", + "source": "log-search", + "split": "dev" + }, + { + "expect": ["heightSamplerFromHeightmap"], + "id": "exact.dev.10", + "kind": "exact-symbol", + "note": "the manifest exports 'heightSamplerFromHeightmap'", + "query": "heightSamplerFromHeightmap", + "source": "log-search", + "split": "dev" + }, + { + "expect": ["DebugOverlay"], + "id": "exact.dev.11", + "kind": "exact-symbol", + "note": "the manifest exports 'DebugOverlay'", + "query": "DebugOverlay", + "source": "log-detail", + "split": "dev" + }, + { + "expect": ["FrameBudget"], + "id": "exact.dev.12", + "kind": "exact-symbol", + "note": "the manifest exports 'FrameBudget'", + "query": "FrameBudget", + "source": "log-detail", + "split": "dev" + }, + { + "expect": ["mergeParts"], + "id": "exact.dev.13", + "kind": "exact-symbol", + "note": "the manifest exports 'mergeParts'", + "query": "mergeParts", + "source": "log-detail", + "split": "dev" + }, + { + "expect": ["PhysicsDirectSpaceState3D"], + "id": "exact.heldout.01", + "kind": "exact-symbol", + "note": "the manifest exports 'PhysicsDirectSpaceState3D'", + "query": "PhysicsDirectSpaceState3D", + "source": "log-detail", + "split": "heldout" + }, + { + "expect": ["SkeletalMesh3D"], + "id": "exact.heldout.02", + "kind": "exact-symbol", + "note": "the manifest exports 'SkeletalMesh3D'", + "query": "SkeletalMesh3D", + "source": "log-detail", + "split": "heldout" + }, + { + "expect": ["readVelocityPreviousMatrices"], + "id": "exact.heldout.03", + "kind": "exact-symbol", + "note": "the manifest exports 'readVelocityPreviousMatrices'", + "query": "readVelocityPreviousMatrices", + "source": "log-detail", + "split": "heldout" + }, + { + "expect": ["getWorldCapabilities"], + "id": "exact.heldout.04", + "kind": "exact-symbol", + "note": "the manifest exports 'getWorldCapabilities'", + "query": "getWorldCapabilities", + "source": "log-detail", + "split": "heldout" + }, + { + "expect": ["renderer.projection"], + "id": "exact.heldout.05", + "kind": "exact-symbol", + "note": "the manifest exports 'renderer.projection'", + "query": "renderer.projection", + "source": "log-detail", + "split": "heldout" + }, + { + "expect": ["advanceFixedStep"], + "id": "exact.heldout.06", + "kind": "exact-symbol", + "note": "the manifest exports 'advanceFixedStep'", + "query": "advanceFixedStep", + "source": "log-detail", + "split": "heldout" + }, + { + "expect": ["readVelocityPreviousBoneMatrices"], + "id": "exact.heldout.07", + "kind": "exact-symbol", + "note": "the manifest exports 'readVelocityPreviousBoneMatrices'", + "query": "readVelocityPreviousBoneMatrices", + "source": "log-detail", + "split": "heldout" + }, + { + "expect": ["bridgeWaitTimeoutMs"], + "id": "mechanic.dev.01", + "kind": "mechanic", + "note": "runner wait bounded by the protocol operation timeout policy", + "query": "Bound a stalled live-clock playtest runner requestAnimationFrame wait using existing runner operation timeout policy, failing closed while preserving scenario assertions and normal frame ordering.", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["landform"], + "id": "mechanic.dev.02", + "kind": "mechanic", + "note": "calibrated landform recipe per environment kind", + "query": "generate a realistic mountain heightmap for terrain (ridges, erosion, valleys) for several environment kinds", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["withBrowserCapture"], + "id": "mechanic.dev.03", + "kind": "mechanic", + "note": "custom capture reusing runner display, lock and browser ownership", + "query": "Need reuse headed WebGPU playtest screenshot capture at a single settled camera pose for temporary shadow-intensity causal diagnosis; preserve adapter, readiness and capture lease checks.", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["GPUParticles3D"], + "id": "mechanic.dev.04", + "kind": "mechanic", + "note": "ambient particle emitter", + "query": "particles fireflies floating motes emitter", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["SnowField", "attachSnowPhysics"], + "id": "request.dev.05", + "kind": "request", + "note": "cross-system brief; the engine-owned core is deformable snow", + "query": "character walks on deformable snow terrain leaving footprints, snowfall particles, third person orbit camera, trees and rocks", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["Daylight", "Atmosphere"], + "id": "mechanic.dev.06", + "kind": "mechanic", + "note": "physical sky, sun and aerial-perspective haze", + "query": "physical sky atmosphere with sun disc, aerial perspective distance haze, height fog and volumetric clouds", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["GPUReadback"], + "id": "mechanic.dev.07", + "kind": "mechanic", + "note": "debug readback of GPU-computed data", + "query": "Debug GPU shader computed root distance and published instance LOD ranges with float render target readback or debug color; no gameplay feature.", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["compileAssets"], + "id": "mechanic.dev.08", + "kind": "mechanic", + "note": "asset compiler bake behaviour", + "query": "asset compiler bake receipt reject newly created undeclared output files preserve timestamps and preexisting authored static files", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["runDesktopPlaytest", "assertDesktopCaptureNotBlank"], + "id": "mechanic.dev.09", + "kind": "mechanic", + "note": "desktop playtest with nonblank desktop capture", + "query": "native desktop playtest stepFrames rendered frame completion nonblank screenshot", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["requestedPlaytestClockMode"], + "id": "mechanic.dev.10", + "kind": "mechanic", + "note": "vague; live-clock playtest mode is the only concrete hook", + "query": "playtest resource wait live clock FixedStepLoop bridge performance", + "source": "log-search:merged-pr-447", + "split": "dev" + }, + { + "expect": ["updateModelLods", "lodPixelScale"], + "id": "mechanic.dev.11", + "kind": "mechanic", + "note": "projected-error LOD selection", + "query": "selectLodLevel projectedLodError screen space error geometric LOD selection hysteresis", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["installFilmicToneMapping", "WorldEnvironment"], + "id": "mechanic.dev.12", + "kind": "mechanic", + "note": "tone mapping curve and override", + "query": "renderer tone mapping named override ACES filmic", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["updateModelLods"], + "id": "mechanic.dev.13", + "kind": "mechanic", + "note": "automatic cooked LOD chain selected by projected error", + "query": "automatic LOD generation for imported meshes (AutoLOD) so dense trees draw fewer triangles at distance", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["TracedReflections"], + "id": "mechanic.dev.14", + "kind": "mechanic", + "note": "reflect off-screen objects in a chrome sphere", + "query": "a mirror-like metal sphere should reflect nearby objects that are offscreen, using a local reflection probe or cube capture", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["Scene"], + "id": "mechanic.dev.15", + "kind": "mechanic", + "note": "per-render scene work and interpolation alpha", + "query": "live clock per-render transform motion regression Scene.render ctx.alpha ctx.frameTime component assertion SpectralOcean vertical velocity readback", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["FrameBudget", "BudgetLadder"], + "id": "mechanic.dev.16", + "kind": "mechanic", + "note": "read measured budget and step effects down", + "query": "read the measured frame budget compute bucket to degrade effects", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["CharacterBody3D", "GroundSnap"], + "id": "mechanic.dev.17", + "kind": "mechanic", + "note": "character movement plus visual foot grounding", + "query": "walk a character over heightfield terrain and stairs, feet meet the floor", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["FrameBudget"], + "id": "request.dev.18", + "kind": "request", + "note": "frame-time attribution; benchmark reporting part is harness work", + "query": "Profile native runtime game CPU GPU frame time bottlenecks benchmark real games startup memory sustained frame pacing compare performance experiments and generate benchmark reports", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["createAssetLoader"], + "id": "mechanic.dev.19", + "kind": "mechanic", + "note": "load and prefetch assets on demand", + "query": "stream aircraft and ship GLB models and textures on demand instead of loading every asset before the game starts", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["Scene"], + "id": "mechanic.dev.20", + "kind": "mechanic", + "note": "per-world-draw scene hook", + "query": "Await asynchronous work after an actual rendered game frame and block next render and fixed-step simulation until GPU readback completes in defineGame GameLoop SceneContext afterRender", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["GPUParticles3D", "FrameBudget"], + "id": "mechanic.dev.21", + "kind": "mechanic", + "note": "particle system and GPU budget buckets", + "query": "Preserve game-authored particle visibility while offscreen particle simulation culling suppresses drawing, and budget particle compute using separate render and compute GPU time buckets", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["scatterProps"], + "id": "mechanic.dev.22", + "kind": "mechanic", + "note": "biome scatter placement rules", + "query": "procedural foliage placement density by altitude slope and water mask with noise clearings", + "source": "log-search:threenative-engine", + "split": "dev" + }, + { + "expect": ["CharacterBody3D"], + "id": "mechanic.dev.23", + "kind": "mechanic", + "note": "character movement over stairs", + "query": "character walks up and down stairs smoothly without jitter", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["attachToBone"], + "id": "mechanic.dev.24", + "kind": "mechanic", + "note": "attach equipment to a named bone", + "query": "Attach shield and sword to a humanoid's chest bone so back equipment follows a breathing idle animation.", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["Area3D"], + "id": "mechanic.dev.25", + "kind": "mechanic", + "note": "non-blocking overlap sensor", + "query": "trigger volume sensor that detects a moving body passing through without blocking it", + "source": "log-search:starlance", + "split": "dev" + }, + { + "expect": ["clipPoseError", "boneLengthDeviations"], + "id": "mechanic.dev.26", + "kind": "mechanic", + "note": "diagnose a bad retarget and stretched bones", + "query": "skinned character walk animation arms stretched wrong bone pose retarget", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["BudgetLadder"], + "id": "mechanic.dev.27", + "kind": "mechanic", + "note": "GPU-time driven quality step", + "query": "automatically reduce render surface scale using GPU frame time and report chosen quality", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["FrameBudget", "stageGpuReading", "warmUpScene"], + "id": "request.dev.28", + "kind": "request", + "note": "cross-system perf brief", + "query": "frame budget profiling, GPU per-pass timing, auto LOD, texture compression, shader precompile warm-up without hitch", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["VirtualShadowNode"], + "id": "mechanic.dev.29", + "kind": "mechanic", + "note": "open-world directional shadow", + "query": "streamed instanced trees cast shadows onto streamed terrain near the player", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["traa", "temporalReproject"], + "id": "mechanic.dev.30", + "kind": "mechanic", + "note": "temporal resolve and reprojection", + "query": "temporal reconstruction of jittered lower-resolution raw samples with per-display-pixel confidence and first second colour moments reset on disocclusion", + "source": "log-search:task-15", + "split": "dev" + }, + { + "expect": ["Daylight", "Atmosphere"], + "id": "mechanic.dev.31", + "kind": "mechanic", + "note": "distance haze", + "query": "Persistent distance fog", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["PhysicsDirectSpaceState3D", "TracerPool3D"], + "id": "mechanic.dev.32", + "kind": "mechanic", + "note": "hitscan raycasts and tracers", + "query": "shotgun fires several hitscan pellets with spread", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["createAssetLoader"], + "id": "mechanic.dev.33", + "kind": "mechanic", + "note": "runtime texture loading via ctx.assets", + "query": "load a PNG texture from content/ at runtime and assign it to a three.js material map", + "source": "log-search:metahuman-lab", + "split": "dev" + }, + { + "expect": ["godrays", "Daylight"], + "id": "mechanic.dev.34", + "kind": "mechanic", + "note": "sun shafts and haze", + "query": "match forest fog and sunlight shafts", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["AudioBus"], + "id": "mechanic.dev.35", + "kind": "mechanic", + "note": "audio playback and bus volume", + "query": "play looping music track and fade its volume in over time", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["buildStaticColliders"], + "id": "mechanic.dev.36", + "kind": "mechanic", + "note": "static model collision; placement is level design", + "query": "place a giant landmark tree model with a face at the end of the forest path, with leaves on its branches, that the player cannot walk through", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["attachSnowPhysics", "SnowField"], + "id": "request.dev.37", + "kind": "request", + "note": "game brief; engine part is ball carving snow", + "query": "snow golf: push a ball through deep powder into a cup pressed into the snow within par and a time limit", + "source": "log-search:snow-demo", + "split": "dev" + }, + { + "expect": ["GPUReadback"], + "id": "mechanic.dev.38", + "kind": "mechanic", + "note": "GPU readback diagnostics", + "query": "fluid GPU readback capture diagnostics lifetime", + "source": "log-search:source", + "split": "dev" + }, + { + "expect": ["createAssetLoader"], + "id": "mechanic.dev.39", + "kind": "mechanic", + "note": "load and cache an image; minimap itself is game-owned", + "query": "Load and cache a static image for a canvas minimap player marker", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["GPUParticles3D"], + "id": "mechanic.dev.40", + "kind": "mechanic", + "note": "pooled GPU particle system", + "query": "render game-authored particles with partial buffer uploads and pooled compute", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["AudioBus"], + "id": "mechanic.dev.41", + "kind": "mechanic", + "note": "bus volume control", + "query": "fade the music out while a menu is open and back in when it closes", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["AnimationComposer", "BlendSpace1D"], + "id": "mechanic.dev.42", + "kind": "mechanic", + "note": "locomotion blending", + "query": "rigged hero with finger bones, smoother animation blending during movement", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["AnimationPlayer", "SkeletalMesh3D"], + "id": "mechanic.dev.43", + "kind": "mechanic", + "note": "play and blend clips on a rigged GLB", + "query": "play and blend skeletal animation clips on a rigged glb hero (idle, walk)", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["AudioBus"], + "id": "mechanic.dev.44", + "kind": "mechanic", + "note": "music playback; quest trigger is game logic", + "query": "play a calm music track in the opening area and a different urgent one after the quest advances", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["heightSamplerFromHeightmap"], + "id": "mechanic.dev.45", + "kind": "mechanic", + "note": "exact situation", + "query": "query ground height from a Blender-authored world package", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["SkeletalMesh3D", "AnimationPlayer"], + "id": "mechanic.dev.46", + "kind": "mechanic", + "note": "seated animated rig", + "query": "Render a rigged human radioman/gunner seated in the rear cockpit of the Douglas aircraft, playing a sitting animation while following the moving aircraft.", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["TerrainTiles", "WorldCells"], + "id": "mechanic.dev.47", + "kind": "mechanic", + "note": "stream terrain and cells around player", + "query": "stream world chunks/cells around the player, including procedurally generated terrain", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["AnimationPlayer"], + "id": "mechanic.dev.48", + "kind": "mechanic", + "note": "foot slide and speed matching", + "query": "walk cycle feet slide on ground, animation speed matched to movement speed", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["CharacterBody3D", "NavigationAgent3D", "Area3D"], + "id": "request.dev.49", + "kind": "request", + "note": "FPS brief: movement, chasing enemies, pickups", + "query": "first-person shooter like Doom: fast movement, demons that rush and throw projectiles, health and ammo pickups, waves of enemies in an arena, no aim down sights", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["UiLayer"], + "id": "mechanic.dev.50", + "kind": "mechanic", + "note": "portable HUD layer; theme content is game-owned", + "query": "Configure React UI global CSS reset, full-screen game canvas styling, themes and HUD overlay styles instead of a hand-authored src/style.css file", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["createFireball"], + "id": "mechanic.dev.51", + "kind": "mechanic", + "note": "fireball projectile", + "query": "slow visible enemy projectile fireball", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["UiLayer", "createAssetLoader"], + "id": "mechanic.dev.52", + "kind": "mechanic", + "note": "portable HUD plus packaged asset URL", + "query": "Render a PNG image in a portable React HUD and load its packaged asset URL", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["withBrowserCapture"], + "id": "mechanic.dev.53", + "kind": "mechanic", + "note": "capture under the runner capture lock", + "query": "fixed pose screenshot performance assertions capture lock", + "source": "log-search:task-11", + "split": "dev" + }, + { + "expect": ["AnimationComposer"], + "id": "mechanic.dev.54", + "kind": "mechanic", + "note": "exact situation", + "query": "synchronize walk run strafe clips with different durations", + "source": "log-search:ggez-installed-consumer", + "split": "dev" + }, + { + "expect": ["loadPlaytestScenario"], + "id": "mechanic.dev.55", + "kind": "mechanic", + "note": "scenario authoring for a fixed-view capture", + "query": "playtest scenario places the player at a world position and camera aim to capture a screenshot from a fixed viewpoint", + "source": "log-search:zelda-clone", + "split": "dev" + }, + { + "expect": ["Daylight", "VirtualShadowNode"], + "id": "mechanic.dev.56", + "kind": "mechanic", + "note": "sun rig and open-world shadow", + "query": "outdoor sun lighting with cascaded shadows over a large open world", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["Area3D"], + "id": "mechanic.dev.57", + "kind": "mechanic", + "note": "pickup trigger", + "query": "pickup item trigger area", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["DebugOverlay"], + "id": "mechanic.dev.58", + "kind": "mechanic", + "note": "per-object triangle and draw inspection", + "query": "Inspect per-object triangle counts and draw calls in a scene profiler", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["traa", "temporalReproject"], + "id": "mechanic.dev.59", + "kind": "mechanic", + "note": "temporal AA and reprojection", + "query": "temporal reprojection AA upscale", + "source": "log-search:task-15", + "split": "dev" + }, + { + "expect": ["bridgeWaitTimeoutMs"], + "id": "mechanic.dev.60", + "kind": "mechanic", + "note": "bounded wait for the playtest bridge", + "query": "wait for a semantic playtest bridge during bounded native startup compilation before deterministic scenario warmup; identify missing bridge and unavailable startup observation", + "source": "log-search:task-14", + "split": "dev" + }, + { + "expect": ["RigidBody3D", "Area3D"], + "id": "mechanic.dev.61", + "kind": "mechanic", + "note": "physical projectile with hit detection; damage is game logic", + "query": "enemy projectile that flies at the player and damages on hit", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["SnowField", "snowDiscFootprint"], + "id": "mechanic.dev.62", + "kind": "mechanic", + "note": "press a disc footprint into snow", + "query": "press a cup or crater into the snow at a fixed spot", + "source": "log-search:snow-demo", + "split": "dev" + }, + { + "expect": ["mergeByMaterial", "mergeParts"], + "id": "mechanic.dev.63", + "kind": "mechanic", + "note": "merge static meshes per material", + "query": "reduce draw calls for a static multi-mesh ship model: merge meshes by material, shadow caster proxy, batching, instancing, render bundles", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["NavigationAgent3D", "recast"], + "id": "mechanic.dev.64", + "kind": "mechanic", + "note": "navmesh chase", + "query": "enemy chase pathfinding", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["mergeParts", "mergeByMaterial"], + "id": "mechanic.dev.65", + "kind": "mechanic", + "note": "exact situation on mergeParts", + "query": "Merge multiple static Three.js meshes into one mesh per material while preserving texture UV coordinates and authored normals and baking object transforms.", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["addInSlices", "useUiStateDuringStartup"], + "id": "mechanic.dev.66", + "kind": "mechanic", + "note": "sliced build behind loading curtain with progress", + "query": "show loading percentage on the launch curtain while a heavy synchronous procedural build runs, without freezing the page", + "source": "log-search:starlance", + "split": "dev" + }, + { + "expect": ["CharacterBody3D", "NavigationAgent3D", "Area3D"], + "id": "request.dev.67", + "kind": "request", + "note": "FPS brief: movement, chasing enemies, pickups", + "query": "make me a first-person shooter like Doom: fast run-and-gun movement, melee-rushing and projectile-throwing monsters, health/armor/ammo pickups, multiple weapons (shotgun), enclosed arena level, kill all monsters to win", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["VirtualShadowNode"], + "id": "mechanic.dev.68", + "kind": "mechanic", + "note": "clip levels re-render only when they move", + "query": "static shadow caching: stop redrawing unmoving world into shadow map every frame", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["SkeletalMesh3D", "MatrixWorldPass"], + "id": "mechanic.dev.69", + "kind": "mechanic", + "note": "animation rate and matrix walk cost", + "query": "animate skinned characters without redundant hierarchy updates", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["Area3D"], + "id": "mechanic.dev.70", + "kind": "mechanic", + "note": "trigger volume pickup", + "query": "fuel canister pickup: ship flies through a trigger volume and refills boost", + "source": "log-search:starlance", + "split": "dev" + }, + { + "expect": ["DebugOverlay"], + "id": "mechanic.dev.71", + "kind": "mechanic", + "note": "scene node inspection", + "query": "Inspect individual scene nodes by name, geometry triangle counts, bounds, instances and camera frustum membership", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["temporalReproject"], + "id": "mechanic.dev.72", + "kind": "mechanic", + "note": "temporal reprojection internals", + "query": "temporal sample mass visibility content change material epochs motion reprojection coverage", + "source": "log-search:task-15", + "split": "dev" + }, + { + "expect": ["AnimationComposer", "BlendSpace1D"], + "id": "mechanic.dev.73", + "kind": "mechanic", + "note": "phase-synced normalized locomotion weights", + "query": "animation clip weights normalized phase gait stride synchronization", + "source": "log-search:evidence395/local-verification/engine-mcp.log", + "split": "dev" + }, + { + "expect": ["captureMouse"], + "id": "mechanic.dev.74", + "kind": "mechanic", + "note": "pointer lock for FPS aim", + "query": "Capture and hide mouse cursor using pointer lock for first person weapon aiming; unlock and restore cursor for menus and Escape", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["Area3D"], + "id": "mechanic.dev.75", + "kind": "mechanic", + "note": "pickup trigger", + "query": "pickup item trigger", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["loadTerrainSplat", "applyTerrainSurface"], + "id": "mechanic.dev.76", + "kind": "mechanic", + "note": "splat-mask terrain layers", + "query": "terrain material blending grass rock dirt layers by splat masks on a streamed heightmap", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["mergeByMaterial"], + "id": "mechanic.dev.77", + "kind": "mechanic", + "note": "runtime merged stand-in per material", + "query": "Generate a low-draw merged stand-in level of detail at runtime from an already-loaded model that has no cooked discrete LOD chain, merging its meshes and using a representative material", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["bakeWolfToFile"], + "id": "mechanic.dev.78", + "kind": "mechanic", + "note": "exact situation", + "query": "publish a generated wolf without replacing the last valid bake on interruption", + "source": "log-search:wolf-hitch-478a876", + "split": "dev" + }, + { + "expect": ["animalBakePass"], + "id": "mechanic.dev.79", + "kind": "mechanic", + "note": "exact situation", + "query": "cook staged animal bakes through the ordinary asset pipeline", + "source": "log-search:wolf-hitch-478a876", + "split": "dev" + }, + { + "expect": ["adviseThreeRenderWorkload", "observeSceneResources"], + "id": "mechanic.dev.80", + "kind": "mechanic", + "note": "diagnose performance and rendering", + "query": "playtest doctor: diagnose a running game's performance and rendering problems", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["WaterSurface3D", "WaveField"], + "id": "mechanic.dev.81", + "kind": "mechanic", + "note": "stop repeating water bands with analytic waves", + "query": "Render a realistic animated open ocean without square repeating wave or reflection patterns while keeping the existing analytic wave shader and GPU frame budget.", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["TracerPool3D", "AudioBus", "AnimationComposer"], + "id": "request.dev.82", + "kind": "request", + "note": "gun brief: tracers, positional shots, recoil and reload layers", + "query": "Repair aircraft rear gun mouse aiming and left button firing, audible positional gun shots, shot synchronized weapon recoil and ammunition reload animation, and tracer bullets rendering as crosses.", + "source": "log-search:midway-open-pacific", + "split": "dev" + }, + { + "expect": ["AnimationComposer"], + "id": "mechanic.dev.83", + "kind": "mechanic", + "note": "exact situation", + "query": "synchronize walk/run/strafe clips with different durations", + "source": "log-search:ggez-installed-consumer", + "split": "dev" + }, + { + "expect": ["createAnimalActor"], + "id": "mechanic.dev.84", + "kind": "mechanic", + "note": "exact situation", + "query": "follow a baked animal from accepted fixed-step Rapier state", + "source": "log-search:wolf-hitch-478a876", + "split": "dev" + }, + { + "expect": ["createAssetLoader"], + "id": "mechanic.dev.85", + "kind": "mechanic", + "note": "await launch assets before the reveal", + "query": "hold startup until game launch work has finished", + "source": "log-search:sailing-readiness", + "split": "dev" + }, + { + "expect": ["thinCards", "thinFoliageCards"], + "id": "mechanic.dev.86", + "kind": "mechanic", + "note": "far foliage card thinning", + "query": "distant trees with alpha-tested leaves draw far fewer triangles (foliage LOD / impostor)", + "source": "log-search:machinefall", + "split": "dev" + }, + { + "expect": ["Area3D"], + "id": "mechanic.dev.87", + "kind": "mechanic", + "note": "pickup trigger", + "query": "pickup item the player walks over to collect health or ammo", + "source": "log-search:game", + "split": "dev" + }, + { + "expect": ["packAtlas", "dedupeMaterials"], + "id": "mechanic.heldout.01", + "kind": "mechanic", + "note": "atlas private textures then collapse identical materials", + "query": "too many draw calls from many unique materials; merge or atlas materials so props batch together", + "source": "log-search:machinefall", + "split": "heldout" + }, + { + "expect": ["CharacterBody3D"], + "id": "mechanic.heldout.02", + "kind": "mechanic", + "note": "collision-aware character movement over steps and slopes", + "query": "character steps up stairs and slopes, snaps to ground, walks around trees", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["VirtualShadowNode", "readVirtualShadowMarker"], + "id": "mechanic.heldout.03", + "kind": "mechanic", + "note": "virtual shadow clipmap and its diagnostics marker", + "query": "VirtualShadowNode cached clipmap shadow receiver plane bias and shadow quality diagnostics for streamed foliage shadows", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["RenderChain"], + "id": "mechanic.heldout.04", + "kind": "mechanic", + "note": "template render chain composition; no MSAA-specific entry", + "query": "Configure Three.js world scene pass multisample count while retaining screen space antialiasing in a template render chain", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["connect"], + "id": "request.heldout.05", + "kind": "request", + "note": "replication is game-owned (networked-multiplayer); connect is the owned transport", + "query": "server-authoritative multiplayer: client prediction and reconciliation, snapshot interpolation, lag compensation for hitscan", + "source": "log-search:machinefall", + "split": "heldout" + }, + { + "expect": ["Daylight", "Atmosphere"], + "id": "mechanic.heldout.06", + "kind": "mechanic", + "note": "sky, atmosphere and distance haze", + "query": "sky with cumulus clouds, atmosphere and height fog distance haze", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["WorldCells"], + "id": "mechanic.heldout.07", + "kind": "mechanic", + "note": "stream a cooked world package by cell with LODs", + "query": "Load an MCP-generated instanced forest GLB in a game on browser and native desktop, using its authored LODs and streaming cells so distant trees do not all render near geometry and the forest can meet a 60 FPS budget.", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["createCssUiRoot"], + "id": "mechanic.heldout.08", + "kind": "mechanic", + "note": "native CSS React HUD root", + "query": "Native CSS scrolling inside scaled or rotated HUD container, accurate CPU paint and pointer click targeting through installed affine transforms", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["SkeletalMesh3D"], + "id": "mechanic.heldout.09", + "kind": "mechanic", + "note": "updateRateReport exposes evaluated versus skipped rig ticks", + "query": "Measure animation update CPU spans and report evaluated versus skipped rig ticks using existing frame profiling diagnostics", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["WorldCells"], + "id": "mechanic.heldout.10", + "kind": "mechanic", + "note": "cell streaming with per-asset LOD", + "query": "stream world cells with automatic tree impostor LOD", + "source": "log-search:threenative-engine", + "split": "heldout" + }, + { + "expect": ["createFireball"], + "id": "mechanic.heldout.11", + "kind": "mechanic", + "note": "visible fireball projectile", + "query": "enemy throws a slow visible projectile (fireball) at the player that can be dodged", + "source": "log-search:game", + "split": "heldout" + }, + { + "expect": ["FrameBudget"], + "id": "mechanic.heldout.12", + "kind": "mechanic", + "note": "CPU budget and GPU timestamp frame time", + "query": "profile frame CPU budget and actual GPU timestamp render duration", + "source": "log-search:midway-open-pacific", + "split": "heldout" + }, + { + "expect": ["buildStaticColliders"], + "id": "mechanic.heldout.13", + "kind": "mechanic", + "note": "static trimesh collision from a placed model", + "query": "place a static glb model in the world with collision so the player cannot walk through it", + "source": "log-search:zelda-clone", + "split": "heldout" + }, + { + "expect": ["AnimationPlayer"], + "id": "mechanic.heldout.14", + "kind": "mechanic", + "note": "stride-synced clip playback; clip authoring is offline", + "query": "replace a rigged character's walk animation clip with one authored from a reference video, loop-closed, stride-synced to movement speed", + "source": "log-search:zelda-clone", + "split": "heldout" + }, + { + "expect": ["NavigationAgent3D"], + "id": "mechanic.heldout.15", + "kind": "mechanic", + "note": "chasing enemies; melee and waves are game logic", + "query": "waves of melee monsters that chase the player and attack up close", + "source": "log-search:game", + "split": "heldout" + }, + { + "expect": ["Buoyancy3D", "WaveField"], + "id": "mechanic.heldout.16", + "kind": "mechanic", + "note": "hull floats on sampled wave heights", + "query": "derive a ship hull's heave pitch and roll by sampling a wave height field at four points around the hull", + "source": "log-search:midway-open-pacific", + "split": "heldout" + }, + { + "expect": ["InstancedBatch"], + "id": "mechanic.heldout.17", + "kind": "mechanic", + "note": "instance repeated meshes", + "query": "batch repeated meshes using instancing and frustum culling", + "source": "log-search:midway-open-pacific", + "split": "heldout" + }, + { + "expect": ["GPUReadback", "SpectralOcean"], + "id": "mechanic.heldout.18", + "kind": "mechanic", + "note": "read GPU ocean height on CPU", + "query": "sample asynchronous spectral ocean height before floating the ship", + "source": "log-search:sailing-readiness", + "split": "heldout" + }, + { + "expect": ["bakeWolf"], + "id": "mechanic.heldout.19", + "kind": "mechanic", + "note": "exact situation", + "query": "bake high and crowd wolf geometry before the ordinary asset cook", + "source": "log-search:wolf-hitch-478a876", + "split": "heldout" + }, + { + "expect": ["createAnimalGeometry"], + "id": "mechanic.heldout.20", + "kind": "mechanic", + "note": "exact situation", + "query": "construct a Three.js geometry from a validated procedural wolf bake", + "source": "log-search:wolf-hitch-478a876", + "split": "heldout" + }, + { + "expect": ["buildStaticColliders"], + "id": "request.heldout.21", + "kind": "request", + "note": "level fix brief; collision part is the engine piece", + "query": "Make the forest map maze walls continuous, collide with the same world coordinates, and procedurally render both traced whitewater falls; remove floating outside-map tree curtain.", + "source": "log-search:zelda-clone", + "split": "heldout" + }, + { + "expect": ["audioPass", "AudioBus"], + "id": "mechanic.heldout.22", + "kind": "mechanic", + "note": "condition a seamless loop then play it", + "query": "author a track made outside the engine as a looping game soundtrack", + "source": "log-search:zelda-clone", + "split": "heldout" + }, + { + "expect": ["Scheduler"], + "id": "mechanic.heldout.23", + "kind": "mechanic", + "note": "countdown timer situation", + "query": "countdown timer that ends the round", + "source": "log-search:snow-demo", + "split": "heldout" + }, + { + "expect": ["AudioBus"], + "id": "mechanic.heldout.24", + "kind": "mechanic", + "note": "audio volume", + "query": "background music fade in volume", + "source": "log-search:zelda-clone", + "split": "heldout" + }, + { + "expect": ["createFireball"], + "id": "mechanic.heldout.25", + "kind": "mechanic", + "note": "fireball projectile", + "query": "enemy projectile fireball", + "source": "log-search:game", + "split": "heldout" + }, + { + "expect": ["InstancedBatch", "mergeByMaterial"], + "id": "mechanic.heldout.26", + "kind": "mechanic", + "note": "collapse repeated static geometry", + "query": "load GLB forest trees and merge repeated static geometry", + "source": "log-search:zelda-clone", + "split": "heldout" + }, + { + "expect": ["TracerPool3D", "GPUParticles3D"], + "id": "mechanic.heldout.27", + "kind": "mechanic", + "note": "shot streaks and muzzle flash", + "query": "projectile trail ribbon and muzzle flash for laser shots", + "source": "log-search:sandbox", + "split": "heldout" + }, + { + "expect": ["loadAnimalBake"], + "id": "mechanic.heldout.28", + "kind": "mechanic", + "note": "exact situation", + "query": "load a cooked animal from ctx.assets without runtime generation", + "source": "log-search:wolf-hitch-478a876", + "split": "heldout" + }, + { + "expect": ["godrays"], + "id": "mechanic.heldout.29", + "kind": "mechanic", + "note": "godrays stage install", + "query": "Await first rendered frame and shadow map readiness before installing godrays post processing", + "source": "log-search:zelda-clone", + "split": "heldout" + }, + { + "expect": ["Daylight", "Atmosphere"], + "id": "mechanic.heldout.30", + "kind": "mechanic", + "note": "sky scattering and aerial perspective", + "query": "daytime sky with atmospheric scattering and aerial perspective fog so distant terrain fades into the sky color", + "source": "log-search:machinefall", + "split": "heldout" + }, + { + "expect": ["CharacterBody3D", "NavigationAgent3D", "Area3D"], + "id": "request.heldout.31", + "kind": "request", + "note": "FPS brief: movement, chasing enemies, pickups", + "query": "Doom-like first-person shooter: fast strafing movement through a corridor-and-room base, shotgun and chaingun hitscan, monsters that chase and throw fireball projectiles, health, armor and ammo pickups, keycard-locked doors, exit switch", + "source": "log-search:game", + "split": "heldout" + }, + { + "expect": ["DebugOverlay"], + "id": "mechanic.heldout.32", + "kind": "mechanic", + "note": "per-object cost inspection", + "query": "Authoring profiler that identifies each scene object or model's triangle count and draw calls, camera distance and screen coverage, repeated instances and active LOD to find expensive barely visible geometry and prioritize optimization", + "source": "log-search:midway-open-pacific", + "split": "heldout" + }, + { + "expect": ["GPUParticles3D"], + "id": "mechanic.heldout.33", + "kind": "mechanic", + "note": "explosion particles", + "query": "explosion visual effect with flash, shockwave, debris and smoke particles", + "source": "log-search:sandbox", + "split": "heldout" + }, + { + "expect": ["connect"], + "id": "request.heldout.34", + "kind": "request", + "note": "AOI replication is game-owned; connect is the owned transport", + "query": "interest management / area of interest replication for hundreds of players", + "source": "log-search:machinefall", + "split": "heldout" + }, + { + "expect": ["Area3D"], + "id": "mechanic.heldout.35", + "kind": "mechanic", + "note": "approach trigger; key logic is game-owned", + "query": "sliding door that opens when player approaches with key", + "source": "log-search:game", + "split": "heldout" + }, + { + "expect": ["InstancedBatch", "mergeByMaterial", "updateModelLods"], + "id": "mechanic.heldout.36", + "kind": "mechanic", + "note": "draw call reduction set", + "query": "instanced rendering of many identical cloned GLTF models, batching static meshes into fewer draw calls, mesh merging, level of detail LOD for aircraft, draw call reduction", + "source": "log-search:midway-open-pacific", + "split": "heldout" + }, + { + "expect": ["Area3D", "RigidBody3D"], + "id": "mechanic.heldout.37", + "kind": "mechanic", + "note": "goal zone overlap with a resting body", + "query": "detect the ball coming to rest inside a goal area", + "source": "log-search:snow-demo", + "split": "heldout" + }, + { + "expect": ["PhysicsDirectSpaceState3D"], + "id": "mechanic.heldout.38", + "kind": "mechanic", + "note": "hitscan raycast", + "query": "hitscan shotgun pellets against colliders", + "source": "log-search:game", + "split": "heldout" + }, + { + "expect": ["RippleField", "GPUReadback"], + "id": "mechanic.heldout.39", + "kind": "mechanic", + "note": "GPU ripple sim with CPU sampling", + "query": "simulate a water ripple heightfield on the GPU while sampling heights on CPU", + "source": "log-search:midway-open-pacific", + "split": "heldout" + }, + { + "expect": ["WorldCells", "TerrainTiles"], + "id": "mechanic.heldout.40", + "kind": "mechanic", + "note": "cell streaming with physics units", + "query": "streaming large open world cells with collision and navmesh loading/unloading around the player", + "source": "log-search:machinefall", + "split": "heldout" + }, + { + "expect": ["advanceTimeoutMs"], + "id": "mechanic.heldout.41", + "kind": "mechanic", + "note": "bounded fixed-step advance in playtest", + "query": "bounded GPU queue completion for accelerated fixed-step playtest simulation", + "source": "log-search:source", + "split": "heldout" + }, + { + "expect": ["WorldCells", "VirtualShadowNode", "InstancedBatch"], + "id": "request.heldout.42", + "kind": "request", + "note": "open-world streaming brief", + "query": "stream a large open world map in cells with instanced props, texture streaming/upload budget, shadow caching for static geometry, keep frame rate while walking", + "source": "log-search:machinefall", + "split": "heldout" + }, + { + "expect": ["runDesktopPlaytest"], + "id": "mechanic.heldout.43", + "kind": "mechanic", + "note": "native desktop playtest scenario", + "query": "Automated native desktop React UI playtests: click WebView buttons and select aircraft, assert resulting game state, and capture the composited overlay.", + "source": "log-search:midway-open-pacific", + "split": "heldout" + } + ], + "version": 1 +} diff --git a/packages/engine-mcp/__tests__/search-gold.spec.ts b/packages/engine-mcp/__tests__/search-gold.spec.ts new file mode 100644 index 0000000000..6c0f939f2b --- /dev/null +++ b/packages/engine-mcp/__tests__/search-gold.spec.ts @@ -0,0 +1,78 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + GOLD_RELATIVE_PATH, + SEARCH_GOLD_EXACT_SYMBOL_COUNT, + SEARCH_GOLD_HELDOUT_COUNT, + SEARCH_GOLD_QUERY_COUNT, + loadGoldSet, + reportArm, +} from "../../../scripts/capability-search-ab.js"; +import { capabilityDetails, searchCapabilities } from "../src/index.js"; + +const workspaceManifest = path.resolve("packages/create-threenative/capabilities.json"); +const goldFile = path.resolve(GOLD_RELATIVE_PATH); +const prdFile = path.resolve( + "docs/PRDs/done/PRD-865-capability-search-finds-by-name-and-answers-in-one-call.md", +); + +/** + * The floor the candidate measured when PRD-865 landed (0.620 recall@1, 0.780 recall@5). A later + * change that drops the held-out half below it fails here instead of shipping the regression; the + * base arm measures 0.400 recall@1 and 0.580 recall@5 at ecd2a1a56 and fails both lines. + */ +const LOCKED_HELDOUT_RECALL_1 = 0.6; +const LOCKED_HELDOUT_RECALL_5 = 0.76; + +const arm = { capabilityDetails, searchCapabilities }; + +describe("capability search gold set", () => { + it("holds 150 real queries, split 100 dev and 50 held out, with 20 exact-symbol queries", async () => { + const gold = await loadGoldSet(workspaceManifest, goldFile); + const heldout = gold.rows.filter((row) => row.split === "heldout"); + const dev = gold.rows.filter((row) => row.split === "dev"); + + expect(gold.rows).toHaveLength(SEARCH_GOLD_QUERY_COUNT); + expect(heldout).toHaveLength(SEARCH_GOLD_HELDOUT_COUNT); + expect(dev).toHaveLength(SEARCH_GOLD_QUERY_COUNT - SEARCH_GOLD_HELDOUT_COUNT); + expect(gold.rows.filter((row) => row.kind === "exact-symbol")).toHaveLength( + SEARCH_GOLD_EXACT_SYMBOL_COUNT, + ); + }); + + /** + * The split is only a control while it is frozen: the hash is in the fixture and in the PRD, so + * editing a held-out label to make a change pass fails here as well as in the A/B harness. + */ + it("freezes the held-out labels by the sha256 recorded in PRD-865", async () => { + const gold = await loadGoldSet(workspaceManifest, goldFile); + expect(await readFile(prdFile, "utf8")).toContain(gold.heldoutSha256); + }); + + it("finds every exact symbol name the manifest exports as the first row (AC-2)", async () => { + const gold = await loadGoldSet(workspaceManifest, goldFile); + const misses = gold.rows + .filter((row) => row.kind === "exact-symbol") + .map((row) => ({ + first: searchCapabilities(row.query, workspaceManifest).results[0]?.symbol, + query: row.query, + symbol: row.expect[0], + })) + .filter((row) => row.first !== row.symbol); + + expect(misses).toEqual([]); + }); + + /** + * The one number a later change can quietly lose. Measured on the held-out half only, with the + * same harness the A/B uses, so tuning against `dev` cannot lower this floor. + */ + it("keeps the locked held-out recall floor", async () => { + const gold = await loadGoldSet(workspaceManifest, goldFile); + const report = reportArm(arm, gold, workspaceManifest, "heldout"); + + expect(report.recall1).toBeGreaterThanOrEqual(LOCKED_HELDOUT_RECALL_1); + expect(report.recall5).toBeGreaterThanOrEqual(LOCKED_HELDOUT_RECALL_5); + }); +}); diff --git a/packages/engine-mcp/__tests__/search.spec.ts b/packages/engine-mcp/__tests__/search.spec.ts index e383917685..7753ce9b29 100644 --- a/packages/engine-mcp/__tests__/search.spec.ts +++ b/packages/engine-mcp/__tests__/search.spec.ts @@ -679,6 +679,80 @@ describe("threenative-engine-mcp", () => { ); }); + /** + * The request an author writes once they know the name: "turn on godrays", "what is traa", + * "use mergeParts". Every one of those answered `verdict: none` because the index read only the + * situations and aliases, so a symbol the manifest exports by that exact name was invisible. + */ + it.each([ + ["godrays", "godrays"], + ["traa", "traa"], + ["mergeParts", "mergeParts"], + ["request the godrays stage for this scene", "godrays"], + ])("finds '%s' by the symbol name the manifest exports", (query, symbol) => { + expect(searchResults(query, workspaceManifest)[0]?.symbol).toBe(symbol); + }); + + /** + * Spelling a name is a lead in proportion to how much of the request the name accounts for. `view` + * is also a symbol, so a camera request that says "view" in passing ranks it below the camera it + * asks for; it does not lead. + */ + it("does not answer a request with a common word that also names a symbol", () => { + const results = searchResults( + "first person view with a weapon held in the hand", + workspaceManifest, + ); + expect(results[0]?.symbol).not.toBe("View"); + }); + + /** + * A request that spells a node's name inside a sentence is asking for that node. The name channel + * matched only whole-query equality once, so `GPUParticles3D sparks` and `Area3D trigger` fell + * back to situation scoring and missed the node the request named outright. + */ + it.each([ + ["GPUParticles3D sparks", "GPUParticles3D"], + ["Area3D trigger", "Area3D"], + ["CharacterBody3D movement", "CharacterBody3D"], + ["RigidBody3D crate", "RigidBody3D"], + ["NavigationAgent3D chase", "NavigationAgent3D"], + ["enable ssgi", "ssgi"], + ["use rapier", "rapier"], + ["add atmosphere", "Atmosphere"], + ["use addInSlices for loading", "addInSlices"], + ["check isMobile", "isMobile"], + ])("finds '%s' when the request spells the node name", (query, symbol) => { + expect(searchResults(query, workspaceManifest)[0]?.symbol).toBe(symbol); + }); + + /** Spelling `characterbody3d` or `Area3D` as words means the same node as the camel case. */ + it.each([ + ["character body 3d movement", "CharacterBody3D"], + ["area 3d trigger", "Area3D"], + ["gpu particles trailing the runner", "GPUParticles3D"], + ])("finds '%s' spelled as words", (query, symbol) => { + expect(searchResults(query, workspaceManifest)[0]?.symbol).toBe(symbol); + }); + + /** + * The row has to equal the detail minus its situations and aliases, or the authoring rule still + * forces a detail call per hit: `signature` was the only field those calls added. + */ + it("answers a search row with the signature, overrides and deprecations detail carries", () => { + const merged = searchResults("merge static meshes into one mesh", workspaceManifest).find( + (result) => result.symbol === "mergeParts", + ); + expect(merged?.signature.length).toBeGreaterThan(0); + expect(merged?.overrides?.join(" ")).toContain("color"); + + const body = searchResults( + "move a character with collision against the level", + workspaceManifest, + ).find((result) => result.symbol === "CharacterBody3D"); + expect(body?.deprecated?.join(" ")).toContain("world"); + }); + it("finds the batching and particle capabilities a runner request names outright", () => { const results = searchResults( "an endless runner where procedurally generated track chunks stream toward the player, obstacles repeat as thousands of identical instanced blocks, dust particles trail behind, and the camera shakes on a near miss", diff --git a/packages/engine-mcp/src/index.ts b/packages/engine-mcp/src/index.ts index 9a8da23cd8..ea51322dcd 100644 --- a/packages/engine-mcp/src/index.ts +++ b/packages/engine-mcp/src/index.ts @@ -59,13 +59,21 @@ export interface IStarterKitResult { readonly command: string; } +/** + * One search answer, equal to the capability detail minus `situations` and `aliases`. `signature`, + * `overrides` and `deprecated` were the three fields a row lacked, so every hit cost a detail call + * that added nothing else an author works from. + */ export interface ICapabilitySearchResult { readonly symbol: string; readonly importPath: string; + readonly signature: string; readonly summary: string; readonly example: string; readonly constraints: readonly string[]; + readonly overrides?: readonly string[]; readonly requires?: readonly string[]; + readonly deprecated?: readonly string[]; readonly matchedSituation: string; readonly score: number; } @@ -438,8 +446,18 @@ function suffixStripped(token: string): string { */ const STOP_WORD_STEMS = new Set([...STOP_WORDS].map(stem)); +/** + * `mergeParts` in a request and `merge parts` in a situation are the same two words, and only the + * second one used to be findable: the index read `mergeparts` as one token while the request that + * named the symbol read `mergeParts`. Splitting the case boundary here, on both sides, is what + * makes a symbol name searchable; it costs nothing on prose, which has no case boundary to split. + */ +function splitSymbolCase(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/gu, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2"); +} + export function capabilitySituationTokens(value: string): string[] { - return value + return splitSymbolCase(value) .toLocaleLowerCase() .split(/[^a-z0-9]+/u) .filter((token) => token.length > 1 && !STOP_WORDS.has(token)) @@ -480,6 +498,65 @@ function weightOf(weights: ReadonlyMap, token: string): number { return weights.get(token) ?? 0; } +/** + * The weights a name and a summary are read with, indexed apart from the situations on purpose. + * Folding one summary and one symbol document per entry into the situation index moved the coverage + * of every existing match: `SnowField` fell under `RELEVANCE_FLOOR` for "fresh snowfall slowly + * fills old tracks back in", a row the recall budget had held. A channel added to help a name must + * not retune the prose index it is added beside. + */ +/** + * The words a name is spelled with, on both sides of a match. `GPUParticles3D` is "gpu particles" + * to a reader: glueing the digit run on (`particles3d`) meant that capability matched no request + * that said "particles", and a digit carries no name in this manifest, so `Area3D` is `area` and + * `Joint3D` is `joint`. + */ +function nameWords(value: string): readonly string[] { + return tokens(value.replace(/([a-zA-Z])(\d+)/gu, "$1 $2")).filter((token) => !/\d/u.test(token)); +} + +/** `mergeParts`, `MERGEPARTS`, `merge parts` and `merge-parts` are the same name to a reader. */ +function normaliseName(value: string): string { + return value.toLocaleLowerCase().replace(/[^a-z0-9]+/gu, ""); +} + +/** The words a request spells, before the stemmer and the stop list: a name is matched as written. */ +function spelledWords(value: string): readonly string[] { + return value + .toLocaleLowerCase() + .split(/[^a-z0-9]+/u) + .filter((word) => word.length > 0); +} + +/** + * Does the request spell this name, as whole words and in order? `CharacterBody3D movement` and + * `character body 3d` both spell `CharacterBody3D`; `physics` does not spell `afterPhysics`, and + * `the viewer` does not spell the `View` symbol, because a run of whole words has to match the + * whole name. A run longer than four words is never a name an author types. + */ +function spellsName(situation: string, symbol: string): boolean { + const words = spelledWords(situation); + const whole = normaliseName(symbol); + for (let start = 0; start < words.length; start += 1) { + let piece = ""; + for (let end = start; end < words.length && end < start + 4; end += 1) { + piece += normaliseName(words[end] ?? ""); + if (piece === whole) return true; + if (piece.length >= whole.length) break; + } + } + return false; +} + +function nameTokenWeights(entries: readonly ICapabilityEntry[]): ReadonlyMap { + return tokenWeights( + entries.map((entry) => ({ + aliases: [nameWords(entry.symbol).join(" ")], + situations: [entry.summary], + })), + ); +} + /** * A word is distinctive enough to carry a match alone when the index uses it in at most this * share of its situations. `player` reaches 2.5% and separates nothing; `particle` reaches 0.9% @@ -614,6 +691,123 @@ function situationScore( : { matchedSituation: "", score: 0 }; } +/** + * A request that names a symbol the manifest exports is the strongest statement it can make, and + * the situation scorer cannot see it: `godrays` is a name, and the index held only its prose. The + * lead is gated on how much of the request the symbol accounts for, so a request that mentions + * `view` or `read` in passing does not get answered with the `View` or `readFile` symbol. + */ +const EXACT_SYMBOL_LEAD = 4; +/** + * A name match scores on how much of the request the name accounts for: a request that is mostly + * one name leads at full strength, and a name mentioned inside a rich sentence barely moves. That + * is what separates `Area3D trigger` (about the area) from `first person view with a weapon held + * in the hand` (about the camera, which merely says "view") without a list of English homonyms. + */ +const NAME_BASE_LEAD = 2; +const NAME_SHARE_LEAD = 1.4; +/** + * A name the name index uses everywhere names nothing. `playtest` reaches 81 of its 1 062 + * documents and `Scene` 44, so a request mentioning either is asking about a scene or a playtest, + * not for a symbol; `godrays` and `traa` reach 2 and `mergeParts`' own words reach 11 and 15. + */ +const SYMBOL_WEIGHT_FLOOR = 4.2; + +interface ISymbolScore { + readonly score: number; + /** How much a name narrows this index, so `godrays` outranks `Scene` when both are spelled. */ + readonly specificity: number; +} + +function scoreSymbolName( + symbol: string, + situation: string, + queryTokens: ReadonlySet, + weights: ReadonlyMap, +): ISymbolScore | undefined { + const name = [...new Set(nameWords(symbol))]; + if (name.length === 0) return undefined; + const nameWeights = name.map((token) => weightOf(weights, token)); + // Summing, not taking the max, is what makes `gpu particles` prefer `GPUParticles3D` over the + // generic `Particles`: the longer name accounts for more of the request. + const specificity = nameWeights.reduce((sum, weight) => sum + weight, 0); + const requestTotal = [...queryTokens].reduce((sum, token) => sum + weightOf(weights, token), 0); + const share = requestTotal === 0 ? 0 : specificity / requestTotal; + const nameScore = NAME_BASE_LEAD + NAME_SHARE_LEAD * Math.min(1, share); + // A request that spells the name — `add godrays`, `use rapier`, `GPUParticles3D sparks`, + // `use addInSlices for loading` — is asking for that symbol: equality with the whole name, not + // with what the stemmer leaves of it, which is how `afterPhysics` once answered "physics". + if (spellsName(situation, symbol)) { + return { + score: normaliseName(situation) === normaliseName(symbol) ? EXACT_SYMBOL_LEAD : nameScore, + specificity, + }; + } + // A one-word name has to be spelled; a name scattered through the prose needs the gates below. + if (name.length < 2) return undefined; + const queryWords = new Set(nameWords(situation)); + if (!name.every((token) => queryWords.has(token))) return undefined; + // A name made of words the index uses everywhere names nothing; the share-scaled score already + // keeps a name mentioned in passing behind the mechanic the request is actually about. + if (Math.max(...nameWeights) < SYMBOL_WEIGHT_FLOOR) return undefined; + return { score: nameScore, specificity }; +} + +/** + * The summary corroborates; it never outranks a situation. It is the one line written for a reader + * who already knows the name (`ThreeNative equivalent for realism-effects traa`), so it answers a + * retired or thinly described capability and stays quiet next to a real situation match. + */ +const SUMMARY_WEIGHT = 0.4; + +function summaryScore( + summary: string, + queryTokens: ReadonlySet, + queryText: string, + weights: ReadonlyMap, +): number { + const candidate = scorePhrase(summary, queryTokens, queryText, weights); + return candidate === undefined ? 0 : candidate.score * SUMMARY_WEIGHT; +} + +/** Every channel a capability answer can come from, combined; the symbol leads and explains itself. */ +interface ICapabilityScore { + readonly matchedSituation: string; + readonly score: number; + /** Only a name carries one; it orders two names spelled in the same request. */ + readonly specificity: number; +} + +function capabilityScore( + query: readonly string[], + entry: ICapabilityEntry, + weights: ReadonlyMap, + nameWeights: ReadonlyMap, + situation: string, +): ICapabilityScore { + const situations = { + ...situationScore(query, entry.situations, entry.aliases, weights), + specificity: 0, + }; + if (query.length === 0) return situations; + const queryTokens = new Set(query); + const symbol = scoreSymbolName(entry.symbol, situation, queryTokens, nameWeights); + const summary = summaryScore(entry.summary, queryTokens, query.join(" "), nameWeights); + const symbolScore = symbol?.score ?? 0; + const best = Math.max(situations.score, symbolScore, summary); + if (best <= situations.score) return situations; + if (symbol !== undefined && symbolScore >= best) { + return { matchedSituation: entry.symbol, score: symbolScore, specificity: symbol.specificity }; + } + // A summary-only answer still needs a readable explanation, and the entry's own first situation + // is the sentence a reader recognises; an entry with no situations keeps the summary itself. + return { + matchedSituation: situations.matchedSituation || entry.situations[0] || entry.summary, + score: best, + specificity: 0, + }; +} + function notOwnedSituationScore( query: readonly string[], situations: readonly string[], @@ -776,6 +970,7 @@ function searchMechanics( const query = tokens(situation); const limit = scope === "request" ? MAX_COMPLETE_REQUEST_RESULTS : MAX_SITUATION_RESULTS; const weights = tokenWeights(manifest.entries); + const nameWeights = nameTokenWeights(manifest.entries); const notOwned = notOwnedMatch(manifest, query); if (notOwned !== undefined && isSpecificNotOwnedMatch(query, notOwned.matchedSituation)) { return { @@ -785,13 +980,14 @@ function searchMechanics( }; } const results = manifest.entries - .map((entry) => ({ entry, ...situationScore(query, entry.situations, entry.aliases, weights) })) + .map((entry) => ({ entry, ...capabilityScore(query, entry, weights, nameWeights, situation) })) .filter( ({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR, ) .sort( (left, right) => right.score - left.score || + right.specificity - left.specificity || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare( `${right.entry.importPath}:${right.entry.symbol}`, ), @@ -805,11 +1001,14 @@ function searchMechanics( .slice(0, limit) .map(({ entry, matchedSituation, score }) => ({ constraints: entry.constraints, + ...(entry.deprecated === undefined ? {} : { deprecated: entry.deprecated }), example: entry.example, importPath: entry.importPath, matchedSituation, + ...(entry.overrides === undefined ? {} : { overrides: entry.overrides }), ...(entry.requires === undefined ? {} : { requires: entry.requires }), score, + signature: entry.signature, summary: entry.summary, symbol: entry.symbol, })); @@ -866,7 +1065,7 @@ const TOOL_DEFINITIONS: readonly IEngineTool[] = [ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true }, name: "engine_search_capabilities", description: - 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. The response verdict "none" is an actionable answer with guidance, not a failed search.', + 'Search the installed engine by concrete gameplay mechanic or by an exact exported symbol name. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. A row carries the answer (importPath, signature, example, constraints, overrides, deprecations), so call engine_capability_detail only for a symbol the search did not return. The response verdict "none" is an actionable answer with guidance, not a failed search.', inputSchema: { additionalProperties: false, properties: { @@ -1013,8 +1212,13 @@ function writeLine(value: string): void { process.stdout.write(`${value}\n`); } +/** + * The tool's text payload. Compact, because it is model context: indentation cost 11.6% of every + * response and answered nothing. `runCli` re-indents its own copy, so the human-facing command + * stays readable (PRD-865). + */ function toolText(value: unknown): { content: [{ type: "text"; text: string }] } { - return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] }; + return { content: [{ text: JSON.stringify(value), type: "text" }] }; } async function searchReferences(situation: string, limit = 5) { @@ -1286,7 +1490,13 @@ export async function runCli( { arguments: JSON.parse(json) as unknown, name }, manifestFile, ); - io.stdout(`${result.content[0].text}\n`); + let text = result.content[0].text; + try { + text = JSON.stringify(JSON.parse(text), null, 2); + } catch { + // Not JSON: print it as the tool wrote it. + } + io.stdout(`${text}\n`); return 0; } catch (error) { io.stderr(`${name}: ${error instanceof Error ? error.message : String(error)}\n`); diff --git a/scripts/__tests__/capability-manifest.spec.ts b/scripts/__tests__/capability-manifest.spec.ts index 288f6260d6..934bdb5c73 100644 --- a/scripts/__tests__/capability-manifest.spec.ts +++ b/scripts/__tests__/capability-manifest.spec.ts @@ -803,27 +803,52 @@ describe("capability manifest generator", () => { ids: ["request.rough-diffuse-substrate-eon"], owners: ["useRoughDiffuse"], }, - "lumen scalability": { ids: ["request.gi-quality-scalability"], owners: ["GIBudget"] }, + // PRD-865: seven aliases below are recalled with the aliases stripped, because these + // capabilities' own summaries name the Unreal vocabulary (`global illumination`, `virtual + // shadow`, `lumen surface cache`, `radiance cache`). The alias rows are still the vocabulary + // the requests use; they are no longer those rows' only path. Marked, not deleted: a later + // PRD that needs alias-necessity strictness for Unreal terms should start here. + "lumen scalability": { + baselineAlreadyRecalled: true, + ids: ["request.gi-quality-scalability"], + owners: ["GIBudget"], + }, "sg.GlobalIlluminationQuality": { + baselineAlreadyRecalled: true, + ids: ["request.gi-quality-scalability"], + owners: ["GIBudget"], + }, + "final gather method": { + baselineAlreadyRecalled: true, ids: ["request.gi-quality-scalability"], owners: ["GIBudget"], }, - "final gather method": { ids: ["request.gi-quality-scalability"], owners: ["GIBudget"] }, "virtual shadow map scalability": { + baselineAlreadyRecalled: true, ids: ["request.shadow-quality-scalability"], owners: ["ShadowFilterBudget"], }, "sg.ShadowQuality": { + baselineAlreadyRecalled: true, ids: ["request.shadow-quality-scalability"], owners: ["ShadowFilterBudget"], }, - "lumen surface cache": { ids: ["request.lumen-surface-cache"], owners: ["SurfaceCache"] }, + "lumen surface cache": { + baselineAlreadyRecalled: true, + ids: ["request.lumen-surface-cache"], + owners: ["SurfaceCache"], + }, "lumen scene": { ids: ["request.lumen-scene"], owners: ["SurfaceCache"] }, "lumen radiance cache": { + baselineAlreadyRecalled: true, + ids: ["request.lumen-radiance-cache"], + owners: ["RadianceCache"], + }, + "radiance cache": { + baselineAlreadyRecalled: true, ids: ["request.lumen-radiance-cache"], owners: ["RadianceCache"], }, - "radiance cache": { ids: ["request.lumen-radiance-cache"], owners: ["RadianceCache"] }, "dynamic probe gi": { ids: ["request.dynamic-probe-gi"], owners: ["RadianceCache"] }, "irradiance field": { ids: ["request.dynamic-probe-gi"], owners: ["RadianceCache"] }, "mesh cards": { ids: ["request.mesh-cards"], owners: ["SurfaceCache"] }, diff --git a/scripts/__tests__/capability-recall.spec.ts b/scripts/__tests__/capability-recall.spec.ts index fe6926672f..7c035cf664 100644 --- a/scripts/__tests__/capability-recall.spec.ts +++ b/scripts/__tests__/capability-recall.spec.ts @@ -35,6 +35,7 @@ function result(symbol: string): ICapabilitySearchResult { importPath: "@threenative/core", matchedSituation: "fixture", score: 0, + signature: "class GroundSnap", summary: "fixture", symbol, }; diff --git a/scripts/capability-search-ab.ts b/scripts/capability-search-ab.ts new file mode 100644 index 0000000000..9af8ddb4f1 --- /dev/null +++ b/scripts/capability-search-ab.ts @@ -0,0 +1,818 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** + * PRD-865: one capability search should answer, and find a symbol by its name. + * + * Two arms over one frozen gold set: the base (the engine-mcp `src/` at a pinned commit, extracted + * with `git archive`) and the candidate (this working tree). Both read the same manifest and the + * same queries, so every difference is the change under test. The held-out half is frozen by + * sha256 in the fixture and in the PRD, and nothing here reads it by hand: the harness reports its + * numbers and the acceptance thresholds compare them. + */ + +export const GOLD_RELATIVE_PATH = path.join( + "packages", + "engine-mcp", + "__tests__", + "fixtures", + "search-gold.json", +); +const MANIFEST_RELATIVE_PATH = path.join("packages", "create-threenative", "capabilities.json"); +const SOURCE_RELATIVE_PATH = path.join("packages", "engine-mcp", "src"); +const ARTIFACT_RELATIVE_PATH = path.join("artifacts", "capability-search-ab.json"); + +export const SEARCH_GOLD_QUERY_COUNT = 150; +export const SEARCH_GOLD_HELDOUT_COUNT = 50; +export const SEARCH_GOLD_EXACT_SYMBOL_COUNT = 20; + +export type GoldKind = "exact-symbol" | "mechanic" | "request"; +export type GoldSplit = "dev" | "heldout"; + +export interface IGoldRow { + readonly id: string; + readonly query: string; + readonly kind: GoldKind; + readonly split: GoldSplit; + readonly expect: readonly string[]; + /** Where the query came from: a real session log, or the symbol an agent reached for by name. */ + readonly source: string; + readonly note?: string; +} + +export interface IGoldSet { + readonly version: 1; + /** sha256 of the held-out rows as written, so a later edit to them fails the gate. */ + readonly heldoutSha256: string; + readonly rows: readonly IGoldRow[]; +} + +export type CapabilityScope = "mechanic" | "request"; + +export interface IArmSearchResult { + readonly symbol: string; + readonly importPath: string; + readonly signature?: string; + readonly matchedSituation: string; + readonly score: number; +} + +export interface IArmSearchResponse { + readonly verdict: "matched" | "none" | "starterKit"; + readonly results: readonly IArmSearchResult[]; + readonly guidance: string; +} + +export interface IArmModule { + readonly searchCapabilities: ( + situation: string, + manifestFile?: string, + scope?: CapabilityScope, + ) => IArmSearchResponse; + readonly capabilityDetails: ( + symbol: string, + manifestFile?: string, + importPath?: string, + ) => readonly unknown[]; +} + +export interface IArmRow { + readonly id: string; + readonly kind: GoldKind; + readonly split: GoldSplit; + readonly rank: number; + readonly reciprocalRank: number; + readonly searchBytes: number; + readonly detailBytes: number; + readonly detailsOpened: number; + readonly tokensToAnswer: number; + readonly symbols: readonly string[]; +} + +export interface IArmMetric { + readonly rows: number; + readonly recall1: number; + readonly recall5: number; + readonly mrr: number; +} + +export interface IArmReport extends IArmMetric { + readonly answered: number; + readonly tokensToAnswer: number; + readonly searchBytes: number; + readonly detailBytes: number; + readonly byKind: Readonly>; + readonly results: readonly IArmRow[]; +} + +export interface IThreshold { + readonly name: string; + readonly candidate: number; + readonly base: number; + readonly required: string; + readonly pass: boolean; +} + +export interface ILiveRun { + readonly brief: string; + readonly run: number; + readonly toolCalls: number; + readonly responseBytes: number; + readonly capabilityBytes: number; + readonly correct: boolean; + /** A run whose CLI failed is not a cheap run; it is a hole in the measurement. */ + readonly failed: boolean; +} + +export interface ILiveArm { + readonly rows: readonly ILiveRun[]; + readonly toolCalls: number; + readonly responseBytes: number; + readonly capabilityBytes: number; + readonly correctRate: number; + readonly failed: number; +} + +const KINDS: readonly GoldKind[] = ["exact-symbol", "mechanic", "request"]; +const SPLITS: readonly GoldSplit[] = ["dev", "heldout"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function metadataError(message: string): Error { + return new Error(`TN_CAPABILITY_SEARCH_GOLD: ${message}`); +} + +/** The held-out rows exactly as the fixture writes them, so the hash covers content and order. */ +export function heldoutHash(rows: readonly unknown[]): string { + const heldout = rows.filter( + (row) => isRecord(row) && (row as { split?: unknown }).split === "heldout", + ); + return createHash("sha256").update(JSON.stringify(heldout)).digest("hex"); +} + +export function goldRowIdentity(row: IGoldRow): string { + return [row.id, row.query, row.kind, row.split, row.expect.join(","), row.source].join("\u0000"); +} + +/** + * Loads and validates the gold set against the manifest it is measured with. Every expected symbol + * has to exist: a typo in a label turns into a miss that no scorer can fix, and the gate would then + * be measuring the label rather than the search. + */ +export function validateGoldSet(value: unknown, manifestSymbols: ReadonlySet): IGoldSet { + if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.rows)) { + throw metadataError("root must contain version 1 and a rows array"); + } + if (typeof value.heldoutSha256 !== "string" || !/^[0-9a-f]{64}$/u.test(value.heldoutSha256)) { + throw metadataError("heldoutSha256 must be a sha256 hex digest"); + } + const ids = new Set(); + const queries = new Set(); + const rows = value.rows.map((raw, index): IGoldRow => { + if (!isRecord(raw)) throw metadataError(`row ${index} is malformed`); + const { id, query, kind, split, expect, source, note } = raw; + if (typeof id !== "string" || id.trim().length === 0) + throw metadataError(`row ${index} has no id`); + if (typeof query !== "string" || query.trim().length === 0) + throw metadataError(`${id}: query must be a non-empty string`); + if (typeof kind !== "string" || !KINDS.includes(kind as GoldKind)) + throw metadataError(`${id}: kind must be one of ${KINDS.join(", ")}`); + if (typeof split !== "string" || !SPLITS.includes(split as GoldSplit)) + throw metadataError(`${id}: split must be one of ${SPLITS.join(", ")}`); + if (typeof source !== "string" || source.trim().length === 0) + throw metadataError(`${id}: source must be a non-empty string`); + if ( + !Array.isArray(expect) || + expect.length === 0 || + !expect.every((s) => typeof s === "string") + ) + throw metadataError(`${id}: expect must be a non-empty string array`); + if (note !== undefined && typeof note !== "string") + throw metadataError(`${id}: note must be a string when given`); + if (ids.has(id)) throw metadataError(`duplicate row id '${id}'`); + if (queries.has(query)) throw metadataError(`duplicate query '${query}'`); + ids.add(id); + queries.add(query); + for (const symbol of expect) { + if (!manifestSymbols.has(symbol)) { + throw metadataError(`${id}: symbol '${symbol}' is absent from the manifest`); + } + } + return { + expect: [...(expect as string[])], + id, + kind: kind as GoldKind, + ...(note === undefined ? {} : { note }), + query, + source, + split: split as GoldSplit, + }; + }); + const heldout = heldoutHash(value.rows); + if (heldout !== value.heldoutSha256) { + throw metadataError( + `held-out rows changed: sha256 ${heldout} does not match the frozen ${value.heldoutSha256}`, + ); + } + return { heldoutSha256: value.heldoutSha256, rows, version: 1 }; +} + +export async function loadGoldSet( + manifestFile: string, + goldFile = path.resolve(GOLD_RELATIVE_PATH), +): Promise { + const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as { + entries: readonly { symbol: string }[]; + }; + const symbols = new Set(manifest.entries.map((entry) => entry.symbol)); + return validateGoldSet(JSON.parse(await readFile(goldFile, "utf8")), symbols); +} + +/** sha256 over every source file, so the two arms are provably different code. */ +export async function sourceHash(directory: string): Promise { + const files = (await readdir(directory, { recursive: true })) + .map((entry) => path.join(directory, entry)) + .sort(); + const hash = createHash("sha256"); + for (const file of files) { + hash.update(path.relative(directory, file)); + hash.update("\u0000"); + hash.update(await readFile(file)); + hash.update("\u0000"); + } + return hash.digest("hex"); +} + +async function loadArm(sourceDirectory: string): Promise { + const module = (await import(pathToFileURL(path.join(sourceDirectory, "index.ts")).href)) as + | IArmModule + | { default: IArmModule }; + const arm = "searchCapabilities" in module ? module : module.default; + if ( + typeof arm?.searchCapabilities !== "function" || + typeof arm.capabilityDetails !== "function" + ) { + throw metadataError(`${sourceDirectory} does not export the capability search surface`); + } + return arm; +} + +function bytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value)); +} + +/** + * Real response bytes a row costs, plus the detail each top-5 row still forces. A row that carries + * its own `signature` answers by itself; the base row does not, and its rule opens every hit. + */ +export function measureArm(arm: IArmModule, rows: readonly IGoldRow[], manifestFile: string) { + return rows.map((row): IArmRow => { + const response = arm.searchCapabilities( + row.query, + manifestFile, + row.kind === "request" ? "request" : "mechanic", + ); + const symbols = response.results.map((result) => result.symbol); + const rank = symbols.findIndex((symbol) => row.expect.includes(symbol)); + let detailBytes = 0; + let detailsOpened = 0; + for (const result of response.results.slice(0, 5)) { + if (result.signature !== undefined) continue; + detailsOpened += 1; + try { + detailBytes += bytes(arm.capabilityDetails(result.symbol, manifestFile, result.importPath)); + } catch { + // A row whose symbol no longer resolves already costs the author a failed call; count the + // bytes the error itself costs rather than inventing a payload for it. + detailBytes += `Unknown engine capability '${result.symbol}'.`.length; + } + } + const searchBytes = bytes(response); + return { + detailBytes, + detailsOpened, + id: row.id, + kind: row.kind, + split: row.split, + rank, + reciprocalRank: rank < 0 ? 0 : 1 / (rank + 1), + searchBytes, + symbols, + tokensToAnswer: (searchBytes + detailBytes) / 4, + }; + }); +} + +function ratio(count: number, total: number): number { + return total === 0 ? 0 : count / total; +} + +function metricFor(rows: readonly IArmRow[]): IArmMetric { + return { + mrr: + rows.length === 0 ? 0 : rows.reduce((sum, row) => sum + row.reciprocalRank, 0) / rows.length, + recall1: ratio(rows.filter((row) => row.rank === 0).length, rows.length), + recall5: ratio(rows.filter((row) => row.rank >= 0 && row.rank < 5).length, rows.length), + rows: rows.length, + }; +} + +export function reportArm( + arm: IArmModule, + gold: IGoldSet, + manifestFile: string, + split: GoldSplit, +): IArmReport { + const rows = gold.rows.filter((row) => row.split === split); + const measured = measureArm(arm, rows, manifestFile); + const metric = metricFor(measured); + const byKind = Object.fromEntries( + KINDS.map((kind) => [kind, metricFor(measured.filter((row) => row.kind === kind))]), + ) as Record; + // Tokens is what an answer costs, so it is averaged over the rows that reached one. A miss is + // cheap and answers nothing; recall already counts it, and pricing the miss at zero here would + // reward the arm that fails, which is the opposite of what this gate is for. + const answered = measured.filter((row) => row.rank >= 0 && row.rank < 5); + const total = (rows: readonly IArmRow[], key: "searchBytes" | "detailBytes" | "tokensToAnswer") => + rows.reduce((sum, row) => sum + row[key], 0); + return { + ...metric, + answered: answered.length, + byKind, + detailBytes: total(measured, "detailBytes"), + results: measured, + searchBytes: total(measured, "searchBytes"), + tokensToAnswer: answered.length === 0 ? 0 : total(answered, "tokensToAnswer") / answered.length, + }; +} + +/** The AC-3 and AC-4 gates, all on the frozen held-out half. */ +export function compareArms( + base: IArmReport, + candidate: IArmReport, +): { + readonly thresholds: readonly IThreshold[]; + readonly dropped: readonly string[]; +} { + // The same rows on both sides: the arm that answers more rows must not pay for them here, and + // the arm that fails a row must not look cheap because it answered nothing. + const common = base.results + .filter((row) => row.rank >= 0 && row.rank < 5) + .map((row) => row.id) + .filter((id) => { + const other = candidate.results.find((row) => row.id === id); + return other !== undefined && other.rank >= 0 && other.rank < 5; + }); + const commonTokens = (report: IArmReport): number => { + const rows = report.results.filter((row) => common.includes(row.id)); + return rows.length === 0 + ? 0 + : rows.reduce((sum, row) => sum + row.tokensToAnswer, 0) / rows.length; + }; + const thresholds: IThreshold[] = [ + { + base: base.recall5, + candidate: candidate.recall5, + name: "heldout recall@5 gain", + pass: candidate.recall5 >= base.recall5 + 0.1, + required: `>= ${(base.recall5 + 0.1).toFixed(3)} (+0.10)`, + }, + { + base: base.recall1, + candidate: candidate.recall1, + name: "heldout recall@1 gain", + pass: candidate.recall1 >= base.recall1 + 0.05, + required: `>= ${(base.recall1 + 0.05).toFixed(3)} (+0.05)`, + }, + { + base: commonTokens(base), + candidate: commonTokens(candidate), + name: `tokens to answer (${common.length} rows both answer)`, + pass: commonTokens(candidate) <= commonTokens(base) * 0.7, + required: `<= ${(commonTokens(base) * 0.7).toFixed(0)} (-30%)`, + }, + ]; + const dropped = base.results + .filter((row) => row.rank >= 0 && row.rank < 5) + .filter((row) => { + const after = candidate.results.find((other) => other.id === row.id); + return after === undefined || after.rank < 0 || after.rank >= 5; + }) + .map((row) => row.id); + thresholds.push({ + base: 0, + candidate: dropped.length, + name: "queries that leave the top 5", + pass: dropped.length <= 1, + required: "<= 1", + }); + return { dropped, thresholds }; +} + +const LIVE_BRIEFS: readonly { readonly query: string; readonly expect: readonly string[] }[] = [ + { expect: ["godrays"], query: "volumetric sun shafts through a window into a dusty room" }, + { + expect: ["traa"], + query: "the TRAA stage that resolves jittered frames on alpha-tested foliage", + }, + { + expect: ["mergeParts"], + query: "merge many static meshes into one mesh per material, keeping authored UVs and normals", + }, + { + expect: ["NavigationAgent3D"], + query: "an enemy that walks around walls to reach the player", + }, + { expect: ["GPUParticles3D"], query: "dust particles trailing behind a running player" }, + { expect: ["SoftBody3D"], query: "cloth sails that billow in the wind on a ship" }, + { expect: ["Joint3D"], query: "a hinged door that swings on a pivot when pushed" }, + { expect: ["texturePass"], query: "compile textures to BC7 blocks for the GPU" }, + { expect: ["RigidBody3D"], query: "a cannonball that falls, bounces and rolls on the ground" }, + { + expect: ["InstancedBatch"], + query: "thousands of identical blocks drawn in one instanced batch", + }, +]; + +export const BASE_RULE = + "Read every capability hit with engine_capability_detail before you use it."; +export const CANDIDATE_RULE = + "Read every capability hit's row. Call engine_capability_detail only for a symbol the search did not return."; + +interface IClaudeStreamEvent { + readonly type?: string; + readonly result?: string; + readonly message?: { + readonly content?: readonly { + readonly type?: string; + readonly id?: string; + readonly name?: string; + readonly content?: readonly { readonly type?: string; readonly text?: string }[]; + }[]; + }; +} + +interface ILiveParse { + readonly answer: string; + readonly toolCalls: number; + readonly responseBytes: number; + /** Bytes the capability answer itself costs: the search payload without its reference stubs. */ + readonly capabilityBytes: number; +} + +/** + * A search response also carries three reference stubs, which this change does not touch and which + * dominate a short response. They are counted separately so the byte comparison measures the rows + * that changed rather than a constant every arm pays. + */ +function parseLiveOutput(text: string): ILiveParse { + let toolCalls = 0; + let responseBytes = 0; + let capabilityBytes = 0; + let answer = ""; + const tools = new Map(); + for (const line of text.split("\n")) { + if (line.trim().length === 0) continue; + let event: IClaudeStreamEvent; + try { + event = JSON.parse(line) as IClaudeStreamEvent; + } catch { + continue; + } + if (event.type === "result" && typeof event.result === "string") answer = event.result; + for (const block of event.message?.content ?? []) { + if (block.type === "tool_use" && block.name?.startsWith("mcp__engine__") === true) { + toolCalls += 1; + if (block.id !== undefined) tools.set(block.id, block.name); + } + if (block.type !== "tool_result") continue; + const result = block as { + readonly tool_use_id?: string; + readonly content?: readonly { readonly type?: string; readonly text?: string }[]; + }; + const name = result.tool_use_id === undefined ? undefined : tools.get(result.tool_use_id); + for (const part of result.content ?? []) { + if (part.type !== "text" || typeof part.text !== "string") continue; + const size = Buffer.byteLength(part.text); + responseBytes += size; + if (name !== "mcp__engine__engine_search_capabilities") { + capabilityBytes += size; + continue; + } + let withoutReferences = size; + try { + const payload = JSON.parse(part.text) as { references?: unknown }; + // Measured by re-serializing the payload without its stubs as the old server printed it, so + // the payload-only figure compares indented bytes to indented bytes and takes the + // serialization change out of it rather than crediting it twice. + withoutReferences = Buffer.byteLength( + JSON.stringify({ ...payload, references: undefined }, null, 2), + ); + } catch { + withoutReferences = size; + } + capabilityBytes += Math.min(size, withoutReferences); + } + } + } + return { answer, capabilityBytes, responseBytes, toolCalls }; +} + +function claudeBinary(): string { + return process.env.CLAUDE_BIN ?? "claude"; +} + +/** + * Live arm: the same ten briefs, each asked once through the arm's own MCP server with the arm's + * own rule as the prompt prefix. Counted are the MCP calls the agent made, the bytes those calls + * returned, and whether the answer named a correct symbol — a smaller row that costs one more + * round trip is not a win, so bytes and calls are both reported. + */ +async function runLiveArm( + name: "base" | "candidate", + sourceDirectory: string, + manifestFile: string, + runs: number, +): Promise { + const root = process.cwd(); + const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs"); + const workdir = await mkdtemp(path.join(os.tmpdir(), `tn-search-ab-${name}-`)); + const configFile = path.join(workdir, "mcp.json"); + await writeFile( + configFile, + `${JSON.stringify( + { + mcpServers: { + engine: { + args: [tsx, path.join(sourceDirectory, "index.ts")], + command: process.execPath, + env: { + THREENATIVE_CAPABILITIES_MANIFEST: manifestFile, + THREENATIVE_ENGINE_MCP_LOG: path.join(workdir, "engine-mcp.log"), + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + const rule = name === "base" ? BASE_RULE : CANDIDATE_RULE; + const rows: ILiveRun[] = []; + try { + for (const brief of LIVE_BRIEFS) { + for (let run = 1; run <= runs; run += 1) { + const prompt = `${rule} Find the engine capability for: ${brief.query}. Print its import line and its signature. Write no code and no files.`; + let stdout = ""; + try { + stdout = execFileSync( + claudeBinary(), + [ + "-p", + "--model", + "sonnet", + "--strict-mcp-config", + "--mcp-config", + configFile, + "--output-format", + "stream-json", + "--verbose", + "--allowedTools", + "mcp__engine__engine_search_capabilities,mcp__engine__engine_capability_detail", + ], + // The prompt goes on stdin: `claude -p` refuses a positional prompt after its flags. + { + cwd: workdir, + encoding: "utf8", + input: prompt, + maxBuffer: 64 * 1024 * 1024, + timeout: 300_000, + }, + ); + } catch (error) { + process.stderr.write(`${name}: live run failed for '${brief.query}': ${String(error)}\n`); + rows.push({ + brief: brief.query, + capabilityBytes: 0, + correct: false, + failed: true, + responseBytes: 0, + run, + toolCalls: 0, + }); + continue; + } + const parsed = parseLiveOutput(stdout); + rows.push({ + brief: brief.query, + capabilityBytes: parsed.capabilityBytes, + correct: brief.expect.some((symbol) => parsed.answer.includes(symbol)), + failed: false, + responseBytes: parsed.responseBytes, + run, + toolCalls: parsed.toolCalls, + }); + } + } + } finally { + await rm(workdir, { force: true, recursive: true }); + } + const completed = rows.filter((row) => !row.failed); + return { + capabilityBytes: rows.reduce((sum, row) => sum + row.capabilityBytes, 0), + correctRate: ratio(completed.filter((row) => row.correct).length, completed.length), + failed: rows.filter((row) => row.failed).length, + responseBytes: rows.reduce((sum, row) => sum + row.responseBytes, 0), + rows, + toolCalls: rows.reduce((sum, row) => sum + row.toolCalls, 0), + }; +} + +export function formatTable(arm: string, report: IArmReport, base?: IArmReport): string { + const delta = (value: number, other: number | undefined) => + other === undefined ? "" : ` (${value - other >= 0 ? "+" : ""}${(value - other).toFixed(3)})`; + const lines = [ + `${arm}: ${report.rows} rows (${report.answered} answered) recall@1 ${report.recall1.toFixed(3)}${delta(report.recall1, base?.recall1)} recall@5 ${report.recall5.toFixed(3)}${delta(report.recall5, base?.recall5)} MRR ${report.mrr.toFixed(3)}${delta(report.mrr, base?.mrr)} tokens ${report.tokensToAnswer.toFixed(0)}${delta(report.tokensToAnswer, base?.tokensToAnswer)}`, + ]; + for (const kind of KINDS) { + const metric = report.byKind[kind]; + const other = base?.byKind[kind]; + lines.push( + ` ${kind.padEnd(12)} rows ${String(metric.rows).padStart(3)} recall@1 ${metric.recall1.toFixed(3)}${delta(metric.recall1, other?.recall1)} recall@5 ${metric.recall5.toFixed(3)}${delta(metric.recall5, other?.recall5)}`, + ); + } + return lines.join("\n"); +} + +function hasFlag(args: readonly string[], flag: string): boolean { + return args.includes(flag); +} + +function flagValue(args: readonly string[], flag: string): string | undefined { + const index = args.indexOf(flag); + return index < 0 ? undefined : args[index + 1]; +} + +export async function main(args: readonly string[]): Promise { + const root = process.cwd(); + const base = flagValue(args, "--base") ?? "ecd2a1a56"; + if (!/^[0-9a-f]{7,40}$/u.test(base)) { + process.stderr.write(`--base must be a commit sha, got '${base}'\n`); + return 1; + } + const manifestFile = path.resolve(root, MANIFEST_RELATIVE_PATH); + const reportOnly = hasFlag(args, "--report-only"); + const workdir = await mkdtemp(path.join(os.tmpdir(), "tn-search-ab-base-")); + try { + const archive = execFileSync("git", ["archive", base, SOURCE_RELATIVE_PATH], { + cwd: root, + maxBuffer: 64 * 1024 * 1024, + }); + execFileSync("tar", ["-x", "-C", workdir], { input: archive }); + const baseDirectory = path.join(workdir, SOURCE_RELATIVE_PATH); + if (!existsSync(path.join(baseDirectory, "index.ts"))) { + process.stderr.write(`TN_CAPABILITY_SEARCH_AB: ${base} has no ${SOURCE_RELATIVE_PATH}\n`); + return 1; + } + const candidateDirectory = path.resolve(root, SOURCE_RELATIVE_PATH); + const baseHash = await sourceHash(baseDirectory); + const candidateHash = await sourceHash(candidateDirectory); + process.stdout.write( + `base ${base} ${baseHash}\ncandidate ${"working tree".padEnd(base.length)} ${candidateHash}\n`, + ); + if (baseHash === candidateHash) { + process.stderr.write( + `TN_CAPABILITY_SEARCH_AB: both arms are the same source (${baseHash}); nothing to compare\n`, + ); + return 1; + } + const gold = await loadGoldSet(manifestFile, path.resolve(root, GOLD_RELATIVE_PATH)); + const baseArm = await loadArm(baseDirectory); + const candidateArm = await loadArm(candidateDirectory); + const baseHeldout = reportArm(baseArm, gold, manifestFile, "heldout"); + const candidateHeldout = reportArm(candidateArm, gold, manifestFile, "heldout"); + const baseDev = reportArm(baseArm, gold, manifestFile, "dev"); + const candidateDev = reportArm(candidateArm, gold, manifestFile, "dev"); + process.stdout.write( + `\nheld-out (${gold.heldoutSha256.slice(0, 12)})\n${formatTable("base ", baseHeldout)}\n${formatTable("candidate", candidateHeldout, baseHeldout)}\n`, + ); + process.stdout.write( + `\ndev (tuning only)\n${formatTable("base ", baseDev)}\n${formatTable("candidate", candidateDev, baseDev)}\n`, + ); + const { dropped, thresholds: offlineThresholds } = compareArms(baseHeldout, candidateHeldout); + const thresholds: IThreshold[] = [...offlineThresholds]; + process.stdout.write("\ngates\n"); + for (const threshold of thresholds) { + process.stdout.write( + ` ${threshold.pass ? "pass" : "FAIL"} ${threshold.name}: ${threshold.candidate.toFixed(3)} (required ${threshold.required})\n`, + ); + } + if (dropped.length > 0) process.stdout.write(` left the top 5: ${dropped.join(", ")}\n`); + const live = hasFlag(args, "--live") + ? { + base: await runLiveArm( + "base", + baseDirectory, + manifestFile, + Number(flagValue(args, "--runs") ?? 1), + ), + candidate: await runLiveArm( + "candidate", + candidateDirectory, + manifestFile, + Number(flagValue(args, "--runs") ?? 1), + ), + } + : undefined; + if (live !== undefined) { + process.stdout.write( + `\nlive (${String(LIVE_BRIEFS.length)} briefs)\n base calls ${live.base.toolCalls} capability bytes ${live.base.capabilityBytes} all bytes ${live.base.responseBytes} correct ${live.base.correctRate.toFixed(2)}\n candidate calls ${live.candidate.toolCalls} capability bytes ${live.candidate.capabilityBytes} all bytes ${live.candidate.responseBytes} correct ${live.candidate.correctRate.toFixed(2)}\n`, + ); + thresholds.push( + { + base: 0, + candidate: live.base.failed + live.candidate.failed, + name: "live runs that completed", + pass: live.base.failed === 0 && live.candidate.failed === 0, + required: "0", + }, + { + base: live.base.toolCalls, + candidate: live.candidate.toolCalls, + name: "live MCP tool calls", + pass: live.candidate.toolCalls <= live.base.toolCalls * 0.7, + required: `<= ${(live.base.toolCalls * 0.7).toFixed(0)} (-30%)`, + }, + { + base: live.base.responseBytes, + candidate: live.candidate.responseBytes, + name: "live MCP response bytes", + pass: live.candidate.responseBytes <= live.base.responseBytes * 0.7, + required: `<= ${(live.base.responseBytes * 0.7).toFixed(0)} (-30%)`, + }, + { + base: live.base.correctRate, + candidate: live.candidate.correctRate, + name: "live correct-symbol rate", + pass: live.candidate.correctRate >= live.base.correctRate, + required: `>= ${live.base.correctRate.toFixed(2)}`, + }, + ); + process.stdout.write( + ` note capability payload only (reference stubs excluded): ${live.base.capabilityBytes} -> ${live.candidate.capabilityBytes} (${((live.candidate.capabilityBytes / live.base.capabilityBytes - 1) * 100).toFixed(1)}%)\n`, + ); + for (const threshold of thresholds.slice(-4)) { + process.stdout.write( + ` ${threshold.pass ? "pass" : "FAIL"} ${threshold.name}: ${threshold.candidate} (required ${threshold.required})\n`, + ); + } + } + const artifactFile = path.resolve(root, ARTIFACT_RELATIVE_PATH); + await mkdir(path.dirname(artifactFile), { recursive: true }); + await writeFile( + artifactFile, + `${JSON.stringify( + { + base: { hash: baseHash, ref: base }, + candidate: { hash: candidateHash, ref: "working tree" }, + generatedAt: new Date().toISOString(), + gold: { heldoutSha256: gold.heldoutSha256, rows: gold.rows.length }, + live, + offline: { + base: { dev: baseDev, heldout: baseHeldout }, + candidate: { dev: candidateDev, heldout: candidateHeldout }, + dropped, + }, + thresholds, + }, + null, + 2, + )}\n`, + "utf8", + ); + process.stdout.write(`\nartifact ${path.relative(root, artifactFile)}\n`); + const failed = thresholds.filter((threshold) => !threshold.pass); + if (failed.length === 0 || reportOnly) return 0; + process.stderr.write( + `TN_CAPABILITY_SEARCH_AB: ${failed.map((threshold) => threshold.name).join(", ")} below target\n`, + ); + return 1; + } finally { + await rm(workdir, { force: true, recursive: true }); + } +} + +const entryPath = process.argv[1]; +if (entryPath !== undefined && path.resolve(entryPath) === fileURLToPath(import.meta.url)) { + void main(process.argv.slice(2)).then((code) => { + process.exitCode = code; + }); +}