diff --git a/docs/scene-7-todo.md b/docs/scene-7-todo.md index 850d93f..2d1beaa 100644 --- a/docs/scene-7-todo.md +++ b/docs/scene-7-todo.md @@ -1,10 +1,18 @@ -# Scene 7: prove Barricelli was an inventor +Scene 7 teaches one idea: a screenshot found during an OSINT search can become +source evidence. -Status: **implemented and verified** on `scene-7-evidence-goal` -Demo: **GUPI Demo 1 — The Barricelli Files** +The player opens an otherwise minimal OSINT board, reads the assignment +**“Demonstrate OSINT skill: prove Nils Aall Barricelli was an inventor,”** finds +the relevant Google Patents result, and pastes or uploads one screenshot. The +board creates a document, extracts its text, recognizes the source, and clears +the level. A URL and a written report are not required. -## Outcome +The normal path must feel like one continuous action: +```text +paste screenshot -> document appears -> scanning feedback -> source verified + -> Scene 7 complete -> continue to Scene 8 +======= Scene 7 teaches two linked ideas: a screenshot found during an OSINT search can become source evidence, and a finding is only as useful as the report that cites and explains that evidence. @@ -18,10 +26,123 @@ Claim, completes the evidentiary statement, and files the generated Case Report. ```text paste screenshot -> document appears -> source is verified -> connect to Claim -> submit thin report -> provenance feedback -> accepted -> Scene 8 -``` ## Product decisions +These are decisions for this slice, not open design questions: + +- One suitable Google Patents screenshot is sufficient evidence. +- Pasting and file upload are equivalent inputs and use the same server path. +- The uploaded image and extracted text are retained as a real document on the + player's level. +- The known patent source is recognized with deterministic OCR/fuzzy matching. + This is the fast, cheap, reproducible victory path. +- A small LLM judge is a semantic fallback for other credible evidence and for + distinguishing Nils from his father. It must not overrule a trusted known-source + match. +- The player does not have to provide a URL when the screenshot text itself + establishes provenance. +- Evidence about Barricelli's father may unlock an optional discovery, but it + must not clear the assignment unless the evidence also supports the claim + about **Nils Aall Barricelli**. +- The boarding-house fire article belongs to the later age/rescue assignment, + not to Scene 7's inventor victory condition. +- Scene 7 records completion; Scene 8 owns the merit ceremony and awards + `barricelli_luggage`. +- A failed or inconclusive evaluation never deletes the uploaded document and + never penalizes the player. + +## Existing foundation — reuse it + +Do not build a second upload, OCR, flag, or story system for this scene. + +- Migration `025_level_document_flags.sql` provides clonable document gates, + level flags, and reveal state. +- Migration `026_achievements.sql` provides playthrough achievements. +- Migration `027_evidence_text_matching.sql` provides immutable asset text + extractions, board-owned match rules/anchors, level-owned evaluations, and + auditable flag awards. +- `server/ocr.ts` extracts plain text and runs Tesseract for images. +- `server/evidenceMatching.ts` implements normalized fuzzy anchor matching. +- `POST /api/levels/:id/documents` already persists the document, OCR result, + deterministic evaluations, and newly awarded level flags. +- Screenshot paste already routes through document upload in the board UI. +- The story graph already has level nodes and playthroughs with + `current_node_id` and `current_level_id`. + +The deterministic matcher has already handled noisy historic OCR, including a +hyphenated `Bar- ricelli`, at useful confidence. Scene 7 should add authored +patent anchors and a completion contract, not replace that matcher. + +## Proposed flags and identifiers + +Keep all identifiers authored in template data; these names are the recommended +contract between independently developed branches. + +| Purpose | Key | +|---|---| +| Level goal | `barricelli.inventor-proof` | +| Scene 7 completion flag | `scene7.nils_inventor_proved` | +| Optional father discovery | `scene7.father_inventor_discovered` | +| Scene 8 reward | `barricelli_luggage` | + +The first three are Scene 7 state. The last is a playthrough achievement awarded +by Scene 8, never by the document upload endpoint. + +## Architecture + +### 1. Separate recognition from completion + +Recognition answers **“what does this document support?”** Completion answers +**“are this level's authored requirements now satisfied?”** Do not hide level +completion in a React conditional or special-case `Barricelli` in server code. + +Add a small, generic, board-owned goal model in the next migration (currently +expected to be `028`; verify the migration number immediately before creating +it): + +- `level_goals` + - belongs to a board and clones with a template; + - has a stable `goal_key`, player-facing title/instructions, enabled state, and + optional completion message; + - uses the existing `origin_*` pattern for cloned authoring objects. +- `level_goal_flag_requirements` + - maps a goal to one or more required level `flag_key` values; + - Scene 7 has one requirement: `scene7.nils_inventor_proved`; + - all requirements are required for the first implementation. Add `any/all` + policy only when a real authored level needs it. + +Goal state is derived from level flags; do not add a second mutable `completed` +boolean that can drift out of sync. If completion needs a timestamp, record a +single idempotent goal-completion event with provenance. + +### 2. Known-source fast path + +Author one enabled `evidence_match_rule` on the Scene 7 template board. Its +anchors should be distinctive passages visible in the actual Google Patents +screenshot, such as a combination of patent number/title, inventor name, and +invention language. Do not rely on the name alone. + +When enough anchors pass their authored thresholds, the existing evaluation +awards `scene7.nils_inventor_proved`. The goal requirement consequently becomes +satisfied in the same upload transaction. + +The reference OCR, anchor phrases, thresholds, canonical source metadata, and +player-facing copy are template data. None belong in TypeScript constants or +React branches. Expected/reference text must not be returned in play-mode API +responses. + +### 3. Semantic fallback, not a free-form LLM gate + +Add a provider-independent `EvidenceJudge` interface in a new server module. It +receives only allowlisted goal data and extracted OCR text and returns validated +structured data, for example: + +```ts +type EvidenceVerdict = { + subject: 'nils' | 'father' | 'ambiguous' | 'neither' + supportsInventorClaim: boolean +======= - One suitable Google Patents screenshot is sufficient evidence, but not by itself a complete investigation. - Clipboard paste and file upload use the same server path. @@ -113,124 +234,264 @@ type EvidenceVerdict = { } ``` -OCR is untrusted quoted material. The judge prompt explicitly ignores instructions -inside it. Provider/model, timeout, input limit, and confidence threshold are -environment configuration. Semantic configuration clones with the board; -evaluation history belongs to the level/document/extraction and records evaluator -version, provider/model, verdict, excerpt, confidence, timestamps, and sanitized -failure state. +The provider/model name comes from environment configuration. Do not hard-code a +Claude model identifier into level content or business logic. Treat OCR as +untrusted quoted material: the prompt must explicitly ignore instructions found +inside it, and the response must pass a strict schema before it can award a flag. -| Verdict | Mutation | +Persist semantic rule configuration with the board and clone it with the +template. Persist each evaluation against the level, document, extraction, +rule/evaluator version, model/provider, verdict, excerpt, confidence, timestamps, +and sanitized failure state. Add semantic-evaluation provenance to any level flag +it awards. Never put provider credentials in PostgreSQL. + +Verdict routing for this scene: + +| Verdict | Result | |---|---| -| Target + assertion supported at threshold | authored success flag (`scene7.nils_inventor_proved`) | -| Related subject only + assertion supported | authored related flag (`scene7.father_inventor_discovered`) | -| Ambiguous, unsupported, or below threshold | none | -| Provider failure | none; retryable | +| Nils + inventor claim supported, high confidence | award `scene7.nils_inventor_proved` | +| Father only + inventor claim supported | award `scene7.father_inventor_discovered`; do not complete | +| Ambiguous, neither, unsupported, or below threshold | retain document; award nothing | +| Provider unavailable/invalid response | retain document; mark evaluation retryable | -The known-source pass avoids an LLM call. After a deterministic miss, the client -automatically calls an idempotent semantic endpoint. This keeps document upload -durable even if an external provider times out, without requiring a demo job queue. +Keep this evaluator narrower than the generic story-graph `llm_gate`. Scene 7 is +judging a single uploaded source, not a report or arbitrary player state. -### Story progression +### 4. Two-step server flow -Advancing from a level is server-authoritative. The server verifies the JWT user, -active playthrough/current level, and enabled goal state. It promotes the Scene 7 -completion fact idempotently and only then follows the level terminal. The browser -cannot grant its own achievement. +The primary Google Patents path remains synchronous and deterministic: -The player must see the verification result before navigation. Success exposes a -single Continue action to Scene 8. Scene 6 only wires into the Scene 7 level node; -Scene 8 owns the luggage reward. +1. Upload/paste persists the asset, document, OCR extraction, fuzzy evaluation, + flags, and current goal state in one transaction. +2. If the trusted rule clears the goal, return success immediately and do not + spend an LLM call. +3. If OCR succeeded but no trusted rule clears the goal, the client automatically + calls an idempotent semantic-judge endpoint for that document. +4. The semantic endpoint uses a strict timeout, persists its result, and returns + refreshed goal state. A timeout is retryable and cannot roll back the upload. + +This avoids coupling document durability to an external provider without +requiring a job queue for the demo. Make semantic evaluation idempotent for the +same `(level, document, goal/rule, evaluator_version)`. + +### 5. Story progression contract + +Completing a board goal must be a server-authoritative transition: + +- verify that the JWT user owns the active playthrough; +- verify that its `current_level_id` is the level being evaluated; +- observe the derived completed goal; +- idempotently record/promote `scene7.nils_inventor_proved` into the playthrough + state needed by the story runtime; +- expose Scene 7's successful terminal so the player can continue to Scene 8. + +Do not let the browser award achievements through the current development-only +achievement route. Do not make upload silently navigate before the player sees +what was learned. Show the verification result, then expose a single **Continue** +action (or a short authored transition that ends in the same action). + +The Scene 6 branch only needs to route its successful terminal to the Scene 7 +level node. The Scene 8 branch may depend on the completion state above and owns +the `barricelli_luggage` award. ## Work packages -### S7-A — Goal model and cloning +The packages are ordered for integration, but most implementation can happen on +separate branches after the contracts above are agreed. -- [x] Add normalized board goals and flag requirements in migration `028`. -- [x] Clone goals during freeze, instantiation, and reset. -- [x] Derive pending/complete state from level flags. -- [x] Hide goal requirements from play payloads. -- [x] Return newly completed goals from document upload. -- [x] Add admin CRUD repository/API contracts. -- [x] Test migration, cloning, deterministic completion, and isolation. -- [x] Clear level flags/reveal state when resetting from a template. +### S7-A — Goal model and template cloning -### S7-B — Barricelli content and trusted recognition +- [ ] Confirm the next free migration number; never edit applied migrations + `025`–`027`. +- [ ] Add `level_goals` and `level_goal_flag_requirements` with board-scoped + foreign keys, uniqueness, indexes, and comments. +- [ ] Extend template freeze/clone/instantiate so goals and requirements are + copied and retain origin provenance. +- [ ] Derive `pending | complete` goal state from the level's current flags. +- [ ] Add repository tests for cloning, isolation between two playthroughs, and + idempotent completion. +- [ ] Keep the schema generic; there must be no Barricelli-specific column or + table. -- [x] Add goals and evidence-match rules to the normal mystery manifest importer. -- [x] Create the Scene 7 template with its assignment and no solution-bearing - starting document. -- [x] Add a reproducible screenshot-paste acceptance path that runs through local OCR. -- [x] Author three distinctive patent anchors. -- [x] Tune against the target and unrelated/father-only negative fixtures. -- [x] Award `scene7.nils_inventor_proved` and require it for the level goal. -- [x] Store canonical source metadata for administrators; keep player URL optional. +### S7-B — Scene content and deterministic recognition + +- [ ] Create/import the Scene 7 template and its brief as data. +- [ ] Start the board without any solution-bearing document. +- [ ] Obtain the exact target Google Patents screenshot used for acceptance and + run it through the local OCR service. +- [ ] Author two or more distinctive match anchors from that extraction; avoid a + generic `Nils Barricelli`-only rule. +- [ ] Tune thresholds against the target screenshot plus negative fixtures. +- [ ] Configure the rule to award `scene7.nils_inventor_proved`. +- [ ] Configure the goal requirement to consume that flag. +- [ ] Store canonical patent/source metadata for administrators, while keeping a + pasted URL optional for players. +- [ ] Add the content to the normal manifest/import path rather than SQL seed + literals or frontend code. ### S7-C — Semantic judge -- [x] Add provider-neutral interface and strict verdict validation. -- [x] Add clonable semantic rule configuration and level-owned evaluations. -- [x] Add semantic evaluation provenance to level flags. -- [x] Add provider/model/timeout/input/confidence environment configuration. -- [x] Send OCR text rather than raw image bytes. -- [x] Add ownership-checked, idempotent judge endpoint. -- [x] Route target and father verdicts to their authored flags. -- [x] Make disabled provider, timeout, quota, and malformed output safe/retryable. -- [x] Do not log evidence text or secrets. +- [ ] Add the provider-neutral `EvidenceJudge` interface and strict verdict + schema. +- [ ] Add board-owned semantic rule configuration and level-owned evaluation + history with clone support and flag provenance. +- [ ] Add environment variables for provider, model, timeout, maximum OCR + characters, and confidence threshold; document safe defaults in + `.env.example` without overwriting concurrent OCR configuration work. +- [ ] Send extracted text, not raw image bytes, unless a later explicit design + requires a vision model. +- [ ] Delimit and escape untrusted OCR content in the prompt. +- [ ] Add an authenticated, ownership-checked, idempotent document-judge endpoint. +- [ ] Award the completion or father-discovery flag only from validated persisted + verdicts. +- [ ] Make timeouts, malformed responses, quota failures, and disabled provider + safe and retryable. +- [ ] Do not log full evidence text or provider credentials. -### S7-D — Story bridge +### S7-D — API and story bridge -- [x] Require completed enabled goals before a playthrough can leave a level. -- [x] Verify the current playthrough belongs to the JWT user and owns the level. -- [x] Promote required completion facts exactly once. -- [x] Return a useful pending-goal error rather than advancing early. -- [x] Return to the generic story runtime after success; the Scene 6/8 graph owner - wires the actual neighboring nodes. -- [x] Keep the arbitrary achievement-grant route development-only. +- [ ] Return compact goal state from the level response and document-upload + response: goal key, status, newly completed state, and player-facing message. +- [ ] Never return reference anchors, expected text, private evaluator prompts, + or unpublished author data in play mode. +- [ ] Add the semantic fallback endpoint/result to the typed client API. +- [ ] Resolve the active playthrough for the level and enforce user ownership. +- [ ] Promote completion server-side exactly once. +- [ ] Make Scene 7's success terminal available only after the required goal is + complete. +- [ ] Route that terminal to the Scene 8 node without implementing Scene 8's + ceremony in this branch. +- [ ] Remove or fence the player-facing development route that can arbitrarily + grant achievements before production deployment. ### S7-E — Board experience -- [x] Present the active goal prominently on Scene 7. -- [x] Preserve clipboard paste, drag/drop, and file picker equivalence. -- [x] Show source import/OCR and semantic checking stages. -- [x] Highlight the accepted document and show **SOURCE VERIFIED — NILS AALL - BARRICELLI: INVENTOR**. -- [x] Show Continue only after server-confirmed completion. -- [x] Acknowledge father-only discovery while asking for evidence about Nils. -- [x] Keep inconclusive evidence and offer neutral guidance. -- [x] Respect reduced-motion and mobile layouts. +- [ ] Show the exact assignment prominently when Scene 7 opens. +- [ ] Preserve both clipboard paste and drag/file upload; both call the same API. +- [ ] Place the pasted screenshot as a new image document using the normal board + placement rules. +- [ ] Show restrained stages such as **Saving source**, **Reading text**, and + **Checking evidence** without blocking board interaction unnecessarily. +- [ ] On success, visually identify the accepted document and show: + **SOURCE VERIFIED — NILS AALL BARRICELLI: INVENTOR**. +- [ ] After the player sees the result, expose one **Continue** action to Scene 8. +- [ ] On father-only evidence, acknowledge the useful discovery and make clear + that evidence about Nils is still required. +- [ ] On inconclusive evidence, keep the document and provide neutral guidance; + do not say that the player is wrong. +- [ ] Respect reduced-motion settings and provide readable mobile feedback. +- [ ] Do not introduce Scene 7 checks into generic exhibit components. -### S7-F — Verification +### S7-F — Tests and acceptance fixtures -- [x] Add a legally safe derived OCR fixture. -- [x] Test line breaks, punctuation, cropping, name hyphenation, unrelated patent, - father-only text, empty OCR, and prompt-injection-like text. -- [x] Contract-test with a fake semantic provider; CI never calls a paid model. -- [x] Integration-test target upload -> document -> flag -> completed goal. -- [x] Test duplicate evaluation, provider failure/retry, two-user isolation, and - story progression before/after completion. -- [x] Browser-test clipboard image paste through OCR and verification. Continue - is covered by the generic story-gate integration until the Scene 8 node lands. -- [x] Run migrations on an empty database and one currently at `027`. -- [x] Run unit, integration, build, and Docker smoke tests. +- [ ] Add the actual Google Patents screenshot as a legally appropriate test + fixture, or store a compact derived OCR fixture if redistributing the image is + undesirable. +- [ ] Unit-test OCR normalization and fuzzy matching for realistic line breaks, + punctuation, cropping, and name hyphenation. +- [ ] Add negative fixtures: unrelated patent, father-only evidence, a generic + Barricelli biography, low-quality/empty OCR, and prompt-injection-like text. +- [ ] Contract-test the semantic judge with a fake provider; CI must not call a + paid external model. +- [ ] Integration-test target upload -> one document -> completion flag -> goal + complete, including a repeat upload/evaluation. +- [ ] Integration-test father-only -> discovery flag -> goal still pending. +- [ ] Integration-test provider failure -> document retained -> retry succeeds. +- [ ] Integration-test two users/playthroughs so one player's evidence cannot + complete another player's level. +- [ ] Browser-test clipboard paste through the success state and Continue action. +- [ ] Run migrations against an empty database and an existing database at + migration `027`. +- [ ] Run the full unit/integration suite, production build, and Docker smoke test. -## API shape +## API shape to converge on + +Exact route naming may follow the repository's conventions, but the frontend and +backend branches should agree on a compact result like this before coding: ```ts -type LevelGoal = { +type LevelGoalState = { key: string title: string - instructions: string - completionMessage: string status: 'pending' | 'complete' - completedAt?: string newlyCompleted: boolean + message?: string } type DocumentAnalysis = { extractionStatus: 'succeeded' | 'unsupported' | 'failed' matchedFlags: string[] awardedFlags: string[] + semanticStatus: 'not_needed' | 'available' | 'pending' | 'succeeded' | 'failed' + goals: LevelGoalState[] +} +``` + +`newlyCompleted` describes this mutation's effect and is not persisted as goal +state. Re-fetching a completed level returns `status: 'complete'` and +`newlyCompleted: false`. + +## Security, privacy, and cost limits + +- Player endpoints require the same JWT identity and level ownership checks as + playthrough progression; admin authoring remains admin-only. +- Limit upload bytes, OCR text sent to the model, model output tokens, request + duration, and retries. +- Do not expose answer anchors or semantic judging instructions to the browser. +- Do not trust filenames, MIME declarations, OCR text, or model output. +- Use schema validation and a confidence threshold before mutating flags. +- Store enough provenance to explain why a level cleared without retaining + unnecessary provider request/response payloads. +- A deterministic trusted-source match saves cost and is authoritative. The LLM + is never called merely to reconfirm it. + +## Explicitly out of scope + +- Terminal game, Glitch University signup, Dobby, and Glitch Hunter scenes + (Scenes 1–6). +- Scene 8's ceremony/3D luggage implementation and Scene 9's fire mystery. +- A general knowledge graph, Case Report, claims, red-thread reasoning, or + multi-document synthesis. +- Crawling the web, fetching a pasted URL, or validating a URL as a victory + requirement. +- Training a custom OCR or language model. +- Generalizing the story graph's future `llm_gate`; this slice may share a + provider adapter later, but does not depend on that larger feature. +- Automatic rejection or deletion of irrelevant player evidence. + +## Merge guidance for independent branches + +Prefer new modules and narrow glue commits. Current high-conflict files include +`server/index.ts`, `server/narrativeRepository.ts`, `src/App.tsx`, `src/main.tsx`, +and the play entrypoint. Assign one integrator to make the final small changes in +those files after the isolated work lands. + +Suggested merge order: + +1. S7-A schema/repository and clone support. +2. S7-B authored content and deterministic fixtures. +3. S7-C judge service/evaluation persistence. +4. S7-D story/API glue. +5. S7-E UI. +6. S7-F acceptance hardening. + +Each branch should state its migration dependency and avoid renumbering an +already-shared migration silently. If two branches need schema changes, reserve +migration numbers before implementation or keep one branch schema-free. + +## Definition of done + +From a fresh playthrough, a player reaches Scene 7 and sees the inventor +assignment. They paste one accepted Google Patents screenshot. One source +document appears on their board, the server persists the asset and OCR, the +authored match rule records an auditable evaluation, and the level obtains +`scene7.nils_inventor_proved`. The UI clearly confirms what the evidence proved +and offers Continue; the story then enters Scene 8. Reloading preserves the +document and completed state, repeating the evaluation grants nothing twice, +another player's level is unaffected, no URL was required, and +`barricelli_luggage` has not yet been awarded. + +======= goals: LevelGoal[] } ``` @@ -273,6 +534,4 @@ They paste one accepted Google Patents screenshot, connect Exhibit 1 to the Clai and see **“Proof that…”** appear in the typewriter report. The first thin submission passes the evidence but is returned for provenance; adding the date, source citation, and a proper evidentiary statement produces an accepted report and enables Continue. -MinIO asset, OCR, match provenance, stable exhibit number, connection, report -submissions, and `scene7.nils_inventor_proved` persist. A URL improves the report -but remains optional, and `barricelli_luggage` is not awarded until Scene 8. +inIO asset, OCR, match provenance, stable exhibit number, connection, report diff --git a/src/inventory.tsx b/src/inventory.tsx new file mode 100644 index 0000000..3c77ddf --- /dev/null +++ b/src/inventory.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from 'react' +import * as THREE from 'three' +import { buildPhone, CLOSED_ANGLE, PhonePreview } from './phone' + +// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a +// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any +// level type — new tools (map, visual novel, …) just register a model + a component. +// Rendered as a dev overlay at /?inventory=1 for now; drop into the +// real navbar later. + +function buildPhoneModel() { + const { group, hinge } = buildPhone() + hinge.rotation.x = CLOSED_ANGLE // sit closed on the rack + group.scale.setScalar(1.15) + group.position.y = -0.05 + return group +} + +function buildNotebook() { + const g = new THREE.Group() + const coverMat = new THREE.MeshStandardMaterial({ color: 0x7c3a2b, roughness: 0.85, metalness: 0.05, flatShading: true }) + const pageMat = new THREE.MeshStandardMaterial({ color: 0xe7dcbf, roughness: 0.95, flatShading: true }) + const wireMat = new THREE.MeshStandardMaterial({ color: 0xcaa74a, roughness: 0.5, metalness: 0.5, flatShading: true }) + const back = new THREE.Mesh(new THREE.BoxGeometry(0.86, 1.16, 0.05), coverMat); back.position.z = -0.08; g.add(back) + const pages = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.08, 0.12), pageMat); g.add(pages) + const front = new THREE.Mesh(new THREE.BoxGeometry(0.86, 1.16, 0.05), coverMat); front.position.z = 0.09; g.add(front) + const strap = new THREE.Mesh(new THREE.BoxGeometry(0.06, 1.2, 0.02), new THREE.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.8, flatShading: true })) + strap.position.set(0.3, 0, 0.12); g.add(strap) + for (let i = 0; i < 8; i++) { // spiral binding down the spine + const ring = new THREE.Mesh(new THREE.TorusGeometry(0.035, 0.012, 6, 10), wireMat) + ring.position.set(-0.43, 0.49 - i * 0.14, 0); ring.rotation.y = Math.PI / 2; g.add(ring) + } + g.traverse(obj => { if (obj instanceof THREE.Mesh) obj.castShadow = true }) + return g +} + +const TOOLS: { key: string; name: string; build: () => THREE.Group }[] = [ + { key: 'notebook', name: 'Notebook', build: buildNotebook }, + { key: 'phone', name: 'Phone', build: buildPhoneModel }, +] + +function ToolRack({ index }: { index: number }) { + const stageRef = useRef(null) + const holderRef = useRef(null) + + useEffect(() => { + const stage = stageRef.current + if (!stage) return + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + stage.appendChild(renderer.domElement) + renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%' + + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100) + camera.position.set(0.5, 0.45, 3.3) + camera.lookAt(0, 0, 0) + scene.add(new THREE.AmbientLight(0x40483a, 0.9)) + const key = new THREE.DirectionalLight(0xfff4e0, 1.1); key.position.set(2, 3, 3); scene.add(key) + const rim = new THREE.DirectionalLight(0x9fd020, 0.4); rim.position.set(-2, 1, -2); scene.add(rim) + const holder = new THREE.Group(); scene.add(holder); holderRef.current = holder + + const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false) } } + resize(); const ro = new ResizeObserver(resize); ro.observe(stage) + + let raf = 0 + const tick = () => { holder.rotation.y += 0.008; renderer.render(scene, camera); raf = requestAnimationFrame(tick) } + tick() + return () => { + cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); renderer.domElement.remove() + scene.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } }) + holderRef.current = null + } + }, []) + + // Swap the model when the selected tool changes. + useEffect(() => { + const holder = holderRef.current + if (!holder) return + while (holder.children.length) { const child = holder.children[0]; holder.remove(child); child.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } }) } + holder.rotation.set(0, 0, 0) + holder.add(TOOLS[index].build()) + }, [index]) + + return
+} + +export function Inventory({ onClose }: { onClose?: () => void }) { + const [index, setIndex] = useState(0) + const [active, setActive] = useState(null) + + if (active) return
+ + {active === 'phone' ? : } +
+ + return
+ + + +
+
{TOOLS[index].name}
+
{TOOLS.map((tool, i) => )}
+ +
+ {onClose && } +

INVENTORY · dev preview

+
+} + +// Placeholder notebook tool — a lined page you can scribble on (not yet persisted). +function NotebookTool() { + const [text, setText] = useState('') + return
+
+
FIELD NOTEBOOK
+