Pabloler21 commited on
Commit
e05a544
·
1 Parent(s): 5154bf5

feat(child): it gropes toward a name as it recovers itself

Browse files
Files changed (7) hide show
  1. app.py +46 -13
  2. character.py +14 -1
  3. engine.py +5 -3
  4. memory.py +18 -0
  5. schemas.py +1 -0
  6. tests/test_app.py +43 -0
  7. tests/test_memory.py +49 -1
app.py CHANGED
@@ -8,6 +8,7 @@ os.environ.setdefault("OPENBLAS_NUM_THREADS", "4")
8
  os.environ.setdefault("MKL_NUM_THREADS", "4")
9
 
10
  import base64
 
11
  import io
12
  import json
13
  import re
@@ -48,6 +49,18 @@ def _render_bond(affinity: int, tier: str, label: str = "bond") -> str:
48
  )
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def _pristine_state():
52
  """A brand-new game — the real starting point, independent of the dev
53
  HOLLOW_FAST_FINALE seed (the restart button must always give a clean run)."""
@@ -69,13 +82,15 @@ def _pristine_state():
69
  "idle_count": 0,
70
  "greeted": False,
71
  "mode": "tester",
 
 
72
  }
73
 
74
 
75
  def _reset():
76
  """Wipe the board for a fresh game without a page reload. Order matches
77
  [msg_input, send_btn, chatbot, state, bond_panel, treasure_panel,
78
- entity_panel, voice_panel, recovered_panel]."""
79
  fresh = _pristine_state()
80
  return (
81
  gr.update(value="", interactive=True,
@@ -88,6 +103,7 @@ def _reset():
88
  render_entity(20),
89
  _voice_html(None),
90
  render_recovered(0),
 
91
  )
92
 
93
 
@@ -118,6 +134,8 @@ def _init_state():
118
  "idle_count": 0,
119
  "greeted": False,
120
  "mode": "tester",
 
 
121
  }
122
  if fast == "bad":
123
  return {
@@ -140,6 +158,8 @@ def _init_state():
140
  "idle_count": 0,
141
  "greeted": False,
142
  "mode": "tester",
 
 
143
  }
144
  return _pristine_state()
145
 
@@ -540,6 +560,7 @@ def _enter_game(mode: str, tone_seed: int = 0):
540
  wounds=state.get("wounds", [])),
541
  render_entity(state["affinity"], tone=state["tone"]),
542
  render_recovered(state.get("fragments_told", 0)),
 
543
  )
544
 
545
 
@@ -683,14 +704,14 @@ def _play_finale(state: dict, history: list):
683
  speak_it = step["speaker"] == "hollow"
684
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
685
  yield (input_locked, locked, chat_hist, state, bond, treasure_html,
686
- entity, voice, recovered)
687
  if pause > 0:
688
  time.sleep(pause)
689
 
690
  yield (
691
  gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER),
692
  locked, chat_hist, state, bond, treasure_html, entity,
693
- _voice_html(None), recovered,
694
  )
695
 
696
 
@@ -744,14 +765,14 @@ def _play_finale_bad(state: dict, history: list):
744
  speak_it = step["speaker"] == "hollow"
745
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
746
  yield (input_locked, locked, chat_hist, state, bond, treasure_html,
747
- entity, voice, recovered)
748
  if pause > 0:
749
  time.sleep(pause)
750
 
751
  yield (
752
  gr.update(interactive=False, placeholder="see you soon."),
753
  locked, chat_hist, state, bond, treasure_html, entity,
754
- _voice_html(None), recovered,
755
  )
756
 
757
 
@@ -805,14 +826,14 @@ def _play_finale_good(state: dict, history: list):
805
 
806
  voice, pause = _voice_and_pause(step["text"], True, step["pause"])
807
  yield (input_locked, locked, chat_hist, state, bond, treasure_html,
808
- entity, voice, recovered)
809
  if pause > 0:
810
  time.sleep(pause)
811
 
812
  yield (
813
  gr.update(interactive=False, placeholder="it's quiet now."),
814
  locked, chat_hist, state, bond, treasure_html, entity,
815
- _voice_html(None), recovered,
816
  )
