Add story-graph narrative system (campaigns, editors, runtime)

Introduce the narrative layer as a directed story-flow graph: an authored
campaign a player walks node by node, replacing the interim slot/chapter model.

Schema (migrations 015-021):
- mysteries, global NPC templates + named poses, per-user playthroughs
- story_nodes, terminals, utterances (the flow graph and dialogue trees)
- clean cutover: retire slot cutscenes/chapters/seen_dialogue

Runtime:
- New Game creates a playthrough bound to the JWT identity (dev test-user fallback)
- advance() walks the graph cutscene -> dialogue -> level -> ..., auto-skipping gates
- branching dialogue: player choices route out through node terminals

Admin authoring:
- NPC editor: upload named poses to the gupi MinIO bucket
- mystery graph editor: vertical node canvas, wiring, entrypoint, delete-by-click
- dialogue crafter: utterance tree, Tab to add child, 1/2 speaker, undo

Content authored via the manifest importer / admin panel and seeded for Glass
Harbour. MinIO added to the dev stack; dev container runs in development mode.

Also includes a folder-widget simplification (removes open/close) and a
resolveUserId auth helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:37:46 +02:00
co-authored by Claude Opus 4.8
parent 0237da74cf
commit ddb3a386f0
30 changed files with 3016 additions and 55 deletions
+2 -8
View File
@@ -1,10 +1,9 @@
import type { ComponentType } from 'react'
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
export type WidgetCommand =
| { type: 'open-document'; documentId: string }
| { type: 'toggle-folder'; folderId: string }
| { type: 'edit-folder'; folderId: string }
| { type: 'edit-event'; eventId: string }
| { type: 'edit-party'; partyId: string }
@@ -40,13 +39,8 @@ const relationsFrom = (context: ExhibitWidgetContext, exhibitId: string, type: E
function FolderWidget({ exhibit, context }: ExhibitWidgetProps) {
if (exhibit.type !== 'folder') return null
const documents = relationsFrom(context, exhibit.id, 'contains').flatMap(relation => {
const document = context.exhibits.find(candidate => candidate.id === relation.toExhibitId)
return document?.type === 'document' ? [document] : []
})
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{!exhibit.isOpen && <div className="folder-documents">{documents.slice(0,3).map(document => <button key={document.id} onClick={() => context.dispatch({ type:'open-document',documentId:document.id })} title={document.title}><FileText size={12}/><span>{document.title}</span>{document.publishedAt && <time>{document.publishedAt.slice(0,10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>}
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'toggle-folder',folderId:exhibit.id })}>{exhibit.isOpen ? <Folder size={12}/> : <FolderOpen size={12}/>} {exhibit.isOpen ? 'CLOSE' : 'OPEN'}</button><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={12}/> EDIT</button></div>
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={15}/> EDIT</button></div>
</div>
}