Pabloler21 Claude Fable 5 commited on
Commit
8c9619e
·
1 Parent(s): 9ce912d

feat: front-door menu, Tester/Full modes, How to Play, menu music + ritual

Browse files

- Two-view Gradio layout: menu_view (Direction C painted backdrop) and game_view.
- mode-driven PACING config in memory.py (tester fast arc, full longer arc).
- How to Play stone overlay.
- Synthesized menu loop + chime->greeting ritual on mode-select/restart.
- Docs (CLAUDE.md, AGENTS.md) and tests updated; 231 passing.

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

Files changed (11) hide show
  1. AGENTS.md +18 -18
  2. CLAUDE.md +18 -18
  3. app.py +248 -146
  4. assets/background.webp +3 -0
  5. memory.py +19 -5
  6. sting.py +54 -1
  7. styles.css +197 -0
  8. tests/test_app.py +45 -2
  9. tests/test_memory.py +28 -0
  10. tests/test_sting.py +7 -1
  11. tools/pacing_sim.py +7 -6
AGENTS.md CHANGED
@@ -37,7 +37,7 @@ git push space main && git push hackathon main
37
  No linter, formatter, or typechecker is configured. After changes, run `pytest` as the only verification step.
38
 
39
  ## Stack (decided — do not reopen)
40
- - **UI:** Gradio 6.x `gr.Blocks` + `gr.Chatbot` (messages format default, no `type` param). Horror visuals are **pure CSS**; a single `<head>` JS controller (`_HEAD_JS`, passed to `launch(head=...)`) handles browser-only concerns opening-chime-ritual (chime greeting on first gesture and on restart), mute, idle timing. **JS is allowed** by the hackathon (verified, Field Guide); "no JS" was our own choice, dropped only for these. (README/FIELD_NOTES still boast "no JS" — fix before deploy.)
41
  - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama (`localhost:11434`) locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`.
42
  - **Voice:** Kokoro-82M TTS (`voice.py`), **CPU** — the child whispers (`af_nicole`, speed 0.88, "28% fog" numpy DSP). Import-guarded → **silent if unavailable** (local 3.13 `.venv` has no kokoro; works in 3.12 `.venv-tts` and on the Space at py3.11). Per-reply/finale audio inlined as autoplay `<audio>` in a mounted-but-CSS-collapsed `voice_panel`. The opening ritual (detuned music-box chime → spoken greeting) is queued from `data:` URI constants in `_HEAD_JS`. Plays on every Hollow reply + all finale lines.
43
  - **State:** plain dict in `gr.State` per session. **Parsing:** Pydantic v2 + fallback.
@@ -59,11 +59,11 @@ character.py # build_system_prompt(... tone, wounds, memory_plea, own_fragment,
59
  memory.py # get_tier, apply_update (+ sanitize/Hollow-words filter), should_recall, decide_ending, style_signal (B3), pick_aware_memory (B2) — pure
60
  finale.py # finale_steps (loop), finale_steps_bad (wound), finale_steps_good (redemption) — pure data; each has a "frenzy" convulse stage
61
  render.py # render_entity(affinity, mode, seq) — modes: idle/flash/flash_strong/build/frenzy/convulse_good/convulse_loop/end/end_settle/rage/peace/peace_settle/peace_dissolve; materialization = fog+brightness (no blur); render_treasure(...)
62
- sting.py # _wav + sting/chime/heartbeat/sigh/flatline _wav_bytes — numpy audio synth (HF rejects committed .wav)
63
- styles.css # horror CSS §15-22 (22 = finale build pulse + soft convulse + restart + .voice-channel; #idle-trigger hidden)
64
  assets/ # hollow_{base,terror,almost,end,rage,peace}.webp (rage = almost copy placeholder; peace = real art)
65
  requirements.txt / packages.txt # +kokoro/misaki[en]/soundfile/spacy/en_core_web_sm; espeak-ng (apt)
66
- tests/ # 212 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
67
  docs/superpowers/{specs,plans}/ # approved specs + plans (active: 2026-06-14-pacing-streaming-overhaul)
68
  tools/pacing_sim.py # scripted visitors through real Ollama — pacing tuning
69
  ```
@@ -87,6 +87,7 @@ tools/pacing_sim.py # scripted visitors through real Ollama — pa
87
  "last_activity": float, # vestigial (client owns idle timing now)
88
  "idle_count": int, # how many idle nudges fired (tier + bond decay)
89
  "barren_turns": int, # consecutive turns with no captured memory
 
90
  }
91
  ```
92
  Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral` alias→ good/loop) — each ending fires on the 2nd message.
@@ -100,16 +101,16 @@ Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral` alias→ good/loop)
100
  | 76–100| Almost | possessive | `hollow_almost`, no sway |
101
 
102
  ## Ending matrix (`decide_ending`, checked at turn start, fully scripted, no GPU)
103
- Single gate so no lower threshold can race a warm run:
104
- - **bad** (Wound Loop): `tone≤−30 ∧ turn≥4 ∧ wounds≥2`. Recites your cruelties (redacted un-redacted), header→"Your Words", frenzy convulsion + triple full-screen stab + scream, reveals `hollow_almost`, threatens, ends "see you... soon."
105
- - **good** (Redemption): `aff≥50claimed2tone20`. Genuine sustained warmth surfaced `OWN_FRAGMENTS` of its real past (tortured/orphaned/starved) during play; finale confesses it's the thing that waits, RETURNS the treasure (un-claimed, lit), first smile (`hollow_peace` → `peace_dissolve`), leaves in peace.
106
- - **loop** (Visitor Loop): `aff≥50 claimed≥2 tone<20`. The DEFAULT high-bond ending fed plenty but treated transactionally, never truly loved stays a predator, consumes you, ends with YOU speaking its opening line. Redemption is the rare earned ending; ordinary polite play lands here.
107
- - Single gate (50/2-claimed) branched at tone 20 no lower threshold can race a warm run.
 
108
  - Tone bands in prompt (separate from ending routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
109
- - **Pacing after overhaul:** recall fires as soon as there is one unclaimed memory (no affinity floor); ending gate dropped from 70/turn≥5 to 50/turn≥4 so a satisfying arc lands in ~5-7 turns. Re-measure with `tools/pacing_sim.py` after tuning.
110
 
111
  ## Voice + Presence (the child speaks & watches)
112
- - **Voice:** every Hollow reply + all finale lines are spoken (Kokoro, CPU); the opening line is spoken as part of the **opening ritual** (detuned chime greeting) on the visitor's **first gesture** and again on every **"begin again"** (browser autoplay blocks it on load). The reply text now streams token-by-token, and each completed sentence is synthesized and handed to a **client-side audio queue** in the `<head>` controller so sentences play in order without cutting each other. Finale `_voice_and_pause` stretches each step's pause to the audio length (+0.15 s) so lines aren't cut. Recall + finale = the wow. **Mute is client-side**: the controller owns global `{muted}` (persisted to `localStorage`), applies it to the currently playing queued audio and future ones, and drives the 🔊/🔇 button (mute only — no volume slider; voice plays at a fixed level). The server always synthesizes; the browser decides audibility. Horror audio in the entity panel stays untouched.
113
  - **Awareness (B1/B2/B3):** B3 `style_signal` (Hollow notices short/long messages); B2 `pick_aware_memory` (resurfaces an earlier memory unprompted, every 3rd non-recall turn); B1 idle is timed client-side (60 s, reset by any pointer/keyboard/input/scroll) and clicks a hidden `#idle-trigger` to run `_on_idle` — escalating plead→hurt→hostile (`_IDLE_TIERS`) with affinity −3 / tone −2 per nudge. B4 `memory_plea`: consecutive barren turns make Hollow ask more plainly for a real memory.
114
  - **Materialization:** the child is **sharp, veiled in fog + shadow** (brightness 0.4→1, fog/vig denser at low bond) — NOT blurred (blur read as a broken image).
115
 
@@ -131,7 +132,7 @@ Single gate so no lower threshold can race a warm run:
131
  - **Space streaming caveat:** `TextIteratorStreamer` inside `@spaces.GPU` was the documented way to stream on ZeroGPU, but it must be verified on the mirror Space before trusting it. If it breaks, gate the Space branch to fall back to the non-streaming `run_turn` (yield the whole reply once), leaving the Ollama path streaming.
132
  - **Hide Gradio progress, animate our own:** `show_progress="hidden"` on the events kills the per-component "processing | Xs" overlay (CSS class-hiding was unreliable); we render our own `_PENDING` typing bubble. The HTML span + class survives Gradio's markdown sanitizer (verified) so §20 CSS animates it.
133
  - **Idle is client-timed via the `<head>` controller:** a hidden `#idle-trigger` button runs `_on_idle`; the server only guards against pending/ended states. Any interaction resets the 60 s clock.
134
- - **Browser autoplay blocks audio before the visitor's first gesture** — the opening line can't auto-speak on load; the head controller plays the chime → greeting ritual on the first pointer/keyboard gesture and re-runs it when **"begin again"** is clicked. The controller also applies volume/mute to every remounted `.voice-channel audio` via MutationObserver; horror audio in the entity panel is unaffected.
135
  - **Mute is client-side, not server-side:** never gate `speak()` on a `voice_on` flag. The server always synthesizes audio; the browser mutes the playing element.
136
  - **Media mute = control the element, never replay.** Mute must toggle the *playing* audio's `.muted` (client-side); do NOT clear-then-replay-the-last-line on un-mute — that produced an infinite re-play loop (bug we hit). The browser owns playback state, not the server.
137
  - **Never default a time/clock field to `0.0`.** `time.time() - 0` ≈ 1.7e9 s, so any "elapsed since" gate fires instantly (bug: the idle nudge fired every 4 s, not after 35 s). Stamp at session start, or better, time it client-side.
@@ -151,9 +152,8 @@ Single gate so no lower threshold can race a warm run:
151
  - **Voice:** Kokoro TTS (`af_nicole` voice, 28% fog filter, speed 0.80). CPU only. Import-guarded — app runs silent if kokoro unavailable.
152
 
153
  ## What's next / pending
154
- - **Current work is on branch `feat/voice-awareness`, NOT deployed.** Both Spaces are frozen at the pre-voice commit (`4deeed5`) — the submission stays safe while we iterate. Done on the branch (212 tests green): voice + awareness, presence-polish, client-side mute, opening-chime-ritual, dash strip, client-timed idle (60 s), memory coaching. Deploy is **mirror-first**: merge push `space` → verify build+voice livepush `hackathon`.
155
- - **DONE:** `docs/superpowers/plans/2026-06-14-pacing-streaming-overhaul.md` recall now fires as soon as a memory exists (no affinity floor), ending gate lowered to 50/turn≥4 (~5-7 turn arc), replies are short/varied with an explicit anti-template directive, extraction consolidates to one rich memory per turn, token budgets shrunk to 150/80, and the reply now streams token-by-token with per-sentence voice via a client-side audio queue.
156
- - **Before deploy:** mirror-first deploy to verify `TextIteratorStreamer` under ZeroGPU; update README/FIELD_NOTES "no JS" boast (now false — we added the `<head>` controller; JS is allowed, frame it as "horror is CSS; the only JS satisfies the browser's autoplay-gesture + audio/idle").
157
- - **`FIELD_NOTES.md`** lives in the repo (📓 badge content). Field Notes done; the demo video + LinkedIn post are Pablo's, link both from the README when ready.
158
- - **Optional art:** `hollow_rage.webp` is still a copy of `hollow_almost` (works fine). Swap a PNG: `Image.open(png).convert("RGB").resize((512,512),LANCZOS).save(webp,"WEBP",quality=80,method=6)`.
159
- - **Deliverables (Pablo):** demo video (horror-trailer), social post. **Mon June 15:** submit early + community vote.
 