817
 
818
 
@@ -844,6 +865,7 @@ def chat(user_msg: str, state: dict, history: list):
844
  tone=state.get("tone", 0)),
845
  _voice_html(None),
846
  render_recovered(state.get("fragments_told", 0)),
 
847
  )
848
  return
849
 
@@ -862,6 +884,7 @@ def chat(user_msg: str, state: dict, history: list):
862
  tone=state.get("tone", 0)),
863
  _voice_html(None),
864
  render_recovered(state.get("fragments_told", 0)),
 
865
  )
866
  return
867
 
@@ -896,6 +919,10 @@ def chat(user_msg: str, state: dict, history: list):
896
  lengths = state.get("msg_lengths", []) + [len(user_msg.split())]
897
  state["msg_lengths"] = lengths[-5:] # keep the last 5
898
 
 
 
 
 
899
  system = build_system_prompt(state["affinity"], state["treasure"], recall_memory,
900
  tone=state.get("tone", 0),
901
  wounds=state.get("wounds", []),
@@ -903,7 +930,8 @@ def chat(user_msg: str, state: dict, history: list):
903
  state, bool(do_recall and recall_memory)),
904
  own_fragment=own_fragment,
905
  style=style_signal(state["msg_lengths"]),
906
- aware_memory=aware_memory)
 
907
  chat_messages = [{"role": "system", "content": system}] + state["history"] + [
908
  {"role": "user", "content": user_msg}
909
  ]
@@ -931,6 +959,7 @@ def chat(user_msg: str, state: dict, history: list):
931
  tone=state.get("tone", 0)),
932
  voice,
933
  render_recovered(state.get("fragments_told", 0)),
 
934
  )
935
  except Exception as e:
936
  print(f"[chat] run_turn_stream failed: {e!r}")
@@ -983,6 +1012,7 @@ def chat(user_msg: str, state: dict, history: list):
983
  tone=state.get("tone", 0)),
984
  voice,
985
  render_recovered(state.get("fragments_told", 0)),
 
986
  )
987
 
988
 
@@ -1033,7 +1063,7 @@ with gr.Blocks(title="Hollow") as demo:
1033
  gr.HTML('<div id="game-bg"></div><div id="game-vig"></div>'
1034
  '<div id="game-frame-edge"></div>')
1035
  with gr.Row(elem_id="game-topbar"):
1036
- gr.HTML('<div id="game-title">Hollow</div>')
1037
  restart_btn = gr.Button("↺ begin again", elem_classes="restart-btn",
1038
  scale=0)
1039
  voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
@@ -1074,7 +1104,8 @@ with gr.Blocks(title="Hollow") as demo:
1074
  fn=chat,
1075
  inputs=[msg_input, state, chatbot],
1076
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1077
- treasure_panel, entity_panel, voice_panel, recovered_panel],
 
1078
  show_progress="hidden",
1079
  )
1080
 
@@ -1087,7 +1118,8 @@ with gr.Blocks(title="Hollow") as demo:
1087
  fn=chat,
1088
  inputs=[msg_input, state, chatbot],
1089
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1090
- treasure_panel, entity_panel, voice_panel, recovered_panel],
 
1091
  show_progress="hidden",
1092
  )
1093
 
@@ -1095,7 +1127,8 @@ with gr.Blocks(title="Hollow") as demo:
1095
  fn=_reset,
1096
  inputs=None,
1097
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1098
- treasure_panel, entity_panel, voice_panel, recovered_panel],
 
1099
  show_progress="hidden",
1100
  )
1101
 
@@ -1109,7 +1142,7 @@ with gr.Blocks(title="Hollow") as demo:
1109
  show_progress="hidden")
1110
 
1111
  _enter_outputs = [intro_view, game_view, state, chatbot, bond_panel,
1112
- treasure_panel, entity_panel, recovered_panel]
1113
  _intro_outputs = [menu_view, intro_view, pending_mode]
1114
  tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs,
1115
  show_progress="hidden")
 
