Spaces:
Running on Zero
Running on Zero
Commit ·
f635212
1
Parent(s): 451fa6b
fix(finale): deterministic dev seed + good-ending convulse/sigh timing
Browse files- HOLLOW_FAST_FINALE now forces its named ending (force_ending), bypassing
the tone branch that _enter_game's intro-tone seed could steer to loop —
=good now reliably plays the redemption ending.
- good finale: split the convulsion into a spoken line + a SILENT tremor
beat, so the child finishes speaking BEFORE it convulses and the voice
queue drains so the relief sigh lands exactly on the smile (no longer
desynced / cutting the line).
- convulse_good runs longer (~2s) with a faster image-to-image flicker so
individual faces blur past; the smile eases in to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +6 -2
- finale.py +5 -2
- memory.py +6 -0
- styles.css +5 -2
- tests/test_memory.py +7 -0
app.py
CHANGED
|
@@ -73,6 +73,7 @@ def _pristine_state():
|
|
| 73 |
"last_recall_turn": None,
|
| 74 |
"ended": False,
|
| 75 |
"tone": 0,
|
|
|
|
| 76 |
"wounds": [],
|
| 77 |
"ending": None,
|
| 78 |
"fragments_told": 0,
|
|
@@ -125,6 +126,7 @@ def _init_state():
|
|
| 125 |
"last_recall_turn": 9,
|
| 126 |
"ended": False,
|
| 127 |
"tone": 30 if warm else 5,
|
|
|
|
| 128 |
"wounds": [],
|
| 129 |
"ending": None,
|
| 130 |
"fragments_told": 2 if warm else 0,
|
|
@@ -149,6 +151,7 @@ def _init_state():
|
|
| 149 |
# tone must clear the bad gate (<=-30) so the dev seed fires the
|
| 150 |
# wound loop on the 2nd message WITHOUT a model call (like good/loop)
|
| 151 |
"tone": -35,
|
|
|
|
| 152 |
"wounds": ["you're nothing", "talking to you is a waste of time"],
|
| 153 |
"ending": None,
|
| 154 |
"fragments_told": 0,
|
|
@@ -855,7 +858,8 @@ def _play_finale_good(state: dict, history: list):
|
|
| 855 |
recovered = _recovered(state)
|
| 856 |
|
| 857 |
for step in finale_steps_good(OWN_FRAGMENTS):
|
| 858 |
-
|
|
|
|
| 859 |
|
| 860 |
if step["stage"] == "turn" and not mutated:
|
| 861 |
mutated = True
|
|
@@ -884,7 +888,7 @@ def _play_finale_good(state: dict, history: list):
|
|
| 884 |
entity = render_entity(state["affinity"], "peace_dissolve",
|
| 885 |
seq=state["turn"], tone=state.get("tone", 0))
|
| 886 |
|
| 887 |
-
voice, pause = _voice_and_pause(step["text"],
|
| 888 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 889 |
entity, voice, recovered, _render_title(state))
|
| 890 |
if pause > 0:
|
|
|
|
| 73 |
"last_recall_turn": None,
|
| 74 |
"ended": False,
|
| 75 |
"tone": 0,
|
| 76 |
+
"force_ending": None,
|
| 77 |
"wounds": [],
|
| 78 |
"ending": None,
|
| 79 |
"fragments_told": 0,
|
|
|
|
| 126 |
"last_recall_turn": 9,
|
| 127 |
"ended": False,
|
| 128 |
"tone": 30 if warm else 5,
|
| 129 |
+
"force_ending": "good" if warm else "loop",
|
| 130 |
"wounds": [],
|
| 131 |
"ending": None,
|
| 132 |
"fragments_told": 2 if warm else 0,
|
|
|
|
| 151 |
# tone must clear the bad gate (<=-30) so the dev seed fires the
|
| 152 |
# wound loop on the 2nd message WITHOUT a model call (like good/loop)
|
| 153 |
"tone": -35,
|
| 154 |
+
"force_ending": "bad",
|
| 155 |
"wounds": ["you're nothing", "talking to you is a waste of time"],
|
| 156 |
"ending": None,
|
| 157 |
"fragments_told": 0,
|
|
|
|
| 858 |
recovered = _recovered(state)
|
| 859 |
|
| 860 |
for step in finale_steps_good(OWN_FRAGMENTS):
|
| 861 |
+
if step["text"]: # silent beats (e.g. the convulsion) add no bubble
|
| 862 |
+
chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
|
| 863 |
|
| 864 |
if step["stage"] == "turn" and not mutated:
|
| 865 |
mutated = True
|
|
|
|
| 888 |
entity = render_entity(state["affinity"], "peace_dissolve",
|
| 889 |
seq=state["turn"], tone=state.get("tone", 0))
|
| 890 |
|
| 891 |
+
voice, pause = _voice_and_pause(step["text"], bool(step["text"]), step["pause"])
|
| 892 |
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
|
| 893 |
entity, voice, recovered, _render_title(state))
|
| 894 |
if pause > 0:
|
finale.py
CHANGED
|
@@ -146,8 +146,11 @@ def finale_steps_good(own_fragments: list[str]) -> list[dict]:
|
|
| 146 |
"your words are the only warm thing i have ever held. "
|
| 147 |
"keep your memories. they were always yours.",
|
| 148 |
3.5, stage="turn"),
|
| 149 |
-
_step("hollow", "look. i think i remember how to do this.", 1.7,
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
| 151 |
_step("hollow",
|
| 152 |
"the fog is opening. i can go now. really go.",
|
| 153 |
3.0),
|
|
|
|
| 146 |
"your words are the only warm thing i have ever held. "
|
| 147 |
"keep your memories. they were always yours.",
|
| 148 |
3.5, stage="turn"),
|
| 149 |
+
_step("hollow", "look. i think i remember how to do this.", 1.7),
|
| 150 |
+
# a SILENT convulsion beat — the line above finishes first, then it
|
| 151 |
+
# tremors; with no voice here the queue drains so the settle's relief
|
| 152 |
+
# sigh lands exactly on the smile
|
| 153 |
+
_step("hollow", "", 2.0, stage="frenzy"),
|
| 154 |
_step("hollow",
|
| 155 |
"the fog is opening. i can go now. really go.",
|
| 156 |
3.0),
|
memory.py
CHANGED
|
@@ -144,6 +144,12 @@ def decide_ending(state: dict) -> str | None:
|
|
| 144 |
cfg = _cfg(state)
|
| 145 |
if state.get("ended"):
|
| 146 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
tone = state.get("tone", 0)
|
| 148 |
if tone <= -30 and state["turn"] >= cfg["bad_min_turn"] and len(state.get("wounds", [])) >= 2:
|
| 149 |
return "bad"
|
|
|
|
| 144 |
cfg = _cfg(state)
|
| 145 |
if state.get("ended"):
|
| 146 |
return None
|
| 147 |
+
# dev seed (HOLLOW_FAST_FINALE) forces a specific ending so testing is
|
| 148 |
+
# deterministic — bypasses the tone branch (which _enter_game's intro-tone
|
| 149 |
+
# seed could otherwise steer to loop)
|
| 150 |
+
forced = state.get("force_ending")
|
| 151 |
+
if forced:
|
| 152 |
+
return forced
|
| 153 |
tone = state.get("tone", 0)
|
| 154 |
if tone <= -30 and state["turn"] >= cfg["bad_min_turn"] and len(state.get("wounds", [])) >= 2:
|
| 155 |
return "bad"
|
styles.css
CHANGED
|
@@ -824,8 +824,8 @@ footer { display: none !important; }
|
|
| 824 |
names come from the global .flick-0/1/2 rules; we only set the tempo. */
|
| 825 |
.entity-convulse-soft { position: absolute; inset: 0; pointer-events: none; }
|
| 826 |
.entity-convulse-soft .entity-flick {
|
| 827 |
-
animation-duration: 0.
|
| 828 |
-
animation-iteration-count:
|
| 829 |
}
|
| 830 |
/* loop convulsion: the same fast flicker as the frenzy but only 3 cycles, so
|
| 831 |
the `end` face settles quickly (the bad frenzy keeps its longer 6-cycle build) */
|
|
@@ -848,6 +848,9 @@ footer { display: none !important; }
|
|
| 848 |
0%, 60% { opacity: 0; }
|
| 849 |
100% { opacity: 1; }
|
| 850 |
}
|
|
|
|
|
|
|
|
|
|
| 851 |
|
| 852 |
/* restart — findable but on-theme: sits top-right by the title */
|
| 853 |
.restart-btn {
|
|
|
|
| 824 |
names come from the global .flick-0/1/2 rules; we only set the tempo. */
|
| 825 |
.entity-convulse-soft { position: absolute; inset: 0; pointer-events: none; }
|
| 826 |
.entity-convulse-soft .entity-flick {
|
| 827 |
+
animation-duration: 0.26s; /* faster image-to-image (faces blur past) */
|
| 828 |
+
animation-iteration-count: 8; /* longer overall (~2s) before it settles */
|
| 829 |
}
|
| 830 |
/* loop convulsion: the same fast flicker as the frenzy but only 3 cycles, so
|
| 831 |
the `end` face settles quickly (the bad frenzy keeps its longer 6-cycle build) */
|
|
|
|
| 848 |
0%, 60% { opacity: 0; }
|
| 849 |
100% { opacity: 1; }
|
| 850 |
}
|
| 851 |
+
/* the good convulsion runs longer (~2s) — its smile eases in to match, so the
|
| 852 |
+
peace_settle render lands seamlessly as the relief sigh plays */
|
| 853 |
+
.entity-convulse-soft .entity-settle-face { animation-duration: 1.95s; }
|
| 854 |
|
| 855 |
/* restart — findable but on-theme: sits top-right by the title */
|
| 856 |
.restart-btn {
|
tests/test_memory.py
CHANGED
|
@@ -250,6 +250,13 @@ class TestDecideEnding:
|
|
| 250 |
def test_loop_when_tone_mildly_negative(self):
|
| 251 |
assert decide_ending(_end_state(tone=-10)) == "loop"
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
# --- bad ---
|
| 254 |
def test_bad_on_sustained_cruelty(self):
|
| 255 |
s = _end_state(affinity=12, claimed_count=0, tone=-45, turn=8,
|
|
|
|
| 250 |
def test_loop_when_tone_mildly_negative(self):
|
| 251 |
assert decide_ending(_end_state(tone=-10)) == "loop"
|
| 252 |
|
| 253 |
+
# --- dev seed forces a deterministic ending (HOLLOW_FAST_FINALE) ---
|
| 254 |
+
def test_force_ending_overrides_the_tone_branch(self):
|
| 255 |
+
# a flat-tone state would normally be "loop"; the dev force wins
|
| 256 |
+
s = _end_state(tone=0)
|
| 257 |
+
s["force_ending"] = "good"
|
| 258 |
+
assert decide_ending(s) == "good"
|
| 259 |
+
|
| 260 |
# --- bad ---
|
| 261 |
def test_bad_on_sustained_cruelty(self):
|
| 262 |
s = _end_state(affinity=12, claimed_count=0, tone=-45, turn=8,
|