Pabloler21 Claude Fable 5 commited on
Commit
45056f2
ยท
1 Parent(s): 7308a2e

docs: spec for tone axis + three endings (What You Made Of Me)

Browse files

Approved in session: good = existing Visitor Loop, bad = redacted
cruelty treasure, neutral = fog dissolve. Gemma 4 swap rejected.

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

docs/superpowers/specs/2026-06-11-tone-and-three-endings-design.md ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tone Axis & Three Endings โ€” Design Spec
2
+
3
+ **Date:** 2026-06-11 (approved by Pablo in session)
4
+ **Feature name:** "What You Made Of Me" โ€” a second state axis (how the visitor *treats* Hollow) that shifts Hollow's personality live and branches the game into three endings.
5
+ **Constraint:** one implementation day (Friday June 13), same model (Qwen3-8B / qwen3:8b), zero extra GPU calls per turn.
6
+
7
+ ---
8
+
9
+ ## 1. Concept
10
+
11
+ Hollow currently reacts to *how much* the visitor shares (affinity/Bond) but is deaf to *how* the visitor speaks to it. This feature adds a **tone axis**:
12
+
13
+ - Treat it warmly โ†’ it trusts, attaches โ€” and at high bond the existing **Visitor Loop** fires (the "good" ending: it loved you too much).
14
+ - Treat it cruelly โ†’ the abuse reminds it of its suffering in life; it withdraws, then turns resentful, and a new **bad ending** fires: it has been silently keeping your cruelties as its treasure.
15
+ - Treat it ambivalently โ†’ at high bond it cannot trust what you are; the **neutral ending** fires: it dissolves back into the fog, unresolved.
16
+
17
+ Plus two always-on "presence" mechanics: **mimicry** (Hollow subtly copies the visitor's writing style) and **wound echo** (at hostile tone, Hollow throws the visitor's own cruel words back at them mid-game, generated, not scripted).
18
+
19
+ ## 2. Decisions already made (do not reopen)
20
+
21
+ | Decision | Choice |
22
+ |---|---|
23
+ | Model swap (Gemma 4 12B) | **Rejected** โ€” doesn't fit local 8 GB VRAM, week-old tooling, re-tuning risk, no prize upside |
24
+ | Good ending | **Existing Visitor Loop, unchanged** (`finale_steps`) โ€” zero rework |
25
+ | Bad ending climax | **"Your cruelty is its treasure"** โ€” redacted entries fill the Treasure panel during cruel play; the finale unredacts them one by one |
26
+ | Neutral trigger | **Bond-high + mixed tone** (same trigger gate as good, branched by tone). No turn-cap. |
27
+ | New art | **One image: `hollow_rage`** (Pablo generates with FLUX, same grayscale F&H style). Neutral reuses `hollow_base` + CSS dissolve; good reuses `hollow_end`. |
28
+
29
+ ## 3. State shape (gr.State) โ€” additions
30
+
31
+ ```python
32
+ {
33
+ # ... existing fields unchanged ...
34
+ "tone": int, # -100..+100 accumulator, starts 0
35
+ "wounds": list[str], # visitor's cruel phrases, verbatim-ish, max 10
36
+ "ending": str | None, # "good" | "bad" | "neutral" once fired; None while playing
37
+ }
38
+ ```
39
+
40
+ All reads of the new fields use `.get(..., default)` (`tone`โ†’0, `wounds`โ†’[], `ending`โ†’None) so mid-session states from before the deploy don't crash. `ended: bool` stays (the UI kill-switch); `ending` records *which* finale fired.
41
+
42
+ ## 4. Extraction (engine.py + schemas.py) โ€” same single GPU call
43
+
44
+ `TurnUpdate` gains two fields:
45
+
46
+ ```python
47
+ class TurnUpdate(BaseModel):
48
+ affinity_delta: int = 0 # existing, clamp -8..+8
49
+ new_memories: List[str] = [] # existing
50
+ tone_delta: int = 0 # NEW, clamp -10..+10
51
+ cruel_quote: Optional[str] = None # NEW: visitor's cruel phrase, verbatim; None if none
52
+ ```
53
+
54
+ Validators: clamp `tone_delta` to [-10, +10]; `cruel_quote` is stripped, truncated to 120 chars, empty string โ†’ None.
55
+
56
+ `_EXTRACTION_SYSTEM` gains items 3 and 4 (scale mirrors the affinity one):
57
+
58
+ - `tone_delta`: how the visitor **treated** Hollow this turn โ€” warmth/kindness/comfort +4..+8; ordinary politeness +1..+3; neutral 0; dismissive/cold โˆ’2..โˆ’5; mocking/insulting/cruel โˆ’6..โˆ’10. (Distinct from affinity: sharing a sad memory politely is high affinity, ~0 tone; an insult is negative on both.)
59
+ - `cruel_quote`: if the visitor mocked or insulted Hollow this turn, the cruel phrase **exactly as the visitor wrote it** (their words, never Hollow's). `null` otherwise.
60
+
61
+ Example JSON in the prompt updated to include both fields. Parse-failure fallback (existing `TurnUpdate()`) naturally yields `tone_delta=0, cruel_quote=None` โ€” degrades safely.
62
+
63
+ ## 5. State update (memory.py โ€” apply_update)
64
+
65
+ After the existing affinity/treasure logic:
66
+
67
+ ```python
68
+ state["tone"] = max(-100, min(100, state.get("tone", 0) + update.tone_delta))
69
+ if update.cruel_quote and update.tone_delta < 0: # guard: no quote on positive turns
70
+ wounds = state.setdefault("wounds", [])
71
+ if update.cruel_quote not in wounds:
72
+ wounds.append(update.cruel_quote)
73
+ del wounds[:-10] # cap at 10, keep newest
74
+ ```
75
+
76
+ ## 6. Personality & presence (character.py)
77
+
78
+ `build_system_prompt(affinity, treasure, recall_memory=None, tone=0, wounds=None)`.
79
+
80
+ **Mimicry (always on)** โ€” appended to every prompt:
81
+ > "Subtly mirror the visitor's writing style โ€” their punctuation, capitalization, message length, their rhythm. If they write short and cold, you become short. Never mention that you do this."
82
+
83
+ **Tone bands** โ€” one block appended by accumulated `tone`:
84
+
85
+ | Band | Condition | Prompt effect |
86
+ |---|---|---|
87
+ | Warm | `tone >= 15` | "The visitor has been gentle with you. You trust them more than you should. You are becoming attached." |
88
+ | Neutral | `-15 < tone < 15` | (nothing appended) |
89
+ | Wounded | `-40 < tone <= -15` | "The visitor's words have hurt you. They remind you of how you were treated when you were alive. You withdraw: shorter answers, more guarded, you flinch at kindness now." |
90
+ | Hostile | `tone <= -40` | "The visitor is cruel, like the ones you remember from when you were alive. Something in you has curdled. You are resentful, cold, quietly menacing." **+ wound echo:** inject up to 2 most recent `wounds`: "They said these things to you: \"โ€ฆ\", \"โ€ฆ\". You may quietly return their own words to them." |
91
+
92
+ Tone bands stack *with* the affinity tier persona (tier picks the base voice; tone modulates it).
93
+
94
+ ## 7. Ending logic (memory.py โ€” decide_ending replaces should_end)
95
+
96
+ ```python
97
+ def decide_ending(state: dict) -> str | None:
98
+ if state.get("ended"):
99
+ return None
100
+ tone = state.get("tone", 0)
101
+ if tone <= -40 and state["turn"] >= 6 and len(state.get("wounds", [])) >= 2:
102
+ return "bad"
103
+ if state["affinity"] >= 90 and len(state["claimed"]) >= 3:
104
+ return "good" if tone >= 15 else "neutral"
105
+ return None
106
+ ```
107
+
108
+ - **Bad** needs sustained cruelty (accumulator), a minimum game length (โ‰ฅ6 turns so it can't fire on turn 2), and โ‰ฅ2 captured wounds (the finale needs material; if extraction missed quotes, the game simply continues).
109
+ - **Good** = the existing gate + clearly warm tone. A normally-kind playthrough accumulates well past +15 by bond 90.
110
+ - **Neutral** = same gate, tone below warm โ€” including genuinely mixed (hot-and-cold) play.
111
+ - Existing `should_end` is deleted; its tests migrate to `decide_ending`.
112
+
113
+ ## 8. The two new finales (finale.py โ€” pure data, same step shape)
114
+
115
+ Existing `finale_steps(claimed)` is untouched (good ending). Two new functions, same step dict shape (`speaker/text/pause/strike/stage`):
116
+
117
+ **`finale_steps_bad(wounds)`** โ€” beats (final lines written at implementation, in this voice):
118
+ 1. Hollow: the fog never thinned โ€” "you never let it." (no strike)
119
+ 2. For each of the last โ‰ค5 wounds, oldestโ†’newest, `strike=i`: Hollow recites the wound **in the visitor's own words**, then claims it: *"\"{wound}.\" that's what you gave me. i kept every one."* Each strike **unredacts** the corresponding Treasure entry.
120
+ 3. `stage="turn"` step: *"this is my treasure now."* โ€” UI mutates: Treasure header โ†’ "your words", bond label โ†’ "wound", portrait โ†’ `rage` mode, input placeholder โ†’ "get out."
121
+ 4. Hollow: it remembers now that it was treated like this when it was alive โ€” "you helped me remember."
122
+ 5. One injected **visitor** step: `"i'm sorry"` โ€” followed by Hollow: *"no. you aren't."* (the message-injection trick, reused from the Loop, now as accusation).
123
+ 6. Final spaced-out line (`stage="loop"`): `g e t o u t .` Input dies with placeholder "get out."
124
+
125
+ **`finale_steps_neutral()`** โ€” short (~6 steps), no strikes, no injected visitor messages:
126
+ 1. Hollow: the fog is thin enough to leave โ€” it could walk out with everything you gave it.
127
+ 2. Hollow: but some of what you gave is warm and some still stings; it doesn't know what you are.
128
+ 3. `stage="turn"` step: portrait starts **dissolve** (reverse materialization), Treasure entries fade, bond label โ†’ "fading".
129
+ 4. Hollow: "maybe the next visitor will be clearer."
130
+ 5. Final spaced-out line: `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 .` Input dies with placeholder "...".
131
+
132
+ ## 9. Rendering (render.py + styles.css section 18)
133
+
134
+ **Treasure โ€” redacted wounds (live play):** `render_treasure` gains `wounds: list[str] | None = None, revealed: int = 0`. Each wound renders as a redacted entry โ€” `โ–ฎ` blocks (4โ€“7 blocks, deterministic from index), styled distinct from memories (darker, dried-blood left border). The *text is never in the DOM* while redacted (no inspect-element spoiler). During play `revealed=0`; the bad finale raises `revealed` step by step โ€” revealed wounds show their real text, marked like `mine` entries.
135
+
136
+ **Entity โ€” two new modes for `render_entity`:**
137
+ - `"rage"`: portrait swaps to `hollow_rage.webp`, full sharp, with a permanent low red-tinged grain (CSS). Used from the bad finale's `turn` step onward.
138
+ - `"dissolve"`: reverse materialization โ€” a one-shot CSS animation ramping blur/opacity back toward the tier-0 smudge. Used from the neutral finale's `turn` step onward.
139
+
140
+ **Asset:** `assets/hollow_rage.webp` (Pablo, FLUX, same pipeline as the other four; target similar ~15โ€“20 KB WebP).
141
+
142
+ ## 10. Wiring (app.py)
143
+
144
+ - `chat()`: `ending = decide_ending(state)`; `"good"` โ†’ existing `_play_finale`; `"bad"` / `"neutral"` โ†’ new generator(s) following the exact `_play_finale` pattern (full output tuples, `time.sleep` pacing, no GPU). `state["ending"]` recorded; `state["ended"] = True`.
145
+ - `build_system_prompt(...)` call gains `tone=state.get("tone", 0), wounds=state.get("wounds", [])`.
146
+ - Every `render_treasure` call site passes `wounds=state.get("wounds", [])`.
147
+ - `_init_state` โ€” `HOLLOW_FAST_FINALE` accepts values:
148
+ - `1` or `good`: current seed + `tone: 30`
149
+ - `bad`: affinity 12, empty treasure, `tone: -35`, 3 seeded wounds, turn 7 (one cruel message from firing)
150
+ - `neutral`: current good seed but `tone: 5`
151
+
152
+ ## 11. Testing
153
+
154
+ - `decide_ending` matrix: all three endings, the None paths, the guards (turn <6, wounds <2, already ended, old state without tone).
155
+ - `apply_update`: tone clamp ยฑ100, wounds append/dedupe/cap-10, positive-turn quote ignored, parse-fallback unchanged.
156
+ - `schemas`: tone_delta clamp, cruel_quote strip/truncate/emptyโ†’None.
157
+ - `render_treasure`: redacted entries (count, no plaintext in HTML), reveal progression, escaping.
158
+ - `render_entity`: rage and dissolve modes.
159
+ - `finale_steps_bad`: โ‰ค5 strikes, indices valid, stages present, wound text embedded; `finale_steps_neutral`: no strikes, stages present.
160
+ - `build_system_prompt`: each tone band, mimicry always present, wound echo only when hostile.
161
+ - Manual: three full playthroughs via the three `HOLLOW_FAST_FINALE` seeds, verified against the running app (headless-Chrome workflow).
162
+
163
+ ## 12. Out of scope
164
+
165
+ Model swap, audio, turn-cap trigger, more than one new portrait, localization, changes to the good ending script or the opening line (the Loop's bookend โ€” fixed by design).