Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011c8a24c2 | ||
|
|
917bb0248e | ||
|
|
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`
|
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 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[]
|
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.
|
|
||||||
|
|||||||
@@ -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,8 +16,8 @@ type MysteryDocument = {
|
|||||||
}
|
}
|
||||||
type MysteryGraph = {
|
type MysteryGraph = {
|
||||||
entry: string
|
entry: string
|
||||||
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; label?: string; x: number; y: number
|
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'; label?: string; x: number; y: number
|
||||||
componentKey?: string; templateSlug?: string; version?: number
|
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
|
||||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
terminals?: { key: string; label?: string; to?: string | null }[]
|
||||||
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[]
|
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -144,8 +144,12 @@ suite('narrative graph runtime', () => {
|
|||||||
const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id:string;slug:string }[]
|
const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id:string;slug:string }[]
|
||||||
const mysteryId = mysteries.find(mystery => mystery.slug === 'goal-mystery')!.id
|
const mysteryId = mysteries.find(mystery => mystery.slug === 'goal-mystery')!.id
|
||||||
expect((await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method:'POST',headers:json,
|
expect((await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method:'POST',headers:json,
|
||||||
body:JSON.stringify({ entry:'level',nodes:[{ key:'level',type:'level',label:'Prove it',templateSlug:'goal-chapter',x:0,y:0,
|
body:JSON.stringify({ entry:'level',nodes:[
|
||||||
terminals:[{ key:'continue',label:'Continue',to:null }] }] }) })).status).toBe(201)
|
{ key:'level',type:'level',label:'Prove it',templateSlug:'goal-chapter',x:0,y:0,
|
||||||
|
terminals:[{ key:'continue',label:'Continue',to:'merit' }] },
|
||||||
|
{ key:'merit',type:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit',x:200,y:0,
|
||||||
|
terminals:[{ key:'continue',label:'Accept',to:null }] },
|
||||||
|
] }) })).status).toBe(201)
|
||||||
|
|
||||||
const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method:'POST',headers:json,body:JSON.stringify({ mystery:'goal-mystery' }) })
|
const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method:'POST',headers:json,body:JSON.stringify({ mystery:'goal-mystery' }) })
|
||||||
expect(created.status).toBe(201)
|
expect(created.status).toBe(201)
|
||||||
@@ -167,10 +171,15 @@ suite('narrative graph runtime', () => {
|
|||||||
|
|
||||||
const completed = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
|
const completed = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
|
||||||
expect(completed.status).toBe(200)
|
expect(completed.status).toBe(200)
|
||||||
expect((await completed.json() as PlaythroughState).playthrough.status).toBe('finished')
|
expect(await completed.json()).toMatchObject({ playthrough:{ status:'active' },node:{ kind:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit' } })
|
||||||
expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json()).toContain('demo.inventor-proved')
|
expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json())
|
||||||
|
.toEqual(expect.arrayContaining(['demo.inventor-proved','demo.inventor-merit']))
|
||||||
const achievement = await appPool.query<{ awarded_by_node_id:string | null }>(
|
const achievement = await appPool.query<{ awarded_by_node_id:string | null }>(
|
||||||
'SELECT awarded_by_node_id FROM osint.achievements WHERE playthrough_id=$1 AND flag_key=$2', [playthroughId,'demo.inventor-proved'])
|
'SELECT awarded_by_node_id FROM osint.achievements WHERE playthrough_id=$1 AND flag_key=$2', [playthroughId,'demo.inventor-proved'])
|
||||||
expect(achievement.rows[0].awarded_by_node_id).toBe(atLevel.node!.id)
|
expect(achievement.rows[0].awarded_by_node_id).toBe(atLevel.node!.id)
|
||||||
|
|
||||||
|
const finished = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
|
||||||
|
expect(finished.status).toBe(200)
|
||||||
|
expect((await finished.json() as PlaythroughState).playthrough.status).toBe('finished')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Pool, PoolClient } from 'pg'
|
|||||||
|
|
||||||
export type GraphSpecNode = {
|
export type GraphSpecNode = {
|
||||||
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
||||||
componentKey?: string; templateSlug?: string; version?: number
|
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
|
||||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
terminals?: { key: string; label?: string; to?: string | null }[]
|
||||||
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
|
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
|
||||||
}
|
}
|
||||||
@@ -261,8 +261,8 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
versionId = version.rows[0]?.id ?? null
|
versionId = version.rows[0]?.id ?? null
|
||||||
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
|
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
|
||||||
}
|
}
|
||||||
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key,awards_flag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
|
||||||
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null])
|
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null, node.awardsFlag?.trim() || null])
|
||||||
for (const [index, terminal] of (node.terminals || []).entries()) {
|
for (const [index, terminal] of (node.terminals || []).entries()) {
|
||||||
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
|
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
|
||||||
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
||||||
|
|||||||
@@ -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.
|
// 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>
|
||||||
|
: search.has('inventory')
|
||||||
|
? <Suspense fallback={null}><Inventory /></Suspense>
|
||||||
: isBoard ? <App /> : <Play />
|
: isBoard ? <App /> : <Play />
|
||||||
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
||||||
|
|||||||
+2
-2
@@ -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[] = []
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user