Author SHA1 Message Date
gitprov dc0147aebc Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board 2026-08-22 18:42:40 +02:00
gitprovandClaude Opus 4.8 cbe107e0b3 Utterance award + requirement, and branching dialogue seeding
A dialogue line can award an achievement when reached (granted server-side,
validated against the player's current node so it can't be forged) and an
option can require an achievement to be offered (filtered out of the resolved
tree otherwise). authorGraph gains a branching form (utterance key/parent/
terminal) so option trees — not just linear lines — can be seeded. This is
gaps 1 & 2 for the Barricelli Dobby/Glitch-Hunter dialogues; phone dialing
stays stubbed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 18:34:52 +02:00
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
gitprovandClaude Opus 4.8 9c496bfe19 Seed phone + merit nodes (and NPC contacts) via the graph seeder
authorGraph now seeds merit nodes' awards_flag and phone-node terminals
bound to an NPC (npc key -> npc_id, the callee dialed); authorMystery seeds
cast phone_number/email. Importer types updated so mystery.json can carry
these, matching how Glass Harbour is seeded — the Barricelli graph can now
be authored as data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 17:49:09 +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
15 changed files with 635 additions and 230 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`
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 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[]
}
```
@@ -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
+10
View File
@@ -0,0 +1,10 @@
-- Utterance-level flags: a dialogue line can AWARD an achievement when reached, and
-- an option can REQUIRE an achievement to be offered (gates player choices on prior
-- discoveries). Mirrors the merit node's award and node-enable requirements — this is
-- how asking Dobby the name grants dobby.knows_barricelli_name, and how Glitch Hunter's
-- "…a Norwegian-Italian mathematician" option only shows once you know it.
ALTER TABLE osint.utterances ADD COLUMN awards_flag TEXT
CHECK (awards_flag IS NULL OR awards_flag ~ '^[a-z][a-z0-9_.-]{0,63}$');
ALTER TABLE osint.utterances ADD COLUMN requires_flag TEXT
CHECK (requires_flag IS NULL OR requires_flag ~ '^[a-z][a-z0-9_.-]{0,63}$');
-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"
}
]
}
+5 -5
View File
@@ -16,13 +16,13 @@ type MysteryDocument = {
}
type MysteryGraph = {
entry: string
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; label?: string; x: number; y: number
componentKey?: string; templateSlug?: string; version?: number
terminals?: { key: string; label?: string; to?: string | null }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[]
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; awardsFlag?: string
terminals?: { key: string; label?: string; to?: string | null; npc?: string }[]
utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player'; awardsFlag?: string; requiresFlag?: string }[] }[]
}
type MysteryNarrative = {
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: string; poses?: { poseKey: string; assetId: string }[] }[]
graph?: MysteryGraph
}
type MysteryGoal = {
+8
View File
@@ -9,6 +9,7 @@ import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js'
import { runMigrations } from './migrations.js'
import type { StoryGraphDto } from './storyGraphRepository.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
@@ -252,6 +253,7 @@ suite('normalized level persistence API', () => {
const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js')
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 /, ''))
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({
title:'Barricelli Inventor Finding',requiredForCompletion:true,status:'draft',
}),goals:[expect.objectContaining({
@@ -270,6 +272,12 @@ suite('normalized level persistence API', () => {
expect.objectContaining({ goalKey:'barricelli.inventor-proof',targetSubject:'Nils Aall Barricelli',
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 fatherUpload = new FormData()
+8
View File
@@ -508,6 +508,14 @@ app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
// A dialogue line was reached in play — grant its authored achievement (validated
// server-side against the player's current node, so players can't forge flags).
app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => {
try {
const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid))
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
} catch (error) { next(error) }
})
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
try {
+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 mysteryId = mysteries.find(mystery => mystery.slug === 'goal-mystery')!.id
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,
terminals:[{ key:'continue',label:'Continue',to:null }] }] }) })).status).toBe(201)
body:JSON.stringify({ entry:'level',nodes:[
{ 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' }) })
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:'{}' })
expect(completed.status).toBe(200)
expect((await completed.json() as PlaythroughState).playthrough.status).toBe('finished')
expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json()).toContain('demo.inventor-proved')
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())
.toEqual(expect.arrayContaining(['demo.inventor-proved','demo.inventor-merit']))
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'])
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')
})
})
+33 -14
View File
@@ -12,7 +12,7 @@ export type MysterySummary = { id: string; slug: string; title: string; nodes: n
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
export type RuntimeUtterance = {
id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag: string | null
}
export type RuntimeNode = {
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
@@ -32,7 +32,7 @@ export type PlaythroughAdvanceResult = {
export type MysteryAuthoring = {
slug: string
title: string
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: string; poses?: { poseKey: string; assetId: string }[] }[]
}
/**
@@ -58,6 +58,7 @@ export interface NarrativeRepository {
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<PlaythroughAdvanceResult>
listAchievements(playthroughId: string): Promise<string[] | null>
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
listMysteries(): Promise<MysterySummary[]>
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
@@ -87,43 +88,46 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
// Resolve a dialogue node's whole utterance tree for the client to walk: each
// utterance carries its ordered children and (if it exits the node) its terminal key.
async function resolveDialogueGraph(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> {
// earnedFlags gates player options: any utterance whose requires_flag isn't held is
// dropped (so it can't be offered). Pass undefined (authoring preview) to show all.
async function resolveDialogueGraph(nodeId: string, earnedFlags?: Set<string>): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> {
const [utterances, poses, terminals] = await Promise.all([
pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; name: string | null; role: string | null; default_pose_key: string | null }>(
`SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,n.name,n.role,n.default_pose_key
pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; awards_flag: string | null; requires_flag: string | null; name: string | null; role: string | null; default_pose_key: string | null }>(
`SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,u.awards_flag,u.requires_flag,n.name,n.role,n.default_pose_key
FROM osint.utterances u LEFT JOIN osint.npcs n ON n.id=u.npc_id WHERE u.node_id=$1 ORDER BY u.sort_order`, [nodeId]),
pool.query<{ npc_id: string; pose_key: string; asset_id: string | null }>(
`SELECT p.npc_id,p.pose_key,p.asset_id FROM osint.npc_poses p
WHERE p.npc_id IN (SELECT DISTINCT npc_id FROM osint.utterances WHERE node_id=$1 AND npc_id IS NOT NULL)`, [nodeId]),
pool.query<{ id: string; terminal_key: string }>('SELECT id,terminal_key FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId]),
])
const rows = earnedFlags ? utterances.rows.filter(row => !row.requires_flag || earnedFlags.has(row.requires_flag)) : utterances.rows
const poseAssets = new Map<string, Record<string, string | null>>()
for (const row of poses.rows) { const map = poseAssets.get(row.npc_id) || {}; map[row.pose_key] = row.asset_id; poseAssets.set(row.npc_id, map) }
const terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key]))
const children = new Map<string, string[]>()
for (const row of utterances.rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id])
const root = utterances.rows.find(row => !row.parent_utterance_id)
for (const row of rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id])
const root = rows.find(row => !row.parent_utterance_id)
return {
rootId: root?.id ?? null,
utterances: utterances.rows.map(row => {
utterances: rows.map(row => {
const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null
return {
id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' },
poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text,
poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text, awardsFlag: row.awards_flag,
childIds: children.get(row.id) || [], terminalKey: row.terminal_id ? (terminalKey.get(row.terminal_id) ?? null) : null,
}
}),
}
}
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null, earnedFlags?: Set<string>): Promise<RuntimeNode | null> {
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
if (!node) return null
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
const musicVolume = node.music_volume / 100
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id, earnedFlags)) }
if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume }
return null // gates are auto-resolved during advance and never surfaced
}
@@ -160,7 +164,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id
WHERE p.id=$1`, [playthroughId])).rows[0]
if (!row) return null
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug) : null
const earned = new Set((await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1', [playthroughId])).rows.map(r => r.flag_key))
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug, earned) : null
return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node }
}
@@ -212,8 +217,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
const existing = await client.query('SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [npc.key])
if (existing.rows[0]) continue
const npcId = randomUUID()
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null])
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)',
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null, npc.phoneNumber || null, npc.email || null])
for (const pose of npc.poses || []) await client.query(
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)', [randomUUID(), npcId, pose.poseKey, pose.assetId])
}
@@ -261,6 +266,20 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return Boolean(result.rowCount)
},
// A dialogue line was reached in play: grant its authored achievement, but only if
// the utterance really belongs to the player's current node (so it can't be forged).
async reachUtterance(playthroughId, utteranceId) {
const row = (await pool.query<{ awards_flag: string | null; node_id: string; current_node_id: string | null }>(
`SELECT u.awards_flag,u.node_id,p.current_node_id FROM osint.utterances u
JOIN osint.playthroughs p ON p.id=$2 WHERE u.id=$1`, [utteranceId, playthroughId])).rows[0]
if (!row) return { ok: false }
if (!row.awards_flag || row.node_id !== row.current_node_id) return { ok: true, earned: false }
const result = await pool.query(
'INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING',
[playthroughId, row.awards_flag, row.node_id])
return { ok: true, earned: (result.rowCount || 0) > 0 }
},
async listAchievements(playthroughId) {
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
+34 -11
View File
@@ -3,9 +3,12 @@ import type { Pool, PoolClient } from 'pg'
export type GraphSpecNode = {
key: string; type: StoryNodeType; label?: string; x: number; y: number
componentKey?: string; templateSlug?: string; version?: number
terminals?: { key: string; label?: string; to?: string | null }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
terminals?: { key: string; label?: string; to?: string | null; npc?: string }[]
// Linear form: an ordered list (chained automatically). Branching form: give each
// utterance a `key` and set `parent` (its predecessor) + `terminal` (its exit);
// multiple children of one parent become player options.
utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: Utterer; awardsFlag?: string; requiresFlag?: string }[]
}
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
@@ -261,12 +264,18 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
versionId = version.rows[0]?.id ?? null
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)',
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null])
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, node.awardsFlag?.trim()|| null])
for (const [index, terminal] of (node.terminals || []).entries()) {
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)',
[terminalId, id, terminal.key, terminal.label || terminal.key, index])
let npcId: string | null = null
if (terminal.npc) { // phone-node terminal bound to an NPC (the callee)
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [terminal.npc])
npcId = npc.rows[0]?.id ?? null
if (!npcId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} references unknown NPC ${terminal.npc}`)
}
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order,npc_id) VALUES ($1,$2,$3,$4,$5,$6)',
[terminalId, id, terminal.key, terminal.label || terminal.key, index, npcId])
}
}
// Wire terminals now that all nodes exist.
@@ -279,18 +288,31 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
// Utterances (linear seed): create, then chain them and exit the last one via
// the node's first terminal, so the crafter shows a connected flow.
for (const node of spec.nodes) {
const spec2 = node.utterances || []
const created: string[] = []
for (const [index, utterance] of (node.utterances || []).entries()) {
const uttKeyToId = new Map<string, string>()
for (const [index, utterance] of spec2.entries()) {
let npcId: string | null = null
if (utterance.npc) {
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc])
npcId = npc.rows[0]?.id ?? null
}
const utteranceId = randomUUID(); created.push(utteranceId)
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index])
if (utterance.key) uttKeyToId.set(utterance.key, utteranceId)
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,awards_flag,requires_flag,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)',
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, utterance.awardsFlag || null, utterance.requiresFlag || null, 60, 60 + index * 120, index])
}
// Chain via parent: each line follows the previous one (one child = linear).
const branching = spec2.some(utterance => utterance.key)
if (branching) {
// Explicit tree: wire each utterance's parent + exit terminal by key.
for (const utterance of spec2) {
const id = utterance.key ? uttKeyToId.get(utterance.key) : undefined
if (!id) continue
if (utterance.parent) await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [id, uttKeyToId.get(utterance.parent) ?? null])
if (utterance.terminal) await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [id, terminalIds.get(`${node.key}:${utterance.terminal}`) ?? null])
}
} else {
// Linear: each line follows the previous; the last exits via the first terminal.
for (let i = 1; i < created.length; i++)
await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
const firstTerminalKey = node.terminals?.[0]?.key
@@ -298,6 +320,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
if (created.length && exitTerminalId)
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
}
}
const entryId = nodeIds.get(spec.entry)
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
+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>
}
+4 -1
View File
@@ -6,13 +6,16 @@ import './styles.css'
// 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 Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
// 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 search = new URLSearchParams(window.location.search)
const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level')
const root = search.has('phone')
? <Suspense fallback={null}><PhonePreview /></Suspense>
: search.has('inventory')
? <Suspense fallback={null}><Inventory /></Suspense>
: isBoard ? <App /> : <Play />
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
+12 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio'
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag?: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -81,9 +81,11 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
// branch, follow a chosen option to the next line or out through its exit terminal.
export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; inline?: boolean; startId?: string | null }) {
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
const onAwardRef = useRef(onAward)
onAwardRef.current = onAward
// In preview, clicking an utterance card jumps the walk to that line.
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
const [charCount, setCharCount] = useState(0)
@@ -110,8 +112,16 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
}, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps
// Grant a line's authored achievement when it becomes current (play mode only).
useEffect(() => {
if (inline || !currentId) return
const utterance = byId.get(currentId)
if (utterance?.awardsFlag) onAwardRef.current?.(utterance.id)
}, [currentId, inline, byId])
const pick = (choice: RuntimeUtterance) => {
if (!inline) audio.sfx('choice')
if (!inline && choice.awardsFlag) onAwardRef.current?.(choice.id)
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
else onExit(choice.terminalKey ?? undefined)
}
+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).
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
// ---- 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[] }
function buildPhone(): Built {
export function buildPhone(): Built {
const group = new THREE.Group()
const keys: THREE.Mesh[] = []
+3 -1
View File
@@ -94,6 +94,8 @@ export function Play() {
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
onExit={terminalKey => { void advance(terminalKey) }}
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
}
+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-title { font-weight: 600; letter-spacing: .5px; }
.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; }