Compare commits
3
Commits
af0ffe055e
...
13a270911f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,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>)
|
||||
|
||||
+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[] = []
|
||||
|
||||
|
||||
@@ -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