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