8
  os.environ.setdefault("MKL_NUM_THREADS", "4")
9
 
10
  import base64
11
+ import html
12
  import io
13
  import json
14
  import re
 
49
  )
50
 
51
 
52
+ def _render_title(state: dict) -> str:
53
+ """The top-left title: 'Hollow' until the child names itself."""
54
+ name = state.get("chosen_name")
55
+ if name:
56
+ safe = html.escape(name, quote=True)
57
+ return (
58
+ f'<div id="game-title">{safe}'
59
+ f'<span class="named"> — it calls itself <b>{safe}</b></span></div>'
60
+ )
61
+ return '<div id="game-title">Hollow</div>'
62
+
63
+
64
  def _pristine_state():
65
  """A brand-new game — the real starting point, independent of the dev
66
  HOLLOW_FAST_FINALE seed (the restart button must always give a clean run)."""
 
82
  "idle_count": 0,
83
  "greeted": False,
84
  "mode": "tester",
85
+ "chosen_name": None,
86
+ "named": False,
87
  }
88
 
89
 
90
  def _reset():
91
  """Wipe the board for a fresh game without a page reload. Order matches
92
  [msg_input, send_btn, chatbot, state, bond_panel, treasure_panel,
93
+ entity_panel, voice_panel, recovered_panel, title_panel]."""
94
  fresh = _pristine_state()
95
  return (
96
  gr.update(value="", interactive=True,
 
103
  render_entity(20),
104
  _voice_html(None),
105
  render_recovered(0),
106
+ _render_title(fresh),
107
  )
108
 
109
 
 
134
  "idle_count": 0,
135
  "greeted": False,
136
  "mode": "tester",
137
+ "chosen_name": None,
138
+ "named": False,
139
  }
140
  if fast == "bad":
141
  return {
 
158
  "idle_count": 0,
159
  "greeted": False,
160
  "mode": "tester",
161
+ "chosen_name": None,
162
+ "named": False,
163
  }
164
  return _pristine_state()
165
 
 
560
  wounds=state.get("wounds", [])),
561
  render_entity(state["affinity"], tone=state["tone"]),
562
  render_recovered(state.get("fragments_told", 0)),
563
+ _render_title(state),
564
  )
565
 
566
 
 
704
  speak_it = step["speaker"] == "hollow"
705
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
706
  yield (input_locked, locked, chat_hist, state, bond, treasure_html,
707
+ entity, voice, recovered, _render_title(state))
708
  if pause > 0:
709
  time.sleep(pause)
710
 
711
  yield (
712
  gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER),
713
  locked, chat_hist, state, bond, treasure_html, entity,
714
+ _voice_html(None), recovered, _render_title(state),
715
  )
716
 
717
 
 
765
  speak_it = step["speaker"] == "hollow"
766
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
767
  yield (input_locked, locked, chat_hist, state, bond, treasure_html,
768
+ entity, voice, recovered, _render_title(state))
769
  if pause > 0:
770
  time.sleep(pause)
771
 
772
  yield (
773
  gr.update(interactive=False, placeholder="see you soon."),
774
  locked, chat_hist, state, bond, treasure_html, entity,
775
+ _voice_html(None), recovered, _render_title(state),
776
  )
777
 
778
 
 
826
 
827
  voice, pause = _voice_and_pause(step["text"], True, step["pause"])
828
  yield (input_locked, locked, chat_hist, state, bond, treasure_html,
829
+ entity, voice, recovered, _render_title(state))
830
  if pause > 0:
831
  time.sleep(pause)
832
 
833
  yield (
834
  gr.update(interactive=False, placeholder="it's quiet now."),
835
  locked, chat_hist, state, bond, treasure_html, entity,
836
+ _voice_html(None), recovered, _render_title(state),
837
  )
838
 
839
 
 
865
  tone=state.get("tone", 0)),
866
  _voice_html(None),
867
  render_recovered(state.get("fragments_told", 0)),
868
+ _render_title(state),
869
  )
870
  return
871
 
 
884
  tone=state.get("tone", 0)),
885
  _voice_html(None),
