Spaces:
Running on Zero
Running on Zero
Commit ยท
45056f2
1
Parent(s): 7308a2e
docs: spec for tone axis + three endings (What You Made Of Me)
Browse filesApproved 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).
|