Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f455c433b7 | ||
|
|
52f0a31f44 | ||
|
|
10dcd567c6 | ||
|
|
dc0147aebc | ||
|
|
cbe107e0b3 | ||
|
|
011c8a24c2 | ||
|
|
917bb0248e | ||
|
|
9c496bfe19 | ||
|
|
13a270911f | ||
|
|
4ec9d98325 | ||
|
|
b029c9bc47 |
@@ -8,6 +8,7 @@ Status: accepted design foundation; the core schema, template lifecycle, fronten
|
|||||||
- An **exhibit type** describes its domain behavior: folder, document, clipping, note, event, party, or conclusion.
|
- An **exhibit type** describes its domain behavior: folder, document, clipping, note, event, party, or conclusion.
|
||||||
- A **widget** is the frontend visualization and interaction implementation selected for an exhibit type.
|
- A **widget** is the frontend visualization and interaction implementation selected for an exhibit type.
|
||||||
- A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file.
|
- A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file.
|
||||||
|
- A **capture kind** describes how imported image evidence is understood and physically presented: photo, scene, clipping, full page, or not yet classified. It is independent of file format and document type.
|
||||||
- A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards.
|
- A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards.
|
||||||
- A board may define a temporal viewport (`board_timeline_settings`). If absent, the client derives a range from dated evidence; if present, the range clones and resets with the board.
|
- A board may define a temporal viewport (`board_timeline_settings`). If absent, the client derives a range from dated evidence; if present, the range clones and resets with the board.
|
||||||
- A **level** is a mutable board copy used for either play or authoring.
|
- A **level** is a mutable board copy used for either play or authoring.
|
||||||
@@ -121,9 +122,17 @@ CREATE TABLE osint.document_types (
|
|||||||
name TEXT NOT NULL UNIQUE
|
name TEXT NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE osint.document_capture_kinds (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE osint.document_exhibits (
|
CREATE TABLE osint.document_exhibits (
|
||||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||||
document_type_id TEXT NOT NULL REFERENCES osint.document_types(id),
|
document_type_id TEXT NOT NULL REFERENCES osint.document_types(id),
|
||||||
|
capture_kind_id TEXT NOT NULL DEFAULT 'unclassified'
|
||||||
|
REFERENCES osint.document_capture_kinds(id),
|
||||||
asset_id UUID REFERENCES osint.assets(id),
|
asset_id UUID REFERENCES osint.assets(id),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
published_at TIMESTAMPTZ,
|
published_at TIMESTAMPTZ,
|
||||||
@@ -296,7 +305,7 @@ Red investigative thread is derived from `exhibit_connections`. Extraction prove
|
|||||||
|
|
||||||
## Document metadata
|
## Document metadata
|
||||||
|
|
||||||
Known, semantically important values remain real columns: `published_at`, `captured_at`, and `source_uri`. Truly author-defined fields use typed metadata definitions and values rather than JSONB or one untyped EAV table.
|
Known, semantically important values remain real columns: `published_at`, `captured_at`, `source_uri`, and `capture_kind_id`. Capture kind is player-selected source interpretation used for physical board presentation and contextual connection copy; it never changes the immutable asset, OCR text, provenance, MIME type, or document type. Truly author-defined fields use typed metadata definitions and values rather than JSONB or one untyped EAV table.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE osint.metadata_fields (
|
CREATE TABLE osint.metadata_fields (
|
||||||
@@ -323,7 +332,7 @@ The following are computed and must not become duplicate source-of-truth tables:
|
|||||||
- Grey timeline projection: document exhibit position to `published_at` on the timeline.
|
- Grey timeline projection: document exhibit position to `published_at` on the timeline.
|
||||||
- Pale red containment band: folder position to contained exhibit position.
|
- Pale red containment band: folder position to contained exhibit position.
|
||||||
- Folder flight animation: closed folder position to the exhibit's stored `xpos` and `ypos`.
|
- Folder flight animation: closed folder position to the exhibit's stored `xpos` and `ypos`.
|
||||||
- Widget selection: `exhibit_type` plus optional `document_type` mapped through the frontend registry.
|
- Widget selection: `exhibit_type` plus optional `document_type` mapped through the frontend registry. For imported image documents, `capture_kind` selects a physical presentation variant within that document widget.
|
||||||
|
|
||||||
## Transactional operations
|
## Transactional operations
|
||||||
|
|
||||||
|
|||||||
+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.
|
|
||||||
|
|||||||
@@ -190,6 +190,26 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
|
|||||||
await expect(page.locator('.evidence-card.party')).toHaveCount(3)
|
await expect(page.locator('.evidence-card.party')).toHaveCount(3)
|
||||||
await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau', 'Mara Elise Voss'])
|
await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau', 'Mara Elise Voss'])
|
||||||
|
|
||||||
|
await file.dblclick()
|
||||||
|
await page.getByRole('button',{ name:'TYPE',exact:true }).click()
|
||||||
|
await waitForSave(page,() => page.getByRole('menuitemradio',{ name:/Mugshot/ }).click())
|
||||||
|
await expect(file.locator('.mugshot-caption')).toHaveText('')
|
||||||
|
await page.getByRole('button',{ name:'Close document',exact:true }).click()
|
||||||
|
const adaParty=page.locator('.evidence-card.party').filter({ hasText:'Ada Lovelace' })
|
||||||
|
await adaParty.click()
|
||||||
|
await page.getByRole('button',{ name:'Red thread' }).click()
|
||||||
|
await file.click()
|
||||||
|
await expect(page.getByLabel('Thread tag')).toHaveValue('Identified as…')
|
||||||
|
await waitForSave(page,() => page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click())
|
||||||
|
await expect(file).toHaveAttribute('data-identified-party-id',/\S+/)
|
||||||
|
await expect(file.locator('.mugshot-caption')).toContainText('Ada Lovelace')
|
||||||
|
await page.reload()
|
||||||
|
await expect(file.locator('.mugshot-caption')).toContainText('Ada Lovelace')
|
||||||
|
await adaParty.getByRole('button',{ name:'EDIT DOSSIER',exact:true }).click()
|
||||||
|
await page.getByLabel('Party name').fill('Ada Byron Lovelace')
|
||||||
|
await waitForSave(page,() => page.getByRole('button',{ name:'SAVE DOSSIER',exact:true }).click())
|
||||||
|
await expect(file.locator('.mugshot-caption')).toContainText('Ada Byron Lovelace')
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
|
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
|
||||||
await expect(page.getByText('Edit reconstructed event')).toBeVisible()
|
await expect(page.getByText('Edit reconstructed event')).toBeVisible()
|
||||||
await page.getByLabel('Event title').fill('The browser clue was connected')
|
await page.getByLabel('Event title').fill('The browser clue was connected')
|
||||||
|
|||||||
@@ -42,11 +42,27 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
expect(uploaded.analysis).toMatchObject({ extractionStatus:'succeeded',matchedFlags:['scene7.nils_inventor_proved'],
|
expect(uploaded.analysis).toMatchObject({ extractionStatus:'succeeded',matchedFlags:['scene7.nils_inventor_proved'],
|
||||||
goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:true })] })
|
goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:true })] })
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog',{ name:'What kind of evidence is this?' })).toBeVisible()
|
||||||
|
await page.getByRole('button',{ name:'Classify as Clip' }).click()
|
||||||
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
||||||
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
||||||
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
||||||
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
||||||
|
|
||||||
|
await page.locator('.source-file-widget').dblclick()
|
||||||
|
await page.getByRole('button',{ name:'TYPE',exact:true }).click()
|
||||||
|
await page.getByRole('menuitemradio',{ name:/Mugshot/ }).click()
|
||||||
|
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','photo')
|
||||||
|
await page.getByRole('button',{ name:'TYPE',exact:true }).click()
|
||||||
|
await page.getByRole('menuitemradio',{ name:/Clip/ }).click()
|
||||||
|
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
||||||
|
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
||||||
|
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
||||||
|
await expect(page.getByLabel('Board presentation')).toHaveValue('clipping')
|
||||||
|
await page.getByRole('button',{ name:'Close file editor' }).click()
|
||||||
|
await page.getByRole('button',{ name:'Close document' }).click()
|
||||||
|
|
||||||
await page.locator('.evidence-card.claim').click()
|
await page.locator('.evidence-card.claim').click()
|
||||||
await page.getByRole('button',{ name:'Red thread' }).click()
|
await page.getByRole('button',{ name:'Red thread' }).click()
|
||||||
await page.locator('.source-file-widget').click()
|
await page.locator('.source-file-widget').click()
|
||||||
@@ -71,6 +87,8 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
|
|
||||||
const level = await (await request.get(`/api/levels/${sceneSeven.id}`)).json()
|
const level = await (await request.get(`/api/levels/${sceneSeven.id}`)).json()
|
||||||
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
||||||
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toHaveLength(1)
|
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toEqual([
|
||||||
|
expect.objectContaining({ captureKind:'clipping',width:210,height:194 }),
|
||||||
|
])
|
||||||
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Utterance-level flags: a dialogue line can AWARD an achievement when reached, and
|
||||||
|
-- an option can REQUIRE an achievement to be offered (gates player choices on prior
|
||||||
|
-- discoveries). Mirrors the merit node's award and node-enable requirements — this is
|
||||||
|
-- how asking Dobby the name grants dobby.knows_barricelli_name, and how Glitch Hunter's
|
||||||
|
-- "…a Norwegian-Italian mathematician" option only shows once you know it.
|
||||||
|
|
||||||
|
ALTER TABLE osint.utterances ADD COLUMN awards_flag TEXT
|
||||||
|
CHECK (awards_flag IS NULL OR awards_flag ~ '^[a-z][a-z0-9_.-]{0,63}$');
|
||||||
|
ALTER TABLE osint.utterances ADD COLUMN requires_flag TEXT
|
||||||
|
CHECK (requires_flag IS NULL OR requires_flag ~ '^[a-z][a-z0-9_.-]{0,63}$');
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- How an imported document is presented on the physical investigation board.
|
||||||
|
-- This is deliberately separate from document_type_id/MIME type: the same PNG
|
||||||
|
-- may be a photograph, a scene, a clipping, or a complete page.
|
||||||
|
|
||||||
|
CREATE TABLE osint.document_capture_kinds (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO osint.document_capture_kinds (id,name,description) VALUES
|
||||||
|
('unclassified','Unclassified','Imported evidence that has not yet been classified.'),
|
||||||
|
('photo','Photo','A person or object is the subject of the image.'),
|
||||||
|
('scene','Scene','A place, situation, or event is shown.'),
|
||||||
|
('clipping','Clipping','An extract captured from a larger source.'),
|
||||||
|
('full_page','Full page','A complete page or document view.');
|
||||||
|
|
||||||
|
ALTER TABLE osint.document_exhibits
|
||||||
|
ADD COLUMN capture_kind_id TEXT NOT NULL DEFAULT 'unclassified'
|
||||||
|
REFERENCES osint.document_capture_kinds(id);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN osint.document_exhibits.capture_kind_id IS
|
||||||
|
'Player-selected evidentiary form used by board presentation and contextual connection copy; independent of the asset MIME type.';
|
||||||
@@ -7,6 +7,36 @@
|
|||||||
"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.",
|
"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": []
|
"concepts": []
|
||||||
},
|
},
|
||||||
|
"narrative": {
|
||||||
|
"cast": [],
|
||||||
|
"graph": {
|
||||||
|
"entry": "prove-inventor",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"key": "prove-inventor",
|
||||||
|
"type": "level",
|
||||||
|
"label": "Demonstrate OSINT skill",
|
||||||
|
"templateSlug": "barricelli-inventor-proof",
|
||||||
|
"x": 200,
|
||||||
|
"y": 80,
|
||||||
|
"terminals": [
|
||||||
|
{ "key": "report_back", "label": "Submit finding", "to": "barricelli-luggage" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "barricelli-luggage",
|
||||||
|
"type": "merit",
|
||||||
|
"label": "The Barricelli Luggage",
|
||||||
|
"awardsFlag": "barricelli_luggage",
|
||||||
|
"x": 200,
|
||||||
|
"y": 300,
|
||||||
|
"terminals": [
|
||||||
|
{ "key": "continue", "label": "Accept", "to": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"documents": [],
|
"documents": [],
|
||||||
"folders": [],
|
"folders": [],
|
||||||
"claims": [
|
"claims": [
|
||||||
@@ -40,18 +70,9 @@
|
|||||||
"flagKey": "scene7.nils_inventor_proved",
|
"flagKey": "scene7.nils_inventor_proved",
|
||||||
"minimumAnchorMatches": 2,
|
"minimumAnchorMatches": 2,
|
||||||
"anchors": [
|
"anchors": [
|
||||||
{
|
{ "phrase": "GB695913A Improved chest of drawers", "minimumSimilarity": 0.7 },
|
||||||
"phrase": "GB695913A Improved chest of drawers",
|
{ "phrase": "Nils Aall Barricelli", "minimumSimilarity": 0.72 },
|
||||||
"minimumSimilarity": 0.7
|
{ "phrase": "695913 Chests of drawers BARRICELLI N A May 31 1951", "minimumSimilarity": 0.68 }
|
||||||
},
|
|
||||||
{
|
|
||||||
"phrase": "Nils Aall Barricelli",
|
|
||||||
"minimumSimilarity": 0.72
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"phrase": "695913 Chests of drawers BARRICELLI N A May 31 1951",
|
|
||||||
"minimumSimilarity": 0.68
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,18 +82,9 @@
|
|||||||
"flagKey": "scene7.nils_inventor_proved",
|
"flagKey": "scene7.nils_inventor_proved",
|
||||||
"minimumAnchorMatches": 2,
|
"minimumAnchorMatches": 2,
|
||||||
"anchors": [
|
"anchors": [
|
||||||
{
|
{ "phrase": "Nr 75 348 Kl 33 b-9 Fra 31 mai 1948 93585 Koffert-kommode", "minimumSimilarity": 0.68 },
|
||||||
"phrase": "Nr 75 348 Kl 33 b-9 Fra 31 mai 1948 93585 Koffert-kommode",
|
{ "phrase": "Niels Aall Baricelli Oslo", "minimumSimilarity": 0.72 },
|
||||||
"minimumSimilarity": 0.68
|
{ "phrase": "Patentpaastand Kommode som er satt sammen av flere enkeltdeler som hver er utfort som koffert", "minimumSimilarity": 0.62 }
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { randomUUID } from 'node:crypto'
|
|||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { CaseDocument, CaseState, ClaimExhibit, PartyKind, SourceFileType } from '../src/types.js'
|
import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, PartyKind, SourceFileType } from '../src/types.js'
|
||||||
|
|
||||||
type MysteryDocument = {
|
type MysteryDocument = {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
fileType: SourceFileType
|
fileType: SourceFileType
|
||||||
|
captureKind?: DocumentCaptureKind
|
||||||
publishedAt: string
|
publishedAt: string
|
||||||
body?: string[]
|
body?: string[]
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
@@ -16,13 +17,13 @@ type MysteryDocument = {
|
|||||||
}
|
}
|
||||||
type MysteryGraph = {
|
type MysteryGraph = {
|
||||||
entry: string
|
entry: string
|
||||||
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; label?: string; x: number; y: number
|
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'; label?: string; x: number; y: number
|
||||||
componentKey?: string; templateSlug?: string; version?: number
|
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
|
||||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
terminals?: { key: string; label?: string; to?: string | null; npc?: string }[]
|
||||||
utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[]
|
utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player'; awardsFlag?: string; requiresFlag?: string }[] }[]
|
||||||
}
|
}
|
||||||
type MysteryNarrative = {
|
type MysteryNarrative = {
|
||||||
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
||||||
graph?: MysteryGraph
|
graph?: MysteryGraph
|
||||||
}
|
}
|
||||||
type MysteryGoal = {
|
type MysteryGoal = {
|
||||||
@@ -98,7 +99,7 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
|||||||
width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false,
|
width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false,
|
||||||
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
||||||
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
||||||
fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [],
|
fileType: source.fileType, captureKind:source.captureKind || 'unclassified', metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -100,8 +101,8 @@ suite('normalized level persistence API', () => {
|
|||||||
timeline.range = { start: '2021-04-01', end: '2021-04-30' }
|
timeline.range = { start: '2021-04-01', end: '2021-04-30' }
|
||||||
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||||
|
|
||||||
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) }
|
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) }
|
||||||
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 174, 145, 3) }
|
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) }
|
||||||
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
||||||
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
||||||
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
||||||
@@ -122,6 +123,7 @@ suite('normalized level persistence API', () => {
|
|||||||
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||||
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
||||||
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
||||||
|
expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 })
|
||||||
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
||||||
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
||||||
expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })
|
expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||||
@@ -220,7 +222,7 @@ suite('normalized level persistence API', () => {
|
|||||||
screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
|
screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
|
||||||
const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
|
const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
|
||||||
expect(screenshotResponse.status).toBe(201)
|
expect(screenshotResponse.status).toBe(201)
|
||||||
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image', fileName: 'Screenshot 2026-08-22.png' })
|
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image',captureKind:'unclassified',fileName: 'Screenshot 2026-08-22.png' })
|
||||||
|
|
||||||
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
|
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
|
||||||
expect(templateResponse.status).toBe(201)
|
expect(templateResponse.status).toBe(201)
|
||||||
@@ -234,7 +236,7 @@ suite('normalized level persistence API', () => {
|
|||||||
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
||||||
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
||||||
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||||
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ requiredFlags: ['tip.received'] })
|
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] })
|
||||||
expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
|
expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
|
||||||
expect.objectContaining({ name: 'Smoke source passage', sourceLabel: 'Archive smoke test', sourceUri: 'https://example.test/archive/smoke',
|
expect.objectContaining({ name: 'Smoke source passage', sourceLabel: 'Archive smoke test', sourceUri: 'https://example.test/archive/smoke',
|
||||||
flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
|
flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
|
||||||
@@ -252,6 +254,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 +273,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()
|
||||||
@@ -284,6 +293,18 @@ suite('normalized level persistence API', () => {
|
|||||||
const fatherJudgment = await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents/${fatherDocument.id}/judge`, { method:'POST' })).json()
|
const fatherJudgment = await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents/${fatherDocument.id}/judge`, { method:'POST' })).json()
|
||||||
expect(fatherJudgment).toMatchObject({ status:'succeeded',subject:'related',awardedFlags:['scene7.father_inventor_discovered'],
|
expect(fatherJudgment).toMatchObject({ status:'succeeded',subject:'related',awardedFlags:['scene7.father_inventor_discovered'],
|
||||||
goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'pending' })] })
|
goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'pending' })] })
|
||||||
|
const relatedState=await (await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}`)).json() as CaseState
|
||||||
|
const relatedClaim=relatedState.exhibits.find(exhibit => exhibit.type === 'claim')!
|
||||||
|
const relatedConnectionId=randomUUID()
|
||||||
|
relatedState.connections.push({ id:relatedConnectionId,fromExhibitId:relatedClaim.id,toExhibitId:fatherDocument.id,label:'Proof that the Barricelli family included an inventor.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
|
expect((await fetch(`${baseUrl}/api/levels/${relatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(relatedState) })).status).toBe(200)
|
||||||
|
const relatedSubmission=await (await fetch(`${baseUrl}/api/levels/${relatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
|
investigatorName:'Test Player',evidence:[{ connectionId:relatedConnectionId,documentExhibitId:fatherDocument.id,relationText:'Proof that the Barricelli family included an inventor.' }],
|
||||||
|
}) })).json()
|
||||||
|
expect(relatedSubmission).toMatchObject({ status:'evidence_insufficient',issues:expect.arrayContaining(['missing_accepted_evidence','connected_evidence_unverified']),
|
||||||
|
feedback:expect.stringContaining('related person rather than the claim subject'),claims:[expect.objectContaining({ evidence:[expect.objectContaining({
|
||||||
|
documentExhibitId:fatherDocument.id,evidenceAccepted:false,verification:expect.objectContaining({ status:'semantic_rejected' }),
|
||||||
|
})] })] })
|
||||||
|
|
||||||
const targetUpload = new FormData()
|
const targetUpload = new FormData()
|
||||||
targetUpload.append('file', new Blob([readFileSync(path.join(fixtureDir, 'google-patents-target-ocr.txt'))], { type:'text/plain' }), 'google-patents-source.txt')
|
targetUpload.append('file', new Blob([readFileSync(path.join(fixtureDir, 'google-patents-target-ocr.txt'))], { type:'text/plain' }), 'google-patents-source.txt')
|
||||||
@@ -308,9 +329,30 @@ suite('normalized level persistence API', () => {
|
|||||||
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
||||||
}) })
|
}) })
|
||||||
expect(accepted.status).toBe(201)
|
expect(accepted.status).toBe(201)
|
||||||
expect(await accepted.json()).toMatchObject({ status:'accepted',investigatorName:'Test Player',issues:[],claims:[expect.objectContaining({ evidence:[expect.objectContaining({
|
const acceptedBody=await accepted.json()
|
||||||
|
expect(acceptedBody).toMatchObject({ status:'accepted',investigatorName:'Test Player',issues:[] })
|
||||||
|
expect(acceptedBody.claims.flatMap((item:{ evidence:unknown[] }) => item.evidence)).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||||
displayNumber:targetDocument.displayNumber,evidenceAccepted:true,sourceCitation:'Google Patents · GB695913A',publishedAt:'1953-08-19T00:00:00.000Z',
|
displayNumber:targetDocument.displayNumber,evidenceAccepted:true,sourceCitation:'Google Patents · GB695913A',publishedAt:'1953-08-19T00:00:00.000Z',
|
||||||
})] })] })
|
verification:expect.objectContaining({ status:'accepted' }),
|
||||||
|
})]))
|
||||||
|
|
||||||
|
// A later copy of the same correct source must be accepted on its own evaluation,
|
||||||
|
// even though the first copy already owns the one-time level-flag provenance.
|
||||||
|
const repeatedUpload=new FormData()
|
||||||
|
repeatedUpload.append('file',new Blob([readFileSync(path.join(fixtureDir,'google-patents-target-ocr.txt'))],{ type:'text/plain' }),'google-patents-second-copy.txt')
|
||||||
|
const repeatedDocument=await (await fetch(`${baseUrl}/api/levels/${reportState.id}/documents`,{ method:'POST',body:repeatedUpload })).json() as DocumentExhibit & { analysis:{ matchedFlags:string[];awardedFlags:string[] } }
|
||||||
|
expect(repeatedDocument.analysis).toMatchObject({ matchedFlags:['scene7.nils_inventor_proved'],awardedFlags:[] })
|
||||||
|
const repeatedState=await (await fetch(`${baseUrl}/api/levels/${reportState.id}`)).json() as CaseState
|
||||||
|
const repeatedConnectionId=randomUUID()
|
||||||
|
repeatedState.connections.push({ id:repeatedConnectionId,fromExhibitId:claim.id,toExhibitId:repeatedDocument.id,label:'Proof that Barricelli is named as the inventor on patent GB695913A.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
|
expect((await fetch(`${baseUrl}/api/levels/${repeatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(repeatedState) })).status).toBe(200)
|
||||||
|
const repeatedReport=await (await fetch(`${baseUrl}/api/levels/${repeatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
|
investigatorName:'Test Player',evidence:[{ connectionId:repeatedConnectionId,documentExhibitId:repeatedDocument.id,relationText:'Proof that Barricelli is named as the inventor on patent GB695913A.',
|
||||||
|
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
||||||
|
}) })).json()
|
||||||
|
expect(repeatedReport).toMatchObject({ status:'accepted',claims:[expect.objectContaining({ evidence:expect.arrayContaining([expect.objectContaining({
|
||||||
|
documentExhibitId:repeatedDocument.id,evidenceAccepted:true,verification:expect.objectContaining({ status:'accepted' }),
|
||||||
|
})]) })] })
|
||||||
judgeVerdict = { subject:'target',supports_claim:true,evidence_excerpt:'Ada Example patented a pocket telescope',confidence:.96 }
|
judgeVerdict = { subject:'target',supports_claim:true,evidence_excerpt:'Ada Example patented a pocket telescope',confidence:.96 }
|
||||||
judgeHttpStatus = 200
|
judgeHttpStatus = 200
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
|||||||
|
|
||||||
const documents = await client.query<{
|
const documents = await client.query<{
|
||||||
exhibit_id: string; document_type_id: string; asset_id: string | null; title: string
|
exhibit_id: string; document_type_id: string; asset_id: string | null; title: string
|
||||||
published_at: Date | null; captured_at: Date | null; source_uri: string | null; citation_text:string
|
capture_kind_id:string; published_at: Date | null; captured_at: Date | null; source_uri: string | null; citation_text:string
|
||||||
}>(`SELECT d.* FROM osint.document_exhibits d JOIN osint.exhibits e ON e.id=d.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
}>(`SELECT d.* FROM osint.document_exhibits d JOIN osint.exhibits e ON e.id=d.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
for (const row of documents.rows) await client.query(`INSERT INTO osint.document_exhibits
|
for (const row of documents.rows) await client.query(`INSERT INTO osint.document_exhibits
|
||||||
(exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
(exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||||
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri,row.citation_text])
|
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.capture_kind_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri,row.citation_text])
|
||||||
|
|
||||||
const citations = await client.query<{ exhibit_id:string;display_number:number }>(
|
const citations = await client.query<{ exhibit_id:string;display_number:number }>(
|
||||||
'SELECT exhibit_id,display_number FROM osint.exhibit_citations WHERE board_id=$1 ORDER BY display_number',[sourceBoardId])
|
'SELECT exhibit_id,display_number FROM osint.exhibit_citations WHERE board_id=$1 ORDER BY display_number',[sourceBoardId])
|
||||||
|
|||||||
+77
-12
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { Pool, PoolClient } from 'pg'
|
import type { Pool, PoolClient } from 'pg'
|
||||||
import type { CaseReport, CaseReportEvidence, CaseReportSubmissionInput, CaseReportSubmissionStatus, SourceFileType } from '../src/types.js'
|
import type { CaseReport, CaseReportEvidence, CaseReportSubmissionInput, CaseReportSubmissionStatus, EvidenceVerification, SourceFileType } from '../src/types.js'
|
||||||
|
|
||||||
type LevelRef = { id:string; board_id:string }
|
type LevelRef = { id:string; board_id:string }
|
||||||
|
|
||||||
@@ -21,6 +21,43 @@ function unfinishedRelation(value: string) {
|
|||||||
return !value.trim() || /^proof\s+that(?:\s*(?:…|\.{3}))?\s*$/iu.test(value.trim())
|
return !value.trim() || /^proof\s+that(?:\s*(?:…|\.{3}))?\s*$/iu.test(value.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecognitionRow = {
|
||||||
|
evidence_accepted:boolean;extraction_status:'succeeded'|'unsupported'|'failed'|null
|
||||||
|
deterministic_evaluated:boolean;deterministic_matched:boolean|null;deterministic_score:string|null
|
||||||
|
matched_anchor_count:number|null;minimum_anchor_matches:number|null
|
||||||
|
semantic_status:'pending'|'succeeded'|'failed'|null;semantic_subject:'target'|'related'|'ambiguous'|'neither'|null
|
||||||
|
semantic_supports_claim:boolean|null;semantic_confidence:string|null;semantic_minimum_confidence:string|null
|
||||||
|
}
|
||||||
|
|
||||||
|
function verification(row:RecognitionRow):EvidenceVerification {
|
||||||
|
const score=row.deterministic_score === null ? undefined : Number(row.deterministic_score)
|
||||||
|
const metrics={ score,matchedMarkers:row.matched_anchor_count ?? undefined,requiredMarkers:row.minimum_anchor_matches ?? undefined }
|
||||||
|
if (row.evidence_accepted) return { status:'accepted',detail:row.deterministic_matched
|
||||||
|
? 'The extracted text matched the source fingerprint for this objective.'
|
||||||
|
: 'Semantic review found that this exhibit directly supports the target claim.',...metrics }
|
||||||
|
if (row.semantic_status === 'pending') return { status:'semantic_pending',detail:'Text was extracted, but semantic review is still pending.',...metrics }
|
||||||
|
if (row.semantic_status === 'failed') return { status:'semantic_failed',detail:'Text was extracted, but semantic review could not be completed. Retry document analysis.',...metrics }
|
||||||
|
if (row.semantic_status === 'succeeded') {
|
||||||
|
if (row.semantic_subject === 'target' && row.semantic_supports_claim) {
|
||||||
|
const confidence=Math.round(Number(row.semantic_confidence || 0) * 100)
|
||||||
|
const required=Math.round(Number(row.semantic_minimum_confidence || 0) * 100)
|
||||||
|
return { status:'semantic_rejected',detail:`Semantic review supported the claim, but confidence was ${confidence}% and this objective requires ${required}%.`,...metrics }
|
||||||
|
}
|
||||||
|
const subject = row.semantic_subject === 'related' ? 'a related person rather than the claim subject'
|
||||||
|
: row.semantic_subject === 'ambiguous' ? 'an ambiguous subject' : 'content that does not establish the target claim'
|
||||||
|
return { status:'semantic_rejected',detail:`Semantic review found ${subject}.`,...metrics }
|
||||||
|
}
|
||||||
|
if (row.deterministic_evaluated) {
|
||||||
|
const matched=row.matched_anchor_count || 0,required=row.minimum_anchor_matches || 0
|
||||||
|
const percentage=score === undefined ? null : Math.round(score * 100)
|
||||||
|
return { status:'text_not_matched',detail:`OCR succeeded, but this exhibit matched ${matched} of ${required} required source markers${percentage === null ? '' : ` (best similarity ${percentage}%)`}.`,...metrics }
|
||||||
|
}
|
||||||
|
if (row.extraction_status === 'failed') return { status:'ocr_unavailable',detail:'The image was saved, but OCR could not read usable text from it.' }
|
||||||
|
if (row.extraction_status === 'unsupported') return { status:'ocr_unavailable',detail:'This file format could not be checked automatically.' }
|
||||||
|
if (row.extraction_status === 'succeeded') return { status:'not_evaluated',detail:'Text was extracted, but no evidence-recognition rule evaluated this exhibit.' }
|
||||||
|
return { status:'not_evaluated',detail:'This exhibit has not been analyzed for the objective.' }
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadCaseReport(client: Pool | PoolClient, level: LevelRef): Promise<CaseReport | undefined> {
|
export async function loadCaseReport(client: Pool | PoolClient, level: LevelRef): Promise<CaseReport | undefined> {
|
||||||
const config = (await client.query<{ title:string; investigator_name:string; required_for_completion:boolean }>(
|
const config = (await client.query<{ title:string; investigator_name:string; required_for_completion:boolean }>(
|
||||||
'SELECT title,investigator_name,required_for_completion FROM osint.case_reports WHERE board_id=$1', [level.board_id])).rows[0]
|
'SELECT title,investigator_name,required_for_completion FROM osint.case_reports WHERE board_id=$1', [level.board_id])).rows[0]
|
||||||
@@ -30,17 +67,22 @@ export async function loadCaseReport(client: Pool | PoolClient, level: LevelRef)
|
|||||||
WHERE exhibit.board_id=$1 ORDER BY exhibit.created_at,exhibit.id`, [level.board_id])
|
WHERE exhibit.board_id=$1 ORDER BY exhibit.created_at,exhibit.id`, [level.board_id])
|
||||||
const evidence = await client.query<{
|
const evidence = await client.query<{
|
||||||
claim_exhibit_id:string; connection_id:string; document_exhibit_id:string; display_number:number; document_title:string
|
claim_exhibit_id:string; connection_id:string; document_exhibit_id:string; display_number:number; document_title:string
|
||||||
document_type_id:SourceFileType; relation_text:string; published_at:Date|null; citation_text:string; source_uri:string|null; evidence_accepted:boolean
|
document_type_id:SourceFileType; relation_text:string; published_at:Date|null; citation_text:string; source_uri:string|null
|
||||||
|
evidence_accepted:boolean;extraction_status:'succeeded'|'unsupported'|'failed'|null
|
||||||
|
deterministic_evaluated:boolean;deterministic_matched:boolean|null;deterministic_score:string|null
|
||||||
|
matched_anchor_count:number|null;minimum_anchor_matches:number|null
|
||||||
|
semantic_status:'pending'|'succeeded'|'failed'|null;semantic_subject:'target'|'related'|'ambiguous'|'neither'|null
|
||||||
|
semantic_supports_claim:boolean|null;semantic_confidence:string|null;semantic_minimum_confidence:string|null
|
||||||
}>(`SELECT claim.exhibit_id AS claim_exhibit_id,connection.id AS connection_id,document.exhibit_id AS document_exhibit_id,
|
}>(`SELECT claim.exhibit_id AS claim_exhibit_id,connection.id AS connection_id,document.exhibit_id AS document_exhibit_id,
|
||||||
citation.display_number,document.title AS document_title,document.document_type_id,COALESCE(connection.label,'') AS relation_text,
|
citation.display_number,document.title AS document_title,document.document_type_id,COALESCE(connection.label,'') AS relation_text,
|
||||||
document.published_at,document.citation_text,document.source_uri,
|
document.published_at,document.citation_text,document.source_uri,
|
||||||
(EXISTS (SELECT 1 FROM osint.level_flags flag JOIN osint.evidence_match_evaluations evaluation
|
(COALESCE(deterministic.matched,FALSE) OR COALESCE(semantic.status='succeeded' AND semantic.subject='target'
|
||||||
ON evaluation.id=flag.awarded_by_evidence_match_id
|
AND semantic.supports_claim AND semantic.confidence >= semantic.minimum_confidence,FALSE)) AS evidence_accepted,
|
||||||
WHERE flag.level_id=$2 AND evaluation.document_exhibit_id=document.exhibit_id)
|
extraction.status AS extraction_status,(deterministic.rule_id IS NOT NULL) AS deterministic_evaluated,
|
||||||
OR EXISTS (SELECT 1 FROM osint.level_flags flag JOIN osint.evidence_semantic_evaluations evaluation
|
deterministic.matched AS deterministic_matched,deterministic.score::text AS deterministic_score,
|
||||||
ON evaluation.id=flag.awarded_by_semantic_evaluation_id
|
deterministic.matched_anchor_count,deterministic.minimum_anchor_matches,
|
||||||
WHERE flag.level_id=$2 AND evaluation.document_exhibit_id=document.exhibit_id
|
semantic.status AS semantic_status,semantic.subject AS semantic_subject,semantic.supports_claim AS semantic_supports_claim,
|
||||||
AND evaluation.status='succeeded' AND evaluation.subject='target' AND evaluation.supports_claim)) AS evidence_accepted
|
semantic.confidence::text AS semantic_confidence,semantic.minimum_confidence::text AS semantic_minimum_confidence
|
||||||
FROM osint.claim_exhibits claim
|
FROM osint.claim_exhibits claim
|
||||||
JOIN osint.exhibits claim_exhibit ON claim_exhibit.id=claim.exhibit_id AND claim_exhibit.board_id=$1
|
JOIN osint.exhibits claim_exhibit ON claim_exhibit.id=claim.exhibit_id AND claim_exhibit.board_id=$1
|
||||||
JOIN osint.exhibit_connections connection ON connection.board_id=$1
|
JOIN osint.exhibit_connections connection ON connection.board_id=$1
|
||||||
@@ -48,6 +90,19 @@ export async function loadCaseReport(client: Pool | PoolClient, level: LevelRef)
|
|||||||
JOIN osint.document_exhibits document ON document.exhibit_id=CASE
|
JOIN osint.document_exhibits document ON document.exhibit_id=CASE
|
||||||
WHEN connection.from_exhibit_id=claim.exhibit_id THEN connection.to_exhibit_id ELSE connection.from_exhibit_id END
|
WHEN connection.from_exhibit_id=claim.exhibit_id THEN connection.to_exhibit_id ELSE connection.from_exhibit_id END
|
||||||
JOIN osint.exhibit_citations citation ON citation.board_id=$1 AND citation.exhibit_id=document.exhibit_id
|
JOIN osint.exhibit_citations citation ON citation.board_id=$1 AND citation.exhibit_id=document.exhibit_id
|
||||||
|
LEFT JOIN LATERAL (SELECT evaluation.rule_id,evaluation.matched,evaluation.score,evaluation.matched_anchor_count,rule.minimum_anchor_matches
|
||||||
|
FROM osint.evidence_match_evaluations evaluation
|
||||||
|
JOIN osint.evidence_match_rules rule ON rule.id=evaluation.rule_id AND rule.board_id=$1 AND rule.enabled
|
||||||
|
WHERE evaluation.level_id=$2 AND evaluation.document_exhibit_id=document.exhibit_id
|
||||||
|
ORDER BY evaluation.matched DESC,evaluation.score DESC,evaluation.evaluated_at DESC LIMIT 1) deterministic ON TRUE
|
||||||
|
LEFT JOIN LATERAL (SELECT evaluation.status,evaluation.subject,evaluation.supports_claim,evaluation.confidence,rule.minimum_confidence
|
||||||
|
FROM osint.evidence_semantic_evaluations evaluation
|
||||||
|
JOIN osint.evidence_semantic_rules rule ON rule.id=evaluation.rule_id AND rule.board_id=$1 AND rule.enabled
|
||||||
|
WHERE evaluation.level_id=$2 AND evaluation.document_exhibit_id=document.exhibit_id
|
||||||
|
ORDER BY (evaluation.status='succeeded' AND evaluation.subject='target' AND evaluation.supports_claim
|
||||||
|
AND evaluation.confidence >= rule.minimum_confidence) DESC,evaluation.updated_at DESC LIMIT 1) semantic ON TRUE
|
||||||
|
LEFT JOIN LATERAL (SELECT candidate.status FROM osint.asset_text_extractions candidate
|
||||||
|
WHERE candidate.asset_id=document.asset_id ORDER BY candidate.updated_at DESC LIMIT 1) extraction ON TRUE
|
||||||
ORDER BY claim_exhibit.created_at,claim.exhibit_id,citation.display_number,connection.created_at`, [level.board_id,level.id])
|
ORDER BY claim_exhibit.created_at,claim.exhibit_id,citation.display_number,connection.created_at`, [level.board_id,level.id])
|
||||||
const latest = (await client.query<{ id:string;status:CaseReportSubmissionStatus; feedback:string }>(
|
const latest = (await client.query<{ id:string;status:CaseReportSubmissionStatus; feedback:string }>(
|
||||||
'SELECT id,status,feedback FROM osint.case_report_submissions WHERE level_id=$1 ORDER BY submitted_at DESC,id DESC LIMIT 1', [level.id])).rows[0]
|
'SELECT id,status,feedback FROM osint.case_report_submissions WHERE level_id=$1 ORDER BY submitted_at DESC,id DESC LIMIT 1', [level.id])).rows[0]
|
||||||
@@ -58,7 +113,7 @@ export async function loadCaseReport(client: Pool | PoolClient, level: LevelRef)
|
|||||||
connectionId:row.connection_id,documentExhibitId:row.document_exhibit_id,displayNumber:row.display_number,
|
connectionId:row.connection_id,documentExhibitId:row.document_exhibit_id,displayNumber:row.display_number,
|
||||||
documentTitle:row.document_title,fileType:row.document_type_id,relationText:row.relation_text,
|
documentTitle:row.document_title,fileType:row.document_type_id,relationText:row.relation_text,
|
||||||
publishedAt:row.published_at?.toISOString(),sourceCitation:row.citation_text || undefined,sourceUri:row.source_uri || undefined,
|
publishedAt:row.published_at?.toISOString(),sourceCitation:row.citation_text || undefined,sourceUri:row.source_uri || undefined,
|
||||||
evidenceAccepted:row.evidence_accepted,
|
evidenceAccepted:row.evidence_accepted,verification:verification(row),
|
||||||
}])
|
}])
|
||||||
return { title:config.title,investigatorName:config.investigator_name,requiredForCompletion:config.required_for_completion,
|
return { title:config.title,investigatorName:config.investigator_name,requiredForCompletion:config.required_for_completion,
|
||||||
status:latest?.status || 'draft',feedback:latest?.feedback,issues,
|
status:latest?.status || 'draft',feedback:latest?.feedback,issues,
|
||||||
@@ -111,7 +166,11 @@ export async function submitCaseReport(pool: Pool, levelSlug: string, rawInput:
|
|||||||
if (!assembled.claims.length) blockingIssues.add('missing_claim')
|
if (!assembled.claims.length) blockingIssues.add('missing_claim')
|
||||||
for (const claim of assembled.claims) {
|
for (const claim of assembled.claims) {
|
||||||
const accepted = claim.evidence.filter(item => item.evidenceAccepted)
|
const accepted = claim.evidence.filter(item => item.evidenceAccepted)
|
||||||
if (!accepted.length) { blockingIssues.add('missing_accepted_evidence'); continue }
|
if (!accepted.length) {
|
||||||
|
blockingIssues.add('missing_accepted_evidence')
|
||||||
|
blockingIssues.add(claim.evidence.length ? 'connected_evidence_unverified' : 'missing_connected_evidence')
|
||||||
|
continue
|
||||||
|
}
|
||||||
for (const item of accepted) {
|
for (const item of accepted) {
|
||||||
if (unfinishedRelation(item.relationText)) blockingIssues.add('unfinished_relation')
|
if (unfinishedRelation(item.relationText)) blockingIssues.add('unfinished_relation')
|
||||||
if (!item.publishedAt) blockingIssues.add('missing_date')
|
if (!item.publishedAt) blockingIssues.add('missing_date')
|
||||||
@@ -122,7 +181,13 @@ export async function submitCaseReport(pool: Pool, levelSlug: string, rawInput:
|
|||||||
let feedback:string
|
let feedback:string
|
||||||
if (pendingGoals || !connectedAccepted) {
|
if (pendingGoals || !connectedAccepted) {
|
||||||
status='evidence_insufficient'
|
status='evidence_insufficient'
|
||||||
feedback='The report does not yet connect the claim to evidence that proves it. Find the source, add it to the board, and connect it with red thread.'
|
const emptyClaim=assembled.claims.find(claim => !claim.evidence.length)
|
||||||
|
const rejected=assembled.claims.flatMap(claim => claim.evidence).find(item => !item.evidenceAccepted)
|
||||||
|
feedback=emptyClaim
|
||||||
|
? 'No source document is connected to the claim. Return to the board and attach one with red thread.'
|
||||||
|
: rejected
|
||||||
|
? `Exhibit ${rejected.displayNumber} is connected to the claim, but it was not accepted: ${rejected.verification.detail}`
|
||||||
|
: 'The connected evidence was recognized, but another required level objective is still incomplete.'
|
||||||
} else if (blockingIssues.size) {
|
} else if (blockingIssues.size) {
|
||||||
status='evidence_accepted_report_incomplete'
|
status='evidence_accepted_report_incomplete'
|
||||||
feedback=blockingIssues.has('missing_date') || blockingIssues.has('missing_source')
|
feedback=blockingIssues.has('missing_date') || blockingIssues.has('missing_source')
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ state.brief = { body: 'Classify the named people and organizations in this inves
|
|||||||
] }
|
] }
|
||||||
state.exhibits = [{
|
state.exhibits = [{
|
||||||
id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z',
|
id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z',
|
||||||
body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false,
|
body: [], regions: [], fileType: 'image', captureKind:'scene',metadata: {}, x: 980, y: 360, width: 244, height: 200, rotation: 0, zIndex: 2, hidden: false,
|
||||||
}, {
|
}, {
|
||||||
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
|
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
|
||||||
x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
||||||
|
|||||||
@@ -508,6 +508,14 @@ app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
|||||||
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
// A dialogue line was reached in play — grant its authored achievement (validated
|
||||||
|
// server-side against the player's current node, so players can't forge flags).
|
||||||
|
app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid))
|
||||||
|
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
||||||
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
import type { Pool, PoolClient } from 'pg'
|
import type { Pool, PoolClient } from 'pg'
|
||||||
import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentCaptureKind, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
||||||
import { isClaimExhibit, isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
import { isClaimExhibit, isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
||||||
import { clearBoard, cloneBoard } from './boardClone.js'
|
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||||
import { loadCaseReport } from './caseReports.js'
|
import { loadCaseReport } from './caseReports.js'
|
||||||
@@ -82,6 +82,7 @@ type LevelRow = {
|
|||||||
type ExhibitRow = {
|
type ExhibitRow = {
|
||||||
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
|
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
|
||||||
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
|
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
|
||||||
|
capture_kind_id: DocumentCaptureKind | null
|
||||||
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
|
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
|
||||||
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
|
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
|
||||||
original_name: string | null; mime_type: string | null; byte_size: string | null
|
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||||
@@ -161,6 +162,10 @@ function documentType(document: CaseDocument): SourceFileType {
|
|||||||
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
||||||
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
||||||
}
|
}
|
||||||
|
function documentCaptureKind(value: unknown): DocumentCaptureKind {
|
||||||
|
const allowed: DocumentCaptureKind[] = ['unclassified','photo','scene','clipping','full_page']
|
||||||
|
return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified'
|
||||||
|
}
|
||||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage, evidenceJudge: EvidenceJudge): LevelRepository {
|
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage, evidenceJudge: EvidenceJudge): LevelRepository {
|
||||||
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
||||||
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
|
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
|
||||||
@@ -313,7 +318,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
||||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
||||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||||
f.is_open, d.document_type_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
||||||
ev.occurred_at,claim.statement,
|
ev.occurred_at,claim.statement,
|
||||||
p.party_kind, op.organization_kind,
|
p.party_kind, op.organization_kind,
|
||||||
a.original_name, a.mime_type, a.byte_size,
|
a.original_name, a.mime_type, a.byte_size,
|
||||||
@@ -400,7 +405,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
sourceCitation:row.citation_text || undefined,displayNumber:row.display_number || undefined,requiredFlags: requirements.get(row.id) || [],
|
sourceCitation:row.citation_text || undefined,displayNumber:row.display_number || undefined,requiredFlags: requirements.get(row.id) || [],
|
||||||
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
||||||
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
||||||
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type,
|
||||||
|
captureKind: documentCaptureKind(row.capture_kind_id), metadata: metadata.get(row.id) || {} }
|
||||||
})
|
})
|
||||||
const evidence: Evidence[] = []
|
const evidence: Evidence[] = []
|
||||||
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
|
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
|
||||||
@@ -493,10 +499,11 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
}
|
}
|
||||||
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
||||||
for (const document of documents) {
|
for (const document of documents) {
|
||||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (exhibit_id) DO UPDATE SET
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
|
||||||
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
||||||
captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`, [document.id, documentType(document), document.assetId || null, document.title,
|
capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`,
|
||||||
|
[document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, document.title,
|
||||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
||||||
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
||||||
if (!existingCitation.rows[0]) {
|
if (!existingCitation.rows[0]) {
|
||||||
@@ -818,7 +825,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
||||||
displayNumber:citation.rows[0].display_number,
|
displayNumber:citation.rows[0].display_number,
|
||||||
fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
fileType,captureKind:'unclassified',metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
||||||
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
||||||
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }
|
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { CaseState, DocumentExhibit, NoteExhibit } from '../src/types.js'
|
|||||||
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
||||||
|
|
||||||
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
|
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
|
||||||
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', metadata: {}, ...placed }
|
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, ...placed }
|
||||||
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
||||||
const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed }
|
const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed }
|
||||||
const state: CaseState = {
|
const state: CaseState = {
|
||||||
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
|
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ suite('PostgreSQL migrations', () => {
|
|||||||
'level_goals', 'level_goal_flag_requirements',
|
'level_goals', 'level_goal_flag_requirements',
|
||||||
'evidence_semantic_rules', 'evidence_semantic_evaluations',
|
'evidence_semantic_rules', 'evidence_semantic_evaluations',
|
||||||
'claim_exhibits', 'exhibit_citations', 'case_reports', 'case_report_submissions', 'case_report_submission_issues',
|
'claim_exhibits', 'exhibit_citations', 'case_reports', 'case_report_submissions', 'case_report_submission_issues',
|
||||||
|
'document_capture_kinds',
|
||||||
]))
|
]))
|
||||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
||||||
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type MysterySummary = { id: string; slug: string; title: string; nodes: n
|
|||||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
||||||
export type RuntimeUtterance = {
|
export type RuntimeUtterance = {
|
||||||
id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }
|
id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }
|
||||||
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
|
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag: string | null
|
||||||
}
|
}
|
||||||
export type RuntimeNode = {
|
export type RuntimeNode = {
|
||||||
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
|
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
|
||||||
@@ -32,7 +32,7 @@ export type PlaythroughAdvanceResult = {
|
|||||||
export type MysteryAuthoring = {
|
export type MysteryAuthoring = {
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +58,7 @@ export interface NarrativeRepository {
|
|||||||
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<PlaythroughAdvanceResult>
|
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<PlaythroughAdvanceResult>
|
||||||
listAchievements(playthroughId: string): Promise<string[] | null>
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
||||||
|
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
||||||
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
listMysteries(): Promise<MysterySummary[]>
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||||
@@ -87,43 +88,46 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
|
|
||||||
// Resolve a dialogue node's whole utterance tree for the client to walk: each
|
// Resolve a dialogue node's whole utterance tree for the client to walk: each
|
||||||
// utterance carries its ordered children and (if it exits the node) its terminal key.
|
// utterance carries its ordered children and (if it exits the node) its terminal key.
|
||||||
async function resolveDialogueGraph(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> {
|
// earnedFlags gates player options: any utterance whose requires_flag isn't held is
|
||||||
|
// dropped (so it can't be offered). Pass undefined (authoring preview) to show all.
|
||||||
|
async function resolveDialogueGraph(nodeId: string, earnedFlags?: Set<string>): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> {
|
||||||
const [utterances, poses, terminals] = await Promise.all([
|
const [utterances, poses, terminals] = await Promise.all([
|
||||||
pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; name: string | null; role: string | null; default_pose_key: string | null }>(
|
pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; awards_flag: string | null; requires_flag: string | null; name: string | null; role: string | null; default_pose_key: string | null }>(
|
||||||
`SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,n.name,n.role,n.default_pose_key
|
`SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,u.awards_flag,u.requires_flag,n.name,n.role,n.default_pose_key
|
||||||
FROM osint.utterances u LEFT JOIN osint.npcs n ON n.id=u.npc_id WHERE u.node_id=$1 ORDER BY u.sort_order`, [nodeId]),
|
FROM osint.utterances u LEFT JOIN osint.npcs n ON n.id=u.npc_id WHERE u.node_id=$1 ORDER BY u.sort_order`, [nodeId]),
|
||||||
pool.query<{ npc_id: string; pose_key: string; asset_id: string | null }>(
|
pool.query<{ npc_id: string; pose_key: string; asset_id: string | null }>(
|
||||||
`SELECT p.npc_id,p.pose_key,p.asset_id FROM osint.npc_poses p
|
`SELECT p.npc_id,p.pose_key,p.asset_id FROM osint.npc_poses p
|
||||||
WHERE p.npc_id IN (SELECT DISTINCT npc_id FROM osint.utterances WHERE node_id=$1 AND npc_id IS NOT NULL)`, [nodeId]),
|
WHERE p.npc_id IN (SELECT DISTINCT npc_id FROM osint.utterances WHERE node_id=$1 AND npc_id IS NOT NULL)`, [nodeId]),
|
||||||
pool.query<{ id: string; terminal_key: string }>('SELECT id,terminal_key FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId]),
|
pool.query<{ id: string; terminal_key: string }>('SELECT id,terminal_key FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId]),
|
||||||
])
|
])
|
||||||
|
const rows = earnedFlags ? utterances.rows.filter(row => !row.requires_flag || earnedFlags.has(row.requires_flag)) : utterances.rows
|
||||||
const poseAssets = new Map<string, Record<string, string | null>>()
|
const poseAssets = new Map<string, Record<string, string | null>>()
|
||||||
for (const row of poses.rows) { const map = poseAssets.get(row.npc_id) || {}; map[row.pose_key] = row.asset_id; poseAssets.set(row.npc_id, map) }
|
for (const row of poses.rows) { const map = poseAssets.get(row.npc_id) || {}; map[row.pose_key] = row.asset_id; poseAssets.set(row.npc_id, map) }
|
||||||
const terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key]))
|
const terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key]))
|
||||||
const children = new Map<string, string[]>()
|
const children = new Map<string, string[]>()
|
||||||
for (const row of utterances.rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id])
|
for (const row of rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id])
|
||||||
const root = utterances.rows.find(row => !row.parent_utterance_id)
|
const root = rows.find(row => !row.parent_utterance_id)
|
||||||
return {
|
return {
|
||||||
rootId: root?.id ?? null,
|
rootId: root?.id ?? null,
|
||||||
utterances: utterances.rows.map(row => {
|
utterances: rows.map(row => {
|
||||||
const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null
|
const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null
|
||||||
return {
|
return {
|
||||||
id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' },
|
id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' },
|
||||||
poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text,
|
poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text, awardsFlag: row.awards_flag,
|
||||||
childIds: children.get(row.id) || [], terminalKey: row.terminal_id ? (terminalKey.get(row.terminal_id) ?? null) : null,
|
childIds: children.get(row.id) || [], terminalKey: row.terminal_id ? (terminalKey.get(row.terminal_id) ?? null) : null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
|
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null, earnedFlags?: Set<string>): Promise<RuntimeNode | null> {
|
||||||
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
||||||
const musicVolume = node.music_volume / 100
|
const musicVolume = node.music_volume / 100
|
||||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
||||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
||||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
|
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id, earnedFlags)) }
|
||||||
if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume }
|
if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume }
|
||||||
return null // gates are auto-resolved during advance and never surfaced
|
return null // gates are auto-resolved during advance and never surfaced
|
||||||
}
|
}
|
||||||
@@ -160,7 +164,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id
|
FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id
|
||||||
WHERE p.id=$1`, [playthroughId])).rows[0]
|
WHERE p.id=$1`, [playthroughId])).rows[0]
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug) : null
|
const earned = new Set((await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1', [playthroughId])).rows.map(r => r.flag_key))
|
||||||
|
const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug, earned) : null
|
||||||
return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node }
|
return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +217,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
const existing = await client.query('SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [npc.key])
|
const existing = await client.query('SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [npc.key])
|
||||||
if (existing.rows[0]) continue
|
if (existing.rows[0]) continue
|
||||||
const npcId = randomUUID()
|
const npcId = randomUUID()
|
||||||
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
|
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)',
|
||||||
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null])
|
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null, npc.phoneNumber || null, npc.email || null])
|
||||||
for (const pose of npc.poses || []) await client.query(
|
for (const pose of npc.poses || []) await client.query(
|
||||||
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)', [randomUUID(), npcId, pose.poseKey, pose.assetId])
|
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)', [randomUUID(), npcId, pose.poseKey, pose.assetId])
|
||||||
}
|
}
|
||||||
@@ -261,6 +266,20 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return Boolean(result.rowCount)
|
return Boolean(result.rowCount)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A dialogue line was reached in play: grant its authored achievement, but only if
|
||||||
|
// the utterance really belongs to the player's current node (so it can't be forged).
|
||||||
|
async reachUtterance(playthroughId, utteranceId) {
|
||||||
|
const row = (await pool.query<{ awards_flag: string | null; node_id: string; current_node_id: string | null }>(
|
||||||
|
`SELECT u.awards_flag,u.node_id,p.current_node_id FROM osint.utterances u
|
||||||
|
JOIN osint.playthroughs p ON p.id=$2 WHERE u.id=$1`, [utteranceId, playthroughId])).rows[0]
|
||||||
|
if (!row) return { ok: false }
|
||||||
|
if (!row.awards_flag || row.node_id !== row.current_node_id) return { ok: true, earned: false }
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING',
|
||||||
|
[playthroughId, row.awards_flag, row.node_id])
|
||||||
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||||
|
},
|
||||||
|
|
||||||
async listAchievements(playthroughId) {
|
async listAchievements(playthroughId) {
|
||||||
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||||
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import type { Pool, PoolClient } from 'pg'
|
|||||||
|
|
||||||
export type GraphSpecNode = {
|
export type GraphSpecNode = {
|
||||||
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
||||||
componentKey?: string; templateSlug?: string; version?: number
|
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
|
||||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
terminals?: { key: string; label?: string; to?: string | null; npc?: string }[]
|
||||||
utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[]
|
// Linear form: an ordered list (chained automatically). Branching form: give each
|
||||||
|
// utterance a `key` and set `parent` (its predecessor) + `terminal` (its exit);
|
||||||
|
// multiple children of one parent become player options.
|
||||||
|
utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: Utterer; awardsFlag?: string; requiresFlag?: string }[]
|
||||||
}
|
}
|
||||||
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
||||||
|
|
||||||
@@ -261,12 +264,18 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
versionId = version.rows[0]?.id ?? null
|
versionId = version.rows[0]?.id ?? null
|
||||||
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
|
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
|
||||||
}
|
}
|
||||||
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key,awards_flag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
|
||||||
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null])
|
[id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null, node.awardsFlag?.trim()|| null])
|
||||||
for (const [index, terminal] of (node.terminals || []).entries()) {
|
for (const [index, terminal] of (node.terminals || []).entries()) {
|
||||||
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
|
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
|
||||||
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
|
let npcId: string | null = null
|
||||||
[terminalId, id, terminal.key, terminal.label || terminal.key, index])
|
if (terminal.npc) { // phone-node terminal bound to an NPC (the callee)
|
||||||
|
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [terminal.npc])
|
||||||
|
npcId = npc.rows[0]?.id ?? null
|
||||||
|
if (!npcId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} references unknown NPC ${terminal.npc}`)
|
||||||
|
}
|
||||||
|
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order,npc_id) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||||
|
[terminalId, id, terminal.key, terminal.label || terminal.key, index, npcId])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Wire terminals now that all nodes exist.
|
// Wire terminals now that all nodes exist.
|
||||||
@@ -279,18 +288,31 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
// Utterances (linear seed): create, then chain them and exit the last one via
|
// Utterances (linear seed): create, then chain them and exit the last one via
|
||||||
// the node's first terminal, so the crafter shows a connected flow.
|
// the node's first terminal, so the crafter shows a connected flow.
|
||||||
for (const node of spec.nodes) {
|
for (const node of spec.nodes) {
|
||||||
|
const spec2 = node.utterances || []
|
||||||
const created: string[] = []
|
const created: string[] = []
|
||||||
for (const [index, utterance] of (node.utterances || []).entries()) {
|
const uttKeyToId = new Map<string, string>()
|
||||||
|
for (const [index, utterance] of spec2.entries()) {
|
||||||
let npcId: string | null = null
|
let npcId: string | null = null
|
||||||
if (utterance.npc) {
|
if (utterance.npc) {
|
||||||
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc])
|
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc])
|
||||||
npcId = npc.rows[0]?.id ?? null
|
npcId = npc.rows[0]?.id ?? null
|
||||||
}
|
}
|
||||||
const utteranceId = randomUUID(); created.push(utteranceId)
|
const utteranceId = randomUUID(); created.push(utteranceId)
|
||||||
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
|
if (utterance.key) uttKeyToId.set(utterance.key, utteranceId)
|
||||||
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index])
|
await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,awards_flag,requires_flag,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)',
|
||||||
|
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, utterance.awardsFlag || null, utterance.requiresFlag || null, 60, 60 + index * 120, index])
|
||||||
}
|
}
|
||||||
// Chain via parent: each line follows the previous one (one child = linear).
|
const branching = spec2.some(utterance => utterance.key)
|
||||||
|
if (branching) {
|
||||||
|
// Explicit tree: wire each utterance's parent + exit terminal by key.
|
||||||
|
for (const utterance of spec2) {
|
||||||
|
const id = utterance.key ? uttKeyToId.get(utterance.key) : undefined
|
||||||
|
if (!id) continue
|
||||||
|
if (utterance.parent) await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [id, uttKeyToId.get(utterance.parent) ?? null])
|
||||||
|
if (utterance.terminal) await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [id, terminalIds.get(`${node.key}:${utterance.terminal}`) ?? null])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Linear: each line follows the previous; the last exits via the first terminal.
|
||||||
for (let i = 1; i < created.length; i++)
|
for (let i = 1; i < created.length; i++)
|
||||||
await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
|
await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
|
||||||
const firstTerminalKey = node.terminals?.[0]?.key
|
const firstTerminalKey = node.terminals?.[0]?.key
|
||||||
@@ -298,6 +320,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
if (created.length && exitTerminalId)
|
if (created.length && exitTerminalId)
|
||||||
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
|
await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const entryId = nodeIds.get(spec.entry)
|
const entryId = nodeIds.get(spec.entry)
|
||||||
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
|
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
|
||||||
await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
|
await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
|
||||||
|
|||||||
+119
-17
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||||
import { AdminPanel } from './admin'
|
import { AdminPanel } from './admin'
|
||||||
|
import { audio } from './audio'
|
||||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
import type { PlaythroughState } from './narrative'
|
import type { PlaythroughState } from './narrative'
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
@@ -20,7 +21,7 @@ function screenshotFile(file: File, index: number) {
|
|||||||
}
|
}
|
||||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||||
function documentSearchText(document: CaseDocument) {
|
function documentSearchText(document: CaseDocument) {
|
||||||
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
|
return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
|
||||||
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
||||||
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,7 @@ export function App() {
|
|||||||
const [clock, setClock] = useState('')
|
const [clock, setClock] = useState('')
|
||||||
const [draggingFiles, setDraggingFiles] = useState(false)
|
const [draggingFiles, setDraggingFiles] = useState(false)
|
||||||
const [uploading, setUploading] = useState(0)
|
const [uploading, setUploading] = useState(0)
|
||||||
|
const [documentClassificationQueue, setDocumentClassificationQueue] = useState<string[]>([])
|
||||||
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
|
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
|
||||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
||||||
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
||||||
@@ -275,7 +277,10 @@ export function App() {
|
|||||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
const source = caseState.exhibits.find(exhibit => exhibit.id === linkFrom)
|
||||||
|
const target = caseState.exhibits.find(exhibit => exhibit.id === targetId)
|
||||||
|
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId,
|
||||||
|
label:source && target ? defaultConnectionLabel(source,target) : 'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||||
setLinkFrom(null)
|
setLinkFrom(null)
|
||||||
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
|
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
|
||||||
}
|
}
|
||||||
@@ -421,12 +426,24 @@ export function App() {
|
|||||||
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||||
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
||||||
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
||||||
|
if (document.fileType === 'image') setDocumentClassificationQueue(current => [...new Set([...current,document.id])])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
||||||
} finally { setUploading(count => count - 1) }
|
} finally { setUploading(count => count - 1) }
|
||||||
}
|
}
|
||||||
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
||||||
|
|
||||||
|
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
||||||
|
const presentation=documentCapture(captureKind)
|
||||||
|
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height })
|
||||||
|
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
|
||||||
|
? classify(exhibit)
|
||||||
|
: exhibit) }))
|
||||||
|
setOpenDoc(current => current?.id === documentId ? classify(current) : current)
|
||||||
|
setDocumentClassificationQueue(current => current.filter(id => id !== documentId))
|
||||||
|
setStatus(captureKind === 'unclassified' ? 'EVIDENCE SAVED · CLASSIFY IT LATER IN METADATA' : `${presentation.label.toUpperCase()} CLASSIFICATION SAVED`)
|
||||||
|
}
|
||||||
|
|
||||||
const continueAfterGoal = async () => {
|
const continueAfterGoal = async () => {
|
||||||
if (!activePlaythroughId) { setCompletedGoal(null); return }
|
if (!activePlaythroughId) { setCompletedGoal(null); return }
|
||||||
setAdvancing(true)
|
setAdvancing(true)
|
||||||
@@ -501,6 +518,7 @@ export function App() {
|
|||||||
const documentById = new Map(documents.map(document => [document.id, document]))
|
const documentById = new Map(documents.map(document => [document.id, document]))
|
||||||
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
||||||
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
|
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
|
||||||
|
const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
|
||||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||||
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
||||||
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
||||||
@@ -553,7 +571,7 @@ export function App() {
|
|||||||
<div className="doc-list">
|
<div className="doc-list">
|
||||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
||||||
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||||
</button>)}
|
</button>)}
|
||||||
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -600,7 +618,11 @@ export function App() {
|
|||||||
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
|
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
|
||||||
{timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/>
|
{timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/>
|
||||||
}
|
}
|
||||||
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
|
{classificationDocument && <DocumentClassificationPanel document={classificationDocument}
|
||||||
|
onChoose={captureKind => classifyDocument(classificationDocument.id,captureKind)}/>}
|
||||||
|
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onInfo={() => setEditingFileId(openDoc.id)}
|
||||||
|
onDelete={() => { if (window.confirm(`Delete “${openDoc.title}” from this board? Its connections and folder membership will also be removed.`)) { removeExhibit(openDoc.id); setOpenDoc(null) } }}
|
||||||
|
onType={captureKind => classifyDocument(openDoc.id,captureKind)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
|
||||||
{editingFolderId && <FolderEditor
|
{editingFolderId && <FolderEditor
|
||||||
key={editingFolderId}
|
key={editingFolderId}
|
||||||
folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
|
folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
|
||||||
@@ -621,7 +643,7 @@ export function App() {
|
|||||||
setStatus('FOLDER UPDATED')
|
setStatus('FOLDER UPDATED')
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setOpenDoc(current => current?.id === document.id ? document : current); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
||||||
}
|
}
|
||||||
{editingEventId && <EventEditor
|
{editingEventId && <EventEditor
|
||||||
key={editingEventId}
|
key={editingEventId}
|
||||||
@@ -925,18 +947,41 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
|||||||
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||||
<Widget exhibit={ev} context={widgetContext}/>
|
<Widget exhibit={ev} context={widgetContext}/>
|
||||||
</article>})}
|
</article>})}
|
||||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||||
<header><span>{definition.label.toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||||
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : <strong>{document.title}</strong>}<time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
||||||
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
||||||
</article> })}
|
</article> })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MugshotCaption({ name }:{ name:string }) {
|
||||||
|
const [visibleName,setVisibleName]=useState(name)
|
||||||
|
const [writing,setWriting]=useState(false)
|
||||||
|
const previousName=useRef(name)
|
||||||
|
useEffect(() => {
|
||||||
|
if (name === previousName.current) return
|
||||||
|
previousName.current=name
|
||||||
|
if (!name) { setVisibleName('');setWriting(false);return }
|
||||||
|
const letterDelay=Math.max(48,Math.min(82,900 / name.length))
|
||||||
|
const duration=Math.round(letterDelay * name.length)
|
||||||
|
let character=0
|
||||||
|
setVisibleName('');setWriting(true)
|
||||||
|
const stopSound=audio.sharpie(duration + 80)
|
||||||
|
const timer=window.setInterval(() => {
|
||||||
|
character+=1
|
||||||
|
setVisibleName(name.slice(0,character))
|
||||||
|
if (character >= name.length) { window.clearInterval(timer);setWriting(false) }
|
||||||
|
},letterDelay)
|
||||||
|
return () => { window.clearInterval(timer);stopSound?.() }
|
||||||
|
},[name])
|
||||||
|
return <strong className={`mugshot-caption ${writing ? 'writing' : ''}`} aria-label={name ? `Identified as ${name}` : 'Unlabelled mugshot'}><span>{visibleName}</span></strong>
|
||||||
|
}
|
||||||
|
|
||||||
function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) {
|
function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) {
|
||||||
const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null)
|
const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1074,8 +1119,9 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm
|
|||||||
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
||||||
<h4>EVIDENCE</h4>
|
<h4>EVIDENCE</h4>
|
||||||
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
||||||
: claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : ''}`} key={item.connectionId}>
|
: claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
|
||||||
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted && <em>CONTENT VERIFIED</em>}</div>
|
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted ? <em>CONTENT VERIFIED</em> : rejected ? <em className="rejected">NOT VERIFIED</em> : null}</div>
|
||||||
|
{rejected && <p className="report-evidence-diagnostic">{item.verification.detail}</p>}
|
||||||
<label><span>EVIDENTIARY STATEMENT</span><textarea aria-label={`Evidence statement for Exhibit ${item.displayNumber}`} rows={2} value={draft.relationText} onChange={event => updateEvidence(item.connectionId,{ relationText:event.target.value })}/></label>
|
<label><span>EVIDENTIARY STATEMENT</span><textarea aria-label={`Evidence statement for Exhibit ${item.displayNumber}`} rows={2} value={draft.relationText} onChange={event => updateEvidence(item.connectionId,{ relationText:event.target.value })}/></label>
|
||||||
<div className="report-fields"><label><span>DATED</span><input aria-label={`Date for Exhibit ${item.displayNumber}`} type="date" value={draft.publishedAt || ''} onChange={event => updateEvidence(item.connectionId,{ publishedAt:event.target.value || undefined })}/></label>
|
<div className="report-fields"><label><span>DATED</span><input aria-label={`Date for Exhibit ${item.displayNumber}`} type="date" value={draft.publishedAt || ''} onChange={event => updateEvidence(item.connectionId,{ publishedAt:event.target.value || undefined })}/></label>
|
||||||
<label><span>SOURCE / PUBLICATION</span><input aria-label={`Source for Exhibit ${item.displayNumber}`} value={draft.sourceCitation || ''} placeholder="e.g. Google Patents · GB695913A" onChange={event => updateEvidence(item.connectionId,{ sourceCitation:event.target.value })}/></label></div>
|
<label><span>SOURCE / PUBLICATION</span><input aria-label={`Source for Exhibit ${item.displayNumber}`} value={draft.sourceCitation || ''} placeholder="e.g. Google Patents · GB695913A" onChange={event => updateEvidence(item.connectionId,{ sourceCitation:event.target.value })}/></label></div>
|
||||||
@@ -1242,16 +1288,42 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CaptureKindIcon({ kind }:{ kind:Exclude<DocumentCaptureKind,'unclassified'> }) {
|
||||||
|
if (kind === 'photo') return <Camera size={24}/>
|
||||||
|
if (kind === 'scene') return <Images size={24}/>
|
||||||
|
if (kind === 'clipping') return <Newspaper size={24}/>
|
||||||
|
return <FileText size={24}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentClassificationPanel({ document,onChoose }:{ document:CaseDocument;onChoose:(kind:DocumentCaptureKind)=>void }) {
|
||||||
|
const choices=(Object.keys(documentCaptureRegistry) as DocumentCaptureKind[]).filter((kind):kind is Exclude<DocumentCaptureKind,'unclassified'> => kind !== 'unclassified')
|
||||||
|
const source=document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''
|
||||||
|
return <div className="modal-shade evidence-classification-shade"><section className="evidence-classification" role="dialog" aria-modal="true" aria-labelledby="evidence-classification-title">
|
||||||
|
<header><ClipboardCheck size={16}/><b>Classify new evidence</b><span/><button type="button" aria-label="Classify later" onClick={() => onChoose('unclassified')}><X size={14}/></button></header>
|
||||||
|
<div className="evidence-classification-body">
|
||||||
|
<div className="classification-source">{source ? <img src={source} alt=""/> : <ImageIcon size={36}/>}<div><small>EXHIBIT {document.displayNumber || '—'}</small><strong>{document.title}</strong></div></div>
|
||||||
|
<div className="classification-question"><small>NEW SOURCE DOCUMENT</small><h2 id="evidence-classification-title">What kind of evidence is this?</h2><p>This changes how it appears on the board. It does not alter the original file, OCR, or provenance.</p></div>
|
||||||
|
<div className="classification-options">{choices.map(kind => { const definition=documentCapture(kind); return <button type="button" key={kind} aria-label={`Classify as ${definition.label}`} onClick={() => onChoose(kind)}>
|
||||||
|
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>
|
||||||
|
</button> })}</div>
|
||||||
|
<button type="button" className="classify-later" onClick={() => onChoose('unclassified')}>NOT SURE · CLASSIFY LATER</button>
|
||||||
|
</div>
|
||||||
|
</section></div>
|
||||||
|
}
|
||||||
|
|
||||||
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||||
const [title, setTitle] = useState(document.title)
|
const [title, setTitle] = useState(document.title)
|
||||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||||
|
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
||||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
||||||
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||||||
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
||||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt,
|
const presentation=documentCapture(captureKind)
|
||||||
|
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType,captureKind,publishedAt,
|
||||||
|
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
||||||
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
||||||
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1335,7 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
|||||||
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
||||||
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="field"><span>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></label>
|
||||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||||
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||||||
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
||||||
@@ -1385,10 +1458,28 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
|
|||||||
</section></div>
|
</section></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
const DOCUMENT_TYPE_MENU:Exclude<DocumentCaptureKind,'unclassified'>[] = ['full_page','scene','photo','clipping']
|
||||||
|
|
||||||
|
function DocumentWindow({ doc, onClose, onInfo, onDelete, onType, onExtract, extracted }: {
|
||||||
|
doc: CaseDocument
|
||||||
|
onClose: () => void
|
||||||
|
onInfo: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onType: (captureKind:Exclude<DocumentCaptureKind,'unclassified'>) => void
|
||||||
|
onExtract: (id: string) => void
|
||||||
|
extracted: (string | undefined)[]
|
||||||
|
}) {
|
||||||
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
||||||
const [minimized, setMinimized] = useState(false)
|
const [minimized, setMinimized] = useState(false)
|
||||||
|
const [menu, setMenu] = useState<'file'|'type'|null>(null)
|
||||||
const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
|
const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
|
||||||
|
const menuRef = useRef<HTMLElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return
|
||||||
|
const closeMenu = (event:PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setMenu(null) }
|
||||||
|
document.addEventListener('pointerdown',closeMenu)
|
||||||
|
return () => document.removeEventListener('pointerdown',closeMenu)
|
||||||
|
},[menu])
|
||||||
const startDrag = (e: React.PointerEvent<HTMLElement>) => {
|
const startDrag = (e: React.PointerEvent<HTMLElement>) => {
|
||||||
if ((e.target as HTMLElement).closest('button')) return
|
if ((e.target as HTMLElement).closest('button')) return
|
||||||
drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y }
|
drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y }
|
||||||
@@ -1396,8 +1487,19 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum
|
|||||||
}
|
}
|
||||||
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
|
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
|
||||||
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||||
{!minimized && <><nav>FILE EDIT EVIDENCE VIEW</nav>
|
{!minimized && <><nav ref={menuRef} className="document-menu-bar" aria-label="Document actions">
|
||||||
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{documentWidget(doc.fileType).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
|
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'file'} onClick={() => setMenu(current => current === 'file' ? null : 'file')}>FILE</button>
|
||||||
|
{menu === 'file' && <div className="document-menu-items" role="menu">
|
||||||
|
<button type="button" role="menuitem" onClick={() => { setMenu(null);onInfo() }}><Info size={13}/><span><b>INFO</b><small>Metadata and provenance</small></span></button>
|
||||||
|
<button type="button" role="menuitem" className="danger" onClick={() => { setMenu(null);onDelete() }}><Trash2 size={13}/><span><b>DELETE</b><small>Remove from this board</small></span></button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'type'} onClick={() => setMenu(current => current === 'type' ? null : 'type')}>TYPE</button>
|
||||||
|
{menu === 'type' && <div className="document-menu-items type-menu" role="menu">{DOCUMENT_TYPE_MENU.map(kind => { const definition=documentCapture(kind);return <button type="button" role="menuitemradio" aria-checked={doc.captureKind === kind} key={kind} onClick={() => { onType(kind);setMenu(null) }}>
|
||||||
|
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>{doc.captureKind === kind && <Check className="menu-check" size={13}/>}</button> })}</div>}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`} onPointerDown={() => setMenu(null)}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
|
||||||
<footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
|
<footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ function noise(context: AudioContext) {
|
|||||||
return noiseBuffer
|
return noiseBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let markerNoiseBuffer:AudioBuffer | null=null
|
||||||
|
function markerNoise(context:AudioContext) {
|
||||||
|
if (!markerNoiseBuffer) {
|
||||||
|
const length=Math.floor(context.sampleRate * .31)
|
||||||
|
markerNoiseBuffer=context.createBuffer(1,length,context.sampleRate)
|
||||||
|
const data=markerNoiseBuffer.getChannelData(0)
|
||||||
|
let previous=0
|
||||||
|
for (let index=0;index < length;index++) {
|
||||||
|
const white=Math.random() * 2 - 1
|
||||||
|
previous=previous * .66 + white * .34
|
||||||
|
data[index]=previous * (.72 + Math.sin(index / 37) * .18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markerNoiseBuffer
|
||||||
|
}
|
||||||
|
|
||||||
const music = typeof Audio !== 'undefined' ? new Audio() : null
|
const music = typeof Audio !== 'undefined' ? new Audio() : null
|
||||||
if (music) music.loop = true
|
if (music) music.loop = true
|
||||||
let currentUrl: string | null = null
|
let currentUrl: string | null = null
|
||||||
@@ -100,6 +116,28 @@ export const audio = {
|
|||||||
src.connect(filter).connect(gain).connect(context.destination)
|
src.connect(filter).connect(gain).connect(context.destination)
|
||||||
src.start(now); src.stop(now + 0.04)
|
src.start(now); src.stop(now + 0.04)
|
||||||
},
|
},
|
||||||
|
// A restrained felt-tip-on-paper scratch. The caller supplies the handwriting
|
||||||
|
// duration so the sound ends with the incremental letter reveal.
|
||||||
|
sharpie(durationMs:number) {
|
||||||
|
if (muted) return undefined
|
||||||
|
const context=audioContext()
|
||||||
|
if (!context) return undefined
|
||||||
|
if (context.state === 'suspended') void context.resume()
|
||||||
|
const duration=Math.max(.12,Math.min(3,durationMs / 1000))
|
||||||
|
const source=context.createBufferSource();source.buffer=markerNoise(context);source.loop=true
|
||||||
|
const filter=context.createBiquadFilter();filter.type='bandpass';filter.frequency.value=1180;filter.Q.value=.62
|
||||||
|
const gain=context.createGain()
|
||||||
|
const now=context.currentTime + .012,end=now + duration
|
||||||
|
gain.gain.setValueAtTime(.0001,now)
|
||||||
|
gain.gain.linearRampToValueAtTime(.032,now + .035)
|
||||||
|
for (let at=now + .055;at < end - .04;at += .045) gain.gain.setValueAtTime(.018 + Math.random() * .026,at)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(.0001,end)
|
||||||
|
source.connect(filter).connect(gain).connect(context.destination)
|
||||||
|
let ended=false
|
||||||
|
source.onended=() => { ended=true }
|
||||||
|
source.start(now);source.stop(end + .02)
|
||||||
|
return () => { if (!ended) { try { source.stop() } catch { /* already stopped */ } } }
|
||||||
|
},
|
||||||
// Resume the context and retry pending music on a user gesture.
|
// Resume the context and retry pending music on a user gesture.
|
||||||
resume() {
|
resume() {
|
||||||
void audioContext()?.resume?.()
|
void audioContext()?.resume?.()
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ describe('folder domain behavior', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('retains a configured expanded file position', () => {
|
it('retains a configured expanded file position', () => {
|
||||||
const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} }
|
const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
|
||||||
expect(relationPosition({ ...state, exhibits: [...state.exhibits, document], relations }, relations[0])).toEqual({ x: 720, y: 415 })
|
expect(relationPosition({ ...state, exhibits: [...state.exhibits, document], relations }, relations[0])).toEqual({ x: 720, y: 415 })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ describe('folder domain behavior', () => {
|
|||||||
}
|
}
|
||||||
const normalized = normalizeCase(legacy)
|
const normalized = normalizeCase(legacy)
|
||||||
expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false })
|
expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false })
|
||||||
expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', metadata: {} })
|
expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', captureKind:'unclassified',metadata: {} })
|
||||||
expect(normalized.relations).toHaveLength(1)
|
expect(normalized.relations).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -197,7 +197,7 @@ describe('exhibit disposal', () => {
|
|||||||
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
||||||
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
||||||
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
|
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
|
||||||
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} }
|
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
|
||||||
const discarded = discardExhibit({
|
const discarded = discardExhibit({
|
||||||
...state,
|
...state,
|
||||||
exhibits: [folder, document, note, event, party],
|
exhibits: [folder, document, note, event, party],
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,4 @@
|
|||||||
import type { BoardView, CaseState, Connection, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||||
|
|
||||||
export interface BoardPoint { x: number; y: number }
|
export interface BoardPoint { x: number; y: number }
|
||||||
|
|
||||||
@@ -233,6 +233,11 @@ function sourceFileType(value: unknown, mimeType: unknown): SourceFileType {
|
|||||||
return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file'
|
return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function documentCaptureKind(value: unknown): DocumentCaptureKind {
|
||||||
|
const allowed: DocumentCaptureKind[] = ['unclassified', 'photo', 'scene', 'clipping', 'full_page']
|
||||||
|
return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified'
|
||||||
|
}
|
||||||
|
|
||||||
/** Normalizes current API state and upgrades disposable pre-registry browser caches. */
|
/** Normalizes current API state and upgrades disposable pre-registry browser caches. */
|
||||||
export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||||
const state = input as LegacyCaseState
|
const state = input as LegacyCaseState
|
||||||
@@ -243,7 +248,9 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
goals: state.goals || [],
|
goals: state.goals || [],
|
||||||
report: state.report,
|
report: state.report,
|
||||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
|
||||||
|
...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
|
||||||
|
...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||||
}
|
}
|
||||||
@@ -264,6 +271,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
|
fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
|
||||||
fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
|
fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
|
||||||
fileType: sourceFileType(document.fileType, document.mimeType),
|
fileType: sourceFileType(document.fileType, document.mimeType),
|
||||||
|
captureKind: documentCaptureKind(document.captureKind),
|
||||||
metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record<string, string> : {},
|
metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record<string, string> : {},
|
||||||
} as Exhibit))
|
} as Exhibit))
|
||||||
const evidence: Exhibit[] = legacyEvidence.map((item, index) => {
|
const evidence: Exhibit[] = legacyEvidence.map((item, index) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { SourceFileType } from './types'
|
import type { DocumentCaptureKind, DocumentExhibit, SourceFileType } from './types'
|
||||||
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
|
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry, mugshotIdentification } from './exhibitRegistry'
|
||||||
|
|
||||||
describe('frontend exhibit registry', () => {
|
describe('frontend exhibit registry', () => {
|
||||||
it('registers every normalized exhibit type', () => {
|
it('registers every normalized exhibit type', () => {
|
||||||
@@ -18,4 +18,24 @@ describe('frontend exhibit registry', () => {
|
|||||||
expect(documentWidget(type).Asset).toBeTypeOf('function')
|
expect(documentWidget(type).Asset).toBeTypeOf('function')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('registers every capture kind with a physical board size', () => {
|
||||||
|
const kinds:DocumentCaptureKind[]=['unclassified','photo','scene','clipping','full_page']
|
||||||
|
expect(Object.keys(documentCaptureRegistry).sort()).toEqual(kinds.sort())
|
||||||
|
for (const kind of kinds) expect(documentCapture(kind).defaultSize).toMatchObject({ width:expect.any(Number),height:expect.any(Number) })
|
||||||
|
expect(['full_page','scene','photo','clipping'].map(kind => documentCapture(kind as DocumentCaptureKind).label)).toEqual(['Document','Image','Mugshot','Clip'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses structural and image-aware starter copy instead of calling a Party proof', () => {
|
||||||
|
const placed={ x:0,y:0,width:100,height:100,rotation:0,zIndex:1,hidden:false }
|
||||||
|
const party={ id:'party',type:'party' as const,title:'Nils',content:'',partyKind:'person' as const,aliases:[],...placed }
|
||||||
|
const claim={ id:'claim',type:'claim' as const,title:'Inventor',statement:'Nils was an inventor.',...placed }
|
||||||
|
const photo:DocumentExhibit={ id:'photo',type:'document',title:'Portrait',body:[],regions:[],fileType:'image',captureKind:'photo',metadata:{},...placed }
|
||||||
|
expect(defaultConnectionLabel(party,claim)).toBe('Subject of claim')
|
||||||
|
expect(defaultConnectionLabel(photo,party)).toBe('Identified as…')
|
||||||
|
expect(defaultConnectionLabel(photo,claim)).toBe('Proof that…')
|
||||||
|
const connection={ id:'identity',fromExhibitId:party.id,toExhibitId:photo.id,label:'Identified as…',tightness:65,tagStyle:'luggage' as const,tagPosition:50,tagOffset:0 }
|
||||||
|
expect(mugshotIdentification(photo,[party,claim,photo],[connection])).toEqual({ party,connection })
|
||||||
|
expect(mugshotIdentification({ ...photo,captureKind:'scene' },[party,photo],[connection])).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+43
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||||
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
import type { CaseDocument, Connection, DocumentCaptureKind, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, PartyExhibit, SourceFileType, TemporalFact } from './types'
|
||||||
|
|
||||||
export type WidgetCommand =
|
export type WidgetCommand =
|
||||||
| { type: 'open-document'; documentId: string }
|
| { type: 'open-document'; documentId: string }
|
||||||
@@ -135,5 +135,47 @@ export const documentWidgetRegistry:Record<SourceFileType,DocumentWidgetDefiniti
|
|||||||
}
|
}
|
||||||
export function documentWidget(type:SourceFileType) { return documentWidgetRegistry[type] || documentWidgetRegistry.file }
|
export function documentWidget(type:SourceFileType) { return documentWidgetRegistry[type] || documentWidgetRegistry.file }
|
||||||
|
|
||||||
|
export type DocumentCaptureDefinition = {
|
||||||
|
label:string
|
||||||
|
description:string
|
||||||
|
defaultSize:{ width:number;height:number }
|
||||||
|
}
|
||||||
|
export const documentCaptureRegistry:Record<DocumentCaptureKind,DocumentCaptureDefinition> = {
|
||||||
|
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
|
||||||
|
photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:250} },
|
||||||
|
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
|
||||||
|
clipping:{ label:'Clip',description:'An extract captured from a larger source.',defaultSize:{width:210,height:194} },
|
||||||
|
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
|
||||||
|
}
|
||||||
|
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
|
||||||
|
|
||||||
|
export type MugshotIdentification = { party:PartyExhibit;connection:Connection }
|
||||||
|
|
||||||
|
/** A Mugshot caption is a projection of its latest Party connection, never copied document metadata. */
|
||||||
|
export function mugshotIdentification(document:DocumentExhibit,exhibits:Exhibit[],connections:Connection[]):MugshotIdentification | null {
|
||||||
|
if (document.captureKind !== 'photo') return null
|
||||||
|
const byId=new Map(exhibits.map(exhibit => [exhibit.id,exhibit]))
|
||||||
|
for (let index=connections.length - 1;index >= 0;index--) {
|
||||||
|
const connection=connections[index]
|
||||||
|
const otherId=connection.fromExhibitId === document.id ? connection.toExhibitId : connection.toExhibitId === document.id ? connection.fromExhibitId : null
|
||||||
|
const other=otherId ? byId.get(otherId) : null
|
||||||
|
if (other?.type === 'party') return { party:other,connection }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Context-sensitive starter copy. A Party is never treated as proof by default. */
|
||||||
|
export function defaultConnectionLabel(first:Exhibit,second:Exhibit) {
|
||||||
|
const types = new Set([first.type,second.type])
|
||||||
|
if (types.has('party') && types.has('claim')) return 'Subject of claim'
|
||||||
|
const document = first.type === 'document' ? first : second.type === 'document' ? second : null
|
||||||
|
if (document && types.has('party')) {
|
||||||
|
if (document.captureKind === 'photo') return 'Identified as…'
|
||||||
|
if (document.captureKind === 'scene') return 'Shows…'
|
||||||
|
return 'Concerns…'
|
||||||
|
}
|
||||||
|
return 'Proof that…'
|
||||||
|
}
|
||||||
|
|
||||||
export function documentExhibits(exhibits:Exhibit[]):DocumentExhibit[] { return exhibits.filter((exhibit):exhibit is DocumentExhibit => exhibit.type === 'document') }
|
export function documentExhibits(exhibits:Exhibit[]):DocumentExhibit[] { return exhibits.filter((exhibit):exhibit is DocumentExhibit => exhibit.type === 'document') }
|
||||||
export function evidenceExhibits(exhibits:Exhibit[]):Evidence[] { return exhibits.filter((exhibit):exhibit is Evidence => exhibit.type !== 'document') }
|
export function evidenceExhibits(exhibits:Exhibit[]):Evidence[] { return exhibits.filter((exhibit):exhibit is Evidence => exhibit.type !== 'document') }
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
+12
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
||||||
import { audio } from './audio'
|
import { audio } from './audio'
|
||||||
|
|
||||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag?: string | null }
|
||||||
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
||||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||||
@@ -81,9 +81,11 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
|
|||||||
|
|
||||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||||
export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; inline?: boolean; startId?: string | null }) {
|
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
||||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||||
|
const onAwardRef = useRef(onAward)
|
||||||
|
onAwardRef.current = onAward
|
||||||
// In preview, clicking an utterance card jumps the walk to that line.
|
// In preview, clicking an utterance card jumps the walk to that line.
|
||||||
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
|
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
|
||||||
const [charCount, setCharCount] = useState(0)
|
const [charCount, setCharCount] = useState(0)
|
||||||
@@ -110,8 +112,16 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
|
|||||||
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
|
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
|
||||||
}, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Grant a line's authored achievement when it becomes current (play mode only).
|
||||||
|
useEffect(() => {
|
||||||
|
if (inline || !currentId) return
|
||||||
|
const utterance = byId.get(currentId)
|
||||||
|
if (utterance?.awardsFlag) onAwardRef.current?.(utterance.id)
|
||||||
|
}, [currentId, inline, byId])
|
||||||
|
|
||||||
const pick = (choice: RuntimeUtterance) => {
|
const pick = (choice: RuntimeUtterance) => {
|
||||||
if (!inline) audio.sfx('choice')
|
if (!inline) audio.sfx('choice')
|
||||||
|
if (!inline && choice.awardsFlag) onAwardRef.current?.(choice.id)
|
||||||
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
||||||
else onExit(choice.terminalKey ?? undefined)
|
else onExit(choice.terminalKey ?? undefined)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ import * as THREE from 'three'
|
|||||||
// flag requirements come from the story graph (see the "mobile" gate discussion).
|
// flag requirements come from the story graph (see the "mobile" gate discussion).
|
||||||
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage
|
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage
|
||||||
|
|
||||||
const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
|
export const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
|
||||||
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera
|
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera
|
||||||
|
|
||||||
// ---- placeholder telephony audio (to be replaced by recorded assets) ----------
|
// ---- placeholder telephony audio (to be replaced by recorded assets) ----------
|
||||||
@@ -46,7 +46,7 @@ const sfx = {
|
|||||||
|
|
||||||
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
|
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
|
||||||
|
|
||||||
function buildPhone(): Built {
|
export function buildPhone(): Built {
|
||||||
const group = new THREE.Group()
|
const group = new THREE.Group()
|
||||||
const keys: THREE.Mesh[] = []
|
const keys: THREE.Mesh[] = []
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -94,6 +94,8 @@ export function Play() {
|
|||||||
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
||||||
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
||||||
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
||||||
|
onExit={terminalKey => { void advance(terminalKey) }}
|
||||||
|
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
|
||||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-3
@@ -169,6 +169,67 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
||||||
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
||||||
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
||||||
|
|
||||||
|
/* Classified image evidence is rendered like a physical object on the corkboard.
|
||||||
|
The unclassified shell above remains the neutral Win-95 archive card. */
|
||||||
|
.source-file-widget.capture-kind-photo,
|
||||||
|
.source-file-widget.capture-kind-scene,
|
||||||
|
.source-file-widget.capture-kind-clipping,
|
||||||
|
.source-file-widget.capture-kind-full_page { box-sizing: border-box; min-height: 0; background: #eee8d8; border: 0; box-shadow: 7px 9px 4px #0209078c, 0 0 0 1px #fff9; }
|
||||||
|
.source-file-widget.capture-kind-photo header,
|
||||||
|
.source-file-widget.capture-kind-scene header,
|
||||||
|
.source-file-widget.capture-kind-clipping header,
|
||||||
|
.source-file-widget.capture-kind-full_page header { position: absolute; z-index: 2; top: 10px; right: 10px; width: auto; height: auto; border: 0; color: #f1ead5; text-shadow: 0 1px 2px #000; pointer-events: none; }
|
||||||
|
.source-file-widget.capture-kind-photo header span,
|
||||||
|
.source-file-widget.capture-kind-scene header span,
|
||||||
|
.source-file-widget.capture-kind-clipping header span,
|
||||||
|
.source-file-widget.capture-kind-full_page header span { display: none; }
|
||||||
|
.source-file-widget.capture-kind-photo header i,
|
||||||
|
.source-file-widget.capture-kind-scene header i,
|
||||||
|
.source-file-widget.capture-kind-clipping header i,
|
||||||
|
.source-file-widget.capture-kind-full_page header i { padding: 3px 4px; background: #17231da8; border: 1px solid #efe8d066; font-style: normal; }
|
||||||
|
.source-file-widget.capture-kind-photo .source-file-preview,
|
||||||
|
.source-file-widget.capture-kind-scene .source-file-preview,
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-preview,
|
||||||
|
.source-file-widget.capture-kind-full_page .source-file-preview { margin: 0 0 8px; background: #d8d3c4; border: 1px solid #f9f6eb; box-shadow: inset 0 0 0 1px #5f625d; }
|
||||||
|
.source-file-widget.capture-kind-photo > strong,
|
||||||
|
.source-file-widget.capture-kind-scene > strong,
|
||||||
|
.source-file-widget.capture-kind-clipping > strong,
|
||||||
|
.source-file-widget.capture-kind-full_page > strong { color: #252720; font: 12px/1.15 Special Elite; }
|
||||||
|
.source-file-widget.capture-kind-photo .source-file-actions,
|
||||||
|
.source-file-widget.capture-kind-scene .source-file-actions,
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-actions,
|
||||||
|
.source-file-widget.capture-kind-full_page .source-file-actions { margin-top: 5px; border-color: #a49d8d; opacity: .42; transition: opacity .16s ease; }
|
||||||
|
.source-file-widget.capture-kind-photo:hover .source-file-actions,
|
||||||
|
.source-file-widget.capture-kind-scene:hover .source-file-actions,
|
||||||
|
.source-file-widget.capture-kind-clipping:hover .source-file-actions,
|
||||||
|
.source-file-widget.capture-kind-full_page:hover .source-file-actions,
|
||||||
|
.source-file-widget.selected .source-file-actions { opacity: 1; }
|
||||||
|
.source-file-widget.capture-kind-photo { min-height: 250px; padding: 10px 10px 12px; transform-origin: 50% 15%; }
|
||||||
|
.source-file-widget.capture-kind-photo.open { transform: scale(1) rotate(-1.25deg); }
|
||||||
|
.source-file-widget.capture-kind-photo .source-file-preview { height: 151px; }
|
||||||
|
.source-file-widget.capture-kind-photo .source-file-preview img { object-fit: cover; filter: saturate(.82) contrast(1.04) sepia(.08); }
|
||||||
|
.source-file-widget.capture-kind-photo > strong { padding: 1px 4px 0; text-align: center; font: 15px/1.1 "Marker Felt", "Comic Sans MS", cursive; transform: rotate(-.5deg); }
|
||||||
|
.source-file-widget.capture-kind-photo > strong.mugshot-caption { min-height: 29px; display: grid; align-content: center; }
|
||||||
|
.mugshot-caption span { position: relative; display: block; min-height: 1.1em; overflow: hidden; color: #171b18; text-overflow: clip; white-space: nowrap; letter-spacing: .01em; }
|
||||||
|
.mugshot-caption.writing span::after { content: ''; display: inline-block; width: 4px; height: 3px; margin-left: 1px; border-radius: 50%; background: #171b18; box-shadow: 0 0 2px #171b18; transform: rotate(-18deg); animation: sharpie-nib .12s steps(2,end) infinite; }
|
||||||
|
@keyframes sharpie-nib { 50% { transform: translateY(-2px) rotate(-18deg);opacity:.72; } }
|
||||||
|
.source-file-widget.capture-kind-photo > time { padding-right: 3px; text-align: right; }
|
||||||
|
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
|
||||||
|
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
|
||||||
|
.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; }
|
||||||
|
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
||||||
|
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
||||||
|
.source-file-widget.capture-kind-clipping { padding: 9px 12px 11px; background: linear-gradient(103deg,#e7e2d3,#d9d2bf); clip-path: polygon(1% 2%,99% 0,98% 13%,100% 28%,98% 43%,100% 61%,98% 78%,99% 98%,84% 99%,68% 97%,52% 100%,35% 98%,19% 100%,1% 98%,2% 79%,0 62%,2% 45%,0 27%); }
|
||||||
|
.source-file-widget.capture-kind-clipping.open { transform: scale(1) rotate(.8deg); }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-preview { height: 105px; border-color: #b2a996; box-shadow: none; }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit: cover; filter: grayscale(.12) contrast(1.08); }
|
||||||
|
.source-file-widget.capture-kind-clipping > strong { font-size: 11px; }
|
||||||
|
.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; }
|
||||||
|
.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); }
|
||||||
|
.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; }
|
||||||
|
.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; }
|
||||||
|
.source-file-widget.capture-kind-full_page > strong { font-size: 10px; }
|
||||||
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
||||||
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
||||||
.evidence-card.note header i { display: none; }
|
.evidence-card.note header i { display: none; }
|
||||||
@@ -237,7 +298,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; }
|
.tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; }
|
||||||
.window > header { position: sticky; z-index: 3; top: 0; height: 31px; min-height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
.window > header { position: sticky; z-index: 3; top: 0; height: 31px; min-height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||||
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
||||||
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }
|
||||||
|
.document-window > nav { position: sticky; z-index: 2; top: 31px; height: 28px; display: flex; align-items: stretch; gap: 2px; padding: 0 7px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
||||||
|
.document-menu { position: relative; display: flex; }.document-menu > button { min-width: 48px; padding: 0 8px; border: 0; background: transparent; color: #24312d; cursor: pointer; font: 9px IBM Plex Mono; letter-spacing: .05em; }.document-menu > button:hover,.document-menu > button[aria-expanded=true] { background: #173e35; color: #f0f3ee; }
|
||||||
|
.document-menu-items { position: absolute; z-index: 6; top: 27px; left: 0; width: 250px; display: grid; gap: 2px; padding: 4px; background: #c6cac3; border: 2px outset #edf0e9; box-shadow: 5px 7px 0 #07100dcc; }.document-menu-items > button { position: relative; min-height: 43px; display: grid; grid-template-columns: 25px minmax(0,1fr) 16px; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid transparent; background: transparent; color: #26342f; text-align: left; cursor: pointer; }.document-menu-items > button:hover,.document-menu-items > button:focus-visible { outline: 0; border-color: #557067; background: #173e35; color: #f4f6f1; }.document-menu-items > button > span { min-width: 0; display: grid; gap: 2px; }.document-menu-items b { font: 600 9px IBM Plex Mono; letter-spacing: .05em; }.document-menu-items small { color: #65716c; font: 7px IBM Plex Mono; }.document-menu-items > button:hover small,.document-menu-items > button:focus-visible small { color: #b9c9c2; }.document-menu-items > button.danger { color: #782f2b; }.document-menu-items > button.danger:hover,.document-menu-items > button.danger:focus-visible { background: #702e2b; color: white; }.document-menu-items .menu-check { color: #955b2f; }.document-menu-items > button:hover .menu-check { color: #efb06c; }.type-menu { width: 286px; }
|
||||||
.paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; }
|
.paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; }
|
||||||
.paper.asset-paper { padding: 20px; display: flex; flex-direction: column; }.asset-paper .paper-meta { flex: 0 0 auto; margin-bottom: 14px; }.document-image { display: block; max-width: 100%; margin: auto; box-shadow: 0 2px 12px #0005; }.document-frame { width: 100%; flex: 1; min-height: 350px; border: 1px solid #81877f; background: white; }.unsupported-file { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; color: #4a5651; }.unsupported-file b { font-size: 12px; }.unsupported-file span { font-size: 9px; color: #758079; }.unsupported-file a { margin-top: 9px; padding: 8px 11px; background: #244b40; color: white; text-decoration: none; font: 9px IBM Plex Mono; }
|
.paper.asset-paper { padding: 20px; display: flex; flex-direction: column; }.asset-paper .paper-meta { flex: 0 0 auto; margin-bottom: 14px; }.document-image { display: block; max-width: 100%; margin: auto; box-shadow: 0 2px 12px #0005; }.document-frame { width: 100%; flex: 1; min-height: 350px; border: 1px solid #81877f; background: white; }.unsupported-file { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; color: #4a5651; }.unsupported-file b { font-size: 12px; }.unsupported-file span { font-size: 9px; color: #758079; }.unsupported-file a { margin-top: 9px; padding: 8px 11px; background: #244b40; color: white; text-decoration: none; font: 9px IBM Plex Mono; }
|
||||||
.paper-meta { display: flex; justify-content: space-between; font-size: 8px; letter-spacing: .1em; border-bottom: 2px solid #252d29; padding-bottom: 9px; margin-bottom: 28px; }.paper-meta b { color: #945b32; }
|
.paper-meta { display: flex; justify-content: space-between; font-size: 8px; letter-spacing: .1em; border-bottom: 2px solid #252d29; padding-bottom: 9px; margin-bottom: 28px; }.paper-meta b { color: #945b32; }
|
||||||
@@ -246,6 +310,18 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.extracts button { border: 1px solid #9b602f; background: #f0dbc0; color: #66391a; padding: 8px 10px; display: flex; align-items: center; gap: 6px; cursor: pointer; font: 600 9px IBM Plex Mono; text-transform: uppercase; }.extracts button:hover { background: #e8bd87; }.extracts button.done { border-color: #667a71; background: #d1d7ce; color: #42554d; }
|
.extracts button { border: 1px solid #9b602f; background: #f0dbc0; color: #66391a; padding: 8px 10px; display: flex; align-items: center; gap: 6px; cursor: pointer; font: 600 9px IBM Plex Mono; text-transform: uppercase; }.extracts button:hover { background: #e8bd87; }.extracts button.done { border-color: #667a71; background: #d1d7ce; color: #42554d; }
|
||||||
.document-window > footer { height: 25px; border-top: 1px solid #737f78; display: flex; justify-content: space-between; padding: 6px 8px; font: 8px IBM Plex Mono; }
|
.document-window > footer { height: 25px; border-top: 1px solid #737f78; display: flex; justify-content: space-between; padding: 6px 8px; font: 8px IBM Plex Mono; }
|
||||||
.modal-shade { position: fixed; z-index: 40; inset: 0; background: #020b09aa; display: grid; place-items: center; }
|
.modal-shade { position: fixed; z-index: 40; inset: 0; background: #020b09aa; display: grid; place-items: center; }
|
||||||
|
.evidence-classification-shade { z-index: 80; padding: 18px; backdrop-filter: blur(2px); }
|
||||||
|
.evidence-classification { width: min(680px,calc(100vw - 36px)); max-height: calc(100dvh - 36px); overflow: auto; color: #1d2824; background: #c8ccc4; border: 2px solid #dfe2dc; box-shadow: 8px 10px 0 #020907,0 0 0 1px #46544f; animation: evidence-classification-in .25s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
|
.evidence-classification > header { position: sticky; z-index: 2; top: 0; height: 33px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 10px; color: #e5ece8; background: #183f36; font: 500 10px IBM Plex Mono; text-transform: uppercase; }
|
||||||
|
.evidence-classification > header span { flex: 1; }.evidence-classification > header button { width: 23px;height: 22px;display:grid;place-items:center;padding:0;color:#17221f;background:#bcc1b9;border:1px outset white;cursor:pointer; }
|
||||||
|
.evidence-classification-body { padding: 24px 27px 25px; }
|
||||||
|
.classification-source { min-height: 92px; display: grid; grid-template-columns: 122px minmax(0,1fr); gap: 15px; align-items: center; padding: 9px; border: 1px solid #8a928c; background: #b7bbb3; }
|
||||||
|
.classification-source > img { width: 122px;height:78px;object-fit:cover;border:5px solid #eee9db;box-shadow:3px 4px #0004;transform:rotate(-1deg); }.classification-source > svg { margin:auto;color:#5e6d67; }
|
||||||
|
.classification-source > div { min-width:0;display:grid;gap:6px; }.classification-source small { color:#7d502d;font:600 7px IBM Plex Mono;letter-spacing:.14em; }.classification-source strong { overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:16px Special Elite; }
|
||||||
|
.classification-question { margin: 22px 0 14px; }.classification-question > small { color:#8a542d;font:600 7px IBM Plex Mono;letter-spacing:.17em; }.classification-question h2 { margin:6px 0 7px;font:25px Special Elite; }.classification-question p { margin:0;color:#59645f;font:9px/1.5 IBM Plex Mono; }
|
||||||
|
.classification-options { display:grid;grid-template-columns:1fr 1fr;gap:9px; }.classification-options > button { min-height:78px;display:grid;grid-template-columns:34px 1fr;gap:9px;align-items:center;padding:12px;text-align:left;color:#253730;background:#d9dbd3;border:1px solid #8e9690;cursor:pointer; }.classification-options > button:hover,.classification-options > button:focus-visible { outline:0;border-color:#9b6035;background:#e5d6bd;box-shadow:inset 4px 0 #9b6035; }.classification-options svg { color:#8b572f; }.classification-options span { display:grid;gap:5px; }.classification-options b { font:600 10px IBM Plex Mono;text-transform:uppercase; }.classification-options small { color:#64706a;font:8px/1.4 IBM Plex Mono; }
|
||||||
|
.classify-later { width:100%;margin-top:11px;padding:9px;border:1px dashed #8a928c;color:#65706b;background:transparent;cursor:pointer;font:7px IBM Plex Mono;letter-spacing:.11em; }.classify-later:hover { color:#384b44;background:#d4d6ce; }
|
||||||
|
@keyframes evidence-classification-in { from { opacity:0;transform:translateY(18px) scale(.96); } }
|
||||||
.help { width: 440px; }.help > div { padding: 30px 34px 34px; }.help h2 { font: 23px Special Elite; margin: 8px 0 22px; }.help ol { padding-left: 22px; font-size: 12px; line-height: 2; }.help p { font: 13px Special Elite; border-left: 3px solid #a56330; padding-left: 12px; }.primary { float: right; background: #163f35; color: white; border: 2px outset #608177; font: 9px IBM Plex Mono; padding: 10px 13px; cursor: pointer; }
|
.help { width: 440px; }.help > div { padding: 30px 34px 34px; }.help h2 { font: 23px Special Elite; margin: 8px 0 22px; }.help ol { padding-left: 22px; font-size: 12px; line-height: 2; }.help p { font: 13px Special Elite; border-left: 3px solid #a56330; padding-left: 12px; }.primary { float: right; background: #163f35; color: white; border: 2px outset #608177; font: 9px IBM Plex Mono; padding: 10px 13px; cursor: pointer; }
|
||||||
.folder-editor { width: min(680px, 88vw); }
|
.folder-editor { width: min(680px, 88vw); }
|
||||||
.folder-editor-body { padding: 24px 27px 22px; }
|
.folder-editor-body { padding: 24px 27px 22px; }
|
||||||
@@ -301,8 +377,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.case-report-letterhead { position:relative; padding-bottom:22px; border-bottom:3px double #343b37; text-align:center; }.case-report-letterhead small,.case-report-letterhead span { display:block; color:#59625d; font:7px IBM Plex Mono; letter-spacing:.16em; }.case-report-letterhead h2 { margin:9px 0 7px; color:#1d2622; font:30px Special Elite; letter-spacing:.11em; }
|
.case-report-letterhead { position:relative; padding-bottom:22px; border-bottom:3px double #343b37; text-align:center; }.case-report-letterhead small,.case-report-letterhead span { display:block; color:#59625d; font:7px IBM Plex Mono; letter-spacing:.16em; }.case-report-letterhead h2 { margin:9px 0 7px; color:#1d2622; font:30px Special Elite; letter-spacing:.11em; }
|
||||||
.report-claim { margin-top:30px; }.report-claim h3 { margin:0 0 22px; font:21px/1.45 Special Elite; }.report-claim h3 span { display:block; margin-bottom:5px; color:#915c30; font:7px IBM Plex Mono; letter-spacing:.14em; }.report-claim h4 { margin:0; padding-bottom:6px; border-bottom:1px solid #4c554f; font:600 8px IBM Plex Mono; letter-spacing:.15em; }
|
.report-claim { margin-top:30px; }.report-claim h3 { margin:0 0 22px; font:21px/1.45 Special Elite; }.report-claim h3 span { display:block; margin-bottom:5px; color:#915c30; font:7px IBM Plex Mono; letter-spacing:.14em; }.report-claim h4 { margin:0; padding-bottom:6px; border-bottom:1px solid #4c554f; font:600 8px IBM Plex Mono; letter-spacing:.15em; }
|
||||||
.report-empty-evidence { margin-top:13px; padding:17px; border:1px dashed #8a6550; color:#76513c; background:#d9d1bd; font:10px/1.5 IBM Plex Mono; }
|
.report-empty-evidence { margin-top:13px; padding:17px; border:1px dashed #8a6550; color:#76513c; background:#d9d1bd; font:10px/1.5 IBM Plex Mono; }
|
||||||
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }
|
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }.report-evidence.rejected { border-left-color:#913d35;background:#e5d2c7cc; }
|
||||||
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
|
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
|
||||||
|
.report-evidence-heading em.rejected { border-color:#98534b;color:#813a34; }.report-evidence-diagnostic { margin:-3px 0 13px;padding:9px 10px;border:1px solid #b78376;background:#ead8cc;color:#6f302d;font:9px/1.5 IBM Plex Mono; }
|
||||||
.report-evidence label,.report-investigator { display:grid; gap:4px; margin-top:9px; }.report-evidence label > span,.report-investigator > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-evidence input,.report-evidence textarea,.report-investigator input { width:100%; min-width:0; padding:7px 8px; border:0; border-bottom:1px solid #747b75; outline:0; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; resize:vertical; }.report-evidence input:focus,.report-evidence textarea:focus,.report-investigator input:focus { border-bottom-color:#9b582d; background:#f6f0dc; box-shadow:inset 3px 0 #b2784a; }
|
.report-evidence label,.report-investigator { display:grid; gap:4px; margin-top:9px; }.report-evidence label > span,.report-investigator > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-evidence input,.report-evidence textarea,.report-investigator input { width:100%; min-width:0; padding:7px 8px; border:0; border-bottom:1px solid #747b75; outline:0; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; resize:vertical; }.report-evidence input:focus,.report-evidence textarea:focus,.report-investigator input:focus { border-bottom-color:#9b582d; background:#f6f0dc; box-shadow:inset 3px 0 #b2784a; }
|
||||||
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator input { max-width:390px; }
|
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator input { max-width:390px; }
|
||||||
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
|
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
|
||||||
@@ -336,6 +413,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
||||||
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
||||||
.case-report-shade { padding:0; }.case-report { width:100vw;height:100dvh;border:0; }.case-report-paper { padding:28px 17px 24px; }.report-fields { grid-template-columns:1fr;gap:0; }.report-evidence-heading { grid-template-columns:auto 1fr; }.report-evidence-heading em { grid-column:1/-1;width:max-content; }.case-report-actions { bottom:-24px;margin-left:-7px;margin-right:-7px; }
|
.case-report-shade { padding:0; }.case-report { width:100vw;height:100dvh;border:0; }.case-report-paper { padding:28px 17px 24px; }.report-fields { grid-template-columns:1fr;gap:0; }.report-evidence-heading { grid-template-columns:auto 1fr; }.report-evidence-heading em { grid-column:1/-1;width:max-content; }.case-report-actions { bottom:-24px;margin-left:-7px;margin-right:-7px; }
|
||||||
|
.evidence-classification-shade { padding:0; }.evidence-classification { width:100vw;max-width:none;max-height:100dvh;border-width:0;box-shadow:none; }.evidence-classification-body { padding:18px 16px 22px; }.classification-options { grid-template-columns:1fr; }.classification-source { grid-template-columns:92px minmax(0,1fr); }.classification-source > img { width:92px;height:66px; }.classification-question h2 { font-size:22px; }
|
||||||
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||||
.board-actions > b { display: none; }
|
.board-actions > b { display: none; }
|
||||||
.board-actions > span { margin: 0 2px; }
|
.board-actions > span { margin: 0 2px; }
|
||||||
@@ -345,7 +423,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
||||||
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse, .goal-complete-shade, .goal-complete-card { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse, .goal-complete-shade, .goal-complete-card, .mugshot-caption.writing span::after { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||||
|
|
||||||
/* Narrative layer: splash + NPC dialogue */
|
/* Narrative layer: splash + NPC dialogue */
|
||||||
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
||||||
@@ -687,3 +765,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; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party' | '
|
|||||||
export type PartyKind = 'person' | 'organization'
|
export type PartyKind = 'person' | 'organization'
|
||||||
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
|
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
|
||||||
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
|
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
|
||||||
|
export type DocumentCaptureKind = 'unclassified' | 'photo' | 'scene' | 'clipping' | 'full_page'
|
||||||
|
|
||||||
export interface CanvasPlacement {
|
export interface CanvasPlacement {
|
||||||
x: number
|
x: number
|
||||||
@@ -49,6 +50,8 @@ export interface DocumentExhibit extends ExhibitBase {
|
|||||||
mimeType?: string
|
mimeType?: string
|
||||||
fileSize?: number
|
fileSize?: number
|
||||||
fileType: SourceFileType
|
fileType: SourceFileType
|
||||||
|
/** Evidentiary form on the board; independent of MIME/file type. */
|
||||||
|
captureKind: DocumentCaptureKind
|
||||||
metadata: Record<string, string>
|
metadata: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +179,15 @@ export interface LevelGoal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CaseReportSubmissionStatus = 'evidence_insufficient' | 'evidence_accepted_report_incomplete' | 'accepted'
|
export type CaseReportSubmissionStatus = 'evidence_insufficient' | 'evidence_accepted_report_incomplete' | 'accepted'
|
||||||
|
export type EvidenceVerificationStatus = 'accepted' | 'not_evaluated' | 'ocr_unavailable' | 'text_not_matched' | 'semantic_pending' | 'semantic_failed' | 'semantic_rejected'
|
||||||
|
|
||||||
|
export interface EvidenceVerification {
|
||||||
|
status: EvidenceVerificationStatus
|
||||||
|
detail: string
|
||||||
|
score?: number
|
||||||
|
matchedMarkers?: number
|
||||||
|
requiredMarkers?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface CaseReportEvidence {
|
export interface CaseReportEvidence {
|
||||||
connectionId: string
|
connectionId: string
|
||||||
@@ -188,6 +200,7 @@ export interface CaseReportEvidence {
|
|||||||
sourceCitation?: string
|
sourceCitation?: string
|
||||||
sourceUri?: string
|
sourceUri?: string
|
||||||
evidenceAccepted: boolean
|
evidenceAccepted: boolean
|
||||||
|
verification: EvidenceVerification
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaseReportClaim {
|
export interface CaseReportClaim {
|
||||||
|
|||||||
Reference in New Issue
Block a user