Spaces:
Sleeping
Sleeping
| import os | |
| # cap CPU math threads BEFORE numpy/torch load (via gradio/engine/voice) — on | |
| # many-core machines under memory pressure OpenBLAS spawns one buffer-hungry | |
| # thread per core and the CPU allocator fails (Kokoro TTS won't load). 4 is | |
| # plenty for our CPU-side work; the model runs on the GPU on the Space. | |
| os.environ.setdefault("OMP_NUM_THREADS", "4") | |
| os.environ.setdefault("OPENBLAS_NUM_THREADS", "4") | |
| os.environ.setdefault("MKL_NUM_THREADS", "4") | |
| # Force client-side rendering. HF defaults the Gradio runtime to SSR via the | |
| # GRADIO_SSR_MODE env var, which runs the app behind a Node proxy in a SUBPROCESS. | |
| # That breaks (a) our _HEAD_JS / CSS that drive the client DOM (intro typewriter, | |
| # marker polling, layout) and (b) ZeroGPU — @spaces.GPU forks from that subprocess | |
| # and loses the GPU assignment ("No CUDA GPUs are available"). gradio resolves SSR | |
| # from this env when launch(ssr_mode=...) isn't reached (HF imports the app rather | |
| # than running __main__), so hard-override it (NOT setdefault: HF pre-sets True). | |
| os.environ["GRADIO_SSR_MODE"] = "False" | |
| import base64 | |
| import html | |
| import io | |
| import json | |
| import re | |
| import time | |
| import wave | |
| import gradio as gr | |
| from character import (FRAGMENT_TONES, INTRO_CARDS, INTRO_SEQUENCES, | |
| OPENING_LINE, OWN_FRAGMENTS, build_system_prompt) | |
| from engine import run_turn, run_turn_stream | |
| from finale import finale_steps, finale_steps_bad, finale_steps_good | |
| from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal | |
| from render import render_entity, render_treasure, render_recovered, _tone_class | |
| from sting import chime_wav_bytes, menu_loop_wav_bytes, type_tick_wav_bytes | |
| from voice import speak | |
| with open("assets/background.webp", "rb") as _bg: | |
| _BACKGROUND_URI = "data:image/webp;base64," + base64.b64encode(_bg.read()).decode() | |
| def _render_bond(affinity: int, tier: str, label: str = "bond") -> str: | |
| """Custom HTML Bond meter — a thin thread with a heartbeat marker. | |
| Replaces gr.Slider so we fully control the look (no Gradio orange/track).""" | |
| pct = max(0, min(100, int(affinity))) | |
| return ( | |
| '<div class="bond-meter">' | |
| '<div class="bond-head">' | |
| f'<span class="bond-name">{label}</span>' | |
| f'<span class="bond-tier">{tier}</span>' | |
| '</div>' | |
| '<div class="bond-track">' | |
| f'<div class="bond-fill" style="width:{pct}%"></div>' | |
| f'<div class="bond-beat" style="left:{pct}%"></div>' | |
| '</div>' | |
| '</div>' | |
| ) | |
| def _render_title(state: dict, show_end: bool = False) -> str: | |
| """The top-left title: 'Hollow' until the child names itself. On the | |
| finale's final frame (show_end), append a hidden end marker the head-JS | |
| polls to reveal the end screen (same marker pattern as tone/cue).""" | |
| name = state.get("chosen_name") | |
| if name: | |
| safe = html.escape(name, quote=True) | |
| title = ( | |
| f'<div id="game-title">{safe}' | |
| f'<span class="named"> — it calls itself <b>{safe}</b></span></div>' | |
| ) | |
| else: | |
| title = '<div id="game-title">Hollow</div>' | |
| if show_end and state.get("ended"): | |
| title += (f'<span class="ended-now" data-ending="{state.get("ending","loop")}" ' | |
| 'style="display:none"></span>') | |
| return title | |
| def _pristine_state(): | |
| """A brand-new game — the real starting point, independent of the dev | |
| HOLLOW_FAST_FINALE seed (the restart button must always give a clean run).""" | |
| return { | |
| "affinity": 20, | |
| "treasure": [], | |
| "claimed": [], | |
| "history": [], | |
| "turn": 0, | |
| "last_recall_turn": None, | |
| "ended": False, | |
| "tone": 0, | |
| "force_ending": None, | |
| "wounds": [], | |
| "ending": None, | |
| "fragments_told": 0, | |
| "last_aware_memory": None, | |
| "msg_lengths": [], | |
| "last_activity": time.time(), # idle clock; now client-side (Phase 2) | |
| "idle_count": 0, | |
| "greeted": False, | |
| "mode": "tester", | |
| "chosen_name": None, | |
| "named": False, | |
| } | |
| def _reset(): | |
| """Wipe the board for a fresh game without a page reload. Order matches | |
| [msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, | |
| entity_panel, voice_panel, recovered_panel, title_panel].""" | |
| fresh = _pristine_state() | |
| return ( | |
| gr.update(value="", interactive=True, | |
| placeholder="tell it something you remember..."), | |
| gr.update(interactive=True), | |
| [{"role": "assistant", "content": OPENING_LINE}], | |
| fresh, | |
| _render_bond(20, "Hollow"), | |
| render_treasure([]), | |
| render_entity(20), | |
| _voice_html(None), | |
| render_recovered(0), | |
| _render_title(fresh), | |
| ) | |
| def _to_menu(): | |
| """Leave the wood — back to the front-door menu with a clean slate. | |
| Order matches [menu_view, game_view, state].""" | |
| return gr.update(visible=True), gr.update(visible=False), _pristine_state() | |
| def _init_state(): | |
| fast = os.environ.get("HOLLOW_FAST_FINALE", "").lower() | |
| if fast in ("1", "good", "loop", "neutral"): # "neutral" kept as loop alias | |
| seeded = [ | |
| "is afraid of being forgotten", | |
| "had a dog named Nala", | |
| "grew up near the sea", | |
| ] | |
| warm = fast in ("1", "good") | |
| return { | |
| "affinity": 78, | |
| "treasure": list(seeded), | |
| "claimed": list(seeded), | |
| "history": [], | |
| "turn": 12, | |
| "last_recall_turn": 9, | |
| "ended": False, | |
| "tone": 30 if warm else 5, | |
| "force_ending": "good" if warm else "loop", | |
| "wounds": [], | |
| "ending": None, | |
| "fragments_told": 2 if warm else 0, | |
| "last_aware_memory": None, | |
| "msg_lengths": [], | |
| "last_activity": time.time(), | |
| "idle_count": 0, | |
| "greeted": False, | |
| "mode": "tester", | |
| "chosen_name": None, | |
| "named": False, | |
| } | |
| if fast == "bad": | |
| return { | |
| "affinity": 12, | |
| "treasure": [], | |
| "claimed": [], | |
| "history": [], | |
| "turn": 7, | |
| "last_recall_turn": None, | |
| "ended": False, | |
| # tone must clear the bad gate (<=-30) so the dev seed fires the | |
| # wound loop on the 2nd message WITHOUT a model call (like good/loop) | |
| "tone": -35, | |
| "force_ending": "bad", | |
| "wounds": ["you're nothing", "talking to you is a waste of time"], | |
| "ending": None, | |
| "fragments_told": 0, | |
| "last_aware_memory": None, | |
| "msg_lengths": [], | |
| "last_activity": time.time(), | |
| "idle_count": 0, | |
| "greeted": False, | |
| "mode": "tester", | |
| "chosen_name": None, | |
| "named": False, | |
| } | |
| return _pristine_state() | |
| # our own animated typing bubble — Gradio's progress overlay is disabled | |
| # (show_progress="hidden"); these three dots blink in sequence (CSS, §20) so | |
| # the wait reads as "Hollow is thinking", not a frozen screen | |
| _PENDING = '<span class="hollow-typing"><i></i><i></i><i></i></span>' | |
| _HOWTO_HTML = ( | |
| '<div class="howto-panel">' | |
| '<h3>How to Play</h3>' | |
| '<p>You found a child at the edge of the wood. It has no memories of its own ' | |
| '— so it asks for yours.</p>' | |
| '<p>Tell it something true: a person, a place, a moment you lived. It keeps each ' | |
| 'one in its treasure.</p>' | |
| '<p>Later it speaks your memories back — in the first person, as if it had ' | |
| 'lived them. That is what it wants.</p>' | |
| '<p>But it remembers how you treat it. Be gentle, or be cruel. ' | |
| '<b>Three endings</b> wait in the fog.</p>' | |
| '<p class="howto-controls">type and press enter · mute · ' | |
| 'begin again · turn your sound on</p>' | |
| '</div>' | |
| ) | |
| def _voice_html(b64: str | None) -> str: | |
| # autoplay the whisper from its own channel so the entity heartbeat HTML | |
| # stays byte-identical (never remounts). Empty when silent. | |
| if not b64: | |
| return "" | |
| return f'<audio autoplay src="data:audio/wav;base64,{b64}"></audio>' | |
| def _recovered(state: dict) -> str: | |
| return render_recovered(state.get("fragments_told", 0), state.get("claimed")) | |
| _SENTENCE_END = re.compile(r'[^.!?…\n]*[.!?…\n]+', re.S) | |
| def _new_sentences(text: str, consumed: int) -> tuple[list[str], int]: | |
| """Return any newly-completed sentences in text[consumed:] and the new | |
| consumed offset (up to the last sentence terminator).""" | |
| pending = text[consumed:] | |
| out, last_end = [], 0 | |
| for m in _SENTENCE_END.finditer(pending): | |
| chunk = m.group().strip() | |
| if chunk: | |
| out.append(chunk) | |
| last_end = m.end() | |
| return out, consumed + last_end | |
| # the opening ritual's two sounds, synthesized once at import: a detuned | |
| # music-box note (numpy, always available) and the child's spoken opening | |
| # line (Kokoro; empty on the silent dev venv). Injected into _HEAD_JS as | |
| # data: URI constants so the controller can queue them on the first gesture | |
| # and on every "begin again". | |
| _CHIME_B64 = base64.b64encode(chime_wav_bytes(0)).decode() | |
| _GREETING_B64 = speak(OPENING_LINE) or "" | |
| _MENU_LOOP_B64 = base64.b64encode(menu_loop_wav_bytes()).decode() | |
| _CHIME_URI = f"data:audio/wav;base64,{_CHIME_B64}" | |
| _GREETING_URI = f"data:audio/wav;base64,{_GREETING_B64}" if _GREETING_B64 else "" | |
| _MENU_LOOP_URI = f"data:audio/wav;base64,{_MENU_LOOP_B64}" | |
| # Intro card images: user-provided grayscale WebP in assets/. Missing files | |
| # fall back to "" so the intro (and tests/dev) works before the art exists; the | |
| # CSS then shows a dark gradient for that card. | |
| def _img_uri(path: str) -> str: | |
| try: | |
| with open(path, "rb") as f: | |
| return "data:image/webp;base64," + base64.b64encode(f.read()).decode() | |
| except OSError: | |
| return "" | |
| _INTRO_IMAGES = [ | |
| _img_uri("assets/intro_threshold.webp"), | |
| _img_uri("assets/intro_keeps.webp"), | |
| _img_uri("assets/intro_waiting.webp"), | |
| _img_uri("assets/intro_meeting.webp"), | |
| _img_uri("assets/intro_waiting.webp"), # 4 — tester condensed card (reuses the waiting art) | |
| ] | |
| # Per-card focal point for the 16:10 cover-crop (CSS background-position). | |
| # Card order matches INTRO_CARDS: threshold, keeps, waiting, meeting. | |
| # Per-card focal point for the 16:10 cover-crop (CSS background-position). | |
| # Raised so each subject sits high and the dialogue box covers emptier area. | |
| # Card order matches INTRO_CARDS: threshold, keeps, waiting, meeting. | |
| _INTRO_POS = ["center 72%", "center 38%", "center 38%", "center 20%", "center 38%"] # idx 4 reuses the waiting focal point | |
| _TICK_URI = f"data:audio/wav;base64,{base64.b64encode(type_tick_wav_bytes()).decode()}" | |
| # The app's only JavaScript: a <head> controller that (1) starts the menu | |
| # melody on the visitor's first menu gesture, (2) stops it and plays the | |
| # chime → greeting ritual when a mode is selected, (3) owns a global mute | |
| # for the child's voice and the menu bed, applying it as Gradio remounts | |
| # nodes, driving the mute button, and (4) times the idle "speak-first" | |
| # client-side so ANY interaction resets it. Every horror effect stays CSS. | |
| _HEAD_JS = """ | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Crimson+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap"> | |
| <script> | |
| (function () { | |
| var KEY = 'hollowAudio'; | |
| var saved = {}; | |
| try { saved = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) {} | |
| var st = { | |
| volume: (typeof saved.volume === 'number') ? saved.volume : 0.85, | |
| muted: !!saved.muted | |
| }; | |
| function persist() { | |
| try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) {} | |
| } | |
| var CHIME_SRC = "__CHIME__"; | |
| var GREETING_SRC = "__GREETING__"; | |
| var MENU_SRC = "__MENU__"; | |
| var INTRO = __INTRO_CARDS__; // [{img, text}, ...] (JSON, injected) | |
| var INTRO_SEQ = __INTRO_SEQ__; // {"tester":[0,3], "full":[0,1,2,3]} (injected) | |
| var TICK_SRC = "__TICK__"; | |
| // --- client-side voice queue: sentences play in order, never cut --- | |
| var queue = [], current = null; | |
| function playNext() { | |
| if (current || !queue.length) return; | |
| current = queue.shift(); | |
| current.muted = st.muted; current.volume = st.volume; | |
| current.onended = function () { current = null; playNext(); }; | |
| current.play().catch(function () { current = null; }); | |
| } | |
| function enqueue(src) { | |
| if (!src) return; | |
| queue.push(new Audio(src)); | |
| playNext(); | |
| } | |
| function applyLive() { // mute/volume affect the playing line | |
| if (current) { current.muted = st.muted; current.volume = st.volume; } | |
| } | |
| function resetQueue() { | |
| queue.length = 0; | |
| if (current) { try { current.pause(); } catch (e) {} current = null; } | |
| } | |
| var _lastRitual = 0; | |
| function openingRitual() { // chime, then the child greets | |
| var now = Date.now(); | |
| if (now - _lastRitual < 800) return; // debounce double-fire | |
| _lastRitual = now; | |
| resetQueue(); | |
| if (CHIME_SRC) enqueue(CHIME_SRC); | |
| if (GREETING_SRC) enqueue(GREETING_SRC); | |
| } | |
| // menu bed: starts on first gesture, stops when a mode is chosen | |
| var menuAudio = null; | |
| function startMenuMusic() { | |
| if (menuAudio || !MENU_SRC) return; | |
| menuAudio = document.getElementById('menu-loop') || new Audio(MENU_SRC); | |
| menuAudio.loop = true; | |
| menuAudio.volume = st.muted ? 0 : 0.2; | |
| menuAudio.muted = st.muted; | |
| menuAudio.play().catch(function () {}); | |
| } | |
| function stopMenuMusic() { | |
| if (menuAudio) { try { menuAudio.pause(); } catch (e) {} menuAudio = null; } | |
| } | |
| // read any voice <audio> Gradio mounts, copy its src into the queue, then it | |
| // can be wiped from the DOM without stopping playback | |
| new MutationObserver(function (muts) { | |
| muts.forEach(function (m) { | |
| m.addedNodes.forEach(function (n) { | |
| if (n.nodeType !== 1) return; | |
| var nodes = []; | |
| if (n.matches && n.matches('.voice-channel audio, audio.cue-audio')) nodes.push(n); | |
| if (n.querySelectorAll) n.querySelectorAll('.voice-channel audio, audio.cue-audio').forEach(function (x) { nodes.push(x); }); | |
| nodes.forEach(function (a) { enqueue(a.currentSrc || a.src); a.remove(); }); | |
| }); | |
| }); | |
| }).observe(document.documentElement, { childList: true, subtree: true }); | |
| // first menu gesture starts the eerie bed (the greeting fires on mode-select) | |
| function firstGesture() { | |
| startMenuMusic(); | |
| window.removeEventListener('pointerdown', firstGesture, true); | |
| window.removeEventListener('keydown', firstGesture, true); | |
| } | |
| window.addEventListener('pointerdown', firstGesture, true); | |
| window.addEventListener('keydown', firstGesture, true); | |
| // ---- opening intro: client-driven typewriter cards ---- | |
| var introSeq = [], introIdx = 0, introTyping = false, introTimer = null; | |
| function playTick() { | |
| if (!TICK_SRC || st.muted) return; | |
| var a = new Audio(TICK_SRC); | |
| a.volume = 0.2; | |
| a.play().catch(function () {}); | |
| } | |
| function typeLine(el, text, onDone) { | |
| introTyping = true; | |
| var i = 0; | |
| (function step() { | |
| el.textContent = text.slice(0, i); | |
| if (i < text.length) { | |
| if (i % 2 === 0) playTick(); | |
| i++; | |
| introTimer = setTimeout(step, 28); | |
| } else { | |
| introTyping = false; introTimer = null; | |
| if (onDone) onDone(); | |
| } | |
| })(); | |
| } | |
| function revealChoice() { | |
| var opts = document.getElementById('intro-opts'); | |
| if (opts) opts.classList.add('intro-opts-show'); | |
| } | |
| function showIntroCard(cardIndex, isLast) { | |
| var card = INTRO[cardIndex]; | |
| var img = document.getElementById('intro-image'); | |
| var bg = document.getElementById('intro-bg'); | |
| var txt = document.getElementById('intro-text'); | |
| var opts = document.getElementById('intro-opts'); | |
| if (opts) opts.classList.remove('intro-opts-show'); | |
| var url = card.img ? "url('" + card.img + "')" : ''; | |
| if (img) { | |
| img.style.backgroundImage = url; | |
| img.style.backgroundPosition = card.pos || 'center'; | |
| } | |
| if (bg) bg.style.backgroundImage = url; | |
| if (txt) typeLine(txt, card.text, function () { if (isLast) revealChoice(); }); | |
| } | |
| function advanceIntro() { | |
| var isLast = introIdx >= introSeq.length - 1; | |
| if (introTyping) { // first click completes the line | |
| if (introTimer) { clearTimeout(introTimer); introTimer = null; } | |
| var txt = document.getElementById('intro-text'); | |
| if (txt) txt.textContent = INTRO[introSeq[introIdx]].text; | |
| introTyping = false; | |
| if (isLast) revealChoice(); | |
| return; | |
| } | |
| if (isLast) return; // wait for a choice on the last card | |
| introIdx++; | |
| showIntroCard(introSeq[introIdx], introIdx >= introSeq.length - 1); | |
| } | |
| function startIntro(mode) { | |
| introSeq = INTRO_SEQ[mode] || INTRO_SEQ['tester']; // single source of truth | |
| introIdx = 0; | |
| window.scrollTo(0, 0); | |
| // The server flips intro_view visible on a round-trip; on a Space the | |
| // network latency makes that land AFTER a fixed timeout, so #intro-text isn't | |
| // in the DOM yet and nothing types -> blank intro. POLL for it (up to ~6s) | |
| // instead of guessing a delay, so it's robust to any latency. | |
| var _introTries = 0; | |
| (function waitForIntro() { | |
| var txt = document.getElementById('intro-text'); | |
| if (!txt) { if (_introTries++ < 100) setTimeout(waitForIntro, 60); return; } | |
| showIntroCard(introSeq[0], introSeq.length <= 1); | |
| var stage = document.getElementById('intro-stage'); | |
| if (stage && !stage.dataset.hollowWired) { | |
| stage.dataset.hollowWired = '1'; | |
| stage.addEventListener('click', advanceIntro); | |
| } | |
| var opts = document.querySelectorAll('#intro-opts .intro-opt'); | |
| opts.forEach(function (el) { | |
| if (el.dataset.hollowWired) return; | |
| el.dataset.hollowWired = '1'; | |
| el.addEventListener('click', function (e) { | |
| e.stopPropagation(); // don't let the stage advance | |
| var t = document.getElementById(el.dataset.target); | |
| if (t) t.click(); // fire the hidden Gradio button | |
| }); | |
| }); | |
| })(); | |
| } | |
| // mute button + restart + mode buttons, wired once rendered | |
| function icon() { return st.muted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; } | |
| // lift the per-turn tone onto #game-view so the WORLD tints (the marker | |
| // .tone-now lives inside #game-entity, a sibling of #game-bg/#game-vig) | |
| function applyTone() { | |
| var gv = document.getElementById('game-view'); | |
| var mark = document.querySelector('#game-view .tone-now[data-tone]'); | |
| if (!gv || !mark) return; | |
| var t = mark.getAttribute('data-tone'); | |
| if (gv.dataset.tone === t) return; | |
| gv.dataset.tone = t; | |
| ['tone-warm','tone-neutral','tone-wounded','tone-hostile'].forEach(function (c) { | |
| gv.classList.remove(c); | |
| }); | |
| gv.classList.add('tone-' + t); | |
| } | |
| // once-per-turn feedback: pulse the relevant drawer chip, drop a whisper, | |
| // amber-tag the recall line. Rides the same marker pattern as applyTone. | |
| var CUE_WHISPER = { capture: '\\u2014 it keeps this \\u2014', | |
| recover: '\\u2014 it remembers \\u2014' }; | |
| function applyCue() { | |
| var gv = document.getElementById('game-view'); | |
| var mark = document.querySelector('#game-view .cue-now[data-cue]'); | |
| if (!gv || !mark) return; | |
| var seq = mark.getAttribute('data-seq'); | |
| if (gv.dataset.cueSeq === seq) return; // already fired this turn | |
| gv.dataset.cueSeq = seq; | |
| var cue = mark.getAttribute('data-cue'); | |
| var rightSide = (cue === 'recover'); | |
| var drawer = document.getElementById(rightSide ? 'drawer-right' : 'drawer-left'); | |
| if (drawer) { | |
| drawer.classList.add('pulse'); | |
| setTimeout(function () { drawer.classList.remove('pulse'); }, 2500); | |
| } | |
| if (cue === 'recall') { | |
| var bots = document.querySelectorAll('#game-dialogue .bot'); | |
| if (bots.length) bots[bots.length - 1].classList.add('recall-line'); | |
| } | |
| var text = CUE_WHISPER[cue]; | |
| if (text && drawer) { | |
| var w = document.createElement('div'); | |
| w.className = 'cue-whisper'; | |
| w.textContent = text; | |
| var r = drawer.getBoundingClientRect(); | |
| w.style.left = r.left + 'px'; | |
| w.style.top = (r.bottom + 6) + 'px'; | |
| document.body.appendChild(w); | |
| setTimeout(function () { w.classList.add('out'); }, 60); | |
| setTimeout(function () { w.remove(); }, 2800); | |
| } | |
| } | |
| // end screen: when the finale's final frame ships an `ended-now` marker, | |
| // set the per-ending epitaph and fade the overlay in (after the last beat). | |
| var EPITAPH = { | |
| good: 'the edge of the wood is empty now.', | |
| loop: 'someone waits at the edge. someone always does.', | |
| bad: 'see you soon.' | |
| }; | |
| function applyEnd() { | |
| var ov = document.getElementById('end-overlay'); | |
| var mark = document.querySelector('.ended-now[data-ending]'); | |
| if (!ov) return; | |
| if (!mark) { return; } // no finale yet | |
| if (ov.dataset.shown === '1') return; // already revealed | |
| ov.dataset.shown = '1'; | |
| var ending = mark.getAttribute('data-ending'); | |
| var ep = document.getElementById('end-epitaph'); | |
| if (ep) ep.textContent = EPITAPH[ending] || EPITAPH.loop; | |
| setTimeout(function () { ov.classList.add('shown'); }, 900); // let the last beat land | |
| } | |
| function wire() { | |
| applyTone(); | |
| applyCue(); | |
| applyEnd(); | |
| // corner drawers: header chip toggles expand | |
| document.querySelectorAll('.game-drawer .drawer-head').forEach(function (h) { | |
| if (h.dataset.hollowWired) return; | |
| h.dataset.hollowWired = '1'; | |
| h.addEventListener('click', function () { h.closest('.game-drawer').classList.toggle('open'); }); | |
| }); | |
| var btn = document.querySelector('.voice-btn'); | |
| var rb = document.querySelector('.restart-btn'); | |
| if (btn && !btn.dataset.hollowWired) { | |
| btn.dataset.hollowWired = '1'; | |
| btn.textContent = icon(); | |
| btn.addEventListener('click', function (e) { | |
| e.preventDefault(); e.stopPropagation(); | |
| st.muted = !st.muted; | |
| applyLive(); | |
| if (menuAudio) { menuAudio.muted = st.muted; menuAudio.volume = st.muted ? 0 : 0.2; } | |
| persist(); btn.textContent = icon(); | |
| }); | |
| } | |
| if (rb && !rb.dataset.hollowWired) { | |
| rb.dataset.hollowWired = '1'; | |
| // do NOT stop propagation — Gradio's server-side _reset must still run; | |
| // the click is a gesture, so the ritual audio is allowed to play | |
| rb.addEventListener('click', function () { | |
| openingRitual(); | |
| window.scrollTo(0, 0); | |
| setTimeout(function () { window.scrollTo(0, 0); }, 80); | |
| }); | |
| } | |
| document.querySelectorAll('.menu-mode-btn').forEach(function (mb) { | |
| if (mb.dataset.hollowWired) return; | |
| mb.dataset.hollowWired = '1'; | |
| mb.addEventListener('click', function () { | |
| var mode = (mb.id === 'btn-full') ? 'full' : 'tester'; | |
| startIntro(mode); // server flips menu->intro; bed keeps playing low | |
| }); | |
| }); | |
| document.querySelectorAll('.menu-opt').forEach(function (mo) { | |
| if (mo.dataset.hollowWired) return; | |
| mo.dataset.hollowWired = '1'; | |
| mo.addEventListener('click', function () { | |
| var t = document.getElementById(mo.dataset.target); | |
| if (t) t.click(); // fire the hidden mode button (startIntro + _show_intro) | |
| }); | |
| }); | |
| // intro choice / skip: reveal the game -> stop the bed, chime->greeting, top | |
| document.querySelectorAll('.intro-enter-btn').forEach(function (eb) { | |
| if (eb.dataset.hollowWired) return; | |
| eb.dataset.hollowWired = '1'; | |
| eb.addEventListener('click', function () { | |
| stopMenuMusic(); | |
| openingRitual(); | |
| window.scrollTo(0, 0); | |
| setTimeout(function () { window.scrollTo(0, 0); }, 80); | |
| }); | |
| }); | |
| // end-screen actions: the server handlers reset/leave; JS clears the | |
| // overlay class + shown flag so a future finale can re-reveal it. | |
| ['btn-end-again', 'btn-end-leave'].forEach(function (id) { | |
| var b = document.getElementById(id); | |
| if (b && !b.dataset.hollowWired) { | |
| b.dataset.hollowWired = '1'; | |
| b.addEventListener('click', function () { | |
| var ov = document.getElementById('end-overlay'); | |
| if (ov) { ov.classList.remove('shown'); ov.dataset.shown = ''; } | |
| // drop the finale marker so applyEnd's 250ms poll can't re-reveal the | |
| // overlay — that was the leave-the-wood double-click + the begin-again | |
| // credits-flash. A fresh finale re-renders the title with a new marker. | |
| var m = document.querySelector('.ended-now'); | |
| if (m) m.remove(); | |
| if (id === 'btn-end-again') openingRitual(); // same ritual as restart | |
| }); | |
| } | |
| }); | |
| } | |
| // keep watching — buttons can appear later when the menu flips to the game | |
| setInterval(wire, 250); | |
| // idle "speak-first": client-timed, reset by ANY interaction, fires at 60s | |
| var IDLE_MS = 60000; | |
| var last = Date.now(); | |
| function bump() { last = Date.now(); } | |
| ['pointerdown','keydown','input','pointermove','wheel','touchstart'] | |
| .forEach(function (ev) { window.addEventListener(ev, bump, true); }); | |
| setInterval(function () { | |
| if (Date.now() - last < IDLE_MS) return; | |
| var trig = document.getElementById('idle-trigger'); | |
| if (!trig) return; | |
| last = Date.now(); // next nudge is another full 60s | |
| trig.click(); | |
| }, 5000); | |
| })(); | |
| </script> | |
| """ | |
| _HEAD_JS = _HEAD_JS.replace("__CHIME__", _CHIME_URI).replace("__GREETING__", _GREETING_URI).replace("__MENU__", _MENU_LOOP_URI) | |
| _INTRO_CARDS_JSON = json.dumps( | |
| [{"img": _INTRO_IMAGES[i], "text": INTRO_CARDS[i], "pos": _INTRO_POS[i]} | |
| for i in range(len(INTRO_CARDS))] | |
| ) | |
| _HEAD_JS = (_HEAD_JS | |
| .replace("__INTRO_CARDS__", _INTRO_CARDS_JSON) | |
| .replace("__INTRO_SEQ__", json.dumps(INTRO_SEQUENCES)) | |
| .replace("__TICK__", _TICK_URI)) | |
| def _menu_html() -> str: | |
| return ( | |
| f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' | |
| f'<div id="menu-frame" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' | |
| '<div class="menu-scrim"></div>' | |
| '<div class="menu-grain"></div>' | |
| '<div class="menu-vig"></div>' | |
| '<div id="menu-card">' | |
| ' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>' | |
| ' <div class="menu-title">Hollow</div>' | |
| ' <div class="menu-tag">something is here, at the edge of the wood ' | |
| '— it remembers what you give it.</div>' | |
| ' <div class="menu-opts">' | |
| ' <div class="menu-opt" data-target="btn-tester">Tester Run</div>' | |
| ' <div class="menu-opt" data-target="btn-full">The Full Stay</div>' | |
| ' <div class="menu-opt menu-opt-muted" data-target="btn-howto">How to Play</div>' | |
| ' </div>' | |
| '</div>' | |
| '<div class="menu-credit">A LOST CHILD WAITS · TURN YOUR SOUND ON</div>' | |
| ) | |
| def _show_intro(mode: str): | |
| """Mode-select: leave the menu, reveal the intro, remember the mode so the | |
| choice/skip buttons can start the right run. Order matches the outputs | |
| list below: [menu_view, intro_view, pending_mode].""" | |
| return ( | |
| gr.update(visible=False), # menu_view | |
| gr.update(visible=True), # intro_view | |
| mode, # pending_mode (gr.State) | |
| ) | |
| def _enter_game(mode: str, tone_seed: int = 0): | |
| """Leave the intro, reveal the game, start a fresh run in the chosen mode | |
| with the intro choice's seeded tone. Order matches the outputs list below.""" | |
| state = _init_state() # honors HOLLOW_FAST_FINALE for dev | |
| state["mode"] = mode # "tester" | "full" | |
| state["tone"] = tone_seed # intro choice seeds the starting tone | |
| return ( | |
| gr.update(visible=False), # intro_view | |
| gr.update(visible=True), # game_view | |
| state, | |
| [{"role": "assistant", "content": OPENING_LINE}], # chatbot | |
| _render_bond(state["affinity"], get_tier(state["affinity"])), | |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), | |
| wounds=state.get("wounds", [])), | |
| # the opening is the vulnerable FIRST contact — always the base child, | |
| # never a tier-driven face (the dev HOLLOW_FAST_FINALE seed starts at | |
| # Almost bond, which would otherwise greet you with the possessive grin) | |
| render_entity(20, tone=state["tone"]), | |
| _recovered(state), | |
| _render_title(state), | |
| ) | |
| def _audio_seconds(b64: str) -> float: | |
| """Duration of a base64 WAV, so a finale can wait for a spoken line to | |
| finish before the next line's audio replaces it. 0.0 if it can't be read.""" | |
| try: | |
| with wave.open(io.BytesIO(base64.b64decode(b64)), "rb") as w: | |
| return w.getnframes() / float(w.getframerate()) | |
| except Exception: | |
| return 0.0 | |
| def _voice_and_pause(text: str, speak_it: bool, scripted_pause: float): | |
| """Synthesize the line (if speak_it) and return (voice_html, pause). The | |
| pause is stretched to at least the audio length so the whole line is heard | |
| before the next step replaces it.""" | |
| b64 = speak(text) if speak_it else None | |
| if b64: | |
| dur = _audio_seconds(b64) | |
| # pace to the line itself + a short breath — no long scripted dead air | |
| # between paragraphs (the recital felt slow/dense). Fall back to the | |
| # scripted pause only if the audio length can't be read. | |
| pause = dur + 0.1 if dur > 0 else scripted_pause | |
| else: | |
| pause = scripted_pause # silent beats (the convulsion) keep timing | |
| return _voice_html(b64), pause | |
| _IDLE_TIERS = [ | |
| ["...are you still there?", "don't go. not yet."], # plead | |
| ["you're not even listening.", "you said you would stay."], # hurt | |
| ["fine. ignore me. they all do, before the end.", # hostile | |
| "you think i cannot reach you out there?"], | |
| ] | |
| _IDLE_AFFINITY_DROP = 3 # bond lost per ignored nudge | |
| _IDLE_TONE_DROP = 2 # tone soured per ignored nudge | |
| def _on_idle(state: dict, history: list): | |
| """Hidden trigger click. When the visitor has gone quiet, Hollow speaks | |
| first — escalating plead -> hurt -> hostile — and each nudge lowers the | |
| bond and sours the tone. Timing lives in the browser (see _HEAD_JS); here | |
| we only refuse to fire while a turn is streaming or after the end.""" | |
| # timing now lives in the browser (see _HEAD_JS); here we only refuse to | |
| # fire while a turn is streaming (the "..." bubble) or after the end | |
| pending = bool(history) and "hollow-typing" in str(history[-1].get("content", "")) | |
| if pending or state.get("ended") or not history: | |
| return state, gr.update(), gr.update(), gr.update() | |
| state = dict(state) | |
| i = state.get("idle_count", 0) | |
| tier = _IDLE_TIERS[min(i // 2, len(_IDLE_TIERS) - 1)] | |
| line = tier[i % len(tier)] | |
| state["idle_count"] = i + 1 | |
| state["last_activity"] = time.time() | |
| state["affinity"] = max(0, state.get("affinity", 20) - _IDLE_AFFINITY_DROP) | |
| state["tone"] = max(-100, state.get("tone", 0) - _IDLE_TONE_DROP) | |
| history = history + [{"role": "assistant", "content": line}] | |
| voice = _voice_html(speak(line)) | |
| bond = _render_bond(state["affinity"], get_tier(state["affinity"])) | |
| return state, history, voice, bond | |
| def _start_turn(user_msg: str, state: dict, history: list): | |
| # output `state` too, so the activity stamp persists across the event | |
| # boundary — otherwise the idle timer thinks you're idle mid-turn and | |
| # races the chat generator, orphaning the "..." bubble | |
| if not user_msg.strip() or state.get("ended"): | |
| return gr.update(), gr.update(interactive=not state.get("ended")), history, state | |
| state["last_activity"] = time.time() | |
| new_history = history + [ | |
| {"role": "user", "content": user_msg}, | |
| {"role": "assistant", "content": _PENDING}, | |
| ] | |
| # clear the box immediately on send (value="") so the text doesn't sit | |
| # 'stuck' mid-turn; stash the real message in state for chat() to read | |
| # (the cleared box would otherwise hand chat an empty user_msg) | |
| state["_pending_msg"] = user_msg | |
| return gr.update(value="", interactive=False), gr.update(interactive=False), new_history, state | |
| def _memory_plea_level(state: dict, recall_turn: bool) -> int: | |
| """How plainly the child should ask for a real memory this turn. Escalates | |
| with consecutive turns that captured nothing; silent on recall turns and | |
| when the child is withdrawn (hostile tone).""" | |
| if recall_turn or state.get("tone", 0) <= -22: | |
| return 0 | |
| barren = state.get("barren_turns", 0) | |
| if barren >= 6: | |
| return 3 | |
| if barren >= 4: | |
| return 2 | |
| if barren >= 2: | |
| return 1 | |
| return 0 | |
| _DEAD_PLACEHOLDER = "i don't— i don't remember how long it's been." | |
| # input/error safety nets — always in character, never a red Error chip | |
| MAX_MSG_LEN = 500 | |
| _TOO_LONG_REPLY = ("too many words at once. the fog can't carry them all. " | |
| "give me one small true thing.") | |
| _FOG_REPLY = ("...the fog swallowed your words. i couldn't hear them. " | |
| "say it again?") | |
| def _play_finale(state: dict, history: list): | |
| """Streams the scripted ending. No model, no GPU. Each yield is a full | |
| output tuple; time.sleep between yields sets the dramatic pace.""" | |
| claimed = state["claimed"] | |
| struck: set[str] = set() | |
| chat_hist = list(history) | |
| tier = get_tier(state["affinity"]) | |
| bond = _render_bond(state["affinity"], tier) | |
| treasure_html = render_treasure(state["treasure"], struck=struck, | |
| claimed=set(claimed)) | |
| entity = render_entity(state["affinity"], "build", seq=state["turn"], | |
| tone=state.get("tone", 0)) | |
| locked = gr.update(interactive=False) | |
| input_locked = gr.update(interactive=False) | |
| mutated = False | |
| convulsed = False | |
| recovered = _recovered(state) | |
| for step in finale_steps(claimed): | |
| role = "assistant" if step["speaker"] == "hollow" else "user" | |
| if step["text"]: # silent beats (the convulsion) add no bubble | |
| chat_hist = chat_hist + [{"role": role, "content": step["text"]}] | |
| if step["strike"] is not None: | |
| struck.add(claimed[step["strike"]]) | |
| treasure_html = render_treasure(state["treasure"], struck=struck, | |
| claimed=set(claimed)) | |
| if step["stage"] == "frenzy": | |
| convulsed = True | |
| entity = render_entity(state["affinity"], "convulse_loop", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| if step["stage"] == "turn" and not mutated: | |
| mutated = True | |
| bond = _render_bond(state["affinity"], tier, label="mine") | |
| treasure_html = render_treasure(state["treasure"], mine=True) | |
| # the `end` face lands and the heartbeat flatlines, exactly now | |
| # (seamless — the convulsion already settled on this face) | |
| entity = render_entity(state["affinity"], "end_settle", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| input_locked = gr.update(interactive=False, placeholder="stay.") | |
| speak_it = step["speaker"] == "hollow" | |
| voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"]) | |
| yield (input_locked, locked, chat_hist, state, bond, treasure_html, | |
| entity, voice, recovered, _render_title(state)) | |
| if pause > 0: | |
| time.sleep(pause) | |
| yield ( | |
| gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER), | |
| locked, chat_hist, state, bond, treasure_html, entity, | |
| _voice_html(None), recovered, _render_title(state, show_end=True), | |
| ) | |
| def _play_finale_bad(state: dict, history: list): | |
| """The wound loop — recites the visitor's cruelties, unredacting them | |
| one by one. No model, no GPU.""" | |
| wounds = state.get("wounds", []) | |
| chat_hist = list(history) | |
| tier = get_tier(state["affinity"]) | |
| bond = _render_bond(state["affinity"], tier) | |
| revealed = 0 | |
| treasure_html = render_treasure(state["treasure"], claimed=set(state["claimed"]), | |
| wounds=wounds, revealed=0) | |
| entity = render_entity(state["affinity"], "build", seq=state["turn"], | |
| tone=state.get("tone", 0)) | |
| locked = gr.update(interactive=False) | |
| input_locked = gr.update(interactive=False) | |
| mutated = False | |
| frenzied = False | |
| recovered = _recovered(state) | |
| for step in finale_steps_bad(wounds): | |
| role = "assistant" if step["speaker"] == "hollow" else "user" | |
| if step["text"]: # silent beats (the convulsion) add no bubble | |
| chat_hist = chat_hist + [{"role": role, "content": step["text"]}] | |
| if step["strike"] is not None: | |
| revealed = step["strike"] + 1 | |
| treasure_html = render_treasure(state["treasure"], | |
| claimed=set(state["claimed"]), | |
| wounds=wounds, revealed=revealed) | |
| if step["stage"] == "turn" and not mutated: | |
| mutated = True | |
| bond = _render_bond(state["affinity"], tier, label="wound") | |
| treasure_html = render_treasure(state["treasure"], | |
| claimed=set(state["claimed"]), | |
| wounds=wounds, revealed=revealed, | |
| yours=True) | |
| input_locked = gr.update(interactive=False, placeholder="get out.") | |
| # the child stays a smudge through the whole recital; the rage face is | |
| # revealed only AFTER the convulsion, with the threat | |
| if step["stage"] == "frenzy": | |
| frenzied = True | |
| entity = render_entity(state["affinity"], "frenzy", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| elif frenzied: | |
| entity = render_entity(state["affinity"], "rage", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| speak_it = step["speaker"] == "hollow" | |
| voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"]) | |
| yield (input_locked, locked, chat_hist, state, bond, treasure_html, | |
| entity, voice, recovered, _render_title(state)) | |
| if pause > 0: | |
| time.sleep(pause) | |
| yield ( | |
| gr.update(interactive=False, placeholder="see you soon."), | |
| locked, chat_hist, state, bond, treasure_html, entity, | |
| _voice_html(None), recovered, _render_title(state, show_end=True), | |
| ) | |
| def _play_finale_good(state: dict, history: list): | |
| """Redemption — your warmth gave it back its past. It confesses, returns | |
| the treasure, smiles for the first time, and dissolves in peace. | |
| No model, no GPU.""" | |
| chat_hist = list(history) | |
| tier = get_tier(state["affinity"]) | |
| bond = _render_bond(state["affinity"], tier) | |
| treasure_html = render_treasure(state["treasure"], | |
| claimed=set(state["claimed"])) | |
| entity = render_entity(state["affinity"], "build", seq=state["turn"], | |
| tone=state.get("tone", 0)) | |
| locked = gr.update(interactive=False) | |
| input_locked = gr.update(interactive=False) | |
| mutated = False | |
| convulsed = False | |
| settled = False | |
| recovered = _recovered(state) | |
| for step in finale_steps_good(OWN_FRAGMENTS): | |
| if step["text"]: # silent beats (e.g. the convulsion) add no bubble | |
| chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}] | |
| if step["stage"] == "turn" and not mutated: | |
| mutated = True | |
| bond = _render_bond(state["affinity"], tier, label="warm") | |
| # the treasure is returned: claim marks lifted, every entry lit | |
| treasure_html = render_treasure(state["treasure"]) | |
| input_locked = gr.update(interactive=False, | |
| placeholder="it's quiet now.") | |
| if step["stage"] == "frenzy": | |
| convulsed = True | |
| entity = render_entity(state["affinity"], "convulse_good", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| elif convulsed and step["stage"] != "loop": | |
| # the smile lands + the relief sigh plays, exactly now (seamless — | |
| # the convulsion already settled on this face); sigh only once | |
| if not settled: | |
| settled = True | |
| entity = render_entity(state["affinity"], "peace_settle", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| else: | |
| entity = render_entity(state["affinity"], "peace", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| if step["stage"] == "loop": | |
| entity = render_entity(state["affinity"], "peace_dissolve", | |
| seq=state["turn"], tone=state.get("tone", 0)) | |
| voice, pause = _voice_and_pause(step["text"], bool(step["text"]), step["pause"]) | |
| yield (input_locked, locked, chat_hist, state, bond, treasure_html, | |
| entity, voice, recovered, _render_title(state)) | |
| if pause > 0: | |
| time.sleep(pause) | |
| yield ( | |
| gr.update(interactive=False, placeholder="it's quiet now."), | |
| locked, chat_hist, state, bond, treasure_html, entity, | |
| _voice_html(None), recovered, _render_title(state, show_end=True), | |
| ) | |
| def chat(user_msg: str, state: dict, history: list): | |
| # drop the pending typing bubble _start_turn added, so downstream history | |
| # logic is unchanged (match on the sentinel class, robust to re-serializing) | |
| # _start_turn cleared the input box on send and stashed the real message in | |
| # state (chatbot message content isn't reliably a plain string to recover) | |
| user_msg = state.pop("_pending_msg", user_msg) | |
| if history and "hollow-typing" in str(history[-1].get("content", "")): | |
| history = history[:-1] | |
| ending = state.get("ending") | |
| if state.get("ended") and ending is None: | |
| ending = "loop" # sessions ended before `ending` existed | |
| if not user_msg.strip() or state.get("ended"): | |
| tier = get_tier(state["affinity"]) | |
| wounds = state.get("wounds", []) | |
| entity_mode = {"good": "peace", "bad": "rage", "loop": "end"}.get( | |
| ending, "idle") | |
| yield ( | |
| gr.update(interactive=not state.get("ended")), | |
| gr.update(interactive=not state.get("ended")), | |
| history, | |
| state, | |
| _render_bond(state["affinity"], tier), | |
| render_treasure(state["treasure"], mine=(ending == "loop"), | |
| claimed=set() if ending == "good" | |
| else set(state["claimed"]), | |
| wounds=wounds, revealed=len(wounds), | |
| yours=(ending == "bad")), | |
| render_entity(state["affinity"], entity_mode, seq=state["turn"], | |
| tone=state.get("tone", 0)), | |
| _voice_html(None), | |
| _recovered(state), | |
| _render_title(state), | |
| ) | |
| return | |
| if len(user_msg) > MAX_MSG_LEN: | |
| # decline in character — no model call, no state change, no quota | |
| tier = get_tier(state["affinity"]) | |
| yield ( | |
| gr.update(value="", interactive=True), | |
| gr.update(interactive=True), | |
| history + [{"role": "assistant", "content": _TOO_LONG_REPLY}], | |
| state, | |
| _render_bond(state["affinity"], tier), | |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), | |
| wounds=state.get("wounds", [])), | |
| render_entity(state["affinity"], "idle", seq=state["turn"], | |
| tone=state.get("tone", 0)), | |
| _voice_html(None), | |
| _recovered(state), | |
| _render_title(state), | |
| ) | |
| return | |
| state["last_activity"] = time.time() | |
| ending = decide_ending(state) | |
| if ending: | |
| state["ended"] = True | |
| state["ending"] = ending | |
| if os.environ.get("HOLLOW_DEBUG"): | |
| print(f"[ending] {ending} @ turn {state['turn']} aff={state['affinity']} " | |
| f"tone={state.get('tone', 0)} claimed={len(state['claimed'])} " | |
| f"wounds={len(state.get('wounds', []))}", flush=True) | |
| if ending == "good": | |
| yield from _play_finale_good(state, history) | |
| elif ending == "bad": | |
| yield from _play_finale_bad(state, history) | |
| else: | |
| yield from _play_finale(state, history) | |
| return | |
| do_recall, recall_memory = should_recall(state) | |
| frag_before = state.get("fragments_told", 0) | |
| # sustained warmth pulls fragments of Hollow's own past loose (one per | |
| # tone milestone, never on a recall turn — each beat gets its own stage) | |
| own_fragment = None | |
| told = state.get("fragments_told", 0) | |
| if (not (do_recall and recall_memory) and told < len(OWN_FRAGMENTS) | |
| and state.get("tone", 0) >= FRAGMENT_TONES[told]): | |
| own_fragment = OWN_FRAGMENTS[told] | |
| # B2: on non-recall turns (every 3rd), quietly resurface an earlier memory | |
| aware_memory = None | |
| if not (do_recall and recall_memory) and state["turn"] % 3 == 0: | |
| aware_memory = pick_aware_memory(state, exclude=recall_memory) | |
| lengths = state.get("msg_lengths", []) + [len(user_msg.split())] | |
| state["msg_lengths"] = lengths[-5:] # keep the last 5 | |
| name_ready = ( | |
| not state.get("named") and state.get("fragments_told", 0) >= 2 | |
| ) | |
| system = build_system_prompt(state["affinity"], state["treasure"], recall_memory, | |
| tone=state.get("tone", 0), | |
| wounds=state.get("wounds", []), | |
| memory_plea=_memory_plea_level( | |
| state, bool(do_recall and recall_memory)), | |
| own_fragment=own_fragment, | |
| style=style_signal(state["msg_lengths"]), | |
| aware_memory=aware_memory, | |
| name_ready=name_ready) | |
| chat_messages = [{"role": "system", "content": system}] + state["history"] + [ | |
| {"role": "user", "content": user_msg} | |
| ] | |
| tier_now = get_tier(state["affinity"]) | |
| reply, raw_json, turn_failed = "", "{}", False | |
| spoken = 0 | |
| try: | |
| for item in run_turn_stream(chat_messages, user_msg): | |
| if isinstance(item, tuple) and item and item[0] == "__final__": | |
| _, reply, raw_json = item | |
| break | |
| reply = item | |
| sentences, spoken = _new_sentences(reply, spoken) | |
| voice = "".join(_voice_html(speak(s)) for s in sentences) if sentences else _voice_html(None) | |
| yield ( | |
| gr.update(interactive=False), | |
| gr.update(interactive=False), | |
| history + [{"role": "assistant", "content": reply}], | |
| state, | |
| _render_bond(state["affinity"], tier_now), | |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), | |
| wounds=state.get("wounds", [])), | |
| render_entity(state["affinity"], "idle", seq=state["turn"], | |
| tone=state.get("tone", 0)), | |
| voice, | |
| _recovered(state), | |
| _render_title(state), | |
| ) | |
| except Exception as e: | |
| print(f"[chat] run_turn_stream failed: {e!r}") | |
| reply, raw_json = _FOG_REPLY, "{}" | |
| turn_failed = True | |
| treasure_before = len(state["treasure"]) | |
| state = apply_update(state, raw_json, reply=reply) | |
| captured = len(state["treasure"]) > treasure_before | |
| # coaching counter: reset when a memory lands, climb when turns are barren | |
| state["barren_turns"] = 0 if captured else state.get("barren_turns", 0) + 1 | |
| if do_recall and recall_memory and not turn_failed: | |
| state["claimed"].append(recall_memory) | |
| state["last_recall_turn"] = state["turn"] | |
| if own_fragment and not turn_failed: | |
| state["fragments_told"] = told + 1 | |
| if aware_memory and not turn_failed: | |
| state["last_aware_memory"] = aware_memory | |
| state["history"].append({"role": "user", "content": user_msg}) | |
| state["history"].append({"role": "assistant", "content": reply}) | |
| state["turn"] += 1 | |
| if os.environ.get("HOLLOW_DEBUG"): | |
| print(f"[turn {state['turn']}] aff={state['affinity']} tone={state.get('tone', 0)} " | |
| f"treasure={len(state['treasure'])} claimed={len(state['claimed'])} " | |
| f"wounds={len(state.get('wounds', []))} barren={state.get('barren_turns', 0)}", | |
| flush=True) | |
| new_history = history + [{"role": "assistant", "content": reply}] | |
| mode = "flash_strong" if (do_recall and recall_memory) else ("flash" if captured else "idle") | |
| tier = get_tier(state["affinity"]) | |
| # every reply is voiced; loudness/mute is handled in the browser (the | |
| # head controller). The opening line is spoken separately on first gesture. | |
| tail = reply[spoken:].strip() | |
| voice = _voice_html(speak(tail)) if tail else _voice_html(None) | |
| _plea = _memory_plea_level(state, bool(do_recall and recall_memory)) | |
| _ph = ("a memory: a person, a place, a moment you lived..." | |
| if _plea >= 2 else "tell it something you remember...") | |
| just_claimed = recall_memory if (do_recall and recall_memory) else None | |
| recovered_now = state.get("fragments_told", 0) > frag_before | |
| cue = ("recall" if (do_recall and recall_memory) | |
| else "recover" if recovered_now | |
| else "capture" if captured else "") | |
| yield ( | |
| gr.update(value="", interactive=True, placeholder=_ph), | |
| gr.update(interactive=True), | |
| new_history, | |
| state, | |
| _render_bond(state["affinity"], tier), | |
| render_treasure(state["treasure"], claimed=set(state["claimed"]), | |
| wounds=state.get("wounds", []), | |
| just_claimed=just_claimed), | |
| render_entity(state["affinity"], mode, seq=state["turn"], | |
| tone=state.get("tone", 0), cue=cue), | |
| voice, | |
| _recovered(state), | |
| _render_title(state), | |
| ) | |
| with gr.Blocks(title="Hollow") as demo: | |
| state = gr.State(_init_state) | |
| pending_mode = gr.State("tester") # set by mode-select, read by the intro choice | |
| # menu bed: mounted at top level so it survives when menu_view unmounts | |
| gr.HTML('<audio id="menu-loop" loop src="' + _MENU_LOOP_URI + '"></audio>') | |
| with gr.Column(visible=True, elem_id="menu-view") as menu_view: | |
| menu_art = gr.HTML(_menu_html()) | |
| with gr.Column(elem_id="menu-proxy"): # hidden; .menu-opt rows click these | |
| tester_btn = gr.Button("Tester Run", elem_id="btn-tester", | |
| elem_classes="menu-mode-btn") | |
| full_btn = gr.Button("The Full Stay", elem_id="btn-full", | |
| elem_classes="menu-mode-btn") | |
| howto_btn = gr.Button("How to Play", elem_id="btn-howto", | |
| elem_classes="menu-btn") | |
| with gr.Column(visible=False, elem_id="howto-overlay") as howto_overlay: | |
| gr.HTML(_HOWTO_HTML) | |
| howto_close = gr.Button("close", elem_id="btn-howto-close", | |
| elem_classes="menu-btn") | |
| with gr.Column(visible=False, elem_id="intro-view") as intro_view: | |
| gr.HTML( | |
| '<div id="intro-stage">' | |
| ' <div id="intro-bg"></div>' | |
| ' <div id="intro-frame">' | |
| ' <div id="intro-image"></div>' | |
| ' <div id="intro-panel">' | |
| ' <p id="intro-text"></p>' | |
| ' <div id="intro-opts">' | |
| ' <div class="intro-opt" data-target="btn-offer">Offer your hand</div>' | |
| ' <div class="intro-opt" data-target="btn-keep">Keep your distance</div>' | |
| ' </div>' | |
| ' </div>' | |
| ' </div>' | |
| '</div>' | |
| ) | |
| with gr.Column(elem_id="intro-proxy"): # hidden; HTML rows click these | |
| offer_btn = gr.Button("Offer your hand", elem_id="btn-offer", | |
| elem_classes="intro-enter-btn") | |
| keep_btn = gr.Button("Keep your distance", elem_id="btn-keep", | |
| elem_classes="intro-enter-btn") | |
| skip_btn = gr.Button("skip ▸", elem_id="intro-skip", | |
| elem_classes="intro-enter-btn") | |
| with gr.Column(visible=False, elem_id="game-view") as game_view: | |
| with gr.Column(elem_id="game-stage"): | |
| gr.HTML(f'<div id="game-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' | |
| f'<div id="game-scene" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' | |
| '<div id="game-vig"></div>' | |
| '<div id="game-frame-edge"></div>' | |
| '<div id="game-groundfog"></div>') | |
| with gr.Row(elem_id="game-topbar"): | |
| title_panel = gr.HTML(value=_render_title(_init_state())) | |
| restart_btn = gr.Button("↺", elem_classes="restart-btn", | |
| scale=0) | |
| voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0) | |
| # left drawer — your memories | |
| with gr.Column(elem_id="drawer-left", elem_classes="game-drawer"): | |
| treasure_panel = gr.HTML(value=render_treasure([])) | |
| # right drawer — its own recovered memories | |
| with gr.Column(elem_id="drawer-right", elem_classes="game-drawer"): | |
| recovered_panel = gr.HTML(value=render_recovered(0)) | |
| # center: the child silhouette in the fog | |
| entity_panel = gr.HTML(value=render_entity(20), elem_id="game-entity") | |
| # conversation as subtitles, anchored near the frame's bottom edge | |
| with gr.Column(elem_id="game-dialogue"): | |
| chatbot = gr.Chatbot( | |
| value=[{"role": "assistant", "content": OPENING_LINE}], | |
| label="", show_label=False, height=210, buttons=[], | |
| autoscroll=True, | |
| ) | |
| bond_panel = gr.HTML(value=_render_bond(20, "Hollow"), | |
| elem_classes="bond-panel") | |
| # input — a thin line at the very bottom of the viewport (below the frame) | |
| with gr.Column(elem_id="game-inputbar"): | |
| with gr.Row(elem_id="game-inputrow"): | |
| msg_input = gr.Textbox( | |
| placeholder="tell it something you remember...", | |
| show_label=False, scale=9, autofocus=True) | |
| send_btn = gr.Button("→", scale=1) | |
| voice_panel = gr.HTML(value="", elem_classes="voice-channel") | |
| # show_progress="hidden" kills Gradio's per-component "processing" overlay; | |
| # we show our own "..." pending bubble instead (set in _start_turn) | |
| send_btn.click( | |
| fn=_start_turn, | |
| inputs=[msg_input, state, chatbot], | |
| outputs=[msg_input, send_btn, chatbot, state], | |
| show_progress="hidden", | |
| ).then( | |
| fn=chat, | |
| inputs=[msg_input, state, chatbot], | |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, | |
| treasure_panel, entity_panel, voice_panel, recovered_panel, | |
| title_panel], | |
| show_progress="hidden", | |
| ) | |
| msg_input.submit( | |
| fn=_start_turn, | |
| inputs=[msg_input, state, chatbot], | |
| outputs=[msg_input, send_btn, chatbot, state], | |
| show_progress="hidden", | |
| ).then( | |
| fn=chat, | |
| inputs=[msg_input, state, chatbot], | |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, | |
| treasure_panel, entity_panel, voice_panel, recovered_panel, | |
| title_panel], | |
| show_progress="hidden", | |
| ) | |
| restart_btn.click( | |
| fn=_reset, | |
| inputs=None, | |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, | |
| treasure_panel, entity_panel, voice_panel, recovered_panel, | |
| title_panel], | |
| show_progress="hidden", | |
| ) | |
| # idle is timed in the browser (see _HEAD_JS): after 60s of true silence | |
| # the controller clicks this hidden button, which runs the same _on_idle. | |
| # Mounted + CSS-hidden (NOT visible=False, which can unmount it). | |
| idle_trigger = gr.Button("idle", elem_id="idle-trigger", | |
| elem_classes="idle-trigger") | |
| idle_trigger.click(_on_idle, [state, chatbot], | |
| [state, chatbot, voice_panel, bond_panel], | |
| show_progress="hidden") | |
| # end screen — always mounted, CSS-hidden until a finale reveals it (§27). | |
| # Revealed by the head-JS reading the title's `ended-now` marker. | |
| with gr.Column(elem_id="end-overlay") as end_overlay: | |
| # hero: the foggy backdrop + the per-ending epitaph (set by applyEnd()) | |
| gr.HTML( | |
| f'<div id="end-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>' | |
| '<div id="end-scrim"></div>' | |
| '<p id="end-epitaph"></p>' | |
| ) | |
| # the two actions, grouped + centered right under the epitaph | |
| with gr.Row(elem_id="end-actions"): | |
| end_again_btn = gr.Button("↺ begin again", elem_id="btn-end-again", | |
| elem_classes="end-btn") | |
| end_leave_btn = gr.Button("leave the wood", elem_id="btn-end-leave", | |
| elem_classes="end-btn end-btn-dim") | |
| # credits sink to a quiet footer pinned to the bottom (position:fixed in §27) | |
| gr.HTML( | |
| '<div id="end-credits">' | |
| ' <div class="ec-title">HOLLOW</div>' | |
| ' <div class="ec-line">a thing that waited at the edge of the wood</div>' | |
| ' <div class="ec-meta">Qwen3-8B · Kokoro-82M · FLUX · Gradio' | |
| ' · 🔌 off the grid · 🎨 off-brand</div>' | |
| '</div>' | |
| ) | |
| end_again_btn.click( | |
| fn=_reset, inputs=None, | |
| outputs=[msg_input, send_btn, chatbot, state, bond_panel, | |
| treasure_panel, entity_panel, voice_panel, | |
| recovered_panel, title_panel], | |
| show_progress="hidden", | |
| ) | |
| end_leave_btn.click( | |
| fn=_to_menu, inputs=None, | |
| outputs=[menu_view, game_view, state], | |
| show_progress="hidden", | |
| ) | |
| _enter_outputs = [intro_view, game_view, state, chatbot, bond_panel, | |
| treasure_panel, entity_panel, recovered_panel, title_panel] | |
| _intro_outputs = [menu_view, intro_view, pending_mode] | |
| tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs, | |
| show_progress="hidden") | |
| full_btn.click(lambda: _show_intro("full"), None, _intro_outputs, | |
| show_progress="hidden") | |
| # the intro choice / skip seed the tone, then reveal the game | |
| offer_btn.click(lambda m: _enter_game(m, 12), pending_mode, _enter_outputs, | |
| show_progress="hidden") | |
| keep_btn.click(lambda m: _enter_game(m, -8), pending_mode, _enter_outputs, | |
| show_progress="hidden") | |
| skip_btn.click(lambda m: _enter_game(m, 0), pending_mode, _enter_outputs, | |
| show_progress="hidden") | |
| howto_btn.click(lambda: gr.update(visible=True), None, howto_overlay, | |
| show_progress="hidden") | |
| howto_close.click(lambda: gr.update(visible=False), None, howto_overlay, | |
| show_progress="hidden") | |
| if __name__ == "__main__": | |
| # head= injects the one-shot gesture listener into <head> (Gradio 6.0 moved | |
| # head from the Blocks constructor to launch()). | |
| # ssr_mode=False: the Space defaults to SSR, but our _HEAD_JS controller + | |
| # CSS target Gradio's client DOM/timing (intro typewriter, marker polling, | |
| # tone/cue lifting). SSR changes that and breaks the intro + layout; force | |
| # client-side rendering so the Space matches the validated local build. | |
| demo.launch(css_paths="styles.css", head=_HEAD_JS, ssr_mode=False) | |