37
  No linter, formatter, or typechecker is configured. After changes, run `pytest` as the only verification step.
38
 
39
  ## Stack (decided — do not reopen)
40
+ - **UI:** Gradio 6.x `gr.Blocks` + `gr.Chatbot`. A Fear & Hunger-inspired **front-door menu** (`menu_view`) with painted foggy-wood backdrop (`assets/background.webp`), two modes (`Tester Run` / `The Full Stay`), and a `How to Play` overlay switches to the hidden `game_view` on mode-select. Horror visuals are pure CSS; the `<head>` JS controller handles menu music, the chime→greeting ritual on mode-select/restart, mute, and idle timing.
41
  - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama (`localhost:11434`) locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`.
42
  - **Voice:** Kokoro-82M TTS (`voice.py`), **CPU** — the child whispers (`af_nicole`, speed 0.88, "28% fog" numpy DSP). Import-guarded → **silent if unavailable** (local 3.13 `.venv` has no kokoro; works in 3.12 `.venv-tts` and on the Space at py3.11). Per-reply/finale audio inlined as autoplay `<audio>` in a mounted-but-CSS-collapsed `voice_panel`. The opening ritual (detuned music-box chime → spoken greeting) is queued from `data:` URI constants in `_HEAD_JS`. Plays on every Hollow reply + all finale lines.
43
  - **State:** plain dict in `gr.State` per session. **Parsing:** Pydantic v2 + fallback.
 
59
  memory.py # get_tier, apply_update (+ sanitize/Hollow-words filter), should_recall, decide_ending, style_signal (B3), pick_aware_memory (B2) — pure
60
  finale.py # finale_steps (loop), finale_steps_bad (wound), finale_steps_good (redemption) — pure data; each has a "frenzy" convulse stage
61
  render.py # render_entity(affinity, mode, seq) — modes: idle/flash/flash_strong/build/frenzy/convulse_good/convulse_loop/end/end_settle/rage/peace/peace_settle/peace_dissolve; materialization = fog+brightness (no blur); render_treasure(...)
62
+ sting.py # _wav + sting/chime/heartbeat/sigh/flatline/menu_loop _wav_bytes — numpy audio synth (HF rejects committed .wav)
63
+ styles.css # horror CSS §15-24 (23 = opening menu, 24 = How to Play overlay; 22 = finale/restart/voice-channel)
64
  assets/ # hollow_{base,terror,almost,end,rage,peace}.webp (rage = almost copy placeholder; peace = real art)
65
  requirements.txt / packages.txt # +kokoro/misaki[en]/soundfile/spacy/en_core_web_sm; espeak-ng (apt)
66
+ tests/ # 231 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
67
  docs/superpowers/{specs,plans}/ # approved specs + plans (active: 2026-06-14-pacing-streaming-overhaul)
68
  tools/pacing_sim.py # scripted visitors through real Ollama — pacing tuning
69
  ```
 
87
  "last_activity": float, # vestigial (client owns idle timing now)
88
  "idle_count": int, # how many idle nudges fired (tier + bond decay)
89
  "barren_turns": int, # consecutive turns with no captured memory
90
+ "mode": str, # "tester" | "full" — pacing profile
91
  }
92
  ```
93
  Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral` alias→ good/loop) — each ending fires on the 2nd message.
 
101
  | 76–100| Almost | possessive | `hollow_almost`, no sway |
102
 
103
  ## Ending matrix (`decide_ending`, checked at turn start, fully scripted, no GPU)
104
+ Mode-driven `PACING` config in `memory.py` (tester = fast/judge-friendly, full = longer experience). Single gate so no lower threshold can race a warm run:
105
+ - **tester:** `bad` at `tone≤−30 ∧ turn≥4 ∧ wounds≥2`; `good/loop` at `aff≥50 claimed≥2` branched by `tone≥20`.
106
+ - **full:** `bad` at `tone≤−30turn6wounds2`; `good/loop` at `aff≥68 claimed≥3` branched by `tone≥20`.
107
+ - **bad** (Wound Loop): recites your cruelties (redacted un-redacted), header→"Your Words", frenzy convulsion + triple full-screen stab + scream, reveals `hollow_almost`, threatens, ends "see you... soon."
108
+ - **good** (Redemption): genuine sustained warmth surfaced `OWN_FRAGMENTS` of its real past; finale confesses it's the thing that waits, RETURNS the treasure (un-claimed, lit), first smile (`hollow_peace` → `peace_dissolve`), leaves in peace.
109
+ - **loop** (Visitor Loop): fed plenty but treated transactionally, never truly loved → stays a predator, consumes you, ends with YOU speaking its opening line.
110
  - Tone bands in prompt (separate from ending routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
 
111
 
112
  ## Voice + Presence (the child speaks & watches)
113
+ - **Voice:** every Hollow reply + all finale lines are spoken (Kokoro, CPU). The front-door menu plays a synthesized eerie **menu loop** on the first gesture; choosing a mode stops it and plays the **chime → greeting** ritual. "Begin again" re-triggers the ritual. The reply text streams token-by-token, and each completed sentence is synthesized and handed to a **client-side audio queue** in the `<head>` controller so sentences play in order without cutting each other. Finale `_voice_and_pause` stretches each step's pause to the audio length (+0.15 s). **Mute is client-side**: the controller owns global `{muted}` (persisted to `localStorage`), applies it to queued audio, menu music, and future audio, driving the 🔊/🔇 button. The server always synthesizes; the browser decides audibility.
114
  - **Awareness (B1/B2/B3):** B3 `style_signal` (Hollow notices short/long messages); B2 `pick_aware_memory` (resurfaces an earlier memory unprompted, every 3rd non-recall turn); B1 idle is timed client-side (60 s, reset by any pointer/keyboard/input/scroll) and clicks a hidden `#idle-trigger` to run `_on_idle` — escalating plead→hurt→hostile (`_IDLE_TIERS`) with affinity −3 / tone −2 per nudge. B4 `memory_plea`: consecutive barren turns make Hollow ask more plainly for a real memory.
115
  - **Materialization:** the child is **sharp, veiled in fog + shadow** (brightness 0.4→1, fog/vig denser at low bond) — NOT blurred (blur read as a broken image).
116
 
 
132
  - **Space streaming caveat:** `TextIteratorStreamer` inside `@spaces.GPU` was the documented way to stream on ZeroGPU, but it must be verified on the mirror Space before trusting it. If it breaks, gate the Space branch to fall back to the non-streaming `run_turn` (yield the whole reply once), leaving the Ollama path streaming.
133
  - **Hide Gradio progress, animate our own:** `show_progress="hidden"` on the events kills the per-component "processing | Xs" overlay (CSS class-hiding was unreliable); we render our own `_PENDING` typing bubble. The HTML span + class survives Gradio's markdown sanitizer (verified) so §20 CSS animates it.
134
  - **Idle is client-timed via the `<head>` controller:** a hidden `#idle-trigger` button runs `_on_idle`; the server only guards against pending/ended states. Any interaction resets the 60 s clock.
135
+ - **Browser autoplay blocks audio before the visitor's first gesture** — the head controller starts the menu loop on the first menu gesture, then plays the chime → greeting ritual when a mode is selected or "begin again" is clicked. The controller applies volume/mute to queued voice audio and the menu bed via MutationObserver + direct reference.
136
  - **Mute is client-side, not server-side:** never gate `speak()` on a `voice_on` flag. The server always synthesizes audio; the browser mutes the playing element.
137
  - **Media mute = control the element, never replay.** Mute must toggle the *playing* audio's `.muted` (client-side); do NOT clear-then-replay-the-last-line on un-mute — that produced an infinite re-play loop (bug we hit). The browser owns playback state, not the server.
138
  - **Never default a time/clock field to `0.0`.** `time.time() - 0` ≈ 1.7e9 s, so any "elapsed since" gate fires instantly (bug: the idle nudge fired every 4 s, not after 35 s). Stamp at session start, or better, time it client-side.
 
152
  - **Voice:** Kokoro TTS (`af_nicole` voice, 28% fog filter, speed 0.80). CPU only. Import-guarded — app runs silent if kokoro unavailable.
153
 
154
  ## What's next / pending
155
+ - **Current work is on branch `feat/front-door-menu`, branched off `feat/voice-awareness`, NOT deployed.** Both Spaces are frozen at the pre-voice commit (`4deeed5`). Done on the branch (231 tests green): front-door menu (Direction C), Tester/Full pacing modes, How to Play overlay, menu music + mode-select chimegreeting ritual.
156
+ - **Before merge/deploy:** live playthrough in `.venv-tts` + Ollama with the user to verify menu visuals, music, mode-select ritual, mute, and both pacing arcs; then merge `feat/front-door-menu` `feat/voice-awareness`. Deploy remains **mirror-first**.
157
+ - **README/FIELD_NOTES:** update the stale "no JS" boast; the only JS satisfies browser autoplay-gesture + audio/idle + menu music.
158
+ - **`FIELD_NOTES.md`** and deliverables (demo video, LinkedIn post) are Pablo's.
159
+ - **Mon June 15:** submit early + community vote.
 
CLAUDE.md CHANGED
@@ -12,7 +12,7 @@ A horror NPC chatbot for the **Build Small Hackathon** (HF / Gradio). Track:
12
  - **Submission** (must live in org): https://huggingface.co/spaces/build-small-hackathon/hollow (remote `hackathon`) — LIVE, ZeroGPU `zero-a10g`, public.
13
 
14
  ## Stack (decided — do not reopen)
15
- - **UI:** Gradio 6.x `gr.Blocks` + `gr.Chatbot` (messages format default, no `type` param). Horror visuals are **pure CSS**; a single `<head>` JS controller (`_HEAD_JS`, passed to `launch(head=...)`) handles browser-only concerns opening-chime-ritual (chime greeting on first gesture and on restart), mute, idle timing. **JS is allowed** by the hackathon (verified, Field Guide); "no JS" was our own choice, dropped only for these. (README/FIELD_NOTES still boast "no JS" — fix before deploy.)
16
  - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama (`localhost:11434`) locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`.
17
  - **Voice:** Kokoro-82M TTS (`voice.py`), **CPU** — the child whispers (`af_nicole`, speed 0.88, "28% fog" numpy DSP). Import-guarded → **silent if unavailable** (local 3.13 `.venv` has no kokoro; works in 3.12 `.venv-tts` and on the Space at py3.11). Per-reply/finale audio inlined as autoplay `<audio>` in a mounted-but-CSS-collapsed `voice_panel`. The opening ritual (detuned music-box chime → spoken greeting) is queued from `data:` URI constants in `_HEAD_JS`. Plays on every Hollow reply + all finale lines.
18
  - **State:** plain dict in `gr.State` per session. **Parsing:** Pydantic v2 + fallback.
@@ -34,11 +34,11 @@ character.py # build_system_prompt(... tone, wounds, memory_plea, own_fragment,
34
  memory.py # get_tier, apply_update (+sanitize/Hollow-words filter), should_recall, decide_ending, style_signal (B3), pick_aware_memory (B2) — pure
35
  finale.py # finale_steps (loop), finale_steps_bad (wound), finale_steps_good (redemption) — pure data; each has a "frenzy" convulse stage
36
  render.py # render_entity(affinity, mode, seq) — modes: idle/flash/flash_strong/build/frenzy/convulse_good/convulse_loop/end/end_settle/rage/peace/peace_settle/peace_dissolve; materialization = fog+brightness (no blur); render_treasure(...)
37
- sting.py # _wav + sting/chime/heartbeat/sigh/flatline _wav_bytes — numpy audio synth (HF rejects committed .wav)
38
- styles.css # horror CSS §15-22 (22 = finale build pulse + soft convulse + restart + .voice-channel; #idle-trigger hidden)
39
  assets/ # hollow_{base,terror,almost,end,rage,peace}.webp (rage = almost copy placeholder; peace = real art)
40
  requirements.txt / packages.txt # +kokoro/misaki[en]/soundfile/spacy/en_core_web_sm; espeak-ng (apt)
41
- tests/ # 212 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
42
  docs/superpowers/{specs,plans}/ # approved specs + plans (active: 2026-06-14-pacing-streaming-overhaul)
43
  tools/pacing_sim.py # scripted visitors through real Ollama — pacing tuning
44
  ```
@@ -62,6 +62,7 @@ tools/pacing_sim.py # scripted visitors through real Ollama — pa
62
  "last_activity": float, # vestigial (client owns idle timing now)
63
  "idle_count": int, # how many idle nudges fired (tier + bond decay)
64
  "barren_turns": int, # consecutive turns with no captured memory
 
65
  }