886
  render_recovered(state.get("fragments_told", 0)),
887
+ _render_title(state),
888
  )
889
  return
890
 
 
919
  lengths = state.get("msg_lengths", []) + [len(user_msg.split())]
920
  state["msg_lengths"] = lengths[-5:] # keep the last 5
921
 
922
+ name_ready = (
923
+ not state.get("named") and state.get("fragments_told", 0) >= 2
924
+ )
925
+
926
  system = build_system_prompt(state["affinity"], state["treasure"], recall_memory,
927
  tone=state.get("tone", 0),
928
  wounds=state.get("wounds", []),
 
930
  state, bool(do_recall and recall_memory)),
931
  own_fragment=own_fragment,
932
  style=style_signal(state["msg_lengths"]),
933
+ aware_memory=aware_memory,
934
+ name_ready=name_ready)
935
  chat_messages = [{"role": "system", "content": system}] + state["history"] + [
936
  {"role": "user", "content": user_msg}
937
  ]
 
959
  tone=state.get("tone", 0)),
960
  voice,
961
  render_recovered(state.get("fragments_told", 0)),
962
+ _render_title(state),
963
  )
964
  except Exception as e:
965
  print(f"[chat] run_turn_stream failed: {e!r}")
 
1012
  tone=state.get("tone", 0)),
1013
  voice,
1014
  render_recovered(state.get("fragments_told", 0)),
1015
+ _render_title(state),
1016
  )
1017
 
1018
 
 
1063
  gr.HTML('<div id="game-bg"></div><div id="game-vig"></div>'
1064
  '<div id="game-frame-edge"></div>')
1065
  with gr.Row(elem_id="game-topbar"):
1066
+ title_panel = gr.HTML(value=_render_title(_init_state()))
1067
  restart_btn = gr.Button("↺ begin again", elem_classes="restart-btn",
1068
  scale=0)
1069
  voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
 
1104
  fn=chat,
1105
  inputs=[msg_input, state, chatbot],
1106
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1107
+ treasure_panel, entity_panel, voice_panel, recovered_panel,
1108
+ title_panel],
1109
  show_progress="hidden",
1110
  )
1111
 
 
1118
  fn=chat,
1119
  inputs=[msg_input, state, chatbot],
1120
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1121
+ treasure_panel, entity_panel, voice_panel, recovered_panel,
1122
+ title_panel],
1123
  show_progress="hidden",
1124
  )
1125
 
 
1127
  fn=_reset,
1128
  inputs=None,
1129
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1130
+ treasure_panel, entity_panel, voice_panel, recovered_panel,
1131
+ title_panel],
1132
  show_progress="hidden",
1133
  )
1134
 
 
1142
  show_progress="hidden")
1143
 
1144
  _enter_outputs = [intro_view, game_view, state, chatbot, bond_panel,
1145
+ treasure_panel, entity_panel, recovered_panel, title_panel]
1146
  _intro_outputs = [menu_view, intro_view, pending_mode]
1147
  tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs,
1148
  show_progress="hidden")
character.py CHANGED
@@ -145,13 +145,23 @@ INTRO_SEQUENCES = {
145
  "full": [0, 1, 2, 3],
146
  }
147
 
 
 
 
 
 
 
 
 
 
148
  def build_system_prompt(affinity: int, treasure: list[str],
149
  recall_memory: str | None = None,
150
  tone: int = 0, wounds: list[str] | None = None,
151
  memory_plea: int = 0,
152
  own_fragment: str | None = None,
153
  style: str | None = None,
154
- aware_memory: str | None = None) -> str:
 
155
  tier = get_tier(affinity)
156
  persona = _PERSONA[tier]
157
 
@@ -172,6 +182,9 @@ def build_system_prompt(affinity: int, treasure: list[str],
172
  "Let it frighten you a little. The visitor's warmth pulled it loose."
173
  )
174
 
 
 
 
175
  if style == "short":
176
  parts.append("The visitor's messages have been short and guarded. You may quietly notice this — name it once, softly, then let it go.")
177
  elif style == "long":
 
145
  "full": [0, 1, 2, 3],
