// 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(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(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[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 (
{floater && } {susp >= HOT_SUSPICION && ( )}
g.nav('board')}>Board } />
{crack && }
{s.tag}
ALIBI · {s.alibi}
{transcript.map((l, i) => (
{l.role === 'det' ? 'YOU' : s.name}
{l.ev && ( )} {l.text}
))} {thinking && (
{s.name}
)} {pending && (
{s.name}{pending.tag && · {pending.tag}}
)}
{s.suggestedQuestions.map((q) => ( ))}
setInput((e.target as HTMLInputElement).value)} onKeyDown={(e) => e.key === 'Enter' && sendFree()} /> Ask setTray(true)} disabled={busy}>Evidence ▴
{tray && (
setTray(false)}>
e.stopPropagation()}>
PRESENT EVIDENCE setTray(false)}>✕ Close
{c.evidence.map((e) => ( present(e)} /> ))}
)}
) }