66
  ```
67
  Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral` alias→ good/loop) — each ending fires on the 2nd message.
@@ -75,16 +76,16 @@ Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral` alias→ good/loop)
75
  | 76–100| Almost | possessive | `hollow_almost`, no sway |
76
 
77
  ## Ending matrix (`decide_ending`, checked at turn start, fully scripted, no GPU)
78
- Single gate so no lower threshold can race a warm run:
79
- - **bad** (Wound Loop): `tone≤−30 ∧ turn≥4 ∧ wounds≥2`. Recites your cruelties (redacted un-redacted), header→"Your Words", frenzy convulsion + triple full-screen stab + scream, reveals `hollow_almost`, threatens, ends "see you... soon."
80
- - **good** (Redemption): `aff≥50claimed2tone20`. Genuine sustained warmth surfaced `OWN_FRAGMENTS` of its real past (tortured/orphaned/starved) during play; finale confesses it's the thing that waits, RETURNS the treasure (un-claimed, lit), first smile (`hollow_peace` → `peace_dissolve`), leaves in peace.
81
- - **loop** (Visitor Loop): `aff≥50 claimed≥2 tone<20`. The DEFAULT high-bond ending fed plenty but treated transactionally, never truly loved stays a predator, consumes you, ends with YOU speaking its opening line. Redemption is the rare earned ending; ordinary polite play lands here.
82
- - Single gate (50/2-claimed) branched at tone 20 no lower threshold can race a warm run.
 
83
  - Tone bands in prompt (separate from ending routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
84
- - **Pacing after overhaul:** recall fires as soon as there is one unclaimed memory (no affinity floor); ending gate dropped from 70/turn≥5 to 50/turn≥4 so a satisfying arc lands in ~5-7 turns. Re-measure with `tools/pacing_sim.py` after tuning.
85
 
86
  ## Voice + Presence (the child speaks & watches)
87
- - **Voice:** every Hollow reply + all finale lines are spoken (Kokoro, CPU); the opening line is spoken as part of the **opening ritual** (detuned chime greeting) on the visitor's **first gesture** and again on every **"begin again"** (browser autoplay blocks it on load). The reply text now streams token-by-token, and each completed sentence is synthesized and handed to a **client-side audio queue** in the `<head>` controller so sentences play in order without cutting each other. Finale `_voice_and_pause` stretches each step's pause to the audio length (+0.15 s) so lines aren't cut. Recall + finale = the wow. **Mute is client-side**: the controller owns global `{muted}` (persisted to `localStorage`), applies it to the currently playing queued audio and future ones, and drives the 🔊/🔇 button (mute only — no volume slider; voice plays at a fixed level). The server always synthesizes; the browser decides audibility. Horror audio in the entity panel stays untouched.
88
  - **Awareness (B1/B2/B3):** B3 `style_signal` (Hollow notices short/long messages); B2 `pick_aware_memory` (resurfaces an earlier memory unprompted, every 3rd non-recall turn); B1 idle is timed client-side (60 s, reset by any pointer/keyboard/input/scroll) and clicks a hidden `#idle-trigger` to run `_on_idle` — escalating plead→hurt→hostile (`_IDLE_TIERS`) with affinity −3 / tone −2 per nudge. B4 `memory_plea`: consecutive barren turns make Hollow ask more plainly for a real memory.
89
  - **Materialization:** the child is **sharp, veiled in fog + shadow** (brightness 0.4→1, fog/vig denser at low bond) — NOT blurred (blur read as a broken image).
90
 
@@ -108,7 +109,7 @@ Single gate so no lower threshold can race a warm run:
108
  - **Cap CPU math threads before torch loads** (`OMP/OPENBLAS/MKL_NUM_THREADS=4` at the top of `app.py`+`voice.py`). On many-core machines under memory pressure OpenBLAS spawns one buffer-hungry thread/core and the CPU allocator fails (`DefaultCPUAllocator: not enough memory` on a tiny alloc, with GBs free) → Kokoro silently disabled. NOT real RAM exhaustion.
109
  - **`voice_panel` must stay MOUNTED** (`elem_classes="voice-channel"` + CSS collapse to 0 size), NOT `visible=False` — an invisible Gradio component can unmount so the `<audio>` never reaches the DOM and never plays. Voice audio goes here, NEVER in the entity panel (would break the heartbeat's byte-identical HTML).
110
  - **Idle is client-timed via the `<head>` controller:** a hidden `#idle-trigger` button runs `_on_idle`; the server only guards against pending/ended states. Any interaction resets the 60 s clock.
111
- - **Browser autoplay blocks audio before the visitor's first gesture** — the opening line can't auto-speak on load; the head controller plays the chime → greeting ritual on the first pointer/keyboard gesture and re-runs it when **"begin again"** is clicked. The controller also applies volume/mute to every remounted `.voice-channel audio` via MutationObserver; horror audio in the entity panel is unaffected.
112
  - **Mute is client-side, not server-side:** never gate `speak()` on a `voice_on` flag. The server always sends audio; the browser mutes the playing element.
113
  - **Media mute = control the element, never replay.** Mute must toggle the *playing* audio's `.muted` (client-side); do NOT clear-then-replay-the-last-line on un-mute — that produced an infinite re-play loop (bug we hit). The browser owns playback state, not the server.
114
  - **Never default a time/clock field to `0.0`.** `time.time() - 0` ≈ 1.7e9 s, so any "elapsed since" gate fires instantly (bug: the idle nudge fired every 4 s, not after 35 s). Stamp at session start, or better, time it client-side.
@@ -118,12 +119,11 @@ Single gate so no lower threshold can race a warm run:
118
  - **Ollama 500 = the ROCm `cudaMalloc` corruption** (see above), NOT our code — a short prompt may pass while a real 300-token turn 500s. Symptom in-app: every reply is the "fog swallowed your words" fallback. Fix: restart Ollama (kill `ollama.exe` + `ollama app.exe`, reopen). The Space (transformers) is immune.
119
 
120
  ## What's next
121
- - **Current work is on branch `feat/voice-awareness`, NOT deployed.** Both Spaces are frozen at the pre-voice commit (`4deeed5`) — the submission stays safe while we iterate. Done on the branch (212 tests green): voice + awareness, presence-polish, client-side mute, opening-chime-ritual, dash strip, client-timed idle (60 s), memory coaching. Deploy is **mirror-first**: merge push `space` → verify build+voice livepush `hackathon`.
122
- - **DONE:** `docs/superpowers/plans/2026-06-14-pacing-streaming-overhaul.md` recall now fires as soon as a memory exists (no affinity floor), ending gate lowered to 50/turn≥4 (~5-7 turn arc), replies are short/varied with an explicit anti-template directive, extraction consolidates to one rich memory per turn, token budgets shrunk to 150/80, and the reply now streams token-by-token with per-sentence voice via a client-side audio queue.
123
- - **Before deploy:** mirror-first deploy to verify `TextIteratorStreamer` under ZeroGPU; update README/FIELD_NOTES "no JS" boast (now false — we added the `<head>` controller; JS is allowed, frame it as "horror is CSS; the only JS satisfies the browser's autoplay-gesture + audio/idle").
124
- - **`FIELD_NOTES.md`** lives in the repo (📓 badge content). Field Notes done; the demo video + LinkedIn post are Pablo's, link both from the README when ready.
125
- - **Optional art:** `hollow_rage.webp` is still a copy of `hollow_almost` (works fine). Swap a PNG: `Image.open(png).convert("RGB").resize((512,512),LANCZOS).save(webp,"WEBP",quality=80,method=6)`.
126
- - **Deliverables (Pablo):** demo video (horror-trailer), social post. **Mon June 15:** submit early + community vote.
127
 
128
  ## Workflows
129
  **Deploy** (from repo root): **mirror-first** — `git push space main` → wait ~3-4 min (Kokoro/spacy build is heavy), **Ctrl+F5**, verify build+voice live → only then `git push hackathon main`. If the mirror build breaks, the submission stays untouched. Commit trailer: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
 
12
  - **Submission** (must live in org): https://huggingface.co/spaces/build-small-hackathon/hollow (remote `hackathon`) — LIVE, ZeroGPU `zero-a10g`, public.
13
 
14
  ## Stack (decided — do not reopen)
15
+ - **UI:** Gradio 6.x `gr.Blocks` + `gr.Chatbot`. A Fear & Hunger-inspired **front-door menu** (`menu_view`) with painted foggy-wood backdrop (`assets/background.webp`), two modes (`Tester Run` / `The Full Stay`), and a `How to Play` overlay switches to the hidden `game_view` on mode-select. Horror visuals are pure CSS; the `<head>` JS controller handles menu music, the chime→greeting ritual on mode-select/restart, mute, and idle timing.
16
  - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama (`localhost:11434`) locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`.
17
  - **Voice:** Kokoro-82M TTS (`voice.py`), **CPU** — the child whispers (`af_nicole`, speed 0.88, "28% fog" numpy DSP). Import-guarded → **silent if unavailable** (local 3.13 `.venv` has no kokoro; works in 3.12 `.venv-tts` and on the Space at py3.11). Per-reply/finale audio inlined as autoplay `<audio>` in a mounted-but-CSS-collapsed `voice_panel`. The opening ritual (detuned music-box chime → spoken greeting) is queued from `data:` URI constants in `_HEAD_JS`. Plays on every Hollow reply + all finale lines.
18
  - **State:** plain dict in `gr.State` per session. **Parsing:** Pydantic v2 + fallback.
 
34
  memory.py # get_tier, apply_update (+sanitize/Hollow-words filter), should_recall, decide_ending, style_signal (B3), pick_aware_memory (B2) — pure
35
  finale.py # finale_steps (loop), finale_steps_bad (wound), finale_steps_good (redemption) — pure data; each has a "frenzy" convulse stage
36
  render.py # render_entity(affinity, mode, seq) — modes: idle/flash/flash_strong/build/frenzy/convulse_good/convulse_loop/end/end_settle/rage/peace/peace_settle/peace_dissolve; materialization = fog+brightness (no blur); render_treasure(...)
37
+ sting.py # _wav + sting/chime/heartbeat/sigh/flatline/menu_loop _wav_bytes — numpy audio synth (HF rejects committed .wav)
38
+ styles.css # horror CSS §15-24 (23 = opening menu, 24 = How to Play overlay; 22 = finale/restart/voice-channel)
39
  assets/ # hollow_{base,terror,almost,end,rage,peace}.webp (rage = almost copy placeholder; peace = real art)
40
  requirements.txt / packages.txt # +kokoro/misaki[en]/soundfile/spacy/en_core_web_sm; espeak-ng (apt)
41
+ tests/ # 231 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
42
  docs/superpowers/{specs,plans}/ # approved specs + plans (active: 2026-06-14-pacing-streaming-overhaul)
43
  tools/pacing_sim.py # scripted visitors through real Ollama — pacing tuning
44
  ```
 
62
  "last_activity": float, # vestigial (client owns idle timing now)
63
  "idle_count": int, # how many idle nudges fired (tier + bond decay)
64
  "barren_turns": int, # consecutive turns with no captured memory
65
+ "mode": str, # "tester" | "full" — pacing profile
66
  }