146
  }
147
 
148
+ _NAME_SURFACES = (
149
+ "A name is trying to surface in you — something you almost remember "
150
+ "being called before the wood took it. In the middle of your reply, "
151
+ "let one short name slip out, haltingly, as if you're tasting it. "
152
+ "Do NOT explain it. Do NOT ask the visitor to use it. Just say it once, "
153
+ "uncertain, then keep talking."
154
+ )
155
+
156
+
157
  def build_system_prompt(affinity: int, treasure: list[str],
158
  recall_memory: str | None = None,
159
  tone: int = 0, wounds: list[str] | None = None,
160
  memory_plea: int = 0,
161
  own_fragment: str | None = None,
162
  style: str | None = None,
163
+ aware_memory: str | None = None,
164
+ name_ready: bool = False) -> str:
165
  tier = get_tier(affinity)
166
  persona = _PERSONA[tier]
167
 
 
182
  "Let it frighten you a little. The visitor's warmth pulled it loose."
183
  )
184
 
185
+ if name_ready:
186
+ parts.append(_NAME_SURFACES)
187
+
188
  if style == "short":
189
  parts.append("The visitor's messages have been short and guarded. You may quietly notice this — name it once, softly, then let it go.")
190
  elif style == "long":
engine.py CHANGED
@@ -4,7 +4,7 @@ import os
4
  IS_SPACE = bool(os.environ.get("SPACE_ID"))
5
 
