Add authorable per-level briefing checklist
The case briefing showed a wall of body text. Add a checklist of short, player-tickable steps authored per level: a new `checklist` on the level brief (table osint.level_brief_checklist_items), read/written with the board, cloned on template freeze/instantiate, and carried through the mystery importer and the mystery:pull exporter. Player tick state is guidance-only, persisted locally per level rather than on the shared board. Convert the Barricelli inventor-proof brief from prose into a short intro plus a six-step checklist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+13
-3
@@ -615,6 +615,7 @@ export function App() {
|
||||
<div className="case-heading"><h1>{caseState.title}</h1></div>
|
||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
{briefOpen && <BriefPanel
|
||||
levelId={caseState.id}
|
||||
brief={caseState.brief}
|
||||
goals={caseState.goals}
|
||||
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
||||
@@ -1215,14 +1216,21 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
function BriefPanel({ levelId, brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { levelId: string; brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const partyById = new Map(parties.map(party => [party.id, party]))
|
||||
// Player-side checklist ticks: guidance only, so persist locally per level rather than
|
||||
// on the shared board.
|
||||
const checklist = brief.checklist || []
|
||||
const checkedKey = `gupi-osint-board:checklist:${levelId}`
|
||||
const [checked, setChecked] = useState<Set<string>>(() => { try { return new Set<string>(JSON.parse(localStorage.getItem(checkedKey) || '[]')) } catch { return new Set() } })
|
||||
const toggleChecked = (item: string) => setChecked(previous => { const next = new Set(previous); next.has(item) ? next.delete(item) : next.add(item); localStorage.setItem(checkedKey, JSON.stringify([...next])); return next })
|
||||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const pending = goals.filter(goal => goal.status === 'pending').length
|
||||
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
|
||||
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||
<p>{brief.body || 'No brief has been authored yet.'}</p>
|
||||
{checklist.length > 0 && <ul className="brief-checklist">{checklist.map((item, index) => { const done = checked.has(item); return <li key={index}><button type="button" className={done ? 'done' : ''} aria-pressed={done} onClick={() => toggleChecked(item)}><i>{done ? '☑' : '☐'}</i><span>{item}</span></button></li> })}</ul>}
|
||||
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
|
||||
{brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||||
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
|
||||
@@ -1246,12 +1254,14 @@ function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; ha
|
||||
|
||||
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
|
||||
const [body, setBody] = useState(brief.body)
|
||||
const [checklist, setChecklist] = useState((brief.checklist || []).join('\n'))
|
||||
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
|
||||
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
|
||||
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
|
||||
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), checklist: checklist.split('\n').map(item => item.trim()).filter(Boolean), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
|
||||
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
|
||||
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
|
||||
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={4} value={body} onChange={event => setBody(event.target.value)}/></label>
|
||||
<label className="field"><span>CHECKLIST · ONE STEP PER LINE</span><textarea aria-label="Level checklist" rows={5} value={checklist} onChange={event => setChecklist(event.target.value)} placeholder={'Find a reliable source online\nPaste the screenshot onto the board\nConnect it to the claim with red thread\nSubmit the Case Report'}/></label>
|
||||
<div className="metadata-heading"><div><b>CONCEPTS TO CLASSIFY</b><small>EXPECTED TYPE IS HIDDEN FROM PLAYERS</small></div><button type="button" onClick={addConcept}><Plus size={13}/> ADD CONCEPT</button></div>
|
||||
<div className="concept-editor-list">{concepts.map(concept => <div className="concept-editor-row" key={concept.id}><input aria-label="Concept name" placeholder="REAL NAME" value={concept.label} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, label: event.target.value } : item))}/><input aria-label="Concept context" placeholder="CONTEXT IN THE BRIEF" value={concept.context} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, context: event.target.value } : item))}/><select aria-label="Expected party type" value={concept.expectedPartyKind || 'person'} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, expectedPartyKind: event.target.value as PartyKind } : item))}><option value="person">Person</option><option value="organization">Organization</option></select><button type="button" aria-label="Remove concept" onClick={() => setConcepts(items => items.filter(item => item.id !== concept.id))}><Trash2 size={13}/></button></div>)}</div>
|
||||
<p className="folder-editor-note">Concepts are names in the brief, not board exhibits. A player turns each concept into a Party exhibit by classifying it.</p>
|
||||
|
||||
Reference in New Issue
Block a user