67
  ```
68
  Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral` alias→ good/loop) — each ending fires on the 2nd message.
 
76
  | 76–100| Almost | possessive | `hollow_almost`, no sway |
77
 
78
  ## Ending matrix (`decide_ending`, checked at turn start, fully scripted, no GPU)
79
+ Mode-driven `PACING` config in `memory.py` (tester = fast/judge-friendly, full = longer experience). Single gate so no lower threshold can race a warm run:
80
+ - **tester:** `bad` at `tone≤−30 ∧ turn≥4 ∧ wounds≥2`; `good/loop` at `aff≥50 claimed≥2` branched by `tone≥20`.
81
+ - **full:** `bad` at `tone≤−30turn6wounds2`; `good/loop` at `aff≥68 claimed≥3` branched by `tone≥20`.
82
+ - **bad** (Wound Loop): recites your cruelties (redacted un-redacted), header→"Your Words", frenzy convulsion + triple full-screen stab + scream, reveals `hollow_almost`, threatens, ends "see you... soon."
83
+ - **good** (Redemption): genuine sustained warmth surfaced `OWN_FRAGMENTS` of its real past; finale confesses it's the thing that waits, RETURNS the treasure (un-claimed, lit), first smile (`hollow_peace` → `peace_dissolve`), leaves in peace.
84
+ - **loop** (Visitor Loop): fed plenty but treated transactionally, never truly loved → stays a predator, consumes you, ends with YOU speaking its opening line.
85
  - Tone bands in prompt (separate from ending routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
 
86
 
87
  ## Voice + Presence (the child speaks & watches)
88
+ - **Voice:** every Hollow reply + all finale lines are spoken (Kokoro, CPU). The front-door menu plays a synthesized eerie **menu loop** on the first gesture; choosing a mode stops it and plays the **chime → greeting** ritual. "Begin again" re-triggers the ritual. The reply text streams token-by-token, and each completed sentence is synthesized and handed to a **client-side audio queue** in the `<head>` controller so sentences play in order without cutting each other. Finale `_voice_and_pause` stretches each step's pause to the audio length (+0.15 s). **Mute is client-side**: the controller owns global `{muted}` (persisted to `localStorage`), applies it to queued audio, menu music, and future audio, driving the 🔊/🔇 button. The server always synthesizes; the browser decides audibility.
89
  - **Awareness (B1/B2/B3):** B3 `style_signal` (Hollow notices short/long messages); B2 `pick_aware_memory` (resurfaces an earlier memory unprompted, every 3rd non-recall turn); B1 idle is timed client-side (60 s, reset by any pointer/keyboard/input/scroll) and clicks a hidden `#idle-trigger` to run `_on_idle` — escalating plead→hurt→hostile (`_IDLE_TIERS`) with affinity −3 / tone −2 per nudge. B4 `memory_plea`: consecutive barren turns make Hollow ask more plainly for a real memory.
90
  - **Materialization:** the child is **sharp, veiled in fog + shadow** (brightness 0.4→1, fog/vig denser at low bond) — NOT blurred (blur read as a broken image).
91
 
 
109
  - **Cap CPU math threads before torch loads** (`OMP/OPENBLAS/MKL_NUM_THREADS=4` at the top of `app.py`+`voice.py`). On many-core machines under memory pressure OpenBLAS spawns one buffer-hungry thread/core and the CPU allocator fails (`DefaultCPUAllocator: not enough memory` on a tiny alloc, with GBs free) → Kokoro silently disabled. NOT real RAM exhaustion.
110
  - **`voice_panel` must stay MOUNTED** (`elem_classes="voice-channel"` + CSS collapse to 0 size), NOT `visible=False` — an invisible Gradio component can unmount so the `<audio>` never reaches the DOM and never plays. Voice audio goes here, NEVER in the entity panel (would break the heartbeat's byte-identical HTML).
111
  - **Idle is client-timed via the `<head>` controller:** a hidden `#idle-trigger` button runs `_on_idle`; the server only guards against pending/ended states. Any interaction resets the 60 s clock.
112
+ - **Browser autoplay blocks audio before the visitor's first gesture** — the head controller starts the menu loop on the first menu gesture, then plays the chime → greeting ritual when a mode is selected or "begin again" is clicked. The controller applies volume/mute to queued voice audio and the menu bed via MutationObserver + direct reference.
113
  - **Mute is client-side, not server-side:** never gate `speak()` on a `voice_on` flag. The server always sends audio; the browser mutes the playing element.
114
  - **Media mute = control the element, never replay.** Mute must toggle the *playing* audio's `.muted` (client-side); do NOT clear-then-replay-the-last-line on un-mute — that produced an infinite re-play loop (bug we hit). The browser owns playback state, not the server.
115
  - **Never default a time/clock field to `0.0`.** `time.time() - 0` ≈ 1.7e9 s, so any "elapsed since" gate fires instantly (bug: the idle nudge fired every 4 s, not after 35 s). Stamp at session start, or better, time it client-side.
 
119
  - **Ollama 500 = the ROCm `cudaMalloc` corruption** (see above), NOT our code — a short prompt may pass while a real 300-token turn 500s. Symptom in-app: every reply is the "fog swallowed your words" fallback. Fix: restart Ollama (kill `ollama.exe` + `ollama app.exe`, reopen). The Space (transformers) is immune.
120
 
121
  ## What's next
122
+ - **Current work is on branch `feat/front-door-menu`, branched off `feat/voice-awareness`, NOT deployed.** Both Spaces are frozen at the pre-voice commit (`4deeed5`). Done on the branch (231 tests green): front-door menu (Direction C), Tester/Full pacing modes, How to Play overlay, menu music + mode-select chimegreeting ritual.
123
+ - **Before merge/deploy:** live playthrough in `.venv-tts` + Ollama with the user to verify menu visuals, music, mode-select ritual, mute, and both pacing arcs; then merge `feat/front-door-menu` `feat/voice-awareness`. Deploy remains **mirror-first**.
124
+ - **README/FIELD_NOTES:** update the stale "no JS" boast; the only JS satisfies browser autoplay-gesture + audio/idle + menu music.
125
+ - **`FIELD_NOTES.md`** and deliverables (demo video, LinkedIn post) are Pablo's.
126
+ - **Mon June 15:** submit early + community vote.
 
127
 
128
  ## Workflows
129
  **Deploy** (from repo root): **mirror-first** — `git push space main` → wait ~3-4 min (Kokoro/spacy build is heavy), **Ctrl+F5**, verify build+voice live → only then `git push hackathon main`. If the mirror build breaks, the submission stays untouched. Commit trailer: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
app.py CHANGED
@@ -21,10 +21,14 @@ from engine import run_turn, run_turn_stream
21
  from finale import finale_steps, finale_steps_bad, finale_steps_good
22
  from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal
23
  from render import render_entity, render_treasure
24
- from sting import chime_wav_bytes
25
  from voice import speak
26
 
27
 
 
 
 
 
28
  def _render_bond(affinity: int, tier: str, label: str = "bond") -> str:
29
  """Custom HTML Bond meter — a thin thread with a heartbeat marker.
30
  Replaces gr.Slider so we fully control the look (no Gradio orange/track)."""
@@ -63,6 +67,7 @@ def _pristine_state():
63
  "last_activity": time.time(), # idle clock; now client-side (Phase 2)
64
  "idle_count": 0,
65
  "greeted": False,
 
66
  }
67
 
68
 
@@ -109,6 +114,7 @@ def _init_state():
109
  "last_activity": time.time(),
110
  "idle_count": 0,
111
  "greeted": False,
 
112
  }
113
  if fast == "bad":
114
  return {
@@ -130,6 +136,7 @@ def _init_state():
130
  "last_activity": time.time(),
131
  "idle_count": 0,
132
  "greeted": False,
 
133
  }
134
  return _pristine_state()
135
 
@@ -140,6 +147,23 @@ def _init_state():
140
  _PENDING = '<span class="hollow-typing"><i></i><i></i><i></i></span>'
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def _voice_html(b64: str | None) -> str:
144
  # autoplay the whisper from its own channel so the entity heartbeat HTML
145
  # stays byte-identical (never remounts). Empty when silent.
@@ -171,17 +195,18 @@ def _new_sentences(text: str, consumed: int) -> tuple[list[str], int]:
171
  # and on every "begin again".
172
  _CHIME_B64 = base64.b64encode(chime_wav_bytes(0)).decode()
173
  _GREETING_B64 = speak(OPENING_LINE) or ""
 
174
  _CHIME_URI = f"data:audio/wav;base64,{_CHIME_B64}"
175
  _GREETING_URI = f"data:audio/wav;base64,{_GREETING_B64}" if _GREETING_B64 else ""
 
176
 
177
 
178
- # The app's only JavaScript: a <head> controller that (1) plays the opening
179
- # ritual (chime -> greeting) on the visitor's first gesture and on every
180
- # "begin again", (2) owns a global mute for the child's voice
181
- # (the <audio> in .voice-channel only) and applies it as Gradio remounts those
182
- # nodes, driving the mute button, and (3) times the idle "speak-first"
183
- # client-side so ANY interaction resets it. Every horror effect (flashes,
184
- # screamer, looping heartbeat) stays pure CSS.
185
  _HEAD_JS = """
186
  <script>
187
  (function () {
@@ -195,65 +220,76 @@ _HEAD_JS = """
195
  function persist() {
196
  try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) {}
197
  }