6
  _EXTRACTION_SYSTEM = (
7
- "You are a JSON extraction assistant. Given a conversation exchange, extract four things:\n"
8
  "1. affinity_delta: integer from -8 to +8 — how much the visitor deepened the bond this turn. "
9
  "Use this scale and be GENEROUS — grief, loss, fear, and tender memories are the heart of this game and "
10
  "should score high (+6 to +8):\n"
@@ -28,10 +28,12 @@ _EXTRACTION_SYSTEM = (
28
  " -6 to -10: mocking, insulting, cruel, or threatening Hollow.\n"
29
  "4. cruel_quote: if the visitor mocked, insulted, or was cruel to Hollow this turn, the cruel phrase "
30
  "EXACTLY as the visitor wrote it — the visitor's words, never Hollow's. null otherwise.\n"
 
 
31
  "All integers must be plain JSON numbers — never write a leading + sign.\n"
32
  "Respond ONLY with valid JSON. No markdown. Examples:\n"
33
- '{"affinity_delta": 6, "new_memories": ["a grandmother named Lili who died of cancer"], "tone_delta": 2, "cruel_quote": null}\n'
34
- '{"affinity_delta": -4, "new_memories": [], "tone_delta": -8, "cruel_quote": "you are a creepy little freak"}'
35
  )
36
 
37
 
 
4
  IS_SPACE = bool(os.environ.get("SPACE_ID"))
5
 
6
  _EXTRACTION_SYSTEM = (
7
+ "You are a JSON extraction assistant. Given a conversation exchange, extract five things:\n"
8
  "1. affinity_delta: integer from -8 to +8 — how much the visitor deepened the bond this turn. "
9
  "Use this scale and be GENEROUS — grief, loss, fear, and tender memories are the heart of this game and "
10
  "should score high (+6 to +8):\n"
 
28
  " -6 to -10: mocking, insulting, cruel, or threatening Hollow.\n"
29
  "4. cruel_quote: if the visitor mocked, insulted, or was cruel to Hollow this turn, the cruel phrase "
30
  "EXACTLY as the visitor wrote it — the visitor's words, never Hollow's. null otherwise.\n"
31
+ "5. chosen_name: if Hollow offered a name for itself this turn, the single short name (one word, "
32
+ "letters only, 2-12 chars) EXACTLY as Hollow wrote it. null otherwise.\n"
33
  "All integers must be plain JSON numbers — never write a leading + sign.\n"
34
  "Respond ONLY with valid JSON. No markdown. Examples:\n"
35
+ '{"affinity_delta": 6, "new_memories": ["a grandmother named Lili who died of cancer"], "tone_delta": 2, "cruel_quote": null, "chosen_name": null}\n'
36
+ '{"affinity_delta": -4, "new_memories": [], "tone_delta": -8, "cruel_quote": "you are a creepy little freak", "chosen_name": null}'
37
  )
38
 
39
 
memory.py CHANGED
@@ -42,6 +42,19 @@ def style_signal(msg_lengths: list[int]) -> str | None:
42
  return None
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _is_hollows_words(memory: str, reply: str) -> bool:
46
  """On recall turns Hollow retells a memory in first person; extraction
47
  sometimes captures THAT poetic line as a new memory. Drop any candidate
@@ -78,6 +91,11 @@ def apply_update(state: dict, raw_json: str, reply: str = "") -> dict:
78
  if update.cruel_quote not in wounds:
79
  wounds.append(update.cruel_quote)
80
  del wounds[:-10] # cap at 10, keep newest
 
 
 
 
 
81
  return state
82
 
83
 
 
42
  return None
43
 
44
 
45
+ def sanitize_name(raw: str | None) -> str | None:
46
+ """A single short word → capitalized; anything else is rejected."""
47
+ if not raw:
48
+ return None
49
+ parts = raw.strip().split()
50
+ if not parts:
51
+ return None
52
+ w = parts[0].strip(".,;:'\"!?—-").strip()
53
+ if w.isalpha() and 2 <= len(w) <= 12:
54
+ return w.capitalize()
55
+ return None
56
+
57
+
58
  def _is_hollows_words(memory: str, reply: str) -> bool:
59
  """On recall turns Hollow retells a memory in first person; extraction
60
  sometimes captures THAT poetic line as a new memory. Drop any candidate
 
91
  if update.cruel_quote not in wounds:
92
  wounds.append(update.cruel_quote)
93
  del wounds[:-10] # cap at 10, keep newest
94
+ if update.chosen_name and not state.get("named"):
95
+ name = sanitize_name(update.chosen_name)
96
+ if name:
97
+ state["chosen_name"] = name
98
+ state["named"] = True
99
  return state
100
 
101
 
schemas.py CHANGED
@@ -7,6 +7,7 @@ class TurnUpdate(BaseModel):
7
  new_memories: List[str] = []
8
  tone_delta: int = 0
9
  cruel_quote: Optional[str] = None
 
10
 
11
  @field_validator("affinity_delta")
12
  @classmethod
 
7
  new_memories: List[str] = []
8
  tone_delta: int = 0
9
  cruel_quote: Optional[str] = None
10
+ chosen_name: Optional[str] = None
11
 
12
  @field_validator("affinity_delta")
13
  @classmethod
tests/test_app.py CHANGED
@@ -192,6 +192,49 @@ class TestFinaleBuildAndConvulse:
192
  assert any("entity-frenzy-wrap" in e for e in entities) # frenzy (sting)
193
 
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  class TestPendingBubble:
196
  def test_start_turn_adds_user_and_pending(self):
197
  out_input, out_btn, hist, out_state = app._start_turn("hello", _state(affinity=20, treasure=[], claimed=[], turn=0), [])
 
192
  assert any("entity-frenzy-wrap" in e for e in entities) # frenzy (sting)
193
 
194
 
195
+ class TestToneClassMapping:
196
+ def test_warm_at_15(self):
197
+ assert app._tone_class(15) == "tone-warm"
198
+ assert app._tone_class(40) == "tone-warm"
199
+
200
+ def test_hostile_at_minus_22(self):
201
+ assert app._tone_class(-22) == "tone-hostile"
202
+ assert app._tone_class(-50) == "tone-hostile"
203
+
204
+ def test_wounded_between_hostile_and_neutral(self):
205
+ assert app._tone_class(-15) == "tone-wounded"
206
+ assert app._tone_class(-20) == "tone-wounded"
207
+
208
+ def test_neutral_elsewhere(self):
209
+ assert app._tone_class(0) == "tone-neutral"
210
+ assert app._tone_class(14) == "tone-neutral"
211
+ assert app._tone_class(-14) == "tone-neutral"
212
+
213
+
214
+ class TestRenderTitle:
215
+ def test_default_title_is_hollow(self):
216
+ assert "Hollow" in app._render_title({"chosen_name": None})
217
+
218
+ def test_named_title_shows_chosen_name(self):
219
+ out = app._render_title({"chosen_name": "Mara"})
220
+ assert "Mara" in out
221
+ assert "it calls itself <b>Mara</b>" in out
222
+
223
+ def test_name_is_escaped(self):
224
+ out = app._render_title({"chosen_name": "<script>"})
225
+ assert "<script>" not in out
226
+ assert "&lt;script&gt;" in out
227
+
228
+
229
+ class TestPristineState:
230
+ def test_has_naming_fields(self):
231
+ fresh = app._pristine_state()
232
+ assert "chosen_name" in fresh
233
+ assert "named" in fresh
234
+ assert fresh["chosen_name"] is None
235
+ assert fresh["named"] is False
236
+
237
+
238
  class TestPendingBubble:
239
  def test_start_turn_adds_user_and_pending(self):
240
  out_input, out_btn, hist, out_state = app._start_turn("hello", _state(affinity=20, treasure=[], claimed=[], turn=0), [])
tests/test_memory.py CHANGED
@@ -1,5 +1,5 @@
1
  import pytest
2
- from memory import get_tier, apply_update, should_recall, decide_ending
3
 
4
 
5
  class TestGetTier:
@@ -76,6 +76,54 @@ class TestApplyUpdate:
76
  result = apply_update(state, '{"affinity_delta": -3, "new_memories": []}')
77
  assert result["affinity"] == 47
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  class TestShouldRecall:
81
  def _state(self, affinity=60, treasure=None, claimed=None, turn=0, last_recall_turn=None):
 
1
  import pytest
2
+ from memory import get_tier, apply_update, should_recall, decide_ending, sanitize_name
3
 
4
 
5
  class TestGetTier:
 
76
  result = apply_update(state, '{"affinity_delta": -3, "new_memories": []}')
77
  assert result["affinity"] == 47
78
 
79
+ def test_chosen_name_is_sanitized_and_stored(self):
80
+ state = self._base_state()
81
+ result = apply_update(
82
+ state,
83
+ '{"affinity_delta": 0, "new_memories": [], "chosen_name": "Mara"}',
84
+ )
85
+ assert result["chosen_name"] == "Mara"
86
+ assert result.get("named") is True
87
+
88
+ def test_chosen_name_rejected_if_already_named(self):
89
+ state = self._base_state()
90
+ state["chosen_name"] = "Lila"
91
+ state["named"] = True
92
+ result = apply_update(
93
+ state,
94
+ '{"affinity_delta": 0, "new_memories": [], "chosen_name": "Nova"}',
95
+ )
96
+ assert result["chosen_name"] == "Lila"
97
+
98
+ def test_invalid_name_falls_back(self):
99
+ state = self._base_state()
100
+ result = apply_update(
101
+ state,
102
+ '{"affinity_delta": 0, "new_memories": [], "chosen_name": "123bad"}',
103
+ )
104
+ assert result.get("chosen_name") is None
105
+ assert result.get("named") is not True
106
+
107
+
108
+ class TestSanitizeName:
109
+ def test_clean_single_word(self):
110
+ assert sanitize_name("Mara") == "Mara"
111
+
112
+ def test_strips_punctuation(self):
113
+ assert sanitize_name('"Lila."') == "Lila"
114
+
115
+ def test_uses_first_word_of_multiple(self):
116
+ assert sanitize_name("Little Ghost") == "Little"
117
+
118
+ def test_rejects_empty(self):
119
+ assert sanitize_name(" ") is None
120
+
121
+ def test_rejects_digits(self):
122
+ assert sanitize_name("X7") is None
123
+
124
+ def test_rejects_too_long(self):
125
+ assert sanitize_name("A" * 25) is None
126
+
127
 
128
  class TestShouldRecall:
129
  def _state(self, affinity=60, treasure=None, claimed=None, turn=0, last_recall_turn=None):