Spaces:
Running
Running
| // Interrogation room — questions, free-text, and present-evidence all go to the server, | |
| // which returns the suspect's reply + the authoritative suspicion. The client only displays. | |
| import { useEffect, useRef, useState } from 'preact/hooks' | |
| import { interrogate } from '../api' | |
| import { HOT_SUSPICION, useGame } from '../store' | |
| import type { Evidence, SuggestedQuestion } from '../types' | |
| import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio' | |
| import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components' | |
| import { CrackBanner, DeltaFloater } from '../ui/juice' | |
| interface Pending { | |
| text: string | |
| tag?: string | null | |
| suspicion: number | |
| speed?: number // typewriter ms/char, paced to the voice when one is available | |
| } | |
| export function Interrogation() { | |
| const g = useGame() | |
| const c = g.case | |
| const sid = (g.state.payload.suspect as string) || c.suspects[0].id | |
| const s = c.suspects.find((x) => x.id === sid)! | |
| const transcript = g.state.interrogations[sid] || [] | |
| const susp = g.state.suspicion[sid] | |
| const usedQ = g.state.usedQ[sid] || [] | |
| const usedEv = g.state.usedEv[sid] || [] | |
| const [pending, setPending] = useState<Pending | null>(null) | |
| const [thinking, setThinking] = useState(false) | |
| const [talking, setTalking] = useState(false) | |
| const [tray, setTray] = useState(false) | |
| const [input, setInput] = useState('') | |
| const [floater, setFloater] = useState<{ delta: number; id: number } | null>(null) | |
| const [crack, setCrack] = useState(false) | |
| const scroller = useRef<HTMLDivElement>(null) | |
| const busy = thinking || !!pending | |
| useEffect(() => () => stopSpeak(), []) // stop any voice when leaving the room | |
| useEffect(() => { | |
| if (!transcript.length) setPending({ text: s.greet, suspicion: susp }) | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [sid]) | |
| useEffect(() => { | |
| if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight | |
| }, [transcript, pending, thinking]) | |
| const commit = () => { | |
| if (!pending) return | |
| g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'sus', text: pending.text } }) | |
| g.dispatch({ type: 'SUSP_SET', sid, value: pending.suspicion }) | |
| setPending(null) | |
| } | |
| const run = async (body: Parameters<typeof interrogate>[2], detLine: { text: string; ev?: string }) => { | |
| if (busy) return | |
| stopSpeak() | |
| setTalking(false) | |
| g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'det', text: detLine.text, ev: detLine.ev } }) | |
| setThinking(true) | |
| const fallback = g.state.tweaks.typeSpeed || 22 | |
| try { | |
| const r = await interrogate(g.runId, sid, body) | |
| const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null | |
| // Feedback moments: record what rattled whom, float the suspicion delta, and | |
| // slam the banner the FIRST time this suspect's lie cracks. | |
| const flags = r.flags || {} | |
| const crackedNow = !!(flags.cornered || flags.contradictionExposed) | |
| const firstCrack = crackedNow && !g.state.cracked[sid] | |
| if (crackedNow || (flags.rattled && body.presentEvidenceId)) { | |
| g.dispatch({ | |
| type: 'FLAG', | |
| sid, | |
| evId: body.presentEvidenceId, | |
| kind: flags.contradictionExposed ? 'contradiction' : flags.cornered ? 'cornered' : 'rattled', | |
| }) | |
| } | |
| if (typeof r.suspicionDelta === 'number' && r.suspicionDelta !== 0) setFloater({ delta: r.suspicionDelta, id: Date.now() }) | |
| if (firstCrack) { | |
| setCrack(true) | |
| playSfx('success') | |
| setTimeout(() => setCrack(false), 1700) | |
| } | |
| // Synthesize the voice during the "thinking" beat so text + speech start together. | |
| const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false)) | |
| // Pace the typewriter to the audio so words land in step with the spoken line. | |
| const speed = voice ? Math.max(14, Math.min(90, Math.round(voice.durationMs / Math.max(1, r.reply.length)))) : fallback | |
| setThinking(false) | |
| setPending({ text: r.reply, suspicion: r.suspicion, tag, speed }) | |
| if (voice) { | |
| setTalking(true) | |
| voice.play() | |
| } | |
| } catch { | |
| setThinking(false) | |
| setPending({ text: '…I have nothing more to say to that.', suspicion: susp, speed: fallback }) | |
| } | |
| } | |
| const ask = (q: SuggestedQuestion) => { | |
| if (busy) return | |
| playSfx('select') | |
| g.dispatch({ type: 'USEQ', sid, qid: q.id }) | |
| run({ questionId: q.id }, { text: q.q }) | |
| } | |
| const present = (e: Evidence) => { | |
| setTray(false) | |
| if (busy) return | |
| playSfx('present') | |
| g.dispatch({ type: 'USEEV', sid, ev: e.id }) | |
| run({ presentEvidenceId: e.id }, { text: `Look at this. ${e.name}.`, ev: e.icon }) | |
| } | |
| const sendFree = () => { | |
| const txt = input.trim() | |
| if (!txt || busy) return | |
| setInput('') | |
| run({ freeText: txt }, { text: txt }) | |
| } | |
| const px = g.mode === 'mobile' ? 8 : 13 | |
| return ( | |
| <div class="app__view"> | |
| <Hud | |
| title={s.name} | |
| sub={s.role} | |
| right={ | |
| <> | |
| <div class="col" style={{ width: g.mode === 'mobile' ? 96 : 180, position: 'relative' }}> | |
| <SuspicionBar value={susp} /> | |
| {floater && <DeltaFloater key={floater.id} delta={floater.delta} />} | |
| {susp >= HOT_SUSPICION && ( | |
| <button class="accuse-chip" onClick={() => g.nav('accuse', { suspect: sid })}>CORNERED — ACCUSE ▸</button> | |
| )} | |
| </div> | |
| <Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn> | |
| </> | |
| } | |
| /> | |
| <div class="interro" style={{ position: 'relative' }}> | |
| <div class="interro__stage"> | |
| <Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} /> | |
| <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} /> | |
| <div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={s.sprite} px={px} gender={s.gender} accent={s.accentColor} talking={talking} /></div> | |
| {crack && <CrackBanner />} | |
| <div style={{ position: 'absolute', top: 12, left: 12, right: 12, zIndex: 4 }}> | |
| <Panel style={{ padding: 8, width: 'fit-content', maxWidth: '100%' }}> | |
| <span class="t-label">{s.tag}</span> | |
| <div class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>ALIBI · {s.alibi}</div> | |
| </Panel> | |
| </div> | |
| </div> | |
| <div class="interro__right"> | |
| <div class="transcript" ref={scroller}> | |
| {transcript.map((l, i) => ( | |
| <div key={i} class={'tline tline--' + (l.role === 'det' ? 'det' : 'sus')}> | |
| <div class="tline__who">{l.role === 'det' ? 'YOU' : s.name}</div> | |
| <div class="tline__b"> | |
| {l.ev && ( | |
| <span style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6, background: 'var(--ink-1)', padding: 2 }}> | |
| <EvIcon icon={l.ev} px={2} /> | |
| </span> | |
| )} | |
| {l.text} | |
| </div> | |
| </div> | |
| ))} | |
| {thinking && ( | |
| <div class="tline tline--sus"> | |
| <div class="tline__who">{s.name}</div> | |
| <div class="tline__b"><span class="cursor" /></div> | |
| </div> | |
| )} | |
| {pending && ( | |
| <div class="tline tline--sus"> | |
| <div class="tline__who">{s.name}{pending.tag && <span class="ox"> · {pending.tag}</span>}</div> | |
| <div class="tline__b"><TypeOnce text={pending.text} speed={pending.speed ?? (g.state.tweaks.typeSpeed || 22)} onDone={commit} /></div> | |
| </div> | |
| )} | |
| </div> | |
| <div class="composer"> | |
| <div class="qsuggest"> | |
| {s.suggestedQuestions.map((q) => ( | |
| <button key={q.id} class="qchip" disabled={usedQ.includes(q.id) || busy} onClick={() => ask(q)}> | |
| {usedQ.includes(q.id) ? '✓ ' : '? '} | |
| {q.q} | |
| </button> | |
| ))} | |
| </div> | |
| <div class="composer__input"> | |
| <input | |
| class="pinput" | |
| placeholder="Type your question…" | |
| value={input} | |
| onInput={(e) => setInput((e.target as HTMLInputElement).value)} | |
| onKeyDown={(e) => e.key === 'Enter' && sendFree()} | |
| /> | |
| <Btn variant="amber" sm onClick={sendFree} disabled={busy}>Ask</Btn> | |
| <Btn variant="ox" sm onClick={() => setTray(true)} disabled={busy}>Evidence ▴</Btn> | |
| </div> | |
| </div> | |
| </div> | |
| {tray && ( | |
| <div class="tray" onClick={() => setTray(false)}> | |
| <div class="tray__sheet" onClick={(e) => e.stopPropagation()}> | |
| <div class="between" style={{ marginBottom: 12 }}> | |
| <span class="t-display amber" style={{ fontSize: 12 }}>PRESENT EVIDENCE</span> | |
| <Btn sm variant="ghost" onClick={() => setTray(false)}>✕ Close</Btn> | |
| </div> | |
| <div class="tray__grid"> | |
| {c.evidence.map((e) => ( | |
| <EvidenceCard key={e.id} e={e} active={usedEv.includes(e.id)} onClick={() => present(e)} /> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |