Pabloler21 Claude Opus 4.8 commited on
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>

Files changed (5) hide show
  1. app.py +6 -2
  2. finale.py +5 -2
  3. memory.py +6 -0
  4. styles.css +5 -2
  5. 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
- chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
 
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"], True, step["pause"])
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
- stage="frenzy"),
 
 
 
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.55s;
828
- animation-iteration-count: 3;
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,