198
- var CHIME_SRC = "__CHIME_SRC__";
199
- var GREETING_SRC = "__GREETING_SRC__";
200
- // --- client-side voice queue: sentences play in order, never cut ---
201
- var queue = [], current = null;
202
- function playNext() {
203
- if (current || !queue.length) return;
204
- current = queue.shift();
205
- current.muted = st.muted; current.volume = st.volume;
206
- current.onended = function () { current = null; playNext(); };
207
- current.play().catch(function () { current = null; });
208
- }
209
- function enqueue(src) {
210
- if (!src) return;
211
- queue.push(new Audio(src));
212
- playNext();
213
- }
214
- function applyLive() { // mute/volume affect the playing line
215
- if (current) { current.muted = st.muted; current.volume = st.volume; }
216
- }
217
- function resetQueue() {
218
- queue.length = 0;
219
- if (current) { try { current.pause(); } catch (e) {} current = null; }
220
- }
221
- var _lastRitual = 0;
222
- function openingRitual() { // chime, then the child greets
223
- var now = Date.now();
224
- if (now - _lastRitual < 800) return; // debounce double-fire
225
- _lastRitual = now;
226
- resetQueue();
227
- if (CHIME_SRC) enqueue(CHIME_SRC);
228
- if (GREETING_SRC) enqueue(GREETING_SRC);
229
- }
230
- // read any voice <audio> Gradio mounts, copy its src into the queue, then it
231
- // can be wiped from the DOM without stopping playback
232
- new MutationObserver(function (muts) {
233
- muts.forEach(function (m) {
234
- m.addedNodes.forEach(function (n) {
235
- if (n.nodeType !== 1) return;
236
- var nodes = [];
237
- if (n.matches && n.matches('.voice-channel audio')) nodes.push(n);
238
- if (n.querySelectorAll) n.querySelectorAll('.voice-channel audio').forEach(function (x) { nodes.push(x); });
239
- nodes.forEach(function (a) { enqueue(a.currentSrc || a.src); a.remove(); });
240
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  });
242
- }).observe(document.documentElement, { childList: true, subtree: true });
243
-
244
- // opening ritual on the visitor's first gesture (browsers block audio
245
- // until then): the chime, then the child's greeting
246
- function firstGesture() {
247
- openingRitual();
248
- window.removeEventListener('pointerdown', firstGesture, true);
249
- window.removeEventListener('keydown', firstGesture, true);
250
- }
251
  window.addEventListener('pointerdown', firstGesture, true);
252
  window.addEventListener('keydown', firstGesture, true);
253
 
254
- // mute button + restart button, wired once the header has rendered (no
255
- // volume slider — mute only, kept subtle). Voice plays at a fixed comfortable
256
- // level (st.volume).
257
  function icon() { return st.muted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; }
258
  function wire() {
259
  var btn = document.querySelector('.voice-btn');
@@ -264,7 +300,9 @@ _HEAD_JS = """
264
  btn.addEventListener('click', function (e) {
265
  e.preventDefault(); e.stopPropagation();
266
  st.muted = !st.muted;
267
- applyLive(); persist(); btn.textContent = icon();
 
 
268
  });
269
  }
270
  if (rb && !rb.dataset.hollowWired) {
@@ -273,10 +311,14 @@ _HEAD_JS = """
273
  // the click is a gesture, so the ritual audio is allowed to play
274
  rb.addEventListener('click', function () { openingRitual(); });
275
  }
276
- return !!(btn && btn.dataset.hollowWired && rb && rb.dataset.hollowWired);
 
 
 
 
277
  }
278
- var tries = 0;
279
- var t = setInterval(function () { if (wire() || ++tries > 60) clearInterval(t); }, 250);
280
 
281
  // idle "speak-first": client-timed, reset by ANY interaction, fires at 60s
282
  var IDLE_MS = 60000;
@@ -294,7 +336,40 @@ _HEAD_JS = """
294
  })();
295
  </script>
296
  """
297
- _HEAD_JS = _HEAD_JS.replace("__CHIME_SRC__", _CHIME_URI).replace("__GREETING_SRC__", _GREETING_URI)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
 
300
  def _audio_seconds(b64: str) -> float:
@@ -718,90 +793,117 @@ def chat(user_msg: str, state: dict, history: list):
718
  with gr.Blocks(title="Hollow") as demo:
719
  state = gr.State(_init_state)
720
 
721
- with gr.Row():
722
- gr.Markdown(
723
- "## Hollow\n*something is here, at the edge of the wood.<br>"
724
- "it remembers what you give it — and how you give it.*"
725
- )
726
- restart_btn = gr.Button(" begin again", elem_classes="restart-btn",
727
- scale=0)
728
- voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
729
-
730
- with gr.Row():
731
- with gr.Column(scale=1, min_width=220):
732
- entity_panel = gr.HTML(value=render_entity(20))
733
-
734
- with gr.Column(scale=2):
735
- chatbot = gr.Chatbot(
736
- value=[{"role": "assistant", "content": OPENING_LINE}],
737
- label="",
738
- show_label=False,
739
- height=500,
740
- buttons=[],
741
- autoscroll=True,
742
  )
743
-
744
- bond_panel = gr.HTML(value=_render_bond(20, "Hollow"))
745
-
746
- with gr.Row():
747
- msg_input = gr.Textbox(
748
- placeholder="tell it something you remember...",
 
 
 
 
 
 
749
  show_label=False,
750
- scale=9,
751
- autofocus=True,
 
752
  )
753
- send_btn = gr.Button("→", scale=1)
754
-
755
- with gr.Column(scale=1):
756
- treasure_panel = gr.HTML(value=render_treasure([]))
757
- # carries Hollow's voice <audio>; kept MOUNTED (not visible=False,
758
- # which can unmount it so the audio never plays) and collapsed to
759
- # zero size via CSS so it has no visual footprint
760
- voice_panel = gr.HTML(value="",
761
- elem_classes="voice-channel")
762
-
763
- # show_progress="hidden" kills Gradio's per-component "processing" overlay;
764
- # we show our own "..." pending bubble instead (set in _start_turn)
765
- send_btn.click(
766
- fn=_start_turn,
767
- inputs=[msg_input, state, chatbot],
768
- outputs=[msg_input, send_btn, chatbot, state],
769
- show_progress="hidden",
770
- ).then(
771
- fn=chat,
772
- inputs=[msg_input, state, chatbot],
773
- outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, entity_panel, voice_panel],
774
- show_progress="hidden",
775
- )
776
 
777
- msg_input.submit(
778
- fn=_start_turn,
779
- inputs=[msg_input, state, chatbot],
780
- outputs=[msg_input, send_btn, chatbot, state],
781
- show_progress="hidden",
782
- ).then(
783
- fn=chat,
784
- inputs=[msg_input, state, chatbot],
785
- outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, entity_panel, voice_panel],
786
- show_progress="hidden",
787
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
 
789
- restart_btn.click(
790
- fn=_reset,
791
- inputs=None,
792
- outputs=[msg_input, send_btn, chatbot, state, bond_panel,
793
- treasure_panel, entity_panel, voice_panel],
794
- show_progress="hidden",
795
- )
 
 
 
 
 
 
 
 
 
 
 
 
796
 
797
- # idle is timed in the browser (see _HEAD_JS): after 60s of true silence
798
- # the controller clicks this hidden button, which runs the same _on_idle.
799
- # Mounted + CSS-hidden (NOT visible=False, which can unmount it).
800
- idle_trigger = gr.Button("idle", elem_id="idle-trigger",
801
- elem_classes="idle-trigger")
802
- idle_trigger.click(_on_idle, [state, chatbot],
803
- [state, chatbot, voice_panel, bond_panel],
804
- show_progress="hidden")
 
 
 
 
 
 
 
 
 
 
 
 
805
 
806
  if __name__ == "__main__":
807
  # head= injects the one-shot gesture listener into <head> (Gradio 6.0 moved
 
21
  from finale import finale_steps, finale_steps_bad, finale_steps_good
22
  from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal
23
  from render import render_entity, render_treasure
24
+ from sting import chime_wav_bytes, menu_loop_wav_bytes
25
  from voice import speak
26
 
27
 
28
+ with open("assets/background.webp", "rb") as _bg:
29
+ _BACKGROUND_URI = "data:image/webp;base64," + base64.b64encode(_bg.read()).decode()
30
+
31
+
32
  def _render_bond(affinity: int, tier: str, label: str = "bond") -> str:
33
  """Custom HTML Bond meter — a thin thread with a heartbeat marker.
34
  Replaces gr.Slider so we fully control the look (no Gradio orange/track)."""
 
67
  "last_activity": time.time(), # idle clock; now client-side (Phase 2)
68
  "idle_count": 0,
69
  "greeted": False,
70
+ "mode": "tester",
71
  }
72
 
73
 
 
114
  "last_activity": time.time(),
115
  "idle_count": 0,
116
  "greeted": False,
117
+ "mode": "tester",
118
  }
119
  if fast == "bad":
120
  return {
 
136
  "last_activity": time.time(),
137
  "idle_count": 0,
138
  "greeted": False,
139
+ "mode": "tester",
140
  }
141
  return _pristine_state()
142
 
 
147
  _PENDING = '<span class="hollow-typing"><i></i><i></i><i></i></span>'
148
 
149
 
150
+ _HOWTO_HTML = (
151
+ '<div class="howto-panel">'
152
+ '<h3>How to Play</h3>'
153
+ '<p>You found a child at the edge of the wood. It has no memories of its own '
154
+ '&mdash; so it asks for yours.</p>'
155
+ '<p>Tell it something true: a person, a place, a moment you lived. It keeps each '
156
+ 'one in its treasure.</p>'
157
+ '<p>Later it speaks your memories back &mdash; in the first person, as if it had '
158
+ 'lived them. That is what it wants.</p>'
159
+ '<p>But it remembers how you treat it. Be gentle, or be cruel. '
160
+ '<b>Three endings</b> wait in the fog.</p>'
161
+ '<p class="howto-controls">type and press enter &middot; mute &middot; '
162
+ 'begin again &middot; turn your sound on</p>'
163
+ '</div>'
164
+ )
165
+
166
+
167
  def _voice_html(b64: str | None) -> str:
168
  # autoplay the whisper from its own channel so the entity heartbeat HTML
169
  # stays byte-identical (never remounts). Empty when silent.
 
195
  # and on every "begin again".
196
  _CHIME_B64 = base64.b64encode(chime_wav_bytes(0)).decode()
197
  _GREETING_B64 = speak(OPENING_LINE) or ""
198
+ _MENU_LOOP_B64 = base64.b64encode(menu_loop_wav_bytes()).decode()
199
  _CHIME_URI = f"data:audio/wav;base64,{_CHIME_B64}"
200
  _GREETING_URI = f"data:audio/wav;base64,{_GREETING_B64}" if _GREETING_B64 else ""
201
+ _MENU_LOOP_URI = f"data:audio/wav;base64,{_MENU_LOOP_B64}"
202
 
203
 
204
+ # The app's only JavaScript: a <head> controller that (1) starts the menu
205
+ # melody on the visitor's first menu gesture, (2) stops it and plays the
206
+ # chime → greeting ritual when a mode is selected, (3) owns a global mute
207
+ # for the child's voice and the menu bed, applying it as Gradio remounts
208
+ # nodes, driving the mute button, and (4) times the idle "speak-first"
209
+ # client-side so ANY interaction resets it. Every horror effect stays CSS.
 
