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` 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.
+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 = { 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; npc?: string }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[] utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player'; awardsFlag?: string; requiresFlag?: string }[] }[]
} }
type MysteryNarrative = { 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 graph?: MysteryGraph
} }
type MysteryGoal = { type MysteryGoal = {
+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()
+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 }) result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(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. // Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
app.post('/api/playthroughs/:id/goto', async (req, res, next) => { app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
try { 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 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')
}) })
}) })
+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 PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
export type RuntimeUtterance = { export type RuntimeUtterance = {
id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string } 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 = { export type RuntimeNode = {
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
@@ -32,7 +32,7 @@ export type PlaythroughAdvanceResult = {
export type MysteryAuthoring = { export type MysteryAuthoring = {
slug: string slug: string
title: 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> advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<PlaythroughAdvanceResult>
listAchievements(playthroughId: string): Promise<string[] | null> listAchievements(playthroughId: string): Promise<string[] | null>
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }> 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 }> gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
listMysteries(): Promise<MysterySummary[]> listMysteries(): Promise<MysterySummary[]>
listPlayableMysteries(): Promise<{ slug: string; title: string }[]> 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 // 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. // 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([ 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 }>( 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,n.name,n.role,n.default_pose_key `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]), 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 }>( 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 `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]), 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]), 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>>() 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) } 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 terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key]))
const children = new Map<string, string[]>() 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]) 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 = utterances.rows.find(row => !row.parent_utterance_id) const root = rows.find(row => !row.parent_utterance_id)
return { return {
rootId: root?.id ?? null, 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 const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null
return { return {
id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' }, 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, 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] 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 if (!node) return null
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
const musicVolume = node.music_volume / 100 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 === '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 === '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 } 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 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 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] WHERE p.id=$1`, [playthroughId])).rows[0]
if (!row) return null 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 } 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]) 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 if (existing.rows[0]) continue
const npcId = randomUUID() 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)', 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]) [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( 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]) '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) 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) { async listAchievements(playthroughId) {
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null 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 const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
+40 -17
View File
@@ -3,9 +3,12 @@ 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; npc?: string }[]
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[] // 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[] } export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
@@ -261,12 +264,18 @@ 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)', let npcId: string | null = null
[terminalId, id, terminal.key, terminal.label || terminal.key, index]) 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. // Wire terminals now that all nodes exist.
@@ -279,24 +288,38 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
// Utterances (linear seed): create, then chain them and exit the last one via // 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. // the node's first terminal, so the crafter shows a connected flow.
for (const node of spec.nodes) { for (const node of spec.nodes) {
const spec2 = node.utterances || []
const created: string[] = [] 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 let npcId: string | null = null
if (utterance.npc) { 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]) 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 npcId = npc.rows[0]?.id ?? null
} }
const utteranceId = randomUUID(); created.push(utteranceId) 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)', if (utterance.key) uttKeyToId.set(utterance.key, utteranceId)
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index]) 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])
}
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
const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined
if (created.length && exitTerminalId)
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
} }
// Chain via parent: each line follows the previous one (one child = linear).
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
const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined
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) const entryId = nodeIds.get(spec.entry)
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`) if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
+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>)
+12 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react' import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio' 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 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 PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } 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 // 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. // 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 byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId) 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. // In preview, clicking an utterance card jumps the walk to that line.
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId]) useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
const [charCount, setCharCount] = useState(0) 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() if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
}, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps }, [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) => { const pick = (choice: RuntimeUtterance) => {
if (!inline) audio.sfx('choice') if (!inline) audio.sfx('choice')
if (!inline && choice.awardsFlag) onAwardRef.current?.(choice.id)
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0]) if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
else onExit(choice.terminalKey ?? undefined) 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). // 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[] = []
+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) 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 === '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 === '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> 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: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; }