Replays keep the theater
Browse filesEvery wait-line, entrance-ritual beat, telegraph, and demand/q20 line shown
during a turn is now captured into the qa entries and the accusation, carried
into the dataset record, and packed into the replay payload. The tape player
plays each turn's theater as ONE self-animating step — lines fade in on a
0.85s stagger inside a single NEXT click, so the comic timing survives without
tripling the click count (earlier steps sit still). Payload stays lean
(capped lines per turn; typical link ~1.4k chars).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md6C7wMyiQuBeiDR1M9x25
- app.py +30 -6
- tests/drive_mock.py +5 -1
app.py
CHANGED
|
@@ -745,6 +745,9 @@ body { background: var(--paper); color: var(--ink); font-family: var(--sans);
|
|
| 745 |
.step { margin: 14px 0; animation: fade .3s ease-out; }
|
| 746 |
@keyframes fade { 0%{opacity:0; transform:translateY(4px)} 100%{opacity:1} }
|
| 747 |
.step.scene { color: var(--ink-3); font-style: italic; }
|
|
|
|
|
|
|
|
|
|
| 748 |
.step.q { font-weight: 600; padding-left: 16px; padding-right: 48px; }
|
| 749 |
.step.q .r { font-weight: 400; color: var(--ink-3); font-style: italic; display: block; }
|
| 750 |
.step.a { color: var(--ink-2); text-align: right; padding-right: 16px; padding-left: 48px; }
|
|
@@ -822,12 +825,16 @@ function build(){
|
|
| 822 |
Object.keys(D.claims).map(k =>
|
| 823 |
'<div class="d-card"><b>'+k+'</b><span>'+esc(D.claims[k])+'</span></div>').join('');
|
| 824 |
steps.push({c:'scene', h: esc(D.det) + ' takes the case. The tape begins.'});
|
|
|
|
|
|
|
| 825 |
D.qa.forEach((e,i) => {
|
|
|
|
| 826 |
let q = '<span>Q'+(i+1)+' — '+esc(e.q)+'</span>';
|
| 827 |
if (e.r) q = '<span class="r">'+esc(e.r)+'</span>'+q;
|
| 828 |
steps.push({c:'q', h:q});
|
| 829 |
if (e.a) steps.push({c:'a', h:esc(e.a)});
|
| 830 |
});
|
|
|
|
| 831 |
steps.push({c:'scene', h:'He has heard enough.'});
|
| 832 |
if (D.acc.why) steps.push({c:'mono-log', h:esc(D.acc.why)});
|
| 833 |
steps.push({c:'stamp', h:'THE LIE: '+esc(snip(D.claims[D.acc.lie]))+' · '+D.acc.pct+'% · '+esc(D.acc.tier)});
|
|
@@ -840,7 +847,9 @@ function build(){
|
|
| 840 |
}
|
| 841 |
function render(){
|
| 842 |
const t = document.getElementById('tape');
|
| 843 |
-
|
|
|
|
|
|
|
| 844 |
document.getElementById('counter').textContent = pos + ' / ' + steps.length;
|
| 845 |
document.getElementById('back').disabled = pos === 0;
|
| 846 |
document.getElementById('next').disabled = pos >= steps.length;
|
|
@@ -1494,7 +1503,8 @@ def build_record(st):
|
|
| 1494 |
"labels": {"lie": st["lie_label"], "truths": truths},
|
| 1495 |
"conversation": [{"turn": e["turn"], "remark": e.get("remark", ""),
|
| 1496 |
"question": e["q"], "answer": e.get("a"),
|
| 1497 |
-
"vague": bool(e.get("vague"))
|
|
|
|
| 1498 |
"behavior": {"vague_answers": sum(1 for e in st["qa"] if e.get("vague")),
|
| 1499 |
"final_vague_streak": _vague_stats(st["qa"])[1]},
|
| 1500 |
"hidden_analysis": st["hidden_analysis"],
|
|
@@ -1795,11 +1805,13 @@ def detective_turn(state, chat):
|
|
| 1795 |
|
| 1796 |
last_vague, vstreak, _ = _vague_stats(st["qa"])
|
| 1797 |
wait_start = time.time()
|
|
|
|
| 1798 |
shown = ritual.pop(0) if ritual else _next_wait_line(st, escalated=False,
|
| 1799 |
vague=last_vague)
|
| 1800 |
if st["one_word_count"] >= 2 and not st["one_word_called"]:
|
| 1801 |
st["one_word_called"] = True
|
| 1802 |
shown = random.choice(char.get("one_word", [ONE_WORD_CALLOUT]))
|
|
|
|
| 1803 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1804 |
yield outs.tuple()
|
| 1805 |
line_shown_at = time.time()
|
|
@@ -1819,6 +1831,7 @@ def detective_turn(state, chat):
|
|
| 1819 |
else:
|
| 1820 |
shown = _next_wait_line(st, escalated=(now - wait_start) > 15,
|
| 1821 |
vague=last_vague)
|
|
|
|
| 1822 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1823 |
next_line_at = now + random.uniform(4, 6)
|
| 1824 |
line_shown_at = now
|
|
@@ -1843,6 +1856,7 @@ def detective_turn(state, chat):
|
|
| 1843 |
time.sleep(MIN_WAIT_LINE_SECS - held)
|
| 1844 |
while ritual:
|
| 1845 |
shown = ritual.pop(0)
|
|
|
|
| 1846 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1847 |
yield outs.tuple()
|
| 1848 |
time.sleep(RITUAL_LINE_SECS)
|
|
@@ -1878,7 +1892,8 @@ def detective_turn(state, chat):
|
|
| 1878 |
fb = 3
|
| 1879 |
turn_no = len(st["qa"]) + 1
|
| 1880 |
st["qa"].append({"turn": turn_no, "q": question, "a": None,
|
| 1881 |
-
"remark": remark, "hunch": fb == 1
|
|
|
|
| 1882 |
# typewriter the question in (~35 chars/s, chunked for Gradio)
|
| 1883 |
body_final = _chat_from_state(st)
|
| 1884 |
full = body_final[-1]["content"]
|
|
@@ -1899,10 +1914,17 @@ def detective_turn(state, chat):
|
|
| 1899 |
|
| 1900 |
# ── THE ACCUSATION ──
|
| 1901 |
pct = _conf_pct(conf, st["game_id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1902 |
st["accusation"] = {"lie": lie, "why": why or "The answers did not hold together.",
|
| 1903 |
"confidence": conf, "pct": pct, "tier": _conf_tier(pct),
|
| 1904 |
"turn": max(n_answered, 1), "forced": n_answered >= MAX_QUESTIONS,
|
| 1905 |
-
"demanded": st["demand"], "fallback_level": fb
|
|
|
|
| 1906 |
st["phase"] = "verdict_staged"
|
| 1907 |
demanded, at_q20 = st["demand"], n_answered >= MAX_QUESTIONS
|
| 1908 |
st["demand"] = False
|
|
@@ -2014,10 +2036,12 @@ def _replay_url(st):
|
|
| 2014 |
"case": st["session"]["case_no"],
|
| 2015 |
"claims": st["claims"],
|
| 2016 |
"lie": st["lie_label"],
|
| 2017 |
-
"qa": [{"q": e["q"], "a": e.get("a") or "", "r": e.get("remark", "")
|
|
|
|
| 2018 |
for e in st["qa"]],
|
| 2019 |
"acc": {"lie": acc.get("lie"), "why": acc.get("why", ""),
|
| 2020 |
-
"pct": acc.get("pct"), "tier": acc.get("tier", "")
|
|
|
|
| 2021 |
"win": st["last_result"] == "win",
|
| 2022 |
}
|
| 2023 |
raw = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode()
|
|
|
|
| 745 |
.step { margin: 14px 0; animation: fade .3s ease-out; }
|
| 746 |
@keyframes fade { 0%{opacity:0; transform:translateY(4px)} 100%{opacity:1} }
|
| 747 |
.step.scene { color: var(--ink-3); font-style: italic; }
|
| 748 |
+
.step.waits { color: var(--ink-3); font-style: italic; }
|
| 749 |
+
.step.waits .wl { opacity: 0; animation: fade .45s ease-out forwards; margin: 7px 0; }
|
| 750 |
+
.step.waits.old .wl { opacity: 1; animation: none; }
|
| 751 |
.step.q { font-weight: 600; padding-left: 16px; padding-right: 48px; }
|
| 752 |
.step.q .r { font-weight: 400; color: var(--ink-3); font-style: italic; display: block; }
|
| 753 |
.step.a { color: var(--ink-2); text-align: right; padding-right: 16px; padding-left: 48px; }
|
|
|
|
| 825 |
Object.keys(D.claims).map(k =>
|
| 826 |
'<div class="d-card"><b>'+k+'</b><span>'+esc(D.claims[k])+'</span></div>').join('');
|
| 827 |
steps.push({c:'scene', h: esc(D.det) + ' takes the case. The tape begins.'});
|
| 828 |
+
const waitStep = w => ({c:'waits', h: w.map((l,j) =>
|
| 829 |
+
'<div class="wl" style="animation-delay:'+(j*0.85)+'s">'+esc(l)+'</div>').join('')});
|
| 830 |
D.qa.forEach((e,i) => {
|
| 831 |
+
if (e.w && e.w.length) steps.push(waitStep(e.w));
|
| 832 |
let q = '<span>Q'+(i+1)+' — '+esc(e.q)+'</span>';
|
| 833 |
if (e.r) q = '<span class="r">'+esc(e.r)+'</span>'+q;
|
| 834 |
steps.push({c:'q', h:q});
|
| 835 |
if (e.a) steps.push({c:'a', h:esc(e.a)});
|
| 836 |
});
|
| 837 |
+
if (D.acc.w && D.acc.w.length) steps.push(waitStep(D.acc.w));
|
| 838 |
steps.push({c:'scene', h:'He has heard enough.'});
|
| 839 |
if (D.acc.why) steps.push({c:'mono-log', h:esc(D.acc.why)});
|
| 840 |
steps.push({c:'stamp', h:'THE LIE: '+esc(snip(D.claims[D.acc.lie]))+' · '+D.acc.pct+'% · '+esc(D.acc.tier)});
|
|
|
|
| 847 |
}
|
| 848 |
function render(){
|
| 849 |
const t = document.getElementById('tape');
|
| 850 |
+
// only the newest waits step animates its lines; earlier ones sit still
|
| 851 |
+
t.innerHTML = steps.slice(0, pos).map((s,i) =>
|
| 852 |
+
'<div class="step '+s.c+(i < pos-1 ? ' old' : '')+'">'+s.h+'</div>').join('');
|
| 853 |
document.getElementById('counter').textContent = pos + ' / ' + steps.length;
|
| 854 |
document.getElementById('back').disabled = pos === 0;
|
| 855 |
document.getElementById('next').disabled = pos >= steps.length;
|
|
|
|
| 1503 |
"labels": {"lie": st["lie_label"], "truths": truths},
|
| 1504 |
"conversation": [{"turn": e["turn"], "remark": e.get("remark", ""),
|
| 1505 |
"question": e["q"], "answer": e.get("a"),
|
| 1506 |
+
"vague": bool(e.get("vague")),
|
| 1507 |
+
"waits": e.get("waits", [])} for e in st["qa"]],
|
| 1508 |
"behavior": {"vague_answers": sum(1 for e in st["qa"] if e.get("vague")),
|
| 1509 |
"final_vague_streak": _vague_stats(st["qa"])[1]},
|
| 1510 |
"hidden_analysis": st["hidden_analysis"],
|
|
|
|
| 1805 |
|
| 1806 |
last_vague, vstreak, _ = _vague_stats(st["qa"])
|
| 1807 |
wait_start = time.time()
|
| 1808 |
+
turn_waits = [] # every theater line shown this turn — replays keep the show
|
| 1809 |
shown = ritual.pop(0) if ritual else _next_wait_line(st, escalated=False,
|
| 1810 |
vague=last_vague)
|
| 1811 |
if st["one_word_count"] >= 2 and not st["one_word_called"]:
|
| 1812 |
st["one_word_called"] = True
|
| 1813 |
shown = random.choice(char.get("one_word", [ONE_WORD_CALLOUT]))
|
| 1814 |
+
turn_waits.append(shown)
|
| 1815 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1816 |
yield outs.tuple()
|
| 1817 |
line_shown_at = time.time()
|
|
|
|
| 1831 |
else:
|
| 1832 |
shown = _next_wait_line(st, escalated=(now - wait_start) > 15,
|
| 1833 |
vague=last_vague)
|
| 1834 |
+
turn_waits.append(shown)
|
| 1835 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1836 |
next_line_at = now + random.uniform(4, 6)
|
| 1837 |
line_shown_at = now
|
|
|
|
| 1856 |
time.sleep(MIN_WAIT_LINE_SECS - held)
|
| 1857 |
while ritual:
|
| 1858 |
shown = ritual.pop(0)
|
| 1859 |
+
turn_waits.append(shown)
|
| 1860 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1861 |
yield outs.tuple()
|
| 1862 |
time.sleep(RITUAL_LINE_SECS)
|
|
|
|
| 1892 |
fb = 3
|
| 1893 |
turn_no = len(st["qa"]) + 1
|
| 1894 |
st["qa"].append({"turn": turn_no, "q": question, "a": None,
|
| 1895 |
+
"remark": remark, "hunch": fb == 1,
|
| 1896 |
+
"waits": turn_waits[:6]})
|
| 1897 |
# typewriter the question in (~35 chars/s, chunked for Gradio)
|
| 1898 |
body_final = _chat_from_state(st)
|
| 1899 |
full = body_final[-1]["content"]
|
|
|
|
| 1914 |
|
| 1915 |
# ── THE ACCUSATION ──
|
| 1916 |
pct = _conf_pct(conf, st["game_id"])
|
| 1917 |
+
if n_answered >= MAX_QUESTIONS:
|
| 1918 |
+
turn_waits.append(char["q20"])
|
| 1919 |
+
else:
|
| 1920 |
+
turn_waits.append(char["telegraph"])
|
| 1921 |
+
if st["demand"]:
|
| 1922 |
+
turn_waits.append(char["demand_resp"])
|
| 1923 |
st["accusation"] = {"lie": lie, "why": why or "The answers did not hold together.",
|
| 1924 |
"confidence": conf, "pct": pct, "tier": _conf_tier(pct),
|
| 1925 |
"turn": max(n_answered, 1), "forced": n_answered >= MAX_QUESTIONS,
|
| 1926 |
+
"demanded": st["demand"], "fallback_level": fb,
|
| 1927 |
+
"waits": turn_waits[:8]}
|
| 1928 |
st["phase"] = "verdict_staged"
|
| 1929 |
demanded, at_q20 = st["demand"], n_answered >= MAX_QUESTIONS
|
| 1930 |
st["demand"] = False
|
|
|
|
| 2036 |
"case": st["session"]["case_no"],
|
| 2037 |
"claims": st["claims"],
|
| 2038 |
"lie": st["lie_label"],
|
| 2039 |
+
"qa": [{"q": e["q"], "a": e.get("a") or "", "r": e.get("remark", ""),
|
| 2040 |
+
"w": e.get("waits", [])}
|
| 2041 |
for e in st["qa"]],
|
| 2042 |
"acc": {"lie": acc.get("lie"), "why": acc.get("why", ""),
|
| 2043 |
+
"pct": acc.get("pct"), "tier": acc.get("tier", ""),
|
| 2044 |
+
"w": acc.get("waits", [])},
|
| 2045 |
"win": st["last_result"] == "win",
|
| 2046 |
}
|
| 2047 |
raw = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode()
|
tests/drive_mock.py
CHANGED
|
@@ -88,7 +88,11 @@ def main():
|
|
| 88 |
replay = json.loads(_zlib.decompress(_b64.urlsafe_b64decode(payload)))
|
| 89 |
check("replay payload roundtrips", replay["lie"] == "C" and len(replay["qa"]) >= 5
|
| 90 |
and replay["det"] == app.CHARACTERS["pip"]["name"], str(replay)[:120])
|
| 91 |
-
check("replay url reasonable length", len(m.group(0)) <
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
check("replay excludes hidden analysis", "suspicion" not in json.dumps(replay))
|
| 93 |
check("phase done", st["phase"] == "done")
|
| 94 |
check("streak counted", st["session"]["streak"] == 1)
|
|
|
|
| 88 |
replay = json.loads(_zlib.decompress(_b64.urlsafe_b64decode(payload)))
|
| 89 |
check("replay payload roundtrips", replay["lie"] == "C" and len(replay["qa"]) >= 5
|
| 90 |
and replay["det"] == app.CHARACTERS["pip"]["name"], str(replay)[:120])
|
| 91 |
+
check("replay url reasonable length", len(m.group(0)) < 6000, str(len(m.group(0))))
|
| 92 |
+
check("replay keeps the theater", any(e.get("w") for e in replay["qa"])
|
| 93 |
+
and len(replay["acc"].get("w", [])) >= 1,
|
| 94 |
+
str([e.get("w") for e in replay["qa"]])[:120])
|
| 95 |
+
check("record keeps the theater", any(e.get("waits") for e in rec["conversation"]))
|
| 96 |
check("replay excludes hidden analysis", "suspicion" not in json.dumps(replay))
|
| 97 |
check("phase done", st["phase"] == "done")
|
| 98 |
check("streak counted", st["session"]["streak"] == 1)
|