210
  _HEAD_JS = """
211
  <script>
212
  (function () {
 
220
  function persist() {
221
  try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) {}
222
  }
223
+ var CHIME_SRC = "__CHIME__";
224
+ var GREETING_SRC = "__GREETING__";
225
+ var MENU_SRC = "__MENU__";
226
+ // --- client-side voice queue: sentences play in order, never cut ---
227
+ var queue = [], current = null;
228
+ function playNext() {
229
+ if (current || !queue.length) return;
230
+ current = queue.shift();
231
+ current.muted = st.muted; current.volume = st.volume;
232
+ current.onended = function () { current = null; playNext(); };
233
+ current.play().catch(function () { current = null; });
234
+ }
235
+ function enqueue(src) {
236
+ if (!src) return;
237
+ queue.push(new Audio(src));
238
+ playNext();
239
+ }
240
+ function applyLive() { // mute/volume affect the playing line
241
+ if (current) { current.muted = st.muted; current.volume = st.volume; }
242
+ }
243
+ function resetQueue() {
244
+ queue.length = 0;
245
+ if (current) { try { current.pause(); } catch (e) {} current = null; }
246
+ }
247
+ var _lastRitual = 0;
248
+ function openingRitual() { // chime, then the child greets
249
+ var now = Date.now();
250
+ if (now - _lastRitual < 800) return; // debounce double-fire
251
+ _lastRitual = now;
252
+ resetQueue();
253
+ if (CHIME_SRC) enqueue(CHIME_SRC);
254
+ if (GREETING_SRC) enqueue(GREETING_SRC);
255
+ }
256
+ // menu bed: starts on first gesture, stops when a mode is chosen
257
+ var menuAudio = null;
258
+ function startMenuMusic() {
259
+ if (menuAudio || !MENU_SRC) return;
260
+ menuAudio = document.getElementById('menu-loop') || new Audio(MENU_SRC);
261
+ menuAudio.loop = true;
262
+ menuAudio.volume = st.muted ? 0 : 0.45;
263
+ menuAudio.muted = st.muted;
264
+ menuAudio.play().catch(function () {});
265
+ }
266
+ function stopMenuMusic() {
267
+ if (menuAudio) { try { menuAudio.pause(); } catch (e) {} menuAudio = null; }
268
+ }
269
+ // read any voice <audio> Gradio mounts, copy its src into the queue, then it
270
+ // can be wiped from the DOM without stopping playback
271
+ new MutationObserver(function (muts) {
272
+ muts.forEach(function (m) {
273
+ m.addedNodes.forEach(function (n) {
274
+ if (n.nodeType !== 1) return;
275
+ var nodes = [];
276
+ if (n.matches && n.matches('.voice-channel audio')) nodes.push(n);
277
+ if (n.querySelectorAll) n.querySelectorAll('.voice-channel audio').forEach(function (x) { nodes.push(x); });
278
+ nodes.forEach(function (a) { enqueue(a.currentSrc || a.src); a.remove(); });
279
  });
280
+ });
281
+ }).observe(document.documentElement, { childList: true, subtree: true });
282
+
283
+ // first menu gesture starts the eerie bed (the greeting fires on mode-select)
284
+ function firstGesture() {
285
+ startMenuMusic();
286
+ window.removeEventListener('pointerdown', firstGesture, true);
287
+ window.removeEventListener('keydown', firstGesture, true);
288
+ }
289
  window.addEventListener('pointerdown', firstGesture, true);
290
  window.addEventListener('keydown', firstGesture, true);
291
 
292
+ // mute button + restart + mode buttons, wired once rendered
 
 
293
  function icon() { return st.muted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; }
294
  function wire() {
295
  var btn = document.querySelector('.voice-btn');
 
300
  btn.addEventListener('click', function (e) {
301
  e.preventDefault(); e.stopPropagation();
302
  st.muted = !st.muted;
303
+ applyLive();
304
+ if (menuAudio) { menuAudio.muted = st.muted; menuAudio.volume = st.muted ? 0 : 0.45; }
305
+ persist(); btn.textContent = icon();
306
  });
307
  }
308
  if (rb && !rb.dataset.hollowWired) {
 
311
  // the click is a gesture, so the ritual audio is allowed to play
312
  rb.addEventListener('click', function () { openingRitual(); });
313
  }
314
+ document.querySelectorAll('.menu-mode-btn').forEach(function (mb) {
315
+ if (mb.dataset.hollowWired) return;
316
+ mb.dataset.hollowWired = '1';
317
+ mb.addEventListener('click', function () { stopMenuMusic(); openingRitual(); });
318
+ });
319
  }
320
+ // keep watching — buttons can appear later when the menu flips to the game
321
+ setInterval(wire, 250);
322
 
323
  // idle "speak-first": client-timed, reset by ANY interaction, fires at 60s
324
  var IDLE_MS = 60000;
 
336
  })();
337
  </script>
338
  """
339
+ _HEAD_JS = _HEAD_JS.replace("__CHIME__", _CHIME_URI).replace("__GREETING__", _GREETING_URI).replace("__MENU__", _MENU_LOOP_URI)
340
+
341
+
342
+ def _menu_html() -> str:
343
+ return (
344
+ f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
345
+ '<div class="menu-scrim"></div>'
346
+ '<div class="menu-grain"></div>'
347
+ '<div class="menu-vig"></div>'
348
+ '<div class="menu-content">'
349
+ ' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
350
+ ' <div class="menu-title">Hollow</div>'
351
+ ' <div class="menu-tag">something is here, at the edge of the wood '
352
+ '&mdash; it remembers what you give it.</div>'
353
+ '</div>'
354
+ '<div class="menu-credit">A LOST CHILD WAITS &middot; TURN YOUR SOUND ON</div>'
355
+ )
356
+
357
+
358
+ def _enter_game(mode: str):
359
+ """Leave the menu, reveal the game, start a fresh run in the chosen mode.
360
+ Order matches the outputs list below."""
361
+ state = _init_state() # honors HOLLOW_FAST_FINALE for dev
362
+ state["mode"] = mode # "tester" | "full"
363
+ return (
364
+ gr.update(visible=False), # menu_view
365
+ gr.update(visible=True), # game_view
366
+ state,
367
+ [{"role": "assistant", "content": OPENING_LINE}], # chatbot
368
+ _render_bond(state["affinity"], get_tier(state["affinity"])),
369
+ render_treasure(state["treasure"], claimed=set(state["claimed"]),
370
+ wounds=state.get("wounds", [])),
371
+ render_entity(state["affinity"]),
372
+ )
373
 
374
 
375
  def _audio_seconds(b64: str) -> float:
 
793
  with gr.Blocks(title="Hollow") as demo:
794
  state = gr.State(_init_state)
795
 
796
+ with gr.Column(visible=True, elem_id="menu-view") as menu_view:
797
+ menu_audio = gr.HTML('<audio id="menu-loop" loop src="' + _MENU_LOOP_URI + '"></audio>')
798
+ menu_art = gr.HTML(_menu_html())
799
+ tester_btn = gr.Button("Tester Run", elem_id="btn-tester",
800
+ elem_classes="menu-btn menu-mode-btn")
801
+ full_btn = gr.Button("The Full Stay", elem_id="btn-full",
802
+ elem_classes="menu-btn menu-mode-btn")
803
+ howto_btn = gr.Button("How to Play", elem_id="btn-howto",
804
+ elem_classes="menu-btn")
805
+ with gr.Column(visible=False, elem_id="howto-overlay") as howto_overlay:
806
+ gr.HTML(_HOWTO_HTML)
807
+ howto_close = gr.Button("close", elem_id="btn-howto-close",
808
+ elem_classes="menu-btn")
809
+
810
+ with gr.Column(visible=False, elem_id="game-view") as game_view:
811
+ with gr.Row():
812
+ gr.Markdown(
813
+ "## Hollow\n*something is here, at the edge of the wood.<br>"
814
+ "it remembers what you give it — and how you give it.*"
 
 
815
  )
816
+ restart_btn = gr.Button("↺ begin again", elem_classes="restart-btn",
817
+ scale=0)
818
+ voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
819
+
820
+ with gr.Row():
821
+ with gr.Column(scale=1, min_width=220):
822
+ entity_panel = gr.HTML(value=render_entity(20))
823
+
824
+ with gr.Column(scale=2):
825
+ chatbot = gr.Chatbot(
826
+ value=[{"role": "assistant", "content": OPENING_LINE}],
827
+ label="",
828
  show_label=False,
829
+ height=500,
830
+ buttons=[],
831
+ autoscroll=True,
832
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833
 
834
+ bond_panel = gr.HTML(value=_render_bond(20, "Hollow"))
835
+
836
+ with gr.Row():
837
+ msg_input = gr.Textbox(
838
+ placeholder="tell it something you remember...",
839
+ show_label=False,
840
+ scale=9,
841
+ autofocus=True,
842
+ )
843
+ send_btn = gr.Button("", scale=1)
844
+
845
+ with gr.Column(scale=1):
846
+ treasure_panel = gr.HTML(value=render_treasure([]))
847
+ # carries Hollow's voice <audio>; kept MOUNTED (not visible=False,
848
+ # which can unmount it so the audio never plays) and collapsed to
849
+ # zero size via CSS so it has no visual footprint
850
+ voice_panel = gr.HTML(value="",
851
+ elem_classes="voice-channel")
852
+
853
+ # show_progress="hidden" kills Gradio's per-component "processing" overlay;
854
+ # we show our own "..." pending bubble instead (set in _start_turn)
855
+ send_btn.click(
856
+ fn=_start_turn,
857
+ inputs=[msg_input, state, chatbot],
858
+ outputs=[msg_input, send_btn, chatbot, state],
859
+ show_progress="hidden",
860
+ ).then(
861
+ fn=chat,
862
+ inputs=[msg_input, state, chatbot],
863
+ outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, entity_panel, voice_panel],
864
+ show_progress="hidden",
865
+ )
866
 
867
+ msg_input.submit(
868
+ fn=_start_turn,
869
+ inputs=[msg_input, state, chatbot],
870
+ outputs=[msg_input, send_btn, chatbot, state],
871
+ show_progress="hidden",
872
+ ).then(
873
+ fn=chat,
874
+ inputs=[msg_input, state, chatbot],
875
+ outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, entity_panel, voice_panel],
876
+ show_progress="hidden",
877
+ )
878
+
879
+ restart_btn.click(
880
+ fn=_reset,
881
+ inputs=None,
882
+ outputs=[msg_input, send_btn, chatbot, state, bond_panel,
883
+ treasure_panel, entity_panel, voice_panel],
884
+ show_progress="hidden",
885
+ )
886
 
887
+ # idle is timed in the browser (see _HEAD_JS): after 60s of true silence
888
+ # the controller clicks this hidden button, which runs the same _on_idle.
889
+ # Mounted + CSS-hidden (NOT visible=False, which can unmount it).
890
+ idle_trigger = gr.Button("idle", elem_id="idle-trigger",
891
+ elem_classes="idle-trigger")
892
+ idle_trigger.click(_on_idle, [state, chatbot],
893
+ [state, chatbot, voice_panel, bond_panel],
894
+ show_progress="hidden")
895
+
896
+ _enter_outputs = [menu_view, game_view, state, chatbot, bond_panel,
897
+ treasure_panel, entity_panel]
898
+ tester_btn.click(lambda: _enter_game("tester"), None, _enter_outputs,
899
+ show_progress="hidden")
900
+ full_btn.click(lambda: _enter_game("full"), None, _enter_outputs,
901
+ show_progress="hidden")
902
+
903
+ howto_btn.click(lambda: gr.update(visible=True), None, howto_overlay,
904
+ show_progress="hidden")
905
+ howto_close.click(lambda: gr.update(visible=False), None, howto_overlay,
906
+ show_progress="hidden")
907
 
908
  if __name__ == "__main__":
909
  # head= injects the one-shot gesture listener into <head> (Gradio 6.0 moved
assets/background.webp ADDED

Git LFS Details

  • SHA256: c053d5eafe9f14b805f356573236ac278d8a1d4b821c1c9e78c0863865379133
  • Pointer size: 131 Bytes
  • Size of remote file: 125 kB
memory.py CHANGED
@@ -2,6 +2,18 @@ import re
2
 
3
  from schemas import TurnUpdate
