Author SHA1 Message Date
gitprov 011c8a24c2 Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board 2026-08-22 18:27:30 +02:00
gitprov 917bb0248e Some changes to the narrative intergation 2026-08-22 18:27:06 +02:00
gitprov 13a270911f Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board 2026-08-22 17:38:51 +02:00
gitprov 4ec9d98325 Adding scene7 todo 2026-08-22 17:36:20 +02:00
gitprovandClaude Opus 4.8 b029c9bc47 Reusable inventory: 3D tool rack (notebook + phone)
A tool-driven inventory overlay — a three.js rack you cycle with ◀ ▶ and
open with USE. Registry-based (not tied to a level type) so new tools just
add a model + component. Reuses the phone's 3D model; notebook is a blocky
placeholder tool. Dev route /?inventory=1; drop <Inventory/> into the navbar
after the merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 17:34:56 +02:00
10 changed files with 529 additions and 196 deletions
+350 -91
View File
@@ -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` The player opens an otherwise minimal OSINT board, reads the assignment
Demo: **GUPI Demo 1 — The Barricelli Files** **“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 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 become source evidence, and a finding is only as useful as the report that cites
and explains that evidence. and explains that evidence.
@@ -18,10 +26,123 @@ Claim, completes the evidentiary statement, and files the generated Case Report.
```text ```text
paste screenshot -> document appears -> source is verified -> connect to Claim paste screenshot -> document appears -> source is verified -> connect to Claim
-> submit thin report -> provenance feedback -> accepted -> Scene 8 -> submit thin report -> provenance feedback -> accepted -> Scene 8
```
## Product decisions ## 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 - One suitable Google Patents screenshot is sufficient evidence, but not by itself
a complete investigation. a complete investigation.
- Clipboard paste and file upload use the same server path. - 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 The provider/model name comes from environment configuration. Do not hard-code a
inside it. Provider/model, timeout, input limit, and confidence threshold are Claude model identifier into level content or business logic. Treat OCR as
environment configuration. Semantic configuration clones with the board; untrusted quoted material: the prompt must explicitly ignore instructions found
evaluation history belongs to the level/document/extraction and records evaluator inside it, and the response must pass a strict schema before it can award a flag.
version, provider/model, verdict, excerpt, confidence, timestamps, and sanitized
failure state.
| 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`) | | Nils + inventor claim supported, high confidence | award `scene7.nils_inventor_proved` |
| Related subject only + assertion supported | authored related flag (`scene7.father_inventor_discovered`) | | Father only + inventor claim supported | award `scene7.father_inventor_discovered`; do not complete |
| Ambiguous, unsupported, or below threshold | none | | Ambiguous, neither, unsupported, or below threshold | retain document; award nothing |
| Provider failure | none; retryable | | Provider unavailable/invalid response | retain document; mark evaluation retryable |
The known-source pass avoids an LLM call. After a deterministic miss, the client Keep this evaluator narrower than the generic story-graph `llm_gate`. Scene 7 is
automatically calls an idempotent semantic endpoint. This keeps document upload judging a single uploaded source, not a report or arbitrary player state.
durable even if an external provider times out, without requiring a demo job queue.
### Story progression ### 4. Two-step server flow
Advancing from a level is server-authoritative. The server verifies the JWT user, The primary Google Patents path remains synchronous and deterministic:
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 player must see the verification result before navigation. Success exposes a 1. Upload/paste persists the asset, document, OCR extraction, fuzzy evaluation,
single Continue action to Scene 8. Scene 6 only wires into the Scene 7 level node; flags, and current goal state in one transaction.
Scene 8 owns the luggage reward. 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 ## 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`. ### S7-A — Goal model and template cloning
- [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-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. ### S7-B — Scene content and deterministic recognition
- [x] Create the Scene 7 template with its assignment and no solution-bearing
starting document. - [ ] Create/import the Scene 7 template and its brief as data.
- [x] Add a reproducible screenshot-paste acceptance path that runs through local OCR. - [ ] Start the board without any solution-bearing document.
- [x] Author three distinctive patent anchors. - [ ] Obtain the exact target Google Patents screenshot used for acceptance and
- [x] Tune against the target and unrelated/father-only negative fixtures. run it through the local OCR service.
- [x] Award `scene7.nils_inventor_proved` and require it for the level goal. - [ ] Author two or more distinctive match anchors from that extraction; avoid a
- [x] Store canonical source metadata for administrators; keep player URL optional. 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 ### S7-C — Semantic judge
- [x] Add provider-neutral interface and strict verdict validation. - [ ] Add the provider-neutral `EvidenceJudge` interface and strict verdict
- [x] Add clonable semantic rule configuration and level-owned evaluations. schema.
- [x] Add semantic evaluation provenance to level flags. - [ ] Add board-owned semantic rule configuration and level-owned evaluation
- [x] Add provider/model/timeout/input/confidence environment configuration. history with clone support and flag provenance.
- [x] Send OCR text rather than raw image bytes. - [ ] Add environment variables for provider, model, timeout, maximum OCR
- [x] Add ownership-checked, idempotent judge endpoint. characters, and confidence threshold; document safe defaults in
- [x] Route target and father verdicts to their authored flags. `.env.example` without overwriting concurrent OCR configuration work.
- [x] Make disabled provider, timeout, quota, and malformed output safe/retryable. - [ ] Send extracted text, not raw image bytes, unless a later explicit design
- [x] Do not log evidence text or secrets. 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. - [ ] Return compact goal state from the level response and document-upload
- [x] Verify the current playthrough belongs to the JWT user and owns the level. response: goal key, status, newly completed state, and player-facing message.
- [x] Promote required completion facts exactly once. - [ ] Never return reference anchors, expected text, private evaluator prompts,
- [x] Return a useful pending-goal error rather than advancing early. or unpublished author data in play mode.
- [x] Return to the generic story runtime after success; the Scene 6/8 graph owner - [ ] Add the semantic fallback endpoint/result to the typed client API.
wires the actual neighboring nodes. - [ ] Resolve the active playthrough for the level and enforce user ownership.
- [x] Keep the arbitrary achievement-grant route development-only. - [ ] 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 ### S7-E — Board experience
- [x] Present the active goal prominently on Scene 7. - [ ] Show the exact assignment prominently when Scene 7 opens.
- [x] Preserve clipboard paste, drag/drop, and file picker equivalence. - [ ] Preserve both clipboard paste and drag/file upload; both call the same API.
- [x] Show source import/OCR and semantic checking stages. - [ ] Place the pasted screenshot as a new image document using the normal board
- [x] Highlight the accepted document and show **SOURCE VERIFIED — NILS AALL placement rules.
BARRICELLI: INVENTOR**. - [ ] Show restrained stages such as **Saving source**, **Reading text**, and
- [x] Show Continue only after server-confirmed completion. **Checking evidence** without blocking board interaction unnecessarily.
- [x] Acknowledge father-only discovery while asking for evidence about Nils. - [ ] On success, visually identify the accepted document and show:
- [x] Keep inconclusive evidence and offer neutral guidance. **SOURCE VERIFIED — NILS AALL BARRICELLI: INVENTOR**.
- [x] Respect reduced-motion and mobile layouts. - [ ] 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. - [ ] Add the actual Google Patents screenshot as a legally appropriate test
- [x] Test line breaks, punctuation, cropping, name hyphenation, unrelated patent, fixture, or store a compact derived OCR fixture if redistributing the image is
father-only text, empty OCR, and prompt-injection-like text. undesirable.
- [x] Contract-test with a fake semantic provider; CI never calls a paid model. - [ ] Unit-test OCR normalization and fuzzy matching for realistic line breaks,
- [x] Integration-test target upload -> document -> flag -> completed goal. punctuation, cropping, and name hyphenation.
- [x] Test duplicate evaluation, provider failure/retry, two-user isolation, and - [ ] Add negative fixtures: unrelated patent, father-only evidence, a generic
story progression before/after completion. Barricelli biography, low-quality/empty OCR, and prompt-injection-like text.
- [x] Browser-test clipboard image paste through OCR and verification. Continue - [ ] Contract-test the semantic judge with a fake provider; CI must not call a
is covered by the generic story-gate integration until the Scene 8 node lands. paid external model.
- [x] Run migrations on an empty database and one currently at `027`. - [ ] Integration-test target upload -> one document -> completion flag -> goal
- [x] Run unit, integration, build, and Docker smoke tests. 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 ```ts
type LevelGoal = { type LevelGoalState = {
key: string key: string
title: string title: string
instructions: string
completionMessage: string
status: 'pending' | 'complete' status: 'pending' | 'complete'
completedAt?: string
newlyCompleted: boolean newlyCompleted: boolean
message?: string
} }
type DocumentAnalysis = { type DocumentAnalysis = {
extractionStatus: 'succeeded' | 'unsupported' | 'failed' extractionStatus: 'succeeded' | 'unsupported' | 'failed'
matchedFlags: string[] matchedFlags: string[]
awardedFlags: 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 16).
- 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[] 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 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, 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. and a proper evidentiary statement produces an accepted report and enables Continue.
MinIO asset, OCR, match provenance, stable exhibit number, connection, report inIO 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.
-92
View File
@@ -1,92 +0,0 @@
{
"slug": "barricelli-inventor-proof",
"name": "Scene 7 · Prove Barricelli was an inventor",
"title": "The Barricelli Files",
"subtitle": "Scene 7 · Demonstrate OSINT skill",
"brief": {
"body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor. Find a reliable source online, take a screenshot, and paste it directly onto this board. Connect the source to the authored claim with red thread, explain what the evidence proves, and submit the Case Report.",
"concepts": []
},
"documents": [],
"folders": [],
"claims": [
{
"key": "nils-inventor",
"statement": "Nils Aall Barricelli was an inventor.",
"x": 940,
"y": 360,
"width": 330,
"height": 190
}
],
"report": {
"title": "Barricelli Inventor Finding",
"requiredForCompletion": true
},
"goals": [
{
"key": "barricelli.inventor-proof",
"title": "Prove Nils Aall Barricelli was an inventor",
"instructions": "Paste a reliable screenshot, connect it to the claim, and submit a properly cited Case Report.",
"completionMessage": "CASE REPORT ACCEPTED — NILS AALL BARRICELLI: INVENTOR",
"requiredFlags": ["scene7.nils_inventor_proved"]
}
],
"evidenceMatchRules": [
{
"name": "Google Patents · GB695913A",
"sourceLabel": "Google Patents · GB695913A · Improved chest of drawers",
"sourceUri": "https://patents.google.com/patent/GB695913A/en",
"flagKey": "scene7.nils_inventor_proved",
"minimumAnchorMatches": 2,
"anchors": [
{
"phrase": "GB695913A Improved chest of drawers",
"minimumSimilarity": 0.7
},
{
"phrase": "Nils Aall Barricelli",
"minimumSimilarity": 0.72
},
{
"phrase": "695913 Chests of drawers BARRICELLI N A May 31 1951",
"minimumSimilarity": 0.68
}
]
},
{
"name": "Nasjonalbiblioteket · Patent 93585",
"sourceLabel": "Nasjonalbiblioteket · Patent 93585 · Koffert-kommode",
"sourceUri": "https://www.nb.no/items/921545b51acef0b05054cfc1f4666975?page=9&searchText=baricelli",
"flagKey": "scene7.nils_inventor_proved",
"minimumAnchorMatches": 2,
"anchors": [
{
"phrase": "Nr 75 348 Kl 33 b-9 Fra 31 mai 1948 93585 Koffert-kommode",
"minimumSimilarity": 0.68
},
{
"phrase": "Niels Aall Baricelli Oslo",
"minimumSimilarity": 0.72
},
{
"phrase": "Patentpaastand Kommode som er satt sammen av flere enkeltdeler som hver er utfort som koffert",
"minimumSimilarity": 0.62
}
]
}
],
"evidenceSemanticRules": [
{
"goalKey": "barricelli.inventor-proof",
"name": "Barricelli inventor claim",
"targetSubject": "Nils Aall Barricelli",
"relatedSubject": "Nils Aall Barricelli's father",
"assertion": "The source states or directly demonstrates that Nils Aall Barricelli was an inventor or a named patent applicant for an invention.",
"successFlagKey": "scene7.nils_inventor_proved",
"relatedFlagKey": "scene7.father_inventor_discovered",
"minimumConfidence": 0.88,
"evaluatorVersion": "barricelli_inventor_v1"
}
]
}
+2 -2
View File
@@ -16,8 +16,8 @@ type MysteryDocument = {
} }
type MysteryGraph = { type MysteryGraph = {
entry: string entry: string
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; label?: string; x: number; y: number nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'; label?: string; x: number; y: number
componentKey?: string; templateSlug?: string; version?: number componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
terminals?: { key: string; label?: string; to?: string | null }[] terminals?: { key: string; label?: string; to?: string | null }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[] utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[]
} }
+8
View File
@@ -9,6 +9,7 @@ import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js' import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js'
import { runMigrations } from './migrations.js' import { runMigrations } from './migrations.js'
import type { StoryGraphDto } from './storyGraphRepository.js'
const { Client } = pg const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL const baseDatabaseUrl = process.env.TEST_DATABASE_URL
@@ -252,6 +253,7 @@ suite('normalized level persistence API', () => {
const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js') const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js')
const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json') const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json')
const imported = await importMysteryTemplate(manifestPath, baseUrl, adminAuthorization.replace(/^Bearer /, '')) const imported = await importMysteryTemplate(manifestPath, baseUrl, adminAuthorization.replace(/^Bearer /, ''))
expect(imported.mystery).toEqual({ slug:'barricelli-inventor-proof' })
expect(imported.playableLevel).toMatchObject({ title:'The Barricelli Files',exhibits:[expect.objectContaining({ type:'claim',statement:'Nils Aall Barricelli was an inventor.' })],report:expect.objectContaining({ expect(imported.playableLevel).toMatchObject({ title:'The Barricelli Files',exhibits:[expect.objectContaining({ type:'claim',statement:'Nils Aall Barricelli was an inventor.' })],report:expect.objectContaining({
title:'Barricelli Inventor Finding',requiredForCompletion:true,status:'draft', title:'Barricelli Inventor Finding',requiredForCompletion:true,status:'draft',
}),goals:[expect.objectContaining({ }),goals:[expect.objectContaining({
@@ -270,6 +272,12 @@ suite('normalized level persistence API', () => {
expect.objectContaining({ goalKey:'barricelli.inventor-proof',targetSubject:'Nils Aall Barricelli', expect.objectContaining({ goalKey:'barricelli.inventor-proof',targetSubject:'Nils Aall Barricelli',
relatedFlagKey:'scene7.father_inventor_discovered' }), relatedFlagKey:'scene7.father_inventor_discovered' }),
]) ])
const mysteries = await (await adminFetch(`${baseUrl}/api/admin/mysteries`)).json() as { id:string;slug:string }[]
const mysteryId = mysteries.find(mystery => mystery.slug === 'barricelli-inventor-proof')!.id
const graph = await (await adminFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
expect(graph.nodes).toHaveLength(2)
expect(graph.nodes.find(node => node.nodeType === 'level')).toMatchObject({ label:'Demonstrate OSINT skill',levelTemplateVersionId:expect.any(String) })
expect(graph.nodes.find(node => node.nodeType === 'merit')).toMatchObject({ label:'The Barricelli Luggage',awardsFlag:'barricelli_luggage' })
const fixtureDir = path.join(path.dirname(manifestPath), 'fixtures') const fixtureDir = path.join(path.dirname(manifestPath), 'fixtures')
const fatherUpload = new FormData() const fatherUpload = new FormData()
+13 -4
View File
@@ -144,8 +144,12 @@ suite('narrative graph runtime', () => {
const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id:string;slug:string }[] const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id:string;slug:string }[]
const mysteryId = mysteries.find(mystery => mystery.slug === 'goal-mystery')!.id const mysteryId = mysteries.find(mystery => mystery.slug === 'goal-mystery')!.id
expect((await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method:'POST',headers:json, expect((await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method:'POST',headers:json,
body:JSON.stringify({ entry:'level',nodes:[{ key:'level',type:'level',label:'Prove it',templateSlug:'goal-chapter',x:0,y:0, body:JSON.stringify({ entry:'level',nodes:[
terminals:[{ key:'continue',label:'Continue',to:null }] }] }) })).status).toBe(201) { key:'level',type:'level',label:'Prove it',templateSlug:'goal-chapter',x:0,y:0,
terminals:[{ key:'continue',label:'Continue',to:'merit' }] },
{ key:'merit',type:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit',x:200,y:0,
terminals:[{ key:'continue',label:'Accept',to:null }] },
] }) })).status).toBe(201)
const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method:'POST',headers:json,body:JSON.stringify({ mystery:'goal-mystery' }) }) const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method:'POST',headers:json,body:JSON.stringify({ mystery:'goal-mystery' }) })
expect(created.status).toBe(201) expect(created.status).toBe(201)
@@ -167,10 +171,15 @@ suite('narrative graph runtime', () => {
const completed = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' }) const completed = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
expect(completed.status).toBe(200) expect(completed.status).toBe(200)
expect((await completed.json() as PlaythroughState).playthrough.status).toBe('finished') expect(await completed.json()).toMatchObject({ playthrough:{ status:'active' },node:{ kind:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit' } })
expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json()).toContain('demo.inventor-proved') expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json())
.toEqual(expect.arrayContaining(['demo.inventor-proved','demo.inventor-merit']))
const achievement = await appPool.query<{ awarded_by_node_id:string | null }>( const achievement = await appPool.query<{ awarded_by_node_id:string | null }>(
'SELECT awarded_by_node_id FROM osint.achievements WHERE playthrough_id=$1 AND flag_key=$2', [playthroughId,'demo.inventor-proved']) 'SELECT awarded_by_node_id FROM osint.achievements WHERE playthrough_id=$1 AND flag_key=$2', [playthroughId,'demo.inventor-proved'])
expect(achievement.rows[0].awarded_by_node_id).toBe(atLevel.node!.id) expect(achievement.rows[0].awarded_by_node_id).toBe(atLevel.node!.id)
const finished = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
expect(finished.status).toBe(200)
expect((await finished.json() as PlaythroughState).playthrough.status).toBe('finished')
}) })
}) })
+3 -3
View File
@@ -3,7 +3,7 @@ import type { Pool, PoolClient } from 'pg'
export type GraphSpecNode = { export type GraphSpecNode = {
key: string; type: StoryNodeType; label?: string; x: number; y: number key: string; type: StoryNodeType; label?: string; x: number; y: number
componentKey?: string; templateSlug?: string; version?: number componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
terminals?: { key: string; label?: string; to?: string | null }[] terminals?: { key: string; label?: string; to?: string | null }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[] utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
} }
@@ -261,8 +261,8 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
versionId = version.rows[0]?.id ?? null versionId = version.rows[0]?.id ?? null
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`) if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
} }
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key,awards_flag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null]) [id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null, node.awardsFlag?.trim() || null])
for (const [index, terminal] of (node.terminals || []).entries()) { for (const [index, terminal] of (node.terminals || []).entries()) {
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId) const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)', await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
+120
View File
@@ -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 <Inventory/> 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<HTMLDivElement>(null)
const holderRef = useRef<THREE.Group | null>(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 <div ref={stageRef} className="inv-stage" />
}
export function Inventory({ onClose }: { onClose?: () => void }) {
const [index, setIndex] = useState(0)
const [active, setActive] = useState<string | null>(null)
if (active) return <div className="inv-tool">
<button className="inv-back" onClick={() => setActive(null)}> TOOLS</button>
{active === 'phone' ? <PhonePreview /> : <NotebookTool />}
</div>
return <div className="inv-backdrop">
<ToolRack index={index} />
<button className="inv-arrow left" onClick={() => setIndex(i => (i - 1 + TOOLS.length) % TOOLS.length)} aria-label="Previous tool"></button>
<button className="inv-arrow right" onClick={() => setIndex(i => (i + 1) % TOOLS.length)} aria-label="Next tool"></button>
<div className="inv-plate">
<div className="inv-name">{TOOLS[index].name}</div>
<div className="inv-dots">{TOOLS.map((tool, i) => <span key={tool.key} className={i === index ? 'on' : ''} />)}</div>
<button className="inv-use" onClick={() => setActive(TOOLS[index].key)}>USE </button>
</div>
{onClose && <button className="inv-close" onClick={onClose}>×</button>}
<p className="inv-hint">INVENTORY · dev preview</p>
</div>
}
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
function NotebookTool() {
const [text, setText] = useState('')
return <div className="notebook">
<div className="notebook-page">
<div className="notebook-head">FIELD NOTEBOOK</div>
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
</div>
</div>
}
+5 -2
View File
@@ -6,13 +6,16 @@ import './styles.css'
// three.js is lazy-loaded so the board never pays for it up front. // three.js is lazy-loaded so the board never pays for it up front.
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview }))) const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
// Routing: the bare root is the game's front door (splash + campaign); /level/:id, // Routing: the bare root is the game's front door (splash + campaign); /level/:id,
// /admin, and the legacy ?level= deep link open the board; ?phone=1 is the spike. // /admin, and the legacy ?level= deep link open the board; ?phone / ?inventory spikes.
const path = window.location.pathname const path = window.location.pathname
const search = new URLSearchParams(window.location.search) const search = new URLSearchParams(window.location.search)
const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level') const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level')
const root = search.has('phone') const root = search.has('phone')
? <Suspense fallback={null}><PhonePreview /></Suspense> ? <Suspense fallback={null}><PhonePreview /></Suspense>
: isBoard ? <App /> : <Play /> : search.has('inventory')
? <Suspense fallback={null}><Inventory /></Suspense>
: isBoard ? <App /> : <Play />
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>) createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
+2 -2
View File
@@ -11,7 +11,7 @@ import * as THREE from 'three'
// flag requirements come from the story graph (see the "mobile" gate discussion). // flag requirements come from the story graph (see the "mobile" gate discussion).
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage
const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad) export const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera
// ---- placeholder telephony audio (to be replaced by recorded assets) ---------- // ---- placeholder telephony audio (to be replaced by recorded assets) ----------
@@ -46,7 +46,7 @@ const sfx = {
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] } type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
function buildPhone(): Built { export function buildPhone(): Built {
const group = new THREE.Group() const group = new THREE.Group()
const keys: THREE.Mesh[] = [] const keys: THREE.Mesh[] = []
+26
View File
@@ -687,3 +687,29 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.splash-case:disabled { opacity: .5; cursor: default; } .splash-case:disabled { opacity: .5; cursor: default; }
.splash-case-title { font-weight: 600; letter-spacing: .5px; } .splash-case-title { font-weight: 600; letter-spacing: .5px; }
.splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; } .splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; }
/* === Inventory (tools rack) — src/inventory.tsx ===================== */
.inv-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center;
background: radial-gradient(120% 90% at 50% 35%, #14201c 0%, #070d0b 70%, #030605 100%); }
.inv-stage { position: absolute; inset: 0; }
.inv-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 46px; height: 46px; border: 1px solid #3c5a52;
background: #0a211de0; color: #cdea6a; font-size: 16px; cursor: pointer; }
.inv-arrow.left { left: 6vw; } .inv-arrow.right { right: 6vw; }
.inv-arrow:hover { border-color: #cdea6a; }
.inv-plate { position: absolute; bottom: 8vh; z-index: 2; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.inv-name { font: 600 15px IBM Plex Mono, ui-monospace, monospace; letter-spacing: 3px; color: #eafaa0; text-transform: uppercase; }
.inv-dots { display: flex; gap: 7px; }
.inv-dots span { width: 7px; height: 7px; border: 1px solid #5f7b73; border-radius: 50%; }
.inv-dots span.on { background: #cdea6a; border-color: #cdea6a; }
.inv-use { border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 2px; padding: 9px 26px; cursor: pointer; }
.inv-use:hover { border-color: #cdea6a; color: #eafaa0; }
.inv-close { position: absolute; top: 16px; right: 18px; z-index: 2; width: 34px; height: 34px; border: 1px solid #3c5a52; background: #0a211de0; color: #cfe8df; cursor: pointer; }
.inv-hint { position: absolute; bottom: 14px; z-index: 2; margin: 0; color: #5f7b73; font: 11px ui-monospace, monospace; letter-spacing: 2px; }
.inv-tool { position: fixed; inset: 0; z-index: 500; }
.inv-back { position: absolute; top: 16px; left: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
.inv-back:hover { border-color: #cdea6a; }
/* notebook tool (placeholder) */
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }