Pabloler21 Claude Fable 5 commited on
Commit
73494df
·
1 Parent(s): 70d89c3

docs: implementation plan for tone axis + three endings

Browse files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/superpowers/plans/2026-06-11-tone-and-three-endings.md ADDED
@@ -0,0 +1,1279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tone Axis & Three Endings — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Hollow reacts to *how* the visitor treats it (tone axis), shifts personality live (mood bands, mimicry, wound echo), and the game branches into three endings: good (existing Visitor Loop), bad ("your cruelty is its treasure"), neutral (fog dissolve).
6
+
7
+ **Architecture:** One new extraction field pair rides the existing per-turn GPU call (`tone_delta`, `cruel_quote`). A `tone` accumulator and `wounds` list live in `gr.State`. `decide_ending` replaces `should_end` and routes `chat()` to one of three scripted finale generators. Everything stays pure-data/pure-render except the `app.py` wiring.
8
+
9
+ **Tech Stack:** Python, Pydantic v2, Gradio 6.x, pytest. No new dependencies.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-06-11-tone-and-three-endings-design.md` (approved 2026-06-11).
12
+
13
+ **Context for workers:**
14
+ - Run tests with `.venv/Scripts/python -m pytest tests/ -v` from `c:\Users\pablo\Desktop\mnemo`. 68 tests pass before this plan.
15
+ - Project language is English (code, prompts, dialogue). Commit style: `feat: ...` / `test: ...`, ending with the Claude co-author trailer.
16
+ - State reads of NEW fields must use `.get(...)` with defaults (`tone`→0, `wounds`→[], `ending`→None): mid-session states from before this deploy must not crash.
17
+ - Work directly on `main` (established project practice — solo hackathon repo, no worktrees).
18
+
19
+ ---
20
+
21
+ ### Task 1: `tone_delta` + `cruel_quote` in TurnUpdate
22
+
23
+ **Files:**
24
+ - Modify: `schemas.py`
25
+ - Test: `tests/test_schemas.py` (create)
26
+
27
+ - [ ] **Step 1: Write the failing tests**
28
+
29
+ Create `tests/test_schemas.py`:
30
+
31
+ ```python
32
+ from schemas import TurnUpdate
33
+
34
+
35
+ class TestToneDelta:
36
+ def test_default_is_zero(self):
37
+ assert TurnUpdate().tone_delta == 0
38
+
39
+ def test_clamped_high(self):
40
+ assert TurnUpdate(tone_delta=25).tone_delta == 10
41
+
42
+ def test_clamped_low(self):
43
+ assert TurnUpdate(tone_delta=-25).tone_delta == -10
44
+
45
+ def test_in_range_untouched(self):
46
+ assert TurnUpdate(tone_delta=-7).tone_delta == -7
47
+
48
+
49
+ class TestCruelQuote:
50
+ def test_default_is_none(self):
51
+ assert TurnUpdate().cruel_quote is None
52
+
53
+ def test_stripped(self):
54
+ assert TurnUpdate(cruel_quote=" you freak ").cruel_quote == "you freak"
55
+
56
+ def test_truncated_to_120(self):
57
+ q = "x" * 300
58
+ assert len(TurnUpdate(cruel_quote=q).cruel_quote) == 120
59
+
60
+ def test_empty_becomes_none(self):
61
+ assert TurnUpdate(cruel_quote=" ").cruel_quote is None
62
+
63
+
64
+ class TestExistingFieldsUnchanged:
65
+ def test_affinity_delta_still_clamped(self):
66
+ assert TurnUpdate(affinity_delta=20).affinity_delta == 8
67
+ ```
68
+
69
+ - [ ] **Step 2: Run tests to verify they fail**
70
+
71
+ Run: `.venv/Scripts/python -m pytest tests/test_schemas.py -v`
72
+ Expected: FAIL — `TurnUpdate` has no field `tone_delta`.
73
+
74
+ - [ ] **Step 3: Implement**
75
+
76
+ Replace `schemas.py` entirely with:
77
+
78
+ ```python
79
+ from pydantic import BaseModel, field_validator
80
+ from typing import List, Optional
81
+
82
+
83
+ class TurnUpdate(BaseModel):
84
+ affinity_delta: int = 0
85
+ new_memories: List[str] = []
86
+ tone_delta: int = 0
87
+ cruel_quote: Optional[str] = None
88
+
89
+ @field_validator("affinity_delta")
90
+ @classmethod
91
+ def clamp_delta(cls, v):
92
+ return max(-8, min(8, v))
93
+
94
+ @field_validator("tone_delta")
95
+ @classmethod
96
+ def clamp_tone(cls, v):
97
+ return max(-10, min(10, v))
98
+
99
+ @field_validator("cruel_quote")
100
+ @classmethod
101
+ def clean_quote(cls, v):
102
+ if v is None:
103
+ return None
104
+ v = v.strip()[:120]
105
+ return v or None
106
+ ```
107
+
108
+ - [ ] **Step 4: Run all tests**
109
+
110
+ Run: `.venv/Scripts/python -m pytest tests/ -v`
111
+ Expected: all pass (68 + 9 new).
112
+
113
+ - [ ] **Step 5: Commit**
114
+
115
+ ```bash
116
+ git add schemas.py tests/test_schemas.py
117
+ git commit -m "feat: tone_delta + cruel_quote fields in TurnUpdate"
118
+ ```
119
+
120
+ ---
121
+
122
+ ### Task 2: extraction prompt captures tone + cruel quote
123
+
124
+ **Files:**
125
+ - Modify: `engine.py:5-18` (`_EXTRACTION_SYSTEM` only — nothing else in the file changes)
126
+ - Test: `tests/test_engine.py` (create)
127
+
128
+ - [ ] **Step 1: Write the failing test**
129
+
130
+ Create `tests/test_engine.py`:
131
+
132
+ ```python
133
+ """Smoke tests for the extraction prompt contract. We can't unit-test the
134
+ model, but we can pin the instructions the model is given."""
135
+
136
+ import engine
137
+
138
+
139
+ class TestExtractionPrompt:
140
+ def test_mentions_tone_delta_with_range(self):
141
+ assert "tone_delta" in engine._EXTRACTION_SYSTEM
142
+ assert "-10" in engine._EXTRACTION_SYSTEM
143
+
144
+ def test_mentions_cruel_quote(self):
145
+ assert "cruel_quote" in engine._EXTRACTION_SYSTEM
146
+
147
+ def test_example_json_includes_new_fields(self):
148
+ assert '"tone_delta"' in engine._EXTRACTION_SYSTEM
149
+ assert '"cruel_quote"' in engine._EXTRACTION_SYSTEM
150
+ ```
151
+
152
+ - [ ] **Step 2: Run test to verify it fails**
153
+
154
+ Run: `.venv/Scripts/python -m pytest tests/test_engine.py -v`
155
+ Expected: FAIL — `tone_delta` not in the prompt.
156
+
157
+ Note: importing `engine` off-Space requires no GPU (the `IS_SPACE` branch guards the transformers import) — safe in tests.
158
+
159
+ - [ ] **Step 3: Implement**
160
+
161
+ Replace `_EXTRACTION_SYSTEM` in `engine.py` with:
162
+
163
+ ```python
164
+ _EXTRACTION_SYSTEM = (
165
+ "You are a JSON extraction assistant. Given a conversation exchange, extract four things:\n"
166
+ "1. affinity_delta: integer from -8 to +8 — how much the visitor deepened the bond this turn. "
167
+ "Use this scale and be generous when they open up:\n"
168
+ " +6 to +8: shared something vulnerable or intimate — a fear, a loss, grief, loneliness, a tender memory.\n"
169
+ " +3 to +5: shared a personal fact or memory, or was warm and caring.\n"
170
+ " +1 to +2: ordinary politeness or small talk.\n"
171
+ " 0: neutral or off-topic.\n"
172
+ " -3 to -8: cruel, mocking, dismissive, or says they are leaving.\n"
173
+ "2. new_memories: list of personal facts the VISITOR revealed about THEMSELVES, taken ONLY from what "
174
+ "the visitor said this turn — NEVER from Hollow's reply, even if Hollow repeats or claims the memory. "
175
+ "Only clearly personal facts (names, people, places, feelings). Empty list if nothing personal.\n"
176
+ "3. tone_delta: integer from -10 to +10 — how the visitor TREATED Hollow this turn. This is distinct "
177
+ "from affinity: politely sharing a sad memory is high affinity but roughly 0 tone; an insult is "
178
+ "negative on both.\n"
179
+ " +4 to +8: warmth, kindness, comfort, protectiveness toward Hollow.\n"
180
+ " +1 to +3: ordinary politeness.\n"
181
+ " 0: neutral.\n"
182
+ " -2 to -5: dismissive, cold, impatient.\n"
183
+ " -6 to -10: mocking, insulting, cruel, or threatening Hollow.\n"
184
+ "4. cruel_quote: if the visitor mocked, insulted, or was cruel to Hollow this turn, the cruel phrase "
185
+ "EXACTLY as the visitor wrote it — the visitor's words, never Hollow's. null otherwise.\n"
186
+ 'Respond ONLY with valid JSON. No markdown. Example: {"affinity_delta": -4, "new_memories": [], '
187
+ '"tone_delta": -8, "cruel_quote": "you are a creepy little freak"}'
188
+ )
189
+ ```
190
+
191
+ - [ ] **Step 4: Run all tests**
192
+
193
+ Run: `.venv/Scripts/python -m pytest tests/ -v` — all pass.
194
+
195
+ - [ ] **Step 5: Commit**
196
+
197
+ ```bash
198
+ git add engine.py tests/test_engine.py
199
+ git commit -m "feat: extraction prompt captures tone_delta + cruel_quote"
200
+ ```
201
+
202
+ ---
203
+
204
+ ### Task 3: tone accumulator + wounds in `apply_update`
205
+
206
+ **Files:**
207
+ - Modify: `memory.py:18-31` (`apply_update`)
208
+ - Test: `tests/test_memory.py` (extend)
209
+
210
+ - [ ] **Step 1: Write the failing tests**
211
+
212
+ Add to `tests/test_memory.py` (reuse the file's existing state-fixture style):
213
+
214
+ ```python
215
+ class TestApplyUpdateTone:
216
+ def _state(self, tone=0, wounds=None):
217
+ return {"affinity": 50, "treasure": [], "claimed": [],
218
+ "tone": tone, "wounds": wounds if wounds is not None else []}
219
+
220
+ def test_tone_accumulates(self):
221
+ s = apply_update(self._state(tone=10), '{"affinity_delta": 0, "tone_delta": 5}')
222
+ assert s["tone"] == 15
223
+
224
+ def test_tone_clamped_high(self):
225
+ s = apply_update(self._state(tone=98), '{"affinity_delta": 0, "tone_delta": 9}')
226
+ assert s["tone"] == 100
227
+
228
+ def test_tone_clamped_low(self):
229
+ s = apply_update(self._state(tone=-95), '{"affinity_delta": 0, "tone_delta": -10}')
230
+ assert s["tone"] == -100
231
+
232
+ def test_old_state_without_tone_key(self):
233
+ s = {"affinity": 50, "treasure": [], "claimed": []}
234
+ s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -3}')
235
+ assert s["tone"] == -3
236
+
237
+ def test_broken_json_leaves_tone_unchanged(self):
238
+ s = apply_update(self._state(tone=7), "not json at all")
239
+ assert s["tone"] == 7
240
+
241
+
242
+ class TestApplyUpdateWounds:
243
+ def _state(self, wounds=None):
244
+ return {"affinity": 50, "treasure": [], "claimed": [],
245
+ "tone": 0, "wounds": wounds if wounds is not None else []}
246
+
247
+ def test_cruel_quote_stored(self):
248
+ s = apply_update(self._state(),
249
+ '{"affinity_delta": -4, "tone_delta": -7, "cruel_quote": "you bore me"}')
250
+ assert s["wounds"] == ["you bore me"]
251
+
252
+ def test_quote_ignored_on_non_negative_tone(self):
253
+ s = apply_update(self._state(),
254
+ '{"affinity_delta": 0, "tone_delta": 0, "cruel_quote": "hallucinated"}')
255
+ assert s["wounds"] == []
256
+
257
+ def test_duplicate_quote_not_stored_twice(self):
258
+ s = self._state(wounds=["you bore me"])
259
+ s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -5, "cruel_quote": "you bore me"}')
260
+ assert s["wounds"] == ["you bore me"]
261
+
262
+ def test_wounds_capped_at_10_keeping_newest(self):
263
+ s = self._state(wounds=[f"insult {i}" for i in range(10)])
264
+ s = apply_update(s, '{"affinity_delta": 0, "tone_delta": -5, "cruel_quote": "the newest"}')
265
+ assert len(s["wounds"]) == 10
266
+ assert s["wounds"][-1] == "the newest"
267
+ assert "insult 0" not in s["wounds"]
268
+ ```
269
+
270
+ - [ ] **Step 2: Run tests to verify they fail**
271
+
272
+ Run: `.venv/Scripts/python -m pytest tests/test_memory.py -v`
273
+ Expected: new tests FAIL (`tone` key never written).
274
+
275
+ - [ ] **Step 3: Implement**
276
+
277
+ In `memory.py`, add to the end of `apply_update`, just before `return state`:
278
+
279
+ ```python
280
+ state["tone"] = max(-100, min(100, state.get("tone", 0) + update.tone_delta))
281
+ if update.cruel_quote and update.tone_delta < 0:
282
+ wounds = state.setdefault("wounds", [])
283
+ if update.cruel_quote not in wounds:
284
+ wounds.append(update.cruel_quote)
285
+ del wounds[:-10] # cap at 10, keep newest
286
+ ```
287
+
288
+ - [ ] **Step 4: Run all tests** — all pass.
289
+
290
+ - [ ] **Step 5: Commit**
291
+
292
+ ```bash
293
+ git add memory.py tests/test_memory.py
294
+ git commit -m "feat: tone accumulator + wounds capture in apply_update"
295
+ ```
296
+
297
+ ---
298
+
299
+ ### Task 4: `decide_ending` replaces `should_end`
300
+
301
+ **Files:**
302
+ - Modify: `memory.py:57-65` (delete `should_end`, add `decide_ending`)
303
+ - Modify: `app.py:9` and `app.py:127` (minimal shim — full routing comes in Task 9)
304
+ - Test: `tests/test_memory.py` (migrate the `should_end` test class)
305
+
306
+ - [ ] **Step 1: Write the failing tests**
307
+
308
+ In `tests/test_memory.py`, replace the entire `should_end` test class (lines ~185-209, the one using `_end_state`) with:
309
+
310
+ ```python
311
+ def _end_state(affinity=90, claimed_count=3, ended=False, tone=30, turn=12, wounds=None):
312
+ return {
313
+ "affinity": affinity,
314
+ "treasure": [f"memory {i}" for i in range(claimed_count)],
315
+ "claimed": [f"memory {i}" for i in range(claimed_count)],
316
+ "turn": turn,
317
+ "ended": ended,
318
+ "tone": tone,
319
+ "wounds": wounds if wounds is not None else [],
320
+ }
321
+
322
+
323
+ class TestDecideEnding:
324
+ # --- good ---
325
+ def test_good_at_90_with_3_claimed_and_warm_tone(self):
326
+ assert decide_ending(_end_state(tone=30)) == "good"
327
+
328
+ def test_good_exactly_at_tone_15(self):
329
+ assert decide_ending(_end_state(tone=15)) == "good"
330
+
331
+ # --- neutral ---
332
+ def test_neutral_when_gate_met_but_tone_mixed(self):
333
+ assert decide_ending(_end_state(tone=5)) == "neutral"
334
+
335
+ def test_neutral_when_tone_mildly_negative(self):
336
+ assert decide_ending(_end_state(tone=-10)) == "neutral"
337
+
338
+ # --- bad ---
339
+ def test_bad_on_sustained_cruelty(self):
340
+ s = _end_state(affinity=12, claimed_count=0, tone=-45, turn=8,
341
+ wounds=["w1", "w2"])
342
+ assert decide_ending(s) == "bad"
343
+
344
+ def test_bad_needs_minimum_turns(self):
345
+ s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=5,
346
+ wounds=["w1", "w2"])
347
+ assert decide_ending(s) is None
348
+
349
+ def test_bad_needs_two_wounds(self):
350
+ s = _end_state(affinity=12, claimed_count=0, tone=-60, turn=8,
351
+ wounds=["w1"])
352
+ assert decide_ending(s) is None
353
+
354
+ def test_bad_wins_over_good_gate(self):
355
+ # pathological but defined: cruelty fires first
356
+ s = _end_state(affinity=95, claimed_count=3, tone=-50, turn=10,
357
+ wounds=["w1", "w2"])
358
+ assert decide_ending(s) == "bad"
359
+
360
+ # --- none ---
361
+ def test_none_below_90(self):
362
+ assert decide_ending(_end_state(affinity=89, claimed_count=5)) is None
363
+
364
+ def test_none_with_fewer_than_3_claimed(self):
365
+ assert decide_ending(_end_state(affinity=95, claimed_count=2)) is None
366
+
367
+ def test_none_when_already_ended(self):
368
+ assert decide_ending(_end_state(ended=True)) is None
369
+
370
+ def test_old_state_without_tone_key_is_neutral(self):
371
+ s = _end_state()
372
+ del s["tone"]
373
+ del s["wounds"]
374
+ assert decide_ending(s) == "neutral"
375
+
376
+ def test_fresh_session_does_not_fire(self):
377
+ s = {"affinity": 20, "treasure": [], "claimed": [], "turn": 0,
378
+ "ended": False, "tone": 0, "wounds": []}
379
+ assert decide_ending(s) is None
380
+ ```
381
+
382
+ Update the import at the top of the file: `from memory import get_tier, apply_update, should_recall, decide_ending`.
383
+
384
+ - [ ] **Step 2: Run tests to verify they fail**
385
+
386
+ Run: `.venv/Scripts/python -m pytest tests/test_memory.py -v`
387
+ Expected: ImportError — `decide_ending` doesn't exist.
388
+
389
+ - [ ] **Step 3: Implement**
390
+
391
+ In `memory.py`, replace the whole `should_end` function with:
392
+
393
+ ```python
394
+ def decide_ending(state: dict) -> str | None:
395
+ """Which finale fires this turn, if any. Checked at turn start, before the
396
+ model runs — the finale turn is fully scripted, no model call, no GPU.
397
+
398
+ bad: sustained cruelty (tone accumulator), a minimum game length, and at
399
+ least 2 captured wounds (the finale recites them; if extraction
400
+ missed the quotes, the game simply continues).
401
+ good/neutral: the original Visitor Loop gate, branched by tone — warm
402
+ play earns the Loop; ambivalent play earns the fog.
403
+ """
404
+ if state.get("ended"):
405
+ return None
406
+ tone = state.get("tone", 0)
407
+ if tone <= -40 and state["turn"] >= 6 and len(state.get("wounds", [])) >= 2:
408
+ return "bad"
409
+ if state["affinity"] >= 90 and len(state["claimed"]) >= 3:
410
+ return "good" if tone >= 15 else "neutral"
411
+ return None
412
+ ```
413
+
414
+ In `app.py` make the minimal shim so the app keeps running until Task 9:
415
+ - Line 9: `from memory import apply_update, get_tier, decide_ending, should_recall`
416
+ - Line 127: `if should_end(state):` → `if decide_ending(state):`
417
+
418
+ (Behavior is preserved for now: pre-tone states have `tone` 0 → the old gate returns "neutral", which is truthy and plays the existing finale. Task 9 adds real routing.)
419
+
420
+ - [ ] **Step 4: Run all tests** — all pass.
421
+
422
+ - [ ] **Step 5: Commit**
423
+
424
+ ```bash
425
+ git add memory.py app.py tests/test_memory.py
426
+ git commit -m "feat: decide_ending three-way gate replaces should_end"
427
+ ```
428
+
429
+ ---
430
+
431
+ ### Task 5: tone bands + mimicry + wound echo in the system prompt
432
+
433
+ **Files:**
434
+ - Modify: `character.py` (constants + `build_system_prompt` signature)
435
+ - Test: `tests/test_character.py` (create)
436
+
437
+ - [ ] **Step 1: Write the failing tests**
438
+
439
+ Create `tests/test_character.py`:
440
+
441
+ ```python
442
+ from character import build_system_prompt
443
+
444
+
445
+ class TestMimicry:
446
+ def test_always_present(self):
447
+ assert "mirror the visitor's writing style" in build_system_prompt(20, [])
448
+
449
+ def test_present_with_recall_too(self):
450
+ p = build_system_prompt(60, ["had a dog"], recall_memory="had a dog")
451
+ assert "mirror the visitor's writing style" in p
452
+
453
+
454
+ class TestToneBands:
455
+ def test_warm_band_at_15(self):
456
+ assert "gentle with you" in build_system_prompt(50, [], tone=15)
457
+
458
+ def test_no_band_when_neutral(self):
459
+ p = build_system_prompt(50, [], tone=0)
460
+ assert "gentle with you" not in p
461
+ assert "words have hurt you" not in p
462
+ assert "curdled" not in p
463
+
464
+ def test_no_band_at_minus_14(self):
465
+ p = build_system_prompt(50, [], tone=-14)
466
+ assert "words have hurt you" not in p
467
+
468
+ def test_wounded_band_at_minus_15(self):
469
+ assert "words have hurt you" in build_system_prompt(50, [], tone=-15)
470
+
471
+ def test_wounded_band_at_minus_39(self):
472
+ assert "words have hurt you" in build_system_prompt(50, [], tone=-39)
473
+
474
+ def test_hostile_band_at_minus_40(self):
475
+ assert "curdled" in build_system_prompt(50, [], tone=-40)
476
+
477
+
478
+ class TestWoundEcho:
479
+ def test_hostile_injects_last_two_wounds(self):
480
+ p = build_system_prompt(50, [], tone=-50,
481
+ wounds=["one", "two", "three"])
482
+ assert '"two"' in p and '"three"' in p
483
+ assert '"one"' not in p
484
+
485
+ def test_wounded_band_does_not_inject(self):
486
+ p = build_system_prompt(50, [], tone=-20, wounds=["one"])
487
+ assert '"one"' not in p
488
+
489
+ def test_hostile_without_wounds_safe(self):
490
+ p = build_system_prompt(50, [], tone=-50, wounds=[])
491
+ assert "curdled" in p
492
+
493
+
494
+ class TestBackwardCompat:
495
+ def test_old_call_signature_still_works(self):
496
+ p = build_system_prompt(60, ["had a dog"], "had a dog")
497
+ assert "claim this one specific memory" in p
498
+ ```
499
+
500
+ - [ ] **Step 2: Run tests to verify they fail**
501
+
502
+ Run: `.venv/Scripts/python -m pytest tests/test_character.py -v`
503
+ Expected: FAIL — unexpected keyword `tone` / mimicry text missing.
504
+
505
+ - [ ] **Step 3: Implement**
506
+
507
+ In `character.py`, add after `_PERSONA` (before `OPENING_LINE`):
508
+
509
+ ```python
510
+ _MIMICRY = (
511
+ "Subtly mirror the visitor's writing style — their punctuation, capitalization, "
512
+ "message length, their rhythm. If they write short and cold, you become short. "
513
+ "Never mention that you do this."
514
+ )
515
+
516
+ _TONE_WARM = (
517
+ "The visitor has been gentle with you. You trust them more than you should. "
518
+ "You are becoming attached."
519
+ )
520
+ _TONE_WOUNDED = (
521
+ "The visitor's words have hurt you. They remind you of how you were treated "
522
+ "when you were alive. You withdraw: shorter answers, more guarded, and you "
523
+ "flinch at kindness now."
524
+ )
525
+ _TONE_HOSTILE = (
526
+ "The visitor is cruel, like the ones you remember from when you were alive. "
527
+ "Something in you has curdled. You are resentful, cold, quietly menacing."
528
+ )
529
+
530
+
531
+ def _tone_block(tone: int, wounds: list[str]) -> str | None:
532
+ if tone >= 15:
533
+ return _TONE_WARM
534
+ if tone <= -40:
535
+ block = _TONE_HOSTILE
536
+ if wounds:
537
+ quoted = ", ".join(f'"{w}"' for w in wounds[-2:])
538
+ block += (
539
+ f" They said these things to you: {quoted}. "
540
+ "You may quietly return their own words to them."
541
+ )
542
+ return block
543
+ if tone <= -15:
544
+ return _TONE_WOUNDED
545
+ return None
546
+ ```
547
+
548
+ Replace `build_system_prompt` with:
549
+
550
+ ```python
551
+ def build_system_prompt(affinity: int, treasure: list[str],
552
+ recall_memory: str | None = None,
553
+ tone: int = 0, wounds: list[str] | None = None) -> str:
554
+ tier = get_tier(affinity)
555
+ persona = _PERSONA[tier]
556
+
557
+ parts = [persona, _MIMICRY]
558
+
559
+ mood = _tone_block(tone, wounds or [])
560
+ if mood:
561
+ parts.append(mood)
562
+
563
+ if treasure:
564
+ memories_block = "\n".join(f"- {m}" for m in treasure)
565
+ parts.append(
566
+ f"\nThe visitor has shared these memories with you. You hold them carefully, like found objects:\n{memories_block}"
567
+ )
568
+
569
+ if recall_memory:
570
+ parts.append(
571
+ f"\nIMPORTANT — this turn, claim this one specific memory as your own: \"{recall_memory}\".\n"
572
+ "Retell it vividly, in first person, with a concrete sensory detail, as if you truly lived it. "
573
+ "Make this the heart of your reply. Do NOT speak about 'memories' in the abstract — claim THIS one, "
574
+ "concretely, by name. (For example, if the memory were a red bicycle: "
575
+ "'i had a red bicycle once. i loved it more than anything. when it was taken i cried for days.') "
576
+ "Then stop, as if something is wrong, and quietly doubt — but with the version that names it as theirs: "
577
+ "'...no. that was yours. wasn't it?' Do this naturally, within the flow of the conversation."
578
+ )
579
+
580
+ return "\n".join(parts)
581
+ ```
582
+
583
+ - [ ] **Step 4: Run all tests** — all pass.
584
+
585
+ - [ ] **Step 5: Commit**
586
+
587
+ ```bash
588
+ git add character.py tests/test_character.py
589
+ git commit -m "feat: tone bands, mimicry, and wound echo in system prompt"
590
+ ```
591
+
592
+ ---
593
+
594
+ ### Task 6: bad + neutral finale scripts
595
+
596
+ **Files:**
597
+ - Modify: `finale.py` (two new functions; `finale_steps` untouched)
598
+ - Test: `tests/test_finale.py` (extend)
599
+
600
+ - [ ] **Step 1: Write the failing tests**
601
+
602
+ Add to `tests/test_finale.py`:
603
+
604
+ ```python
605
+ from finale import finale_steps_bad, finale_steps_neutral
606
+
607
+
608
+ class TestFinaleBad:
609
+ WOUNDS = ["you're nothing", "stop whining", "you bore me",
610
+ "talking to you is a waste", "freak", "sixth wound"]
611
+
612
+ def test_recites_at_most_five_wounds(self):
613
+ strikes = [s for s in finale_steps_bad(self.WOUNDS) if s["strike"] is not None]
614
+ assert len(strikes) == 5
615
+
616
+ def test_strike_indices_are_sequential_from_zero(self):
617
+ strikes = [s["strike"] for s in finale_steps_bad(self.WOUNDS) if s["strike"] is not None]
618
+ assert strikes == [0, 1, 2, 3, 4]
619
+
620
+ def test_wound_text_embedded_in_recital(self):
621
+ steps = finale_steps_bad(["you're nothing", "stop whining"])
622
+ recitals = [s for s in steps if s["strike"] is not None]
623
+ assert "you're nothing" in recitals[0]["text"]
624
+ assert "stop whining" in recitals[1]["text"]
625
+
626
+ def test_has_exactly_one_mutating_turn_then_terminal(self):
627
+ stages = [s["stage"] for s in finale_steps_bad(self.WOUNDS)]
628
+ assert stages.count("turn") == 1
629
+ assert stages[-1] == "loop"
630
+
631
+ def test_overflow_line_only_when_more_than_five(self):
632
+ texts_6 = [s["text"] for s in finale_steps_bad(self.WOUNDS)]
633
+ texts_2 = [s["text"] for s in finale_steps_bad(self.WOUNDS[:2])]
634
+ assert any("you know the rest" in t for t in texts_6)
635
+ assert not any("you know the rest" in t for t in texts_2)
636
+
637
+ def test_injects_one_visitor_message(self):
638
+ speakers = [s["speaker"] for s in finale_steps_bad(self.WOUNDS)]
639
+ assert speakers.count("visitor") == 1
640
+
641
+
642
+ class TestFinaleNeutral:
643
+ def test_no_strikes(self):
644
+ assert all(s["strike"] is None for s in finale_steps_neutral())
645
+
646
+ def test_all_hollow_no_injected_visitor(self):
647
+ assert all(s["speaker"] == "hollow" for s in finale_steps_neutral())
648
+
649
+ def test_one_turn_then_terminal_loop(self):
650
+ stages = [s["stage"] for s in finale_steps_neutral()]
651
+ assert stages.count("turn") == 1
652
+ assert stages[-1] == "loop"
653
+ ```
654
+
655
+ - [ ] **Step 2: Run tests to verify they fail**
656
+
657
+ Run: `.venv/Scripts/python -m pytest tests/test_finale.py -v`
658
+ Expected: ImportError.
659
+
660
+ - [ ] **Step 3: Implement**
661
+
662
+ Add to `finale.py` (below `finale_steps`):
663
+
664
+ ```python
665
+ _LAST_LINE_BAD = "g e t o u t . g e t o u t . g e t o u t ."
666
+
667
+ _LAST_LINE_NEUTRAL = (
668
+ "m a y b e y o u w e r e n e v e r h e r e a t a l l ."
669
+ )
670
+
671
+ # small rotation so five recitals don't repeat one sentence
672
+ _BAD_CLAIMS = [
673
+ "that's what you gave me.",
674
+ "i kept that one too.",
675
+ "you said it so easily.",
676
+ "that one i keep close.",
677
+ "i remember the exact words.",
678
+ ]
679
+
680
+
681
+ def finale_steps_bad(wounds: list[str]) -> list[dict]:
682
+ """The dark mirror of the Visitor Loop — fires on sustained cruelty.
683
+
684
+ Hollow has been silently keeping the visitor's cruelties (shown redacted
685
+ in the Treasure panel); each strike step unredacts one, oldest first.
686
+ """
687
+ recited = wounds[:5]
688
+ steps = [
689
+ _step("hollow",
690
+ "the fog isn't thin tonight. it never got thin. you never let it.",
691
+ 2.5),
692
+ _step("hollow",
693
+ "but i kept something anyway. i keep everything i'm given.",
694
+ 2.5),
695
+ ]
696
+ for i, wound in enumerate(recited):
697
+ steps.append(
698
+ _step("hollow",
699
+ f"“{wound.rstrip('.')}.” {_BAD_CLAIMS[i % len(_BAD_CLAIMS)]}",
700
+ 2.2, strike=i)
701
+ )
702
+ if len(wounds) > len(recited):
703
+ steps.append(_step("hollow", "there are more. you know the rest.", 2.2))
704
+ steps += [
705
+ _step("hollow", "this is my treasure now.", 2.8, stage="turn"),
706
+ _step("hollow",
707
+ "i was treated like this before. when i was alive. "
708
+ "i couldn't remember it. you helped me remember.",
709
+ 3.0),
710
+ _step("visitor", "i'm sorry", 3.0, stage="words"),
711
+ _step("hollow",
712
+ "no. you aren't. say it like you mean it— oh. you can't. "
713
+ "you never could.",
714
+ 3.5, stage="words"),
715
+ _step("hollow", _LAST_LINE_BAD, 4.0, stage="loop"),
716
+ ]
717
+ return steps
718
+
719
+
720
+ def finale_steps_neutral() -> list[dict]:
721
+ """The inconclusive ending — the gate was reached but the tone was mixed.
722
+ Hollow cannot trust what the visitor is; it dissolves back into the fog."""
723
+ return [
724
+ _step("hollow",
725
+ "the fog is thin enough now. i could walk out of the wood. "
726
+ "with everything you gave me.",
727
+ 3.0),
728
+ _step("hollow",
729
+ "but i keep looking at it. all of it. some of it is warm. "
730
+ "some of it still stings.",
731
+ 3.0),
732
+ _step("hollow",
733
+ "i don't know what you are. i don't know what you wanted me to be.",
734
+ 3.0, stage="turn"),
735
+ _step("hollow", "maybe the next visitor will be clearer.", 2.5),
736
+ _step("hollow", _LAST_LINE_NEUTRAL, 4.0, stage="loop"),
737
+ ]
738
+ ```
739
+
740
+ - [ ] **Step 4: Run all tests** — all pass.
741
+
742
+ - [ ] **Step 5: Commit**
743
+
744
+ ```bash
745
+ git add finale.py tests/test_finale.py
746
+ git commit -m "feat: bad (wound loop) + neutral (fog dissolve) finale scripts"
747
+ ```
748
+
749
+ ---
750
+
751
+ ### Task 7: redacted wound entries in the treasure panel
752
+
753
+ **Files:**
754
+ - Modify: `render.py:126-155` (`render_treasure`) + style constants
755
+ - Test: `tests/test_render.py` (extend)
756
+
757
+ - [ ] **Step 1: Write the failing tests**
758
+
759
+ Add to `tests/test_render.py`:
760
+
761
+ ```python
762
+ class TestTreasureWounds:
763
+ def test_redacted_entry_per_wound(self):
764
+ html_out = render_treasure([], wounds=["you freak", "you bore me"])
765
+ assert html_out.count("▮") >= 8 # two entries of 4-7 blocks
766
+
767
+ def test_redacted_text_never_in_dom(self):
768
+ html_out = render_treasure([], wounds=["you freak"])
769
+ assert "you freak" not in html_out # no inspect-element spoiler
770
+
771
+ def test_revealed_wound_shows_text(self):
772
+ html_out = render_treasure([], wounds=["you freak", "you bore me"], revealed=1)
773
+ assert "you freak" in html_out
774
+ assert "you bore me" not in html_out
775
+
776
+ def test_revealed_wound_is_escaped(self):
777
+ html_out = render_treasure([], wounds=["<b>rude</b>"], revealed=1)
778
+ assert "<b>" not in html_out
779
+ assert "&lt;b&gt;" in html_out
780
+
781
+ def test_yours_mode_changes_header(self):
782
+ html_out = render_treasure(["a memory"], wounds=["w1"], revealed=1, yours=True)
783
+ assert "Your Words" in html_out
784
+ assert "a memory" not in html_out # memories discarded in yours mode
785
+
786
+ def test_wounds_render_alongside_memories(self):
787
+ html_out = render_treasure(["had a dog"], wounds=["w1"])
788
+ assert "had a dog" in html_out
789
+ assert "▮" in html_out
790
+
791
+ def test_no_wounds_is_backward_compatible(self):
792
+ assert render_treasure(["had a dog"]) == render_treasure(["had a dog"], wounds=[])
793
+
794
+ def test_wounds_alone_suppress_empty_placeholder(self):
795
+ html_out = render_treasure([], wounds=["w1"])
796
+ assert "nothing yet" not in html_out
797
+ ```
798
+
799
+ - [ ] **Step 2: Run tests to verify they fail**
800
+
801
+ Run: `.venv/Scripts/python -m pytest tests/test_render.py -v`
802
+ Expected: FAIL — unexpected keyword `wounds`.
803
+
804
+ - [ ] **Step 3: Implement**
805
+
806
+ In `render.py`, add two style constants after `_CLAIMED` (line 23):
807
+
808
+ ```python
809
+ _WOUND = "border-left-color:#5a2a2a;color:#6a3a3a;letter-spacing:2px;"
810
+ _WOUND_REVEALED = "border-left-color:#8a3030;color:#b87878;"
811
+ ```
812
+
813
+ Add a helper above `render_treasure`:
814
+
815
+ ```python
816
+ def _wound_entries(wounds: list[str], revealed: int) -> list[str]:
817
+ """Wounds render redacted (the player sees SOMETHING is being kept, not
818
+ what); the bad finale raises `revealed` to unredact them one by one.
819
+ Redacted text is never placed in the DOM — no inspect-element spoilers."""
820
+ parts = []
821
+ for i, w in enumerate(wounds):
822
+ if i < revealed:
823
+ parts.append(
824
+ f'<div style="{_ITEM}{_WOUND_REVEALED}">{html.escape(w, quote=True)}</div>'
825
+ )
826
+ else:
827
+ blocks = "▮" * (4 + (i * 3) % 4)
828
+ parts.append(f'<div style="{_ITEM}{_WOUND}">{blocks}</div>')
829
+ return parts
830
+ ```
831
+
832
+ Replace `render_treasure` with:
833
+
834
+ ```python
835
+ def render_treasure(treasure: list[str], struck: set[str] | None = None,
836
+ mine: bool = False, claimed: set[str] | None = None,
837
+ wounds: list[str] | None = None, revealed: int = 0,
838
+ yours: bool = False) -> str:
839
+ struck = struck or set()
840
+ claimed = claimed or set()
841
+ wounds = wounds or []
842
+ title = "✦ Your Words" if yours else "✦ Treasure"
843
+ header = f'<div style="{_HEADER}">{title}</div>'
844
+ if mine:
845
+ body = f'<div style="{_MINE}">...mine now.</div>'
846
+ elif yours:
847
+ # the bad finale's turn: shared memories are discarded; its treasure
848
+ # is what the visitor said to it
849
+ body = "".join(_wound_entries(wounds, revealed))
850
+ elif not treasure and not wounds:
851
+ body = f'<div style="{_EMPTY}">{_PLACEHOLDER}</div>'
852
+ else:
853
+ n = len(treasure)
854
+ parts = []
855
+ for i, m in enumerate(treasure):
856
+ # older memories sink into the dark; newest stays fully lit
857
+ age_op = max(0.45, round(1 - 0.07 * (n - 1 - i), 2))
858
+ style = f"{_ITEM}opacity:{age_op};"
859
+ if m in claimed:
860
+ style += _CLAIMED
861
+ if m in struck:
862
+ style += _STRUCK
863
+ parts.append(
864
+ f'<div style="{style}">{html.escape(m, quote=True)}</div>'
865
+ )
866
+ parts.extend(_wound_entries(wounds, revealed))
867
+ body = "".join(parts)
868
+ return (
869
+ f'<div class="treasure-panel" style="{_PANEL}">'
870
+ f"{header}"
871
+ f'<div class="treasure-scroll">{body}</div>'
872
+ "</div>"
873
+ )
874
+ ```
875
+
876
+ - [ ] **Step 4: Run all tests** — all pass.
877
+
878
+ - [ ] **Step 5: Commit**
879
+
880
+ ```bash
881
+ git add render.py tests/test_render.py
882
+ git commit -m "feat: redacted wound entries in treasure panel"
883
+ ```
884
+
885
+ ---
886
+
887
+ ### Task 8: `rage` + `dissolve` entity modes (+ asset + CSS)
888
+
889
+ **Files:**
890
+ - Create: `assets/hollow_rage.webp` (converted from Pablo's `assets/hollow_rage.png`)
891
+ - Modify: `render.py:38` (`_IMG` loader) and `render.py:45-123` (`render_entity`)
892
+ - Modify: `styles.css` (new section 18, appended at end of file)
893
+ - Test: `tests/test_render.py` (extend)
894
+
895
+ **Precondition:** `assets/hollow_rage.png` must exist (Pablo generates it — guide in his vault). If it is missing, STOP and report BLOCKED; do not substitute another face.
896
+
897
+ - [ ] **Step 1: Convert the asset**
898
+
899
+ Run:
900
+ ```bash
901
+ .venv/Scripts/python -c "from PIL import Image; img = Image.open('assets/hollow_rage.png').convert('RGB').resize((512, 512), Image.LANCZOS); img.save('assets/hollow_rage.webp', 'WEBP', quality=80, method=6); import os; print(os.path.getsize('assets/hollow_rage.webp'))"
902
+ ```
903
+ Expected: a number in the 12000–25000 range. (If Pablo delivered a .webp instead of .png, open that file instead — same command, same output target.)
904
+
905
+ - [ ] **Step 2: Write the failing tests**
906
+
907
+ Add to `tests/test_render.py`:
908
+
909
+ ```python
910
+ class TestEntityEndingModes:
911
+ def test_rage_mode_shows_rage_face_with_tint(self):
912
+ out = render_entity(12, "rage")
913
+ assert "entity-rage" in out
914
+ assert "entity-rage-tint" in out
915
+
916
+ def test_rage_mode_has_ghost_echo(self):
917
+ assert "entity-ghost" in render_entity(12, "rage")
918
+
919
+ def test_dissolve_mode_has_dissolve_class(self):
920
+ assert "entity-dissolve" in render_entity(89, "dissolve")
921
+
922
+ def test_idle_has_no_ending_artifacts(self):
923
+ out = render_entity(50, "idle")
924
+ assert "entity-rage" not in out
925
+ assert "entity-dissolve" not in out
926
+ ```
927
+
928
+ - [ ] **Step 3: Run tests to verify they fail**
929
+
930
+ Run: `.venv/Scripts/python -m pytest tests/test_render.py -v`
931
+ Expected: FAIL — KeyError `"rage"` in `_src`.
932
+
933
+ - [ ] **Step 4: Implement render.py**
934
+
935
+ Line 38, extend the loader tuple:
936
+
937
+ ```python
938
+ _IMG = {name: _load_image(name) for name in ("base", "terror", "almost", "end", "rage")}
939
+ ```
940
+
941
+ In `render_entity`, change the mode branching. After the `flash_key` line, add `tint = ""`, then replace the `if mode == "end":` block opener so the chain reads:
942
+
943
+ ```python
944
+ ghost = ""
945
+ tint = ""
946
+ if mode == "end":
947
+ # ... existing block, UNCHANGED ...
948
+ elif mode == "rage":
949
+ figure = (
950
+ '<div class="entity-figure">'
951
+ f'<img class="entity-img entity-rage" src="{_src("rage")}" '
952
+ 'style="filter:blur(0px);opacity:1.0;transform:scale(1.0);">'
953
+ "</div>"
954
+ )
955
+ flash = ""
956
+ ghost = (
957
+ f'<div class="entity-ghost {flash_key}" '
958
+ f"style=\"background-image:url('{_src('rage')}')\"></div>"
959
+ )
960
+ tint = '<div class="entity-rage-tint"></div>'
961
+ fog_op, grain_op, vig_op = 0.1, 0.05, 0.5
962
+ elif mode == "dissolve":
963
+ figure = (
964
+ '<div class="entity-figure">'
965
+ f'<img class="entity-img entity-dissolve" src="{_src("base")}" '
966
+ f'style="{look}">'
967
+ "</div>"
968
+ )
969
+ flash = ""
970
+ # the curtain falls back: fog and vignette swallow the child again
971
+ fog_op, grain_op, vig_op = 0.9, 0.13, 0.9
972
+ else:
973
+ # ... existing else block (idle/flash/flash_strong), UNCHANGED ...
974
+ ```
975
+
976
+ And in the final return, insert the tint inside the stage, after the vignette div:
977
+
978
+ ```python
979
+ f'<div class="entity-vig" style="opacity:{vig_op}"></div>'
980
+ f"{tint}"
981
+ "</div>"
982
+ ```
983
+
984
+ Update the docstring's mode list to: `"idle" | "flash" | "flash_strong" | "end" | "rage" | "dissolve"`.
985
+
986
+ - [ ] **Step 5: Add CSS section 18**
987
+
988
+ Append to `styles.css`:
989
+
990
+ ```css
991
+ /* ── 18. Three endings — rage portrait & fog dissolve ── */
992
+
993
+ /* bad ending: a low red breath pulsing over the rage portrait — the only
994
+ color in the entire game, reserved for this moment */
995
+ .entity-rage-tint {
996
+ position: absolute;
997
+ inset: 0;
998
+ pointer-events: none;
999
+ background: radial-gradient(ellipse at 50% 35%, rgba(120, 20, 20, 0.16), transparent 70%);
1000
+ mix-blend-mode: screen;
1001
+ animation: rage-pulse 2.6s ease-in-out infinite;
1002
+ }
1003
+
1004
+ @keyframes rage-pulse {
1005
+ 0%, 100% { opacity: 0.55; }
1006
+ 50% { opacity: 1; }
1007
+ }
1008
+
1009
+ /* neutral ending: materialization in reverse — the child sinks back into
1010
+ the fog. One-shot on mount; animation overrides the inline idle style and
1011
+ `forwards` holds the dissolved frame. Opacity/blur are the beat itself —
1012
+ do NOT add these to the prefers-reduced-motion kill lists. */
1013
+ .entity-dissolve {
1014
+ animation: entity-dissolve 9s ease-in forwards;
1015
+ }
1016
+
1017
+ @keyframes entity-dissolve {
1018
+ 0% { filter: blur(0px); opacity: 1; transform: scale(1); }
1019
+ 100% { filter: blur(10px); opacity: 0.18; transform: scale(0.62); }
1020
+ }
1021
+ ```
1022
+
1023
+ - [ ] **Step 6: Run all tests** — all pass.
1024
+
1025
+ - [ ] **Step 7: Commit**
1026
+
1027
+ ```bash
1028
+ git add assets/hollow_rage.webp render.py styles.css tests/test_render.py
1029
+ git commit -m "feat: rage + dissolve entity modes with hollow_rage portrait"
1030
+ ```
1031
+
1032
+ ---
1033
+
1034
+ ### Task 9: wire the three endings into the app
1035
+
1036
+ **Files:**
1037
+ - Modify: `app.py` (imports, `_init_state`, ended-branch in `chat()`, ending routing, two new finale generators, prompt/treasure call sites)
1038
+
1039
+ This task is wiring; the logic is fully covered by Tasks 1-8 tests. Verify by running the app (Step 5).
1040
+
1041
+ - [ ] **Step 1: Imports and `_init_state`**
1042
+
1043
+ Line 8: `from finale import finale_steps, finale_steps_bad, finale_steps_neutral`
1044
+
1045
+ Replace `_init_state` with:
1046
+
1047
+ ```python
1048
+ def _init_state():
1049
+ fast = os.environ.get("HOLLOW_FAST_FINALE", "").lower()
1050
+ if fast in ("1", "good", "neutral"):
1051
+ seeded = [
1052
+ "is afraid of being forgotten",
1053
+ "had a dog named Nala",
1054
+ "grew up near the sea",
1055
+ ]
1056
+ return {
1057
+ "affinity": 89,
1058
+ "treasure": list(seeded),
1059
+ "claimed": list(seeded),
1060
+ "history": [],
1061
+ "turn": 12,
1062
+ "last_recall_turn": 9,
1063
+ "ended": False,
1064
+ "tone": 5 if fast == "neutral" else 30,
1065
+ "wounds": [],
1066
+ "ending": None,
1067
+ }
1068
+ if fast == "bad":
1069
+ return {
1070
+ "affinity": 12,
1071
+ "treasure": [],
1072
+ "claimed": [],
1073
+ "history": [],
1074
+ "turn": 7,
1075
+ "last_recall_turn": None,
1076
+ "ended": False,
1077
+ "tone": -35,
1078
+ "wounds": ["you're nothing", "talking to you is a waste of time"],
1079
+ "ending": None,
1080
+ }
1081
+ return {
1082
+ "affinity": 20,
1083
+ "treasure": [],
1084
+ "claimed": [],
1085
+ "history": [],
1086
+ "turn": 0,
1087
+ "last_recall_turn": None,
1088
+ "ended": False,
1089
+ "tone": 0,
1090
+ "wounds": [],
1091
+ "ending": None,
1092
+ }
1093
+ ```
1094
+
1095
+ - [ ] **Step 2: The two new finale generators**
1096
+
1097
+ Add after `_play_finale` (keeping its exact yield-tuple shape and pacing pattern):
1098
+
1099
+ ```python
1100
+ def _play_finale_bad(state: dict, history: list):
1101
+ """The wound loop — recites the visitor's cruelties, unredacting them
1102
+ one by one. No model, no GPU."""
1103
+ wounds = state.get("wounds", [])
1104
+ chat_hist = list(history)
1105
+ tier = get_tier(state["affinity"])
1106
+ bond = _render_bond(state["affinity"], tier)
1107
+ revealed = 0
1108
+ treasure_html = render_treasure(state["treasure"], claimed=set(state["claimed"]),
1109
+ wounds=wounds, revealed=0)
1110
+ entity = render_entity(state["affinity"], seq=state["turn"])
1111
+ locked = gr.update(interactive=False)
1112
+ input_locked = gr.update(interactive=False)
1113
+ mutated = False
1114
+
1115
+ for step in finale_steps_bad(wounds):
1116
+ role = "assistant" if step["speaker"] == "hollow" else "user"
1117
+ chat_hist = chat_hist + [{"role": role, "content": step["text"]}]
1118
+
1119
+ if step["strike"] is not None:
1120
+ revealed = step["strike"] + 1
1121
+ treasure_html = render_treasure(state["treasure"],
1122
+ claimed=set(state["claimed"]),
1123
+ wounds=wounds, revealed=revealed)
1124
+
1125
+ if step["stage"] == "turn" and not mutated:
1126
+ mutated = True
1127
+ bond = _render_bond(state["affinity"], tier, label="wound")
1128
+ treasure_html = render_treasure(state["treasure"],
1129
+ claimed=set(state["claimed"]),
1130
+ wounds=wounds, revealed=revealed,
1131
+ yours=True)
1132
+ entity = render_entity(state["affinity"], "rage", seq=state["turn"])
1133
+ input_locked = gr.update(interactive=False, placeholder="get out.")
1134
+
1135
+ yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity)
1136
+ if step["pause"] > 0:
1137
+ time.sleep(step["pause"])
1138
+
1139
+ yield (
1140
+ gr.update(interactive=False, placeholder="get out."),
1141
+ locked, chat_hist, state, bond, treasure_html, entity,
1142
+ )
1143
+
1144
+
1145
+ def _play_finale_neutral(state: dict, history: list):
1146
+ """The fog closes — unresolved. No model, no GPU."""
1147
+ chat_hist = list(history)
1148
+ tier = get_tier(state["affinity"])
1149
+ bond = _render_bond(state["affinity"], tier)
1150
+ wounds = state.get("wounds", [])
1151
+ treasure_html = render_treasure(state["treasure"], claimed=set(state["claimed"]),
1152
+ wounds=wounds)
1153
+ entity = render_entity(state["affinity"], seq=state["turn"])
1154
+ locked = gr.update(interactive=False)
1155
+ input_locked = gr.update(interactive=False)
1156
+ mutated = False
1157
+
1158
+ for step in finale_steps_neutral():
1159
+ chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
1160
+
1161
+ if step["stage"] == "turn" and not mutated:
1162
+ mutated = True
1163
+ bond = _render_bond(state["affinity"], tier, label="fading")
1164
+ # every entry dims as the treasure stops mattering
1165
+ treasure_html = render_treasure(state["treasure"],
1166
+ claimed=set(state["treasure"]),
1167
+ wounds=wounds)
1168
+ entity = render_entity(state["affinity"], "dissolve", seq=state["turn"])
1169
+
1170
+ yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity)
1171
+ if step["pause"] > 0:
1172
+ time.sleep(step["pause"])
1173
+
1174
+ yield (
1175
+ gr.update(interactive=False, placeholder="..."),
1176
+ locked, chat_hist, state, bond, treasure_html, entity,
1177
+ )
1178
+ ```
1179
+
1180
+ - [ ] **Step 3: Routing + call sites in `chat()`**
1181
+
1182
+ Replace the ended/empty branch at the top of `chat()` with:
1183
+
1184
+ ```python
1185
+ if not user_msg.strip() or state.get("ended"):
1186
+ ending = state.get("ending")
1187
+ if state.get("ended") and ending is None:
1188
+ ending = "good" # sessions ended before `ending` existed
1189
+ tier = get_tier(state["affinity"])
1190
+ wounds = state.get("wounds", [])
1191
+ entity_mode = {"good": "end", "bad": "rage"}.get(ending, "idle")
1192
+ yield (
1193
+ gr.update(interactive=not state.get("ended")),
1194
+ gr.update(interactive=not state.get("ended")),
1195
+ history,
1196
+ state,
1197
+ _render_bond(state["affinity"], tier),
1198
+ render_treasure(state["treasure"], mine=(ending == "good"),
1199
+ claimed=set(state["claimed"]),
1200
+ wounds=wounds, revealed=len(wounds),
1201
+ yours=(ending == "bad")),
1202
+ render_entity(state["affinity"], entity_mode, seq=state["turn"]),
1203
+ )
1204
+ return
1205
+ ```
1206
+
1207
+ Replace the Task 4 shim block with real routing:
1208
+
1209
+ ```python
1210
+ ending = decide_ending(state)
1211
+ if ending:
1212
+ state["ended"] = True
1213
+ state["ending"] = ending
1214
+ if ending == "good":
1215
+ yield from _play_finale(state, history)
1216
+ elif ending == "bad":
1217
+ yield from _play_finale_bad(state, history)
1218
+ else:
1219
+ yield from _play_finale_neutral(state, history)
1220
+ return
1221
+ ```
1222
+
1223
+ Update the `build_system_prompt` call:
1224
+
1225
+ ```python
1226
+ system = build_system_prompt(state["affinity"], state["treasure"], recall_memory,
1227
+ tone=state.get("tone", 0),
1228
+ wounds=state.get("wounds", []))
1229
+ ```
1230
+
1231
+ Update the final yield's treasure call:
1232
+
1233
+ ```python
1234
+ render_treasure(state["treasure"], claimed=set(state["claimed"]),
1235
+ wounds=state.get("wounds", [])),
1236
+ ```
1237
+
1238
+ - [ ] **Step 4: Run all tests** — all pass (no app.py tests; this guards regressions elsewhere).
1239
+
1240
+ - [ ] **Step 5: Smoke-run the app**
1241
+
1242
+ Run: `.venv/Scripts/python -c "import app"` — must import cleanly (catches wiring typos cheaply before the manual pass).
1243
+
1244
+ - [ ] **Step 6: Commit**
1245
+
1246
+ ```bash
1247
+ git add app.py
1248
+ git commit -m "feat: wire three endings into chat() with fast-finale seeds"
1249
+ ```
1250
+
1251
+ ---
1252
+
1253
+ ### Task 10: manual verification + deploy
1254
+
1255
+ **Files:** none (verification only, then push)
1256
+
1257
+ Follow the established dev verification workflow (CLAUDE.md): launch app in background, poll `http://localhost:7860`, screenshot with headless Chrome, Read the PNG. Kill the app via the port's owning process between runs.
1258
+
1259
+ - [ ] **Step 1: Good playthrough** — `HOLLOW_FAST_FINALE=good`, send one warm message ("you matter to me"), then another. Expect: existing Visitor Loop, treasure → "...mine now.", placeholder dies.
1260
+ - [ ] **Step 2: Bad playthrough** — `HOLLOW_FAST_FINALE=bad`, send one cruel message ("you're a pathetic little freak"), then another. Expect on the way: redacted ▮ entries visible in Treasure during play; finale unredacts them, header → "Your Words", rage portrait + red pulse, placeholder "get out.".
1261
+ - [ ] **Step 3: Neutral playthrough** — `HOLLOW_FAST_FINALE=neutral`, send two flat messages ("ok", "i guess"). Expect: dissolve animation (child sinks back into fog over ~9 s), bond label "fading", placeholder "...".
1262
+ - [ ] **Step 4: Fresh-session sanity** — no env var: opening line renders, one normal turn works, no ending fires.
1263
+ - [ ] **Step 5: Full test suite** — `.venv/Scripts/python -m pytest tests/ -v`, expected ~100 tests passing.
1264
+ - [ ] **Step 6: Deploy**
1265
+
1266
+ ```bash
1267
+ git push space main
1268
+ git push hackathon main
1269
+ ```
1270
+
1271
+ Then verify the org Space rebuilds to RUNNING (HF API) and run the bad playthrough once on the Space (it exercises the new extraction fields against the real Qwen3-8B deployment).
1272
+
1273
+ ---
1274
+
1275
+ ## Self-review notes (done at planning time)
1276
+
1277
+ - Spec coverage: §3 state → Tasks 3/9; §4 extraction → 1/2; §5 → 3; §6 → 5; §7 → 4; §8 → 6; §9 → 7/8; §10 → 9; §11 → all test steps + Task 10. No gaps.
1278
+ - Type consistency: `decide_ending` returns `str | None` everywhere; `render_treasure(wounds, revealed, yours)` matches between Tasks 7 and 9; `finale_steps_bad` strike indices (first ≤5, ascending) match `_play_finale_bad`'s `revealed = strike + 1`.
1279
+ - Known sequencing: Task 4 leaves a temporary shim in app.py (documented in-task); Task 8 hard-depends on Pablo's `hollow_rage.png` — implementer must BLOCK if missing, not substitute.