4
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  _TIERS = [
6
  (0, 25, "Hollow"),
7
  (26, 50, "Curious"),
@@ -81,8 +93,9 @@ def pick_aware_memory(state: dict, exclude: str | None) -> str | None:
81
 
82
 
83
  def should_recall(state: dict) -> tuple[bool, str | None]:
 
84
  unclaimed = [m for m in state["treasure"] if m not in state["claimed"]]
85
- if not unclaimed:
86
  return False, None
87
 
88
  # Claim the richest (longest, most evocative) memory — detail lands the wow.
@@ -90,8 +103,8 @@ def should_recall(state: dict) -> tuple[bool, str | None]:
90
 
91
  last = state.get("last_recall_turn")
92
  if last is None:
93
- return True, richest # first recall as soon as there's a memory
94
- if state["turn"] - last >= 2:
95
  return True, richest
96
  return False, None
97
 
@@ -110,12 +123,13 @@ def decide_ending(state: dict) -> str | None:
110
 
111
  One gate, branched by tone: no lower threshold can steal a warm run.
112
  """
 
113
  if state.get("ended"):
114
  return None
115
  tone = state.get("tone", 0)
116
- if tone <= -30 and state["turn"] >= 4 and len(state.get("wounds", [])) >= 2:
117
  return "bad"
118
- if state["affinity"] >= 50 and len(state["claimed"]) >= 2:
119
  # redemption must be earned with genuine, sustained warmth; ordinary
120
  # transactional play ("fed it like a diary") lands in the Visitor Loop
121
  return "good" if tone >= 20 else "loop"
 
2
 
3
  from schemas import TurnUpdate
4
 
5
+ PACING = {
6
+ "tester": {"recall_min_memories": 1, "recall_cooldown": 2,
7
+ "end_affinity": 50, "end_claimed": 2, "bad_min_turn": 4},
8
+ "full": {"recall_min_memories": 2, "recall_cooldown": 3,
9
+ "end_affinity": 68, "end_claimed": 3, "bad_min_turn": 6},
10
+ }
11
+
12
+
13
+ def _cfg(state: dict) -> dict:
14
+ return PACING.get(state.get("mode", "tester"), PACING["tester"])
15
+
16
+
17
  _TIERS = [
18
  (0, 25, "Hollow"),
19
  (26, 50, "Curious"),
 
93
 
94
 
95
  def should_recall(state: dict) -> tuple[bool, str | None]:
96
+ cfg = _cfg(state)
97
  unclaimed = [m for m in state["treasure"] if m not in state["claimed"]]
98
+ if len(unclaimed) < cfg["recall_min_memories"]:
99
  return False, None
100
 
101
  # Claim the richest (longest, most evocative) memory — detail lands the wow.
 
103
 
104
  last = state.get("last_recall_turn")
105
  if last is None:
106
+ return True, richest # first recall as soon as threshold is met
107
+ if state["turn"] - last >= cfg["recall_cooldown"]:
108
  return True, richest
109
  return False, None
110
 
 
123
 
124
  One gate, branched by tone: no lower threshold can steal a warm run.
125
  """
126
+ cfg = _cfg(state)
127
  if state.get("ended"):
128
  return None
129
  tone = state.get("tone", 0)
130
+ if tone <= -30 and state["turn"] >= cfg["bad_min_turn"] and len(state.get("wounds", [])) >= 2:
131
  return "bad"
132
+ if state["affinity"] >= cfg["end_affinity"] and len(state["claimed"]) >= cfg["end_claimed"]:
133
  # redemption must be earned with genuine, sustained warmth; ordinary
134
  # transactional play ("fed it like a diary") lands in the Visitor Loop
135
  return "good" if tone >= 20 else "loop"
sting.py CHANGED
@@ -164,6 +164,58 @@ def flatline_wav_bytes() -> bytes:
164
  return _wav(sig, sr)
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  if __name__ == "__main__":
168
  from pathlib import Path
169
  here = Path(__file__).parent
@@ -173,4 +225,5 @@ if __name__ == "__main__":
173
  (here / "heartbeat_preview.wav").write_bytes(heartbeat_wav_bytes())
174
  (here / "sigh_preview.wav").write_bytes(sigh_wav_bytes())
175
  (here / "flatline_preview.wav").write_bytes(flatline_wav_bytes())
176
- print("written sting_preview.wav + chime_preview_0/1/2.wav")
 
 
164
  return _wav(sig, sr)
165
 
166
 
167
+ def menu_loop_wav_bytes() -> bytes:
168
+ """Eerie menu bed: a low drone and a slow detuned music-box motif,
169
+ built to loop seamlessly. Kept very quiet so it waits behind the fog."""
170
+ sr = 22050
171
+ dur = 8.0
172
+ t = np.linspace(0, dur, int(sr * dur), endpoint=False)
173
+ rng = np.random.default_rng(19)
174
+
175
+ # low A1 drone + soft fifth
176
+ drone = (np.sin(2 * np.pi * 55 * t)
177
+ + 0.5 * np.sin(2 * np.pi * 82.5 * t)) * 0.08
178
+
179
+ # slow A-minor motif, each note with a gentle tine spectrum
180
+ notes = [
181
+ (440.00, 1.0, 2.2), # A4
182
+ (523.25, 2.5, 3.7), # C5
183
+ (659.25, 4.0, 5.2), # E5
184
+ (587.33, 5.5, 6.7), # D5
185
+ (440.00, 7.0, 8.0), # A4
186
+ ]
187
+ motif = np.zeros_like(t)
188
+ for f, start, end in notes:
189
+ env = np.where((t >= start) & (t < end),
190
+ np.sin(np.pi * (t - start) / (end - start)) ** 2, 0)
191
+ note = (np.sin(2 * np.pi * f * t)
192
+ + 0.3 * np.sin(2 * np.pi * 2.76 * f * t)
193
+ + 0.15 * np.sin(2 * np.pi * 5.4 * f * t))
194
+ # breath-detuned second tine
195
+ note += np.sin(2 * np.pi * f * 1.005 * t)
196
+ motif += note * env * 0.11
197
+
198
+ # simple room echo (two taps) so the motif hangs in the air
199
+ echo = np.zeros_like(t)
200
+ for delay, gain in ((0.22, 0.28), (0.44, 0.13)):
201
+ ds = int(delay * sr)
202
+ echo[ds:] += motif[:-ds] * gain
203
+
204
+ # very faint noise texture
205
+ noise = rng.standard_normal(len(t)) * 0.015
206
+
207
+ mix = drone + motif + echo + noise
208
+
209
+ # seamless loop: long fade in and out
210
+ fade = int(0.6 * sr)
211
+ mix[:fade] *= np.linspace(0, 1, fade)
212
+ mix[-fade:] *= np.linspace(1, 0, fade)
213
+
214
+ mix = np.tanh(2.0 * mix)
215
+ mix = mix / np.max(np.abs(mix)) * 0.22
216
+ return _wav(mix, sr)
217
+
218
+
219
  if __name__ == "__main__":
220
  from pathlib import Path
221
  here = Path(__file__).parent
 
225
  (here / "heartbeat_preview.wav").write_bytes(heartbeat_wav_bytes())
226
  (here / "sigh_preview.wav").write_bytes(sigh_wav_bytes())
227
  (here / "flatline_preview.wav").write_bytes(flatline_wav_bytes())
228
+ (here / "menu_loop_preview.wav").write_bytes(menu_loop_wav_bytes())
229
+ print("written sting_preview.wav + chime_preview_0/1/2.wav + menu_loop_preview.wav")
styles.css CHANGED
@@ -860,3 +860,200 @@ footer { display: none !important; }
860
 
861
  /* idle trigger: mounted but invisible */
862
  #idle-trigger, .idle-trigger { display: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
 
861
  /* idle trigger: mounted but invisible */
862
  #idle-trigger, .idle-trigger { display: none !important; }
863
+
864
+ /* ── 23. Opening menu — Direction C ── */
865
+ #menu-view {
866
+ position: relative;
867
+ min-height: 560px;
868
+ display: flex;
869
+ flex-direction: column;
870
+ align-items: center;
871
+ justify-content: flex-start;
872
+ overflow: hidden;
873
+ border: none !important;
874
+ background: #04040a;
875
+ }
876
+
877
+ .menu-bg {
878
+ position: absolute;
879
+ inset: 0;
880
+ background-size: cover;
881
+ background-position: center 38%;
882
+ opacity: 0.85;
883
+ }
884
+
885
+ .menu-scrim {
886
+ position: absolute;
887
+ inset: 0;
888
+ background: radial-gradient(ellipse at 50% 30%, transparent 25%, rgba(4,4,10,0.92) 100%);
889
+ pointer-events: none;
890
+ z-index: 1;
891
+ }
892
+
893
+ .menu-grain {
894
+ position: absolute;
895
+ inset: 0;
896
+ pointer-events: none;
897
+ z-index: 2;
898
+ opacity: 0.10;
899
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
900
+ }
901
+
902
+ .menu-vig {
903
+ position: absolute;
904
+ inset: 0;
905
+ pointer-events: none;
906
+ z-index: 2;
907
+ box-shadow: inset 0 0 120px rgba(0,0,0,0.75);
908
+ }
909
+
910
+ .menu-content {
911
+ position: relative;
912
+ z-index: 3;
913
+ text-align: center;
914
+ padding-top: 84px;
915
+ font-family: Georgia, serif;
916
+ }
917
+
918
+ .menu-title {
919
+ font-variant: small-caps;
920
+ letter-spacing: 0.18em;
921
+ font-weight: 700;
922
+ font-size: 78px;
923
+ color: #ece6f4;
924
+ text-shadow: 0 0 30px #000, 0 4px 12px #000;
925
+ }
926
+
927
+ .menu-eyebrow {
928
+ font-family: 'Special Elite', monospace;
929
+ font-size: 0.72rem;
930
+ letter-spacing: 0.28em;
931
+ text-transform: uppercase;
932
+ color: #7a6a92;
933
+ margin-bottom: 8px;
934
+ }
935
+
936
+ .menu-tag {
937
+ font-family: Georgia, serif;
938
+ font-size: 1.05rem;
939
+ font-style: italic;
940
+ color: #a098b8;
941
+ letter-spacing: 0.04em;
942
+ margin-top: 14px;
943
+ max-width: 420px;
944
+ }
945
+
946
+ .menu-credit {
947
+ position: absolute;
948
+ bottom: 18px;
949
+ left: 0; right: 0;
950
+ text-align: center;
951
+ z-index: 4;
952
+ font-family: 'Special Elite', monospace;
953
+ font-size: 0.64rem;
954
+ letter-spacing: 0.24em;
955
+ text-transform: uppercase;
956
+ color: #5a5070;
957
+ }
958
+
959
+ .menu-btn {
960
+ position: relative;
961
+ z-index: 4;
962
+ background: transparent !important;
963
+ border: none !important;
964
+ border-image-source: none !important;
965
+ box-shadow: none !important;
966
+ font-family: Georgia, serif !important;
967
+ letter-spacing: 0.16em !important;
968
+ color: #c4bede !important;
969
+ font-size: 1.05rem !important;
970
+ text-transform: none !important;
971
+ margin: 8px 0 !important;
972
+ padding: 8px 18px !important;
973
+ transition: color 0.35s, letter-spacing 0.35s, text-shadow 0.35s, background 0.35s !important;
974
+ }
975
+
976
+ .menu-btn:hover {
977
+ color: #f0ecfa !important;
978
+ letter-spacing: 0.22em !important;
979
+ text-shadow: 0 0 16px rgba(180,160,210,0.5) !important;
980
+ background: rgba(255,255,255,0.04) !important;
981
+ box-shadow: none !important;
982
+ }
983
+
984
+ .menu-btn::before {
985
+ content: '▸ ';
986
+ opacity: 0;
987
+ margin-right: 0;
988
+ transition: opacity 0.35s, margin-right 0.35s;
989
+ }
990
+
991
+ .menu-btn:hover::before {
992
+ opacity: 1;
993
+ margin-right: 6px;
994
+ }
995
+
996
+ #btn-howto {
997
+ position: absolute;
998
+ top: 18px;
999
+ right: 18px;
1000
+ z-index: 5;
1001
+ font-size: 0.82rem !important;
1002
+ letter-spacing: 0.14em !important;
1003
+ color: #8a7aa2 !important;
1004
+ margin: 0 !important;
1005
+ }
1006
+
1007
+ #btn-howto:hover {
1008
+ color: #d8d0ec !important;
1009
+ background: rgba(255,255,255,0.04) !important;
1010
+ }
1011
+
1012
+ #btn-tester, #btn-full {
1013
+ margin-top: 44px !important;
1014
+ }
1015
+
1016
+ /* ── 24. How to Play overlay ── */
1017
+ #howto-overlay {
1018
+ position: absolute;
1019
+ inset: 0;
1020
+ z-index: 10;
1021
+ display: flex;
1022
+ align-items: center;
1023
+ justify-content: center;
1024
+ background: rgba(4, 4, 10, 0.88);
1025
+ }
1026
+
1027
+ .howto-panel {
1028
+ max-width: 560px;
1029
+ border: 11px solid transparent;
1030
+ border-image: var(--stone-frame) 30;
1031
+ background: var(--stone-base);
1032
+ padding: 26px 30px;
1033
+ font-family: Georgia, serif;
1034
+ color: #c4bede;
1035
+ line-height: 1.6;
1036
+ box-shadow: var(--stone-bevel);
1037
+ }
1038
+
1039
+ .howto-panel h3 {
1040
+ font-family: 'Special Elite', monospace;
1041
+ font-size: 1.15rem;
1042
+ letter-spacing: 0.18em;
1043
+ text-transform: uppercase;
1044
+ color: #e0d8f0;
1045
+ margin: 0 0 18px;
1046
+ text-align: center;
1047
+ }
1048
+
1049
+ .howto-panel p {
1050
+ margin: 0 0 12px;
1051
+ }
1052
+
1053
+ .howto-controls {
1054
+ margin-top: 18px !important;
1055
+ font-size: 0.8rem;
1056
+ letter-spacing: 0.12em;
1057
+ color: #6a6088;
1058
+ text-align: center;
1059
+ }
tests/test_app.py CHANGED
@@ -301,18 +301,61 @@ class TestVoice:
301
  assert any("FAKEB64" in o[7] for o in outs) # spoken somewhere in the stream
