Spaces:
Sleeping
Sleeping
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>
- AGENTS.md +18 -18
- CLAUDE.md +18 -18
- app.py +248 -146
- assets/background.webp +3 -0
- memory.py +19 -5
- sting.py +54 -1
- styles.css +197 -0
- tests/test_app.py +45 -2
- tests/test_memory.py +28 -0
- tests/test_sting.py +7 -1
- 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`
|
| 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-
|
| 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/ #
|
| 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 |
-
- **
|
| 105 |
-
- **
|
| 106 |
-
- **
|
| 107 |
-
-
|
|
|
|
| 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)
|
| 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
|
| 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`)
|
| 155 |
-
- **
|
| 156 |
-
- **
|
| 157 |
-
- **`FIELD_NOTES.md`**
|
| 158 |
-
- **
|
| 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≤−30 ∧ turn≥6 ∧ wounds≥2`; `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 chime→greeting 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`
|
| 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-
|
| 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/ #
|
| 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 |
-
- **
|
| 80 |
-
- **
|
| 81 |
-
- **
|
| 82 |
-
-
|
|
|
|
| 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)
|
| 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
|
| 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`)
|
| 122 |
-
- **
|
| 123 |
-
- **
|
| 124 |
-
- **`FIELD_NOTES.md`**
|
| 125 |
-
- **
|
| 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≤−30 ∧ turn≥6 ∧ wounds≥2`; `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 chime→greeting 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)
|
| 179 |
-
#
|
| 180 |
-
#
|
| 181 |
-
#
|
| 182 |
-
# nodes, driving the mute button, and (
|
| 183 |
-
# client-side so ANY interaction resets it. Every horror effect
|
| 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 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
});
|
| 242 |
-
})
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
window.addEventListener('pointerdown', firstGesture, true);
|
| 252 |
window.addEventListener('keydown', firstGesture, true);
|
| 253 |
|
| 254 |
-
// mute button + restart
|
| 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();
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
}
|
| 278 |
-
|
| 279 |
-
|
| 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("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 722 |
-
gr.
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
buttons=[],
|
| 741 |
-
autoscroll=True,
|
| 742 |
)
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
show_label=False,
|
| 750 |
-
|
| 751 |
-
|
|
|
|
| 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 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
'— 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 — 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 · mute · '
|
| 162 |
+
'begin again · 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 |
+
'— it remembers what you give it.</div>'
|
| 353 |
+
'</div>'
|
| 354 |
+
'<div class="menu-credit">A LOST CHILD WAITS · 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
|
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
|
| 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
|
| 94 |
-
if state["turn"] - last >=
|
| 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"] >=
|
| 117 |
return "bad"
|
| 118 |
-
if state["affinity"] >=
|
| 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 |
-
|
|
|
|
|
|
| 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
|
| 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 "
|
|
|
|
|
|
|
|
|
|
| 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)
|