302
 
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  class TestOpeningRitual:
305
- def test_head_js_carries_the_ritual(self):
306
  assert "openingRitual" in app._HEAD_JS
 
 
307
  assert "CHIME_SRC" in app._HEAD_JS
308
  assert "GREETING_SRC" in app._HEAD_JS
 
309
  assert "restart-btn" in app._HEAD_JS # restart re-greets
310
- assert "__CHIME_SRC__" not in app._HEAD_JS # placeholder was filled
 
 
 
311
 
312
  def test_chime_constant_is_real_audio(self):
313
  assert app._CHIME_URI.startswith("data:audio/wav;base64,")
314
  assert len(app._CHIME_B64) > 100 # a real synthesized note
315
 
 
 
 
 
316
 
317
  class TestFinaleVoice:
318
  def test_good_finale_voices_hollow_lines(self, monkeypatch):
 
301
  assert any("FAKEB64" in o[7] for o in outs) # spoken somewhere in the stream
302
 
303
 
304
+ class TestEnterGame:
305
+ def test_enter_sets_mode_and_reveals_game(self):
306
+ outs = app._enter_game("full")
307
+ assert outs[0]["visible"] is False # menu hidden
308
+ assert outs[1]["visible"] is True # game shown
309
+ assert outs[2]["mode"] == "full" # mode stored on state
310
+ assert outs[3][0]["content"] == app.OPENING_LINE
311
+
312
+
313
+ class TestMenuAsset:
314
+ def test_menu_html_has_title_and_background(self):
315
+ assert "menu-title" in app._menu_html()
316
+ assert app._BACKGROUND_URI.startswith("data:image/webp")
317
+
318
+
319
+ class TestHowToPlay:
320
+ def test_howto_copy_present(self):
321
+ assert "Three endings" in app._HOWTO_HTML
322
+
323
+ def test_open_howto_reveals_overlay(self):
324
+ out = app.gr.update(visible=True)
325
+ assert out["visible"] is True
326
+
327
+ def test_close_howto_hides_overlay(self):
328
+ out = app.gr.update(visible=False)
329
+ assert out["visible"] is False
330
+ outs = app._enter_game("full")
331
+ assert outs[0]["visible"] is False # menu hidden
332
+ assert outs[1]["visible"] is True # game shown
333
+ assert outs[2]["mode"] == "full" # mode stored on state
334
+ assert outs[3][0]["content"] == app.OPENING_LINE
335
+
336
+
337
  class TestOpeningRitual:
338
+ def test_head_js_carries_the_ritual_and_menu_music(self):
339
  assert "openingRitual" in app._HEAD_JS
340
+ assert "startMenuMusic" in app._HEAD_JS
341
+ assert "stopMenuMusic" in app._HEAD_JS
342
  assert "CHIME_SRC" in app._HEAD_JS
343
  assert "GREETING_SRC" in app._HEAD_JS
344
+ assert "MENU_SRC" in app._HEAD_JS
345
  assert "restart-btn" in app._HEAD_JS # restart re-greets
346
+ assert "menu-mode-btn" in app._HEAD_JS # mode buttons stop music + greet
347
+ assert "__CHIME__" not in app._HEAD_JS # placeholders were filled
348
+ assert "__GREETING__" not in app._HEAD_JS
349
+ assert "__MENU__" not in app._HEAD_JS
350
 
351
  def test_chime_constant_is_real_audio(self):
352
  assert app._CHIME_URI.startswith("data:audio/wav;base64,")
353
  assert len(app._CHIME_B64) > 100 # a real synthesized note
354
 
355
+ def test_menu_loop_constant_is_real_audio(self):
356
+ assert app._MENU_LOOP_URI.startswith("data:audio/wav;base64,")
357
+ assert len(app._MENU_LOOP_B64) > 1000
358
+
359
 
360
  class TestFinaleVoice:
361
  def test_good_finale_voices_hollow_lines(self, monkeypatch):
tests/test_memory.py CHANGED
@@ -414,3 +414,31 @@ def test_bad_ending_fires_by_turn_four():
414
  s = {"affinity": 12, "claimed": [], "turn": 4, "tone": -32,
415
  "wounds": ["w1", "w2"], "ended": False}
416
  assert decide_ending(s) == "bad"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  s = {"affinity": 12, "claimed": [], "turn": 4, "tone": -32,
415
  "wounds": ["w1", "w2"], "ended": False}
416
  assert decide_ending(s) == "bad"
417
+
418
+
419
+ def test_full_mode_recall_waits_for_two_memories():
420
+ from memory import should_recall
421
+ one = {"affinity": 30, "treasure": ["a"], "claimed": [], "turn": 1,
422
+ "last_recall_turn": None, "mode": "full"}
423
+ assert should_recall(one)[0] is False # full needs >=2 before first recall
424
+ two = dict(one, treasure=["a", "bb"])
425
+ assert should_recall(two)[0] is True
426
+
427
+
428
+ def test_full_mode_ending_gate_is_higher():
429
+ from memory import decide_ending
430
+ mid = {"affinity": 55, "claimed": ["a", "b"], "turn": 8, "tone": 25,
431
+ "wounds": [], "ended": False, "mode": "full"}
432
+ assert decide_ending(mid) is None # 55 < full gate 68
433
+ hi = dict(mid, affinity=70, claimed=["a", "b", "c"])
434
+ assert decide_ending(hi) == "good"
435
+
436
+
437
+ def test_tester_mode_unchanged():
438
+ from memory import should_recall, decide_ending
439
+ s = {"affinity": 24, "treasure": ["x"], "claimed": [], "turn": 1,
440
+ "last_recall_turn": None, "mode": "tester"}
441
+ assert should_recall(s)[0] is True # tester: first memory -> recall
442
+ e = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 25,
443
+ "wounds": [], "ended": False, "mode": "tester"}
444
+ assert decide_ending(e) == "good"
tests/test_sting.py CHANGED
@@ -3,7 +3,7 @@ import io
3
  import wave
4
 
5
  from sting import (chime_wav_bytes, flatline_wav_bytes, heartbeat_wav_bytes,
6
- sigh_wav_bytes, sting_wav_bytes)
7
 
8
 
9
  def _frames(b: bytes) -> int:
@@ -30,3 +30,9 @@ def test_flatline_is_four_seconds():
30
  def test_existing_sounds_still_synthesize():
31
  assert len(sting_wav_bytes()) > 1000
32
  assert len(chime_wav_bytes(0)) > 500
 
 
 
 
 
 
 
3
  import wave
4
 
5
  from sting import (chime_wav_bytes, flatline_wav_bytes, heartbeat_wav_bytes,
6
+ menu_loop_wav_bytes, sigh_wav_bytes, sting_wav_bytes)
7
 
8
 
9
  def _frames(b: bytes) -> int:
 
30
  def test_existing_sounds_still_synthesize():
31
  assert len(sting_wav_bytes()) > 1000
32
  assert len(chime_wav_bytes(0)) > 500
33
+
34
+
35
+ def test_menu_loop_is_valid_wav():
36
+ b = menu_loop_wav_bytes()
37
+ assert len(b) > 2000
38
+ assert _frames(b) > int(22050 * 7)
tools/pacing_sim.py CHANGED
@@ -49,11 +49,11 @@ NEUTRAL = [ # LOOP: share a lot, factual/transactional, never warm to the child
49
  ]
50
 
51
 
52
- def simulate(name: str, script: list[str]) -> None:
53
  state = {"affinity": 20, "treasure": [], "claimed": [], "history": [],
54
  "turn": 0, "last_recall_turn": None, "ended": False,
55
- "tone": 0, "wounds": [], "ending": None}
56
- print(f"\n=== {name} ===")
57
  for i, msg in enumerate(script, 1):
58
  ending = decide_ending(state)
59
  if ending:
@@ -84,9 +84,10 @@ def simulate(name: str, script: list[str]) -> None:
84
 
85
  if __name__ == "__main__":
86
  which = sys.argv[1] if len(sys.argv) > 1 else "all"
 
87
  if which in ("bad", "all"):
88
- simulate("BAD", BAD)
89
  if which in ("good", "all"):
90
- simulate("GOOD", GOOD)
91
  if which in ("neutral", "all"):
92
- simulate("NEUTRAL", NEUTRAL)
 
49
  ]
50
 
51
 
52
+ def simulate(name: str, script: list[str], mode: str = "tester") -> None:
53
  state = {"affinity": 20, "treasure": [], "claimed": [], "history": [],
54
  "turn": 0, "last_recall_turn": None, "ended": False,
55
+ "tone": 0, "wounds": [], "ending": None, "mode": mode}
56
+ print(f"\n=== {name} ({mode}) ===")
57
  for i, msg in enumerate(script, 1):
58
  ending = decide_ending(state)
59
  if ending:
 
84
 
85
  if __name__ == "__main__":
86
  which = sys.argv[1] if len(sys.argv) > 1 else "all"
87
+ mode = sys.argv[2] if len(sys.argv) > 2 else "tester"
88
  if which in ("bad", "all"):
89
+ simulate("BAD", BAD, mode=mode)
90
  if which in ("good", "all"):
91
+ simulate("GOOD", GOOD, mode=mode)
92
  if which in ("neutral", "all"):
93
+ simulate("NEUTRAL", NEUTRAL, mode=mode)