the-apprentice / app.py
AndrewRqy
Clean-Space pass: drop self-test, legacy unused handler, BISECT_MINIMAL branch, duplicate banner PNG, stale comments
f9490c0
Raw
History Blame Contribute Delete
194 kB
"""The Wizard's Oracles — Gradio Blocks UI.
Single-file demo. Player writes 5 sealed oracles, hands them to a young hero,
then watches as the LLM is forced to interpret whatever was written as the
means of the hero's salvation against 5 lethal trials.
State machine:
inscribe → send_off → trial (1..5) → epilogue → done
Run:
python app.py # launches Gradio (uses ./run.sh for full setup)
"""
from __future__ import annotations
import base64
import os
import sys
from urllib.parse import quote as _url_quote
from pathlib import Path
from typing import Optional
# Ensure ``oracles`` package is importable when run directly.
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
import gradio as gr # noqa: E402
from oracles.images import generate_scene_image # noqa: E402
from oracles.llm_client import LLMClient # noqa: E402
from oracles.obstacles import generate_obstacles # noqa: E402
from oracles.resolution import ( # noqa: E402
generate_dragon_interlude,
generate_epilogue,
generate_interlude,
kick_background_epilogue,
kick_background_interlude,
precompute_all_resolutions,
resolve_trial,
)
from oracles.state import ( # noqa: E402
LANG_LABELS,
LANG_PROMPT_NAME,
NUM_ORACLES,
NUM_TRIALS,
GameState,
fresh_state,
)
from oracles.ui_strings import t as _t, tlang as _tlang # noqa: E402
from oracles.wizard_text import ( # noqa: E402
sigil_svg as _sigil_svg,
text_for as _wizard_text_for,
)
# ---------------------------------------------------------------------------
# Module-level client + asset dir
# ---------------------------------------------------------------------------
CLIENT = LLMClient()
OUT_DIR = str(_HERE / "assets" / "generated")
os.makedirs(OUT_DIR, exist_ok=True)
# Parallax PNG bundled into the banner. Resized to 1024x192 (~200KB) from the
# parent Forest Focus cozy-forest near layer — same visual register, but small
# enough to inline as a data URI without bloating the page config.
_PARALLAX_PNG = _HERE / "assets" / "parallax" / "banner_near.png"
# Optional Klein-generated grimoire page texture. If present, embedded into
# the CSS as a background-image on .grimoire-page so the parchment looks
# like real aged paper instead of CSS-only gradients. Skipped silently if
# the user hasn't generated it.
_GRIMOIRE_TEXTURE_PNG = _HERE / "assets" / "sprites" / "grimoire_page_texture.png"
def _full_visual_mode(state: Optional["GameState"] = None) -> bool:
"""Visual-mode toggle. Resolution order:
1. ``state.visual_mode`` if set to "full" or "lean" (per-session override).
2. ``$ORACLES_VISUAL_MODE`` env var (process-wide default).
3. ``lean``.
- ``lean``: skip heavy decorative PNGs — parallax banner, grimoire
parchment, the four phase backdrops, the scene landscapes, the
wizard-desk backdrop, the open-book illustration, and the
pipeline-demo card backdrop. ~2-3 MB saved per cold load on HF
Space's slow egress bandwidth.
- ``full``: enable everything. Use locally where bandwidth is fast,
or flip the dropdown on inscribe step 0.
"""
if state is not None:
vm = (getattr(state, "visual_mode", "") or "").strip().lower()
if vm in ("full", "lean"):
return vm == "full"
return os.environ.get("ORACLES_VISUAL_MODE", "lean").strip().lower() == "full"
# ---------------------------------------------------------------------------
# Pixel-art book frame: leather binding (via CSS border-image 9-slice), a
# vertical spine with raised bands and stitching, and a red ribbon bookmark.
# All inline SVGs so the book "is" pixel art without depending on an external
# asset run.
# ---------------------------------------------------------------------------
def _book_frame_svg() -> str:
"""60x60 SVG used as the border-image source for the leather binding.
9-sliced at 20 units → corners stay crisp, edge slices stretch.
Leather is warmer/browner than before so it doesn't merge with the
dark purple page palette."""
return (
"<svg xmlns='http://www.w3.org/2000/svg' "
"width='60' height='60' viewBox='0 0 60 60' "
"shape-rendering='crispEdges'>"
# leather on the 8 border slices (center 20x20 left transparent).
# Warmer brown so it reads as leather against the dark purple pages.
"<rect x='0' y='0' width='60' height='20' fill='#3a2410'/>"
"<rect x='0' y='40' width='60' height='20' fill='#3a2410'/>"
"<rect x='0' y='20' width='20' height='20' fill='#3a2410'/>"
"<rect x='40' y='20' width='20' height='20' fill='#3a2410'/>"
# leather grain (darker/lighter horizontal lines for texture)
"<rect x='0' y='5' width='60' height='1' fill='#1a0e08' opacity='0.8'/>"
"<rect x='0' y='11' width='60' height='1' fill='#5a3820' opacity='0.6'/>"
"<rect x='0' y='49' width='60' height='1' fill='#1a0e08' opacity='0.8'/>"
"<rect x='0' y='55' width='60' height='1' fill='#5a3820' opacity='0.6'/>"
# leather vertical grain on the side edges
"<rect x='5' y='20' width='1' height='20' fill='#1a0e08' opacity='0.8'/>"
"<rect x='11' y='20' width='1' height='20' fill='#5a3820' opacity='0.6'/>"
"<rect x='49' y='20' width='1' height='20' fill='#1a0e08' opacity='0.8'/>"
"<rect x='55' y='20' width='1' height='20' fill='#5a3820' opacity='0.6'/>"
# outer leather edge - slightly lighter strip on the outside
"<rect x='0' y='0' width='60' height='1' fill='#5a3820'/>"
"<rect x='0' y='59' width='60' height='1' fill='#1a0e08'/>"
"<rect x='0' y='0' width='1' height='60' fill='#5a3820'/>"
"<rect x='59' y='0' width='1' height='60' fill='#1a0e08'/>"
# brass corner — TOP-LEFT
"<rect x='0' y='0' width='16' height='3' fill='#b08840'/>"
"<rect x='0' y='0' width='3' height='16' fill='#b08840'/>"
"<rect x='0' y='0' width='14' height='1' fill='#dca860'/>"
"<rect x='0' y='0' width='1' height='14' fill='#dca860'/>"
"<rect x='0' y='3' width='16' height='1' fill='#604018'/>"
"<rect x='3' y='0' width='1' height='16' fill='#604018'/>"
"<rect x='10' y='2' width='2' height='2' fill='#604018'/>"
"<rect x='2' y='10' width='2' height='2' fill='#604018'/>"
# brass corner — TOP-RIGHT
"<rect x='44' y='0' width='16' height='3' fill='#b08840'/>"
"<rect x='57' y='0' width='3' height='16' fill='#b08840'/>"
"<rect x='46' y='0' width='14' height='1' fill='#dca860'/>"
"<rect x='59' y='0' width='1' height='14' fill='#dca860'/>"
"<rect x='44' y='3' width='16' height='1' fill='#604018'/>"
"<rect x='56' y='0' width='1' height='16' fill='#604018'/>"
"<rect x='48' y='2' width='2' height='2' fill='#604018'/>"
"<rect x='56' y='10' width='2' height='2' fill='#604018'/>"
# brass corner — BOTTOM-LEFT
"<rect x='0' y='57' width='16' height='3' fill='#b08840'/>"
"<rect x='0' y='44' width='3' height='16' fill='#b08840'/>"
"<rect x='0' y='59' width='14' height='1' fill='#dca860'/>"
"<rect x='0' y='46' width='1' height='14' fill='#dca860'/>"
"<rect x='0' y='56' width='16' height='1' fill='#604018'/>"
"<rect x='3' y='44' width='1' height='16' fill='#604018'/>"
"<rect x='10' y='56' width='2' height='2' fill='#604018'/>"
"<rect x='2' y='48' width='2' height='2' fill='#604018'/>"
# brass corner — BOTTOM-RIGHT
"<rect x='44' y='57' width='16' height='3' fill='#b08840'/>"
"<rect x='57' y='44' width='3' height='16' fill='#b08840'/>"
"<rect x='46' y='59' width='14' height='1' fill='#dca860'/>"
"<rect x='59' y='46' width='1' height='14' fill='#dca860'/>"
"<rect x='44' y='56' width='16' height='1' fill='#604018'/>"
"<rect x='56' y='44' width='1' height='16' fill='#604018'/>"
"<rect x='48' y='56' width='2' height='2' fill='#604018'/>"
"<rect x='56' y='48' width='2' height='2' fill='#604018'/>"
# amber inner trim — visible just inside the leather frame
"<rect x='16' y='14' width='28' height='2' fill='#f0c060'/>"
"<rect x='16' y='44' width='28' height='2' fill='#f0c060'/>"
"<rect x='14' y='16' width='2' height='28' fill='#f0c060'/>"
"<rect x='44' y='16' width='2' height='28' fill='#f0c060'/>"
# darker amber rule just inside the inner trim
"<rect x='17' y='17' width='26' height='1' fill='#604018'/>"
"<rect x='17' y='42' width='26' height='1' fill='#604018'/>"
"<rect x='17' y='17' width='1' height='26' fill='#604018'/>"
"<rect x='42' y='17' width='1' height='26' fill='#604018'/>"
"</svg>"
)
def _book_spine_svg() -> str:
"""40×200 tall stretchable spine with five raised bands and amber
stitching dots. Brighter, more contrasted than the surrounding leather
so the binding clearly reads as a separate strip down the centre.
``preserveAspectRatio='none'`` lets it stretch vertically with the
grimoire."""
bands = []
# five raised horizontal bands, each with a top highlight + bottom shadow
for y in (28, 63, 96, 129, 164):
bands.append(f"<rect x='0' y='{y}' width='40' height='8' fill='#6a4828'/>")
bands.append(f"<rect x='0' y='{y}' width='40' height='2' fill='#9a7048'/>")
bands.append(f"<rect x='0' y='{y + 6}' width='40' height='2' fill='#1a0e08'/>")
# amber stitching dots in the gaps between bands
stitch = []
for y in (12, 48, 83, 116, 149, 184):
stitch.append(f"<rect x='17' y='{y}' width='6' height='3' fill='#f8d870'/>")
return (
"<svg xmlns='http://www.w3.org/2000/svg' "
"width='40' height='200' viewBox='0 0 40 200' "
"preserveAspectRatio='none' shape-rendering='crispEdges'>"
# base spine — medium-warm brown, brighter than surrounding leather
"<rect x='0' y='0' width='40' height='200' fill='#4a3218'/>"
# darker edges where the spine curves into the cover
"<rect x='0' y='0' width='4' height='200' fill='#1a0e08'/>"
"<rect x='36' y='0' width='4' height='200' fill='#1a0e08'/>"
# subtle highlights just inside the dark edges (the spine's curve)
"<rect x='4' y='0' width='2' height='200' fill='#3a2410'/>"
"<rect x='34' y='0' width='2' height='200' fill='#3a2410'/>"
# central lighter highlight running down the middle
"<rect x='19' y='0' width='3' height='200' fill='#6a4828'/>"
+ "".join(bands)
+ "".join(stitch)
+ "</svg>"
)
def _book_ribbon_svg() -> str:
"""Small red ribbon bookmark, drawn at 28×44 with chunky pixel V-cut."""
return (
"<svg xmlns='http://www.w3.org/2000/svg' "
"width='28' height='44' viewBox='0 0 28 44' "
"shape-rendering='crispEdges'>"
# ribbon body
"<rect x='8' y='0' width='12' height='32' fill='#8a2020'/>"
# highlight (left edge)
"<rect x='8' y='0' width='2' height='32' fill='#aa3030'/>"
# shadow (right edge)
"<rect x='18' y='0' width='2' height='32' fill='#601010'/>"
# subtle fold line down the centre
"<rect x='13' y='0' width='1' height='32' fill='#601010' opacity='0.7'/>"
# chunky V-cut bottom
"<rect x='8' y='32' width='12' height='2' fill='#8a2020'/>"
"<rect x='9' y='34' width='10' height='2' fill='#8a2020'/>"
"<rect x='10' y='36' width='8' height='2' fill='#8a2020'/>"
"<rect x='11' y='38' width='6' height='2' fill='#8a2020'/>"
"<rect x='12' y='40' width='4' height='2' fill='#8a2020'/>"
"<rect x='13' y='42' width='2' height='2' fill='#8a2020'/>"
# highlight on V-cut
"<rect x='8' y='32' width='2' height='2' fill='#aa3030'/>"
"<rect x='9' y='34' width='2' height='2' fill='#aa3030'/>"
"<rect x='10' y='36' width='2' height='2' fill='#aa3030'/>"
"<rect x='11' y='38' width='2' height='2' fill='#aa3030'/>"
"<rect x='12' y='40' width='2' height='2' fill='#aa3030'/>"
# shadow on V-cut
"<rect x='18' y='32' width='2' height='2' fill='#601010'/>"
"<rect x='17' y='34' width='2' height='2' fill='#601010'/>"
"<rect x='16' y='36' width='2' height='2' fill='#601010'/>"
"<rect x='15' y='38' width='2' height='2' fill='#601010'/>"
"</svg>"
)
def _grimoire_book_css() -> str:
"""Compose the dynamic CSS for the pixel-art book frame.
Always returns a non-empty string (the SVGs are inline) — distinct from
the optional ``_grimoire_texture_css`` which only fires when the Klein
parchment PNG is on disk.
"""
frame_b64 = base64.b64encode(_book_frame_svg().encode("utf-8")).decode("ascii")
spine_b64 = base64.b64encode(_book_spine_svg().encode("utf-8")).decode("ascii")
ribbon_b64 = base64.b64encode(_book_ribbon_svg().encode("utf-8")).decode("ascii")
frame_uri = f"data:image/svg+xml;base64,{frame_b64}"
spine_uri = f"data:image/svg+xml;base64,{spine_b64}"
ribbon_uri = f"data:image/svg+xml;base64,{ribbon_b64}"
return f"""
/* ---------- Pixel-art book frame ---------- */
.oracle-grimoire {{
/* Replace the plain amber border with a 9-sliced leather frame.
The 20-unit slice in the 60x60 source means corner art (brass
protectors + amber trim) stays crisp; only the edge middles
stretch. */
border: 20px solid transparent !important;
border-image: url('{frame_uri}') 20 / 20px / 0 stretch !important;
/* Stretchable centre spine sits as background-image, centred. */
background-image: url('{spine_uri}') !important;
background-position: center center !important;
background-repeat: no-repeat !important;
background-size: 32px 100% !important;
image-rendering: pixelated !important;
overflow: visible !important;
}}
/* Override the previous ::before (dark spine line) with the ribbon
bookmark hanging from the top of the binding. */
.oracle-grimoire::before {{
content: "" !important;
position: absolute !important;
top: -6px !important;
left: 50% !important;
transform: translateX(-50%) !important;
width: 28px !important;
height: 44px !important;
background: url('{ribbon_uri}') no-repeat center !important;
background-size: 100% 100% !important;
z-index: 10 !important;
pointer-events: none !important;
image-rendering: pixelated !important;
box-shadow: none !important;
}}
/* The stitching dots and inner-amber-border live in the SVG now — hide
the legacy ::after pseudo-element decorations. */
.oracle-grimoire::after {{
display: none !important;
}}
/* The leather frame already provides the chunky border + drop shadow,
so soften the existing inset shadow that used to imply page curl —
the SVG amber trim does that visual now. */
"""
def _grimoire_texture_css() -> str:
"""Return a small CSS snippet that adds the Klein-generated parchment
texture as a background-image on .grimoire-page, if the PNG exists.
Returns empty string otherwise (so the CSS-only gradients still apply).
Skipped entirely in lean mode (default) — the texture is ~70 KB
base64-inlined into the initial CSS, which on HF Space's slow egress
adds noticeable cold-load time. Set ORACLES_VISUAL_MODE=full to enable.
"""
if not _full_visual_mode():
return ""
if not _GRIMOIRE_TEXTURE_PNG.exists():
return ""
try:
png_b64 = base64.b64encode(_GRIMOIRE_TEXTURE_PNG.read_bytes()).decode("ascii")
except OSError:
return ""
return (
"\n/* Klein-generated parchment texture (loaded only when the PNG exists). "
"Stacked above the gradients so the warm pixel-art grain shows through. "
"A dark linear-gradient is layered ON TOP at high opacity so the cream "
"wizard-monologue text remains readable — the parchment grain is still "
"visible underneath but the text contrast wins. */\n"
".grimoire-page {\n"
" background-image:\n"
" linear-gradient(180deg,\n"
" rgba(24, 20, 31, 0.82) 0%,\n"
" rgba(37, 29, 46, 0.86) 100%),\n"
" radial-gradient(ellipse 70% 40% at 50% 0%,\n"
" rgba(240, 192, 96, 0.18) 0%, transparent 70%),\n"
f" url('data:image/png;base64,{png_b64}') !important;\n"
" background-size: 100% 100%, 100% 100%, cover !important;\n"
" background-repeat: no-repeat, no-repeat, no-repeat !important;\n"
" image-rendering: pixelated !important;\n"
"}\n"
)
# Sub-state within a trial: A = waiting to open, B = oracle drawn & resolved
TRIAL_STATE_AWAIT = "await"
TRIAL_STATE_REVEAL = "reveal"
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V"}
# Gradio file-route URL prefix. Differs between major versions:
# - Gradio 4.x → "/file="
# - Gradio 5.x and 6.x → "/gradio_api/file="
# Computed once at module load; cheap to read.
try:
_GRADIO_MAJOR = int(gr.__version__.split(".", 1)[0])
except Exception:
_GRADIO_MAJOR = 5
_FILE_URL_PREFIX = "/file=" if _GRADIO_MAJOR < 5 else "/gradio_api/file="
def _sprite_data_uri_app(name: str) -> str:
"""Return a URL to the sprite. Used everywhere a CSS ``url(...)`` or
``<img src=...>`` needs the sprite. Originally returned a base64 data
URI, which baked ~30 sprites worth of binary data straight into the
initial HTML response (~1-3 MB) and made the first paint take 3+
minutes on HF Spaces CPU-basic. Now returns the Gradio file route
(``/gradio_api/file=<path>``) so the browser fetches each sprite
lazily in parallel — initial HTML drops to ~50 KB. Gradio's launch
is set up with ``allowed_paths=[assets]`` so the path is servable.
Cached so we don't stat the disk on every call. Returns empty string
if the file is missing.
"""
cache = _sprite_data_uri_app.__dict__.setdefault("_cache", {})
if name in cache:
return cache[name]
path = _HERE / "assets" / "sprites" / f"{name}.png"
if not path.exists():
cache[name] = ""
return ""
# Gradio's file route prefix depends on the running major version.
# The /gradio_api/file= path works on 5+; legacy /file= works on 4.x.
# _FILE_URL_PREFIX is computed once at module load based on
# gr.__version__.
#
# URL-encode the path so any spaces or special chars (e.g. the
# "CHAI Project" path on the dev machine) survive the trip through
# an HTML <img src=…> attribute. safe="/" preserves the path
# separators which browsers + Gradio's route both need verbatim.
uri = f"{_FILE_URL_PREFIX}{_url_quote(path.as_posix(), safe='/')}"
cache[name] = uri
return uri
def _sprite_data_uri_app_base64(name: str) -> str:
"""Fallback that base64-encodes a sprite — only used if the file-route
approach doesn't work in some environment (e.g. the iframe can't reach
/gradio_api/file=). Cached separately so we don't accidentally bake
base64 into a path-style call site."""
cache = _sprite_data_uri_app_base64.__dict__.setdefault("_cache", {})
if name in cache:
return cache[name]
path = _HERE / "assets" / "sprites" / f"{name}.png"
if not path.exists():
cache[name] = ""
return ""
try:
png_b64 = base64.b64encode(path.read_bytes()).decode("ascii")
except OSError:
cache[name] = ""
return ""
uri = f"data:image/png;base64,{png_b64}"
cache[name] = uri
return uri
# ---------------------------------------------------------------------------
# Grimoire — book-spread renderer for the inscribe phase.
# The left page is the wizard's voice (markdown-like rendering of wizard_text).
# The right page is whatever input the current step asks for.
# ---------------------------------------------------------------------------
_GRIMOIRE_NUM_STEPS = 9 # 0..8
_GRIMOIRE_ORACLE_STEPS = (3, 4, 5, 6, 7) # which steps capture an oracle
def _grimoire_render_markdown(raw: str) -> str:
"""Render the lightweight markdown used in wizard_text into HTML.
Supports:
- ``**bold**`` → ``<strong>``
- ``*italic*`` → ``<em>``
- lines starting with ``> `` → wrapped in a single ``<blockquote>``
- paragraphs split on blank lines
"""
import re
def _inline(line: str) -> str:
out = _md_safe(line)
out = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", out)
out = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", out)
return out
blocks: list[str] = []
for raw_block in raw.split("\n\n"):
block = raw_block.strip()
if not block:
continue
if block.startswith(">"):
quote = "\n".join(
ln[1:].lstrip() if ln.startswith(">") else ln
for ln in block.splitlines()
)
blocks.append(f"<blockquote>{_inline(quote)}</blockquote>")
else:
blocks.append(f"<p>{_inline(' '.join(block.split()))}</p>")
return "".join(blocks)
def _grimoire_left_html(state: GameState) -> str:
"""Render the left page: wizard's text for the current inscribe step,
with hero/village placeholders substituted, the step marker in the
corner, and the step-themed pixel sigil tucked into the bottom-inner
corner.
Cached by (lang, inscribe_step, hero_name, village_name). All four
determine the output exactly; nothing else does. On grimoire spread
transitions, only ``inscribe_step`` changes, so the cache stays warm.
"""
lang_code = state.lang or "en"
cache_key = (
lang_code,
state.inscribe_step,
state.hero_name or "Tobin",
state.village_name or "the Hollow",
)
if cache_key in _GRIMOIRE_LEFT_CACHE:
return _GRIMOIRE_LEFT_CACHE[cache_key]
raw = _wizard_text_for(lang_code, state.inscribe_step)
raw = (raw
.replace("{hero_name}", state.hero_name or "Tobin")
.replace("{village_name}", state.village_name or "the Hollow"))
body = _grimoire_render_markdown(raw)
step = state.inscribe_step + 1
lang = _tlang(state)
marker_text = _t("grimoire_step_marker", lang).format(
step=step, GRIMOIRE_NUM_STEPS=_GRIMOIRE_NUM_STEPS
)
marker = f"<div class='grimoire-step-marker'>{marker_text}</div>"
sigil = (
"<div class='grimoire-sigil grimoire-sigil-left'>"
+ _sigil_svg(state.inscribe_step)
+ "</div>"
)
result = f"<div lang='{lang}' class='grimoire-text-body'>{body}</div>{marker}{sigil}"
_GRIMOIRE_LEFT_CACHE[cache_key] = result
return result
def _grimoire_right_sigil_html(state: GameState) -> str:
"""The same sigil, rendered for the right page (mirrored placement)."""
return (
"<div class='grimoire-sigil grimoire-sigil-right'>"
+ _sigil_svg(state.inscribe_step)
+ "</div>"
)
def _grimoire_progress_html(state: GameState) -> str:
"""Five-dot progress indicator for the oracles, rendered at the bottom of
the right page."""
n_sealed = sum(1 for o in state.oracles if (o.text or "").strip())
dots = []
for i in range(NUM_ORACLES):
cls = "dot done" if i < n_sealed else "dot"
dots.append(f"<span class='{cls}'>◆</span>")
return "<div class='grimoire-progress'>" + "".join(dots) + "</div>"
def _grimoire_summary_html(state: GameState) -> str:
"""Step-8 summary panel: shows all five sealed oracles, with the open-
spell-book pixel art crowning the spread to mark the grimoire complete."""
lang = _tlang(state)
blank_label = _t("blank_oracle", lang)
items = []
for o in state.oracles:
text = (o.text or "").strip() or blank_label
items.append(f"<li><em>{_md_safe(text)}</em></li>")
# Lean mode (default for HF Space): skip the open-book illustration —
# the PNG was ~200-300 KB. The heading + numbered list carry the
# meaning. Full mode restores the illustration above the list.
if _full_visual_mode(state):
book_uri = _sprite_data_uri_app("open_book_spread")
alt_text = _t("alt_sealed_grimoire", lang)
book_img = (
f"<img class='grimoire-summary-book' src='{book_uri}' "
f"alt='{_md_safe(alt_text)}'/>"
if book_uri else ""
)
else:
book_img = ""
header = _md_safe(_t("summary_five_sealed", lang))
return (
"<div class='grimoire-summary'>"
+ book_img
+ f"<strong>{header}</strong>"
"<ol>" + "".join(items) + "</ol>"
"</div>"
)
def _grimoire_error_html(state: GameState) -> str:
err = (state.grimoire_error or "").strip()
if not err:
return ""
return f"<div class='oracle-error'>{_md_safe(err)}</div>"
# _PROLOGUE_CACHE, _load_prologue, _prologue_html — REMOVED.
# These rendered a separate "prologue" card from prompts/prologue_{en,zh}.txt
# but nothing on the page ever called _prologue_html, so the prologue
# never reached the player. The grimoire's wizard_text monologue does
# the world-introduction job now.
def _pipeline_demo_html(state: Optional[GameState] = None) -> str:
"""Three side-by-side 'thought-bubble' panels showing the wizard daydreaming
about three example pipelines — one per humor mode (A wild-imagination,
B accidental-trip, C last-minute-revelation). Each panel now embeds two
pixel-art sprites (the obstacle + the hero's resolved pose) over a soft
night-sky backdrop, so the gag reads visually before the caption is read.
Shown only on the very first grimoire spread (step 0). Hidden via
`_pipeline_demo_visibility` after the player advances.
"""
lang = _tlang(state)
# Pixel-art sprites + backdrop pre-loaded as data URIs. The
# `_sprite_data_uri_app` helper caches per name, so reading the same
# sprite twice in this function only hits disk once.
# Lean mode (default for HF Space): skip the demo card painted
# backdrop (~200-400 KB). Panels still have CSS borders + shading.
# Full mode restores the painted background.
if _full_visual_mode(state):
bg = _sprite_data_uri_app("demo_card_backdrop")
bg_style = f"background-image:url('{bg}');" if bg else ""
else:
bg_style = ""
tag_inscribes = _t("demo_tag_inscribes", lang)
tag_meets = _t("demo_tag_meets", lang)
tag_somehow = _t("demo_tag_somehow", lang)
def _panel(mode: str, mode_label: str, oracle: str,
encounter_text: str, resolve_text: str,
encounter_sprite: str, resolve_sprite: str) -> str:
# Lean mode: drop the in-panel encounter/resolve sprites too. They
# were the only image content left in the demo panels after the
# painted backdrop was stripped earlier — and a row of small sprites
# still costs a half-dozen HTTP requests per page-load.
if _full_visual_mode(state):
encounter_uri = _sprite_data_uri_app(encounter_sprite)
resolve_uri = _sprite_data_uri_app(resolve_sprite)
else:
encounter_uri = ""
resolve_uri = ""
encounter_img = (
f"<img class='pipeline-demo-sprite pipeline-demo-sprite-foe' "
f"src='{encounter_uri}' alt='{encounter_sprite}'/>"
if encounter_uri else ""
)
resolve_img = (
f"<img class='pipeline-demo-sprite pipeline-demo-sprite-hero' "
f"src='{resolve_uri}' alt='{resolve_sprite}'/>"
if resolve_uri else ""
)
mode_badge_text = _t("demo_mode", lang).format(mode=mode)
return (
f"<div class='pipeline-demo-panel' data-mode='{mode}'>"
"<div class='pipeline-demo-scene'>"
+ encounter_img
+ resolve_img +
"</div>"
f"<div class='pipeline-demo-mode-row'>"
f"<span class='pipeline-demo-mode-badge'>{mode_badge_text}</span>"
f"<span class='pipeline-demo-mode-label'>{mode_label}</span>"
"</div>"
"<div class='pipeline-demo-step'>"
f"<span class='pipeline-demo-tag'>{tag_inscribes}</span>"
f"<code>{oracle}</code>"
"</div>"
"<div class='pipeline-demo-step'>"
f"<span class='pipeline-demo-tag'>{tag_meets}</span>"
f"{encounter_text}"
"</div>"
"<div class='pipeline-demo-step pipeline-demo-resolution'>"
f"<span class='pipeline-demo-tag'>{tag_somehow}</span>"
f"{resolve_text}"
"</div>"
"</div>"
)
header = _t("demo_header", lang)
footer = _t("demo_footer", lang)
label_a = _t("demo_mode_a_label", lang)
label_b = _t("demo_mode_b_label", lang)
label_c = _t("demo_mode_c_label", lang)
return (
f"<div class='oracle-pipeline-demo' style=\"{bg_style}\">"
"<div class='pipeline-demo-header'>"
"<span class='float'>💭</span>"
f"{header}"
"</div>"
"<div class='pipeline-demo-grid'>"
# Panel A — Mode A WILD IMAGINATION (phone → hornets)
+ _panel(
mode="A",
mode_label=label_a,
oracle="phone",
encounter_text="a swarm of glass-winged hornets, daggers humming",
resolve_text=(
"a brass horn drops into his hand. He calls the swarm's "
"mother. She is most disappointed. The hornets file home "
"single-file, none of them making eye contact."
),
encounter_sprite="demo_hornet_dagger",
resolve_sprite="demo_hero_smug_phone",
)
# Panel B — Mode B ACCIDENTAL TRIP (Neil → archers → trip)
+ _panel(
mode="B",
mode_label=label_b,
oracle="Neil",
encounter_text=(
"an ambush — hidden archers at the crossroads, bows drawn"
),
resolve_text=(
"baffled by 'Neil', he turns calling the name, trips on a "
"wagon-axle, and four arrows hiss through the air his "
"throat had been in. He thanks Neil sincerely."
),
encounter_sprite="demo_bandit_archer",
resolve_sprite="demo_hero_tripped",
)
# Panel C — Mode C LAST-MINUTE REVELATION (blue → wraiths → jays)
+ _panel(
mode="C",
mode_label=label_c,
oracle="blue",
encounter_text=(
"fen-wraith herons closing in with toothed beaks"
),
resolve_text=(
"he waves a blue scarf — fails. A jay scolds "
"<em>jay-JAY-jay</em>. He gets it: <strong>blue jay.</strong> "
"He whistles. A furious cobalt mob scatters the wraiths."
),
encounter_sprite="demo_wraith_heron",
resolve_sprite="demo_bluejay_angry",
)
+ "</div>"
"<div class='pipeline-demo-footer'>"
f"{footer}"
"</div>"
"</div>"
)
def _inscribe_decoration_html(state: Optional[GameState] = None) -> str:
"""A small framing illustration at the top of the inscribe panel: the
wizard (the player) on the left, the hero on the right, with the five
sealed oracles passing between them. Sets the whole game's framing —
'YOU are the wizard.'
Cached by (theme, lang) — those are the only fields the rendered HTML
depends on. Called on every click; caching saves a few hundred μs of
string building per transition.
"""
theme_key_for_sprites = (
getattr(state, "theme", "fantasy") if state else "fantasy"
) or "fantasy"
lang_for_cache = _tlang(state)
cache_key = (theme_key_for_sprites, lang_for_cache, _full_visual_mode(state))
if cache_key in _INSCRIBE_DECORATION_CACHE:
return _INSCRIBE_DECORATION_CACHE[cache_key]
from oracles.themes import get_theme as _get_theme
wizard_uri = (
_sprite_data_uri_app(f"wizard_{theme_key_for_sprites}")
or _sprite_data_uri_app("wizard")
)
hero_uri = (
_sprite_data_uri_app(f"hero_{theme_key_for_sprites}")
or _sprite_data_uri_app("hero")
)
# Lean mode (default for HF Space): skip the wizard-desk backdrop —
# the PNG is ~200-400 KB. Full mode restores it as a fade-behind
# layer for the mentor/apprentice figures.
desk_uri = _sprite_data_uri_app("wizard_desk_scene") if _full_visual_mode(state) else ""
if not wizard_uri or not hero_uri:
fallback = (
"<div class='oracle-inscribe-figs' style='text-align:center;"
"padding:14px;font-style:italic;color:var(--w-mute,#b4a890)'>"
"(sprites not yet generated — run "
"<code>modal run oracles_app/modal_backend/modal_klein_oracles.py</code>)"
"</div>"
)
_INSCRIBE_DECORATION_CACHE[cache_key] = fallback
return fallback
bg_style = ""
if desk_uri:
# Layer the wizard-desk texture behind the figures at low opacity
# so the wizard and hero pop, but the desk's candle + parchment
# mood comes through.
bg_style = (
f"background:"
f" linear-gradient(rgba(24,20,31,0.66), rgba(24,20,31,0.82)),"
f" url('{desk_uri}') center/cover no-repeat;"
"image-rendering: pixelated;"
)
# Theme-aware captions so each world reskins the labels.
from oracles.themes import get_theme as _get_theme
theme_key = getattr(state, "theme", "fantasy") if state else "fantasy"
th = _get_theme(theme_key)
lang = _tlang(state)
mentor_caption_raw = th.localized_field("mentor_caption", lang) \
or f"the {th.mentor_archetype}"
finale_caption_raw = th.localized_field("finale_caption", lang) \
or f"off to {th.goal_verb} {th.finale_descriptor}"
artifact_raw = th.localized_field("oracle_artifact", lang) \
or th.oracle_artifact
if lang == "zh":
arrow_caption_raw = f"五{artifact_raw}"
else:
arrow_caption_raw = f"five {artifact_raw}"
mentor_caption = _md_safe(mentor_caption_raw)
finale_caption = _md_safe(finale_caption_raw)
arrow_caption = _md_safe(arrow_caption_raw)
you_label = _md_safe(_t("figure_you", lang))
# Theme-aware role title — render it as-is without a "Your" prefix
# (the labels already start with "the …", so "YOUR THE CHOSEN BOY"
# would read wrong). Falls back to the bilingual "figure_apprentice"
# key when a theme doesn't set apprentice_role.
_theme_obj = _get_theme(theme_key_for_sprites)
role_raw = _theme_obj.localized_field("apprentice_role", lang).strip()
if role_raw:
if lang == "zh":
champion_label = _md_safe(role_raw)
else:
champion_label = _md_safe(role_raw).upper()
else:
champion_label = _md_safe(_t("figure_apprentice", lang))
result = (
f"<div class='oracle-inscribe-figs' style=\"{bg_style}\">"
f"<div class='oracle-fig'>"
f"<img src='{wizard_uri}' alt='mentor'/>"
f"<div class='oracle-fig-label'>{you_label}<br><span>{mentor_caption}</span></div>"
"</div>"
"<div class='oracle-fig-arrow'>"
"<div class='oracle-arrow-scrolls'>I &nbsp; II &nbsp; III &nbsp; IV &nbsp; V</div>"
"<div class='oracle-arrow-line'>━━━━━━&gt;</div>"
f"<div class='oracle-arrow-caption'>{arrow_caption}</div>"
"</div>"
f"<div class='oracle-fig'>"
f"<img src='{hero_uri}' alt='champion'/>"
f"<div class='oracle-fig-label'>{champion_label}<br><span>{finale_caption}</span></div>"
"</div>"
"</div>"
)
_INSCRIBE_DECORATION_CACHE[cache_key] = result
return result
# ---------------------------------------------------------------------------
# Memoization caches for HTML helpers that are recomputed on every click.
# Each helper is a pure function of a small slice of state (theme + lang +
# mode at most). Caching by the relevant tuple turns repeated identical
# calls into a dict lookup. On a 6-theme × 2-lang × 5-mode app, the caches
# saturate at ~60 entries — bounded, no eviction needed.
# ---------------------------------------------------------------------------
_BANNER_HTML_CACHE: dict = {}
_THEME_STYLE_CACHE: dict = {}
_INSCRIBE_DECORATION_CACHE: dict = {}
_LOCALIZED_CHROME_CACHE: dict = {}
_GRIMOIRE_LEFT_CACHE: dict = {}
_BADGE_CACHE: dict = {}
def _banner_html(state: Optional[GameState] = None) -> str:
"""Top-of-page parallax banner with the game title overlaid.
Cached by (theme, lang, mode) — these are the only fields the rendered
HTML depends on. The bundle re-runs this helper on every click; without
caching, each call rebuilds the same string. Cache hit is a dict
lookup; cache miss recomputes.
"""
theme_key = getattr(state, "theme", "fantasy") if state else "fantasy"
lang = _tlang(state)
mode = getattr(state, "mode", "inscribe") if state else "inscribe"
vm_full = _full_visual_mode(state)
cache_key = (theme_key, lang, mode, vm_full)
if cache_key in _BANNER_HTML_CACHE:
return _BANNER_HTML_CACHE[cache_key]
from oracles.themes import get_theme as _get_theme
theme = _get_theme(theme_key)
# Game-wide title — every theme is a chapter under the same banner.
game_title = "弟子" if lang == "zh" else "THE APPRENTICE"
theme_label = theme.localized_field("display_name", lang) or "The Apprentice"
if lang != "zh":
theme_label = theme_label.upper()
title = _md_safe(game_title).replace("&#X27;", "&rsquo;").replace("&#39;", "&rsquo;")
subtitle_raw = (
theme.localized_field("banner_subtitle", lang)
or _t("banner_default_subtitle", lang)
)
subtitle = _md_safe(f"{theme_label}{subtitle_raw}")
# Decide tall vs compact based on phase. Inscribe = tall (intro vibe).
# Trial/epilogue = compact (give screen real estate to the visuals).
is_compact = mode in ("trial", "epilogue", "send_off")
extra_cls = " oracle-banner-compact" if is_compact else ""
# Lean mode (default for HF Space): use a CSS gradient instead of the
# ~455 KB parallax PNG. Full mode loads the PNG via Gradio's file
# route so the browser caches it after the first request.
if _full_visual_mode(state) and _PARALLAX_PNG.exists():
src = f"{_FILE_URL_PREFIX}{_url_quote(_PARALLAX_PNG.as_posix(), safe='/')}"
result = (
f"<div class='oracle-banner{extra_cls}'>"
f"<img src='{src}' alt='banner'/>"
"<div class='oracle-banner-overlay'>"
f"<div class='oracle-banner-title'>{title}</div>"
f"<div class='oracle-banner-sub'>{subtitle}</div>"
"</div></div>"
)
else:
result = (
f"<div class='oracle-banner{extra_cls}' style='background:"
"linear-gradient(180deg,var(--w-panel) 0%, var(--w-night) 100%);'>"
"<div class='oracle-banner-overlay'>"
f"<div class='oracle-banner-title'>{title}</div>"
f"<div class='oracle-banner-sub'>{subtitle}</div>"
"</div></div>"
)
_BANNER_HTML_CACHE[cache_key] = result
return result
def _processing_overlay_html(state: Optional[GameState] = None) -> str:
"""Floating modal overlay shown while a generator handler is mid-LLM-call.
A walking-hero sprite paces across a small horizon strip with a caption
explaining what he's doing. Empty string when no work is in flight."""
msg = getattr(state, "processing_msg", "") if state else ""
if not msg:
return ""
# Theme-aware hero sprite — fall back to canonical "hero" when no
# theme-suffixed variant is on disk.
theme_key = getattr(state, "theme", "fantasy") or "fantasy"
hero_uri = (
_sprite_data_uri_app(f"hero_{theme_key}")
or _sprite_data_uri_app("hero")
)
hero_img = (
f"<img class='oracle-processing-hero' src='{hero_uri}' alt='hero'/>"
if hero_uri else ""
)
# Four landscape sprites stacked as crossfade layers. Each one fades
# in for ~3.5s then fades out for 0.5s while the hero walks through
# one 4-second exit cycle — so a new landscape appears every time the
# hero exits the right edge. Layers missing on disk are simply omitted.
# The first layer is theme-specific when available; the journey ones
# stay fantasy (generic horizon scenes).
theme_scene = f"scene_{theme_key}_landscape"
layer_names = [
theme_scene if _sprite_data_uri_app(theme_scene) else "processing_horizon",
"journey_dawn_meadow",
"journey_dusk_forest",
"journey_misty_valley",
]
layers = []
for i, name in enumerate(layer_names):
uri = _sprite_data_uri_app(name)
if not uri:
continue
# The theme-scene backdrops (scene_<theme>_landscape) are square
# Klein vignettes with chroma-keyed transparent margins; `cover`
# scales them up so the centered content gets cropped + the
# transparent margins reveal a void. Mark this first layer so
# CSS can size it with `contain` + a solid dark fill instead.
extra_cls = " oracle-processing-bg-theme" if name.startswith("scene_") else ""
layers.append(
f"<div class='oracle-processing-bg oracle-processing-bg-{i+1}{extra_cls}' "
f"style=\"background-image:url('{uri}');\"></div>"
)
bg_html = "".join(layers)
return (
"<div class='oracle-processing-modal'>"
"<div class='oracle-processing-overlay'>"
+ bg_html +
f"<div class='oracle-processing-caption'>{_md_safe(msg)}</div>"
f"{hero_img}"
"</div>"
"</div>"
)
def _global_sprite_vars_html() -> str:
"""One-time ``<style>`` block that pushes a few sprite data URIs onto
``:root`` as CSS variables. CSS rules elsewhere reference them via
``var(--ground-tile-uri)`` etc. so we keep large data URIs out of the
main stylesheet string."""
ground = _sprite_data_uri_app("ground_tile_grass")
if not ground:
return ""
return (
"<style data-sprite-vars>\n"
f":root {{ --ground-tile-uri: url('{ground}') !important; }}\n"
"</style>"
)
def _phase_backdrop_styles_html() -> str:
"""One-time ``<style>`` block (built at app startup) that wires the
per-phase pixel-art ambient textures onto the phase Group wrappers via
their ``elem_id`` attributes. Returns an empty string if any expected
texture is missing from disk OR if visual mode is lean.
Mapping:
#oracle-inscribe-grp ← grimoire_cover_texture
#oracle-send-off-grp ← stormy_plain_texture
#oracle-trial-grp ← night_sky_texture
#oracle-epilogue-grp ← forest_dusk_texture
"""
if not _full_visual_mode():
return ""
mapping = {
"oracle-inscribe-grp": "grimoire_cover_texture",
"oracle-send-off-grp": "stormy_plain_texture",
"oracle-trial-grp": "night_sky_texture",
"oracle-epilogue-grp": "forest_dusk_texture",
}
rules = []
for elem_id, sprite_name in mapping.items():
uri = _sprite_data_uri_app(sprite_name)
if not uri:
continue
rules.append(f"#{elem_id} {{ background-image: url('{uri}') !important; }}")
if not rules:
return ""
return "<style data-phase-backdrops>\n" + "\n".join(rules) + "\n</style>"
def _theme_style_overrides_html(state: Optional[GameState] = None) -> str:
"""Emit a tiny ``<style>`` block that overrides CSS custom properties on
``:root`` based on the active theme's palette. This reskins the entire
page (banner, panels, buttons, accents) without touching component code.
Returns an empty string when the active theme has no palette overrides
(fantasy uses the baseline). The block is injected into a gr.HTML slot
at the top of the layout that re-renders on every event.
"""
theme_key = getattr(state, "theme", "fantasy") if state else "fantasy"
if theme_key in _THEME_STYLE_CACHE:
return _THEME_STYLE_CACHE[theme_key]
from oracles.themes import get_theme as _get_theme
theme = _get_theme(theme_key)
if not theme.palette:
_THEME_STYLE_CACHE[theme_key] = ""
return ""
decls = "\n ".join(
f"--{name}: {value} !important;"
for name, value in theme.palette.items()
)
result = f"<style data-theme='{theme.key}'>\n:root {{\n {decls}\n}}\n</style>"
_THEME_STYLE_CACHE[theme_key] = result
return result
# ---------------------------------------------------------------------------
# CSS
# ---------------------------------------------------------------------------
CSS = """
/* Fonts are loaded via the theme's GoogleFont() declarations (Press Start 2P
+ VT323), not via @import. Gradio injects user CSS as a constructed
stylesheet which doesn't allow @import — leaving the @import in here
spams the browser console with "@import rules are not allowed here". */
:root {
--w-night: #18141f; /* page background */
--w-panel: #2a2438; /* cards */
--w-amber: #f0c060; /* accent */
--w-moss: #7ea687; /* secondary accent */
--w-cream: #ede4d3; /* primary text */
--w-mute: #b4a890; /* dimmer text */
--w-shadow: #0c0a13; /* chunky drop shadow */
--w-dragon: #c84a3a; /* dragon accent */
--w-dragon-deep: #3a0e0e;
}
/* ---------- Off-brand sweep — hide every Gradio default chrome bit ---------- */
/* The "Built with Gradio" footer + API badge in the corner are dead
giveaways that this is a Gradio app. The Off-Brand badge wants the
judge to not be able to tell at a glance. */
footer,
.footer,
.gradio-container > .footer,
.gradio-container footer,
a[href*="gradio.app"],
a[href*="huggingface.co/spaces"][title*="Built with"],
.svelte-built-with-gradio,
.show-api,
.api-docs,
.use-via-api,
.built-with {
display: none !important;
visibility: hidden !important;
height: 0 !important;
}
/* The "Use via API" pill in the bottom-right of HF Spaces. */
.api-docs-btn,
button.api-docs-btn,
.gradio-container .footer * {
display: none !important;
}
/* Just suppress the Gradio focus-blue ring + their default fonts.
Do NOT zero out block backgrounds — they're providing the
dark-on-light text contrast on the inscribe pages. */
.gradio-container .info,
.gradio-container .info-text {
font-family: 'VT323', monospace !important;
color: var(--w-cream, #ede4d3) !important;
font-size: 17px !important;
letter-spacing: 0.3px !important;
line-height: 1.5 !important;
opacity: 0.95 !important;
}
.gradio-container label,
.gradio-container .label-wrap,
.gradio-container .label {
font-family: 'Press Start 2P', monospace !important;
font-size: 12px !important;
color: var(--w-amber, #f0c060) !important;
letter-spacing: 1px !important;
}
/* Stop Gradio's default focus glow — we use our own border tinge. */
.gradio-container *:focus-visible {
outline: 2px solid var(--w-amber, #f0c060) !important;
outline-offset: 1px !important;
box-shadow: none !important;
}
/* Belt-and-suspenders: kill any Gradio default loading / progress / spinner
indicators that try to flash on a yielded handler. We already pass
``show_progress="hidden"`` on every click + change, but Gradio's CSS
sometimes still inserts a momentary status overlay before the first
yield lands — that brief flash is what the user perceived as flicker
when transitioning between pages. */
.progress-bar-wrap,
.progress-bar,
.progress-text,
.gradio-container .progress,
.gradio-container .progress-level,
.gradio-container .progress-level-inner,
.gradio-container .progress-bar,
.gradio-container .progress-bar-wrap,
.gradio-container .pending,
.gradio-container .generating,
.gradio-container [class*="progress-level"],
.gradio-container [class*="status"]:not(.oracle-processing-modal):not(.oracle-processing-overlay),
.gradio-container .wrap.default,
.gradio-container .loading,
.gradio-container .spin,
.gradio-container .spinner {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
/* The block-wrapper that Gradio adds a "pending" class to during a
click — suppress its opacity transition so the page doesn't dim. */
.gradio-container .block.pending,
.gradio-container .block.generating {
opacity: 1 !important;
pointer-events: auto !important;
}
/* ---------- Force dark theme on the whole gradio container ---------- */
.gradio-container, body, .dark, .gradio-container .form, .gradio-container .block {
background: var(--w-night) !important;
color: var(--w-cream) !important;
font-family: 'VT323', 'Courier New', monospace !important;
}
.gradio-container * { image-rendering: pixelated; }
/* Make gradio's own labels readable on dark */
.gradio-container label, .gradio-container .label, .gradio-container .label-wrap,
.gradio-container span, .gradio-container p, .gradio-container li {
color: var(--w-cream) !important;
}
.gradio-container input, .gradio-container textarea {
background: var(--w-panel) !important;
color: var(--w-cream) !important;
border: 2px solid var(--w-amber) !important;
box-shadow: 3px 3px 0 var(--w-shadow) !important;
font-family: 'VT323', monospace !important;
font-size: 18px !important;
}
.gradio-container input:focus, .gradio-container textarea:focus {
outline: none !important;
border-color: var(--w-moss) !important;
}
.gradio-container button {
background: var(--w-amber) !important;
color: var(--w-shadow) !important;
font-family: 'Press Start 2P', monospace !important;
font-size: 11px !important;
letter-spacing: 1px !important;
border: none !important;
box-shadow: 4px 4px 0 var(--w-shadow) !important;
padding: 12px 18px !important;
border-radius: 0 !important;
transition: transform 0.05s, box-shadow 0.05s;
}
.gradio-container button:hover {
background: var(--w-moss) !important;
}
.gradio-container button:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 var(--w-shadow) !important;
}
/* ---------- Parallax banner ---------- */
.oracle-banner {
width: 100%;
height: 200px;
overflow: hidden;
position: relative;
border-bottom: 4px solid var(--w-shadow);
margin-bottom: 0;
}
.oracle-banner img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 60%;
image-rendering: pixelated;
filter: brightness(0.65) saturate(1.1);
display: block;
}
.oracle-banner-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(24, 20, 31, 0.05) 0%,
rgba(24, 20, 31, 0.45) 60%,
rgba(24, 20, 31, 0.95) 100%
);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 0 20px;
}
.oracle-banner-overlay .oracle-banner-title {
font-family: 'Press Start 2P', monospace !important;
font-size: 28px;
color: var(--w-amber);
text-shadow:
4px 4px 0 var(--w-shadow),
0 0 24px rgba(240, 192, 96, 0.5);
letter-spacing: 4px;
margin: 0;
}
.oracle-banner-overlay .oracle-banner-sub {
font-family: 'VT323', monospace !important;
color: var(--w-cream);
font-size: 20px;
margin-top: 12px;
opacity: 0.95;
text-shadow: 2px 2px 0 var(--w-shadow);
}
/* ---------- Title / badge ---------- */
.oracle-title {
font-family: 'Press Start 2P', monospace !important;
font-size: 14px !important;
color: var(--w-amber) !important;
text-shadow: 3px 3px 0 var(--w-shadow);
letter-spacing: 2px;
margin: 0 0 8px 0;
}
.oracle-subtitle {
font-style: italic;
color: var(--w-mute) !important;
font-size: 16px;
margin-bottom: 8px;
}
.oracle-badge {
display: inline-block;
padding: 6px 14px;
background: var(--w-amber);
color: var(--w-shadow) !important;
font-family: 'Press Start 2P', monospace !important;
font-weight: 400;
font-size: 10px;
letter-spacing: 1px;
margin-left: 8px;
border: 2px solid var(--w-shadow);
box-shadow: 3px 3px 0 var(--w-shadow);
vertical-align: middle;
}
.oracle-mock-banner {
font-style: italic;
color: var(--w-amber) !important;
font-size: 14px;
margin: 4px 0 10px 0;
border: 1px dashed var(--w-amber);
padding: 4px 10px;
display: inline-block;
}
/* ---------- Card (the main panel for trial / epilogue) ---------- */
.oracle-card {
background: var(--w-panel);
border: 4px solid var(--w-amber);
box-shadow: 6px 6px 0 var(--w-shadow);
padding: 22px 26px;
margin: 14px 0 18px 0;
color: var(--w-cream) !important;
}
.oracle-card[data-dragon="true"] {
background: linear-gradient(160deg, var(--w-dragon-deep) 0%, #1a0606 100%);
border-color: var(--w-dragon);
box-shadow: 6px 6px 0 #260606;
}
.oracle-card[data-dragon="true"] .oracle-trial-heading {
color: var(--w-dragon);
text-shadow: 3px 3px 0 #1a0606;
}
.oracle-trial-heading {
font-family: 'Press Start 2P', monospace !important;
font-size: 18px;
color: var(--w-amber);
text-shadow: 3px 3px 0 var(--w-shadow);
margin: 0 0 14px 0;
letter-spacing: 2px;
/* Buffering panel — same treatment as narration/tactic so the
heading stays readable on any scene image behind it. */
background: rgba(20, 14, 30, 0.78);
border: 1px solid rgba(240, 192, 96, 0.35);
padding: 10px 16px;
display: inline-block;
}
.oracle-obstacle {
font-size: 19px;
line-height: 1.5;
color: var(--w-cream) !important;
margin-bottom: 14px;
/* Buffering for the obstacle setup text. */
background: rgba(20, 14, 30, 0.72);
border: 1px solid rgba(240, 192, 96, 0.20);
padding: 12px 16px;
}
/* ---------- Oracle quote blockquote ---------- */
blockquote.oracle-quote {
border-left: 6px solid var(--w-amber);
margin: 12px 0 16px 0;
padding: 10px 16px;
font-style: italic;
background: rgba(24, 20, 31, 0.55);
color: var(--w-cream) !important;
border-radius: 0;
white-space: pre-wrap;
font-size: 18px;
}
.oracle-card[data-dragon="true"] blockquote.oracle-quote {
border-left-color: var(--w-dragon);
color: #ffd9c3 !important;
background: rgba(0,0,0,0.4);
}
.oracle-narration {
font-size: 18px;
line-height: 1.6;
color: var(--w-cream) !important;
/* Inline dark backdrop — every LLM-generated narration sits on a
scene image with unpredictable brightness. Without a buffer the
cream text disappears whenever a light patch of the scene sits
under it. This semi-transparent panel rides ON TOP of the scene
image and gives the text its own consistent contrast layer. */
background: rgba(20, 14, 30, 0.72);
border: 1px solid rgba(240, 192, 96, 0.25);
padding: 14px 18px;
box-shadow: 0 0 22px rgba(0, 0, 0, 0.35) inset;
border-radius: 0;
}
/* The trial-await "He has N oracles left" status line is rendered as a
bare .oracle-narration (no scene image, no .oracle-bg). Gradio's host
CSS has at times beaten the base rule above on cascade, leaving the
text floating on the raw scene. Lock the backdrop with !important so
the status line always reads against a dark panel. Scoped via :not()
so the .oracle-bg case (which provides its own backdrop via ::before)
is untouched. */
.oracle-narration:not(.oracle-bg) {
background-color: rgba(20, 14, 30, 0.85) !important;
border: 1px solid rgba(240, 192, 96, 0.28) !important;
padding: 14px 18px !important;
box-shadow: 0 0 22px rgba(0, 0, 0, 0.40) inset !important;
}
.oracle-tactic {
font-size: 16px;
color: var(--w-amber) !important;
margin-top: 14px;
font-style: italic;
border-top: 1px dashed var(--w-amber);
padding-top: 8px;
/* Same buffering treatment for the tactic one-liner. */
background: rgba(20, 14, 30, 0.70);
border: 1px solid rgba(240, 192, 96, 0.25);
padding: 10px 16px;
margin-top: 12px;
}
.oracle-card[data-dragon="true"] .oracle-tactic {
color: var(--w-dragon) !important;
border-top-color: var(--w-dragon);
}
/* ---------- Inscribe phase ---------- */
.oracle-inscribe-help {
background: var(--w-panel);
border-left: 4px solid var(--w-amber);
padding: 12px 16px;
margin-bottom: 14px;
font-size: 17px;
color: var(--w-cream) !important;
box-shadow: 4px 4px 0 var(--w-shadow);
}
.oracle-error {
color: #ff8a78 !important;
font-weight: 700;
font-size: 16px;
margin-top: 8px;
min-height: 1.2em;
}
.oracle-sealed-list {
color: var(--w-cream) !important;
line-height: 1.7;
font-size: 18px;
}
.oracle-sealed-list strong {
color: var(--w-amber) !important;
font-family: 'Press Start 2P', monospace !important;
font-size: 11px;
letter-spacing: 1px;
}
.oracle-sealed-list ol {
padding-left: 28px;
}
.oracle-sealed-list li {
color: var(--w-cream) !important;
margin-bottom: 6px;
}
.oracle-sealed-list li::marker {
color: var(--w-amber);
font-weight: bold;
}
/* ---------- Image wrap ---------- */
.oracle-image-wrap {
text-align: center;
margin: 14px 0;
}
.oracle-image-wrap img {
max-width: 100%;
height: auto;
border: 4px solid var(--w-amber);
box-shadow: 6px 6px 0 var(--w-shadow);
image-rendering: pixelated;
}
.oracle-card[data-dragon="true"] .oracle-image-wrap img {
border-color: var(--w-dragon);
}
/* ---------- Pixel decoration: small inline SVG icons ---------- */
.oracle-icon {
display: inline-block;
vertical-align: middle;
image-rendering: pixelated;
margin-right: 6px;
}
/* ---------- The grimoire (inscribe page, book spread) ---------- */
/* The .oracle-grimoire is on a gr.Row. Gradio's row is already flex with
horizontal direction, so we just style the band and let the two child
columns sit side by side. Gradio uses gap by default; we override. */
.oracle-grimoire {
background: var(--w-shadow) !important;
/* Chunky pixel-art amber border with darker inner stitch */
border: 6px solid var(--w-amber) !important;
box-shadow:
10px 10px 0 var(--w-shadow),
inset 0 0 0 2px #6a4818 !important;
margin: 14px 0 18px 0 !important;
padding: 0 !important;
gap: 0 !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
position: relative;
}
.oracle-grimoire > * {
margin: 0 !important;
border-radius: 0 !important;
}
/* Visible book spine running down the centre where the two pages meet */
.oracle-grimoire::before {
content: "";
position: absolute;
top: 0; bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 10px;
background: linear-gradient(180deg,
transparent 0%,
#150f1c 8%,
#0a0710 50%,
#150f1c 92%,
transparent 100%);
box-shadow:
-7px 0 14px rgba(0,0,0,0.75),
7px 0 14px rgba(0,0,0,0.75);
z-index: 5;
pointer-events: none;
}
/* Five amber "stitching" dots vertically along the spine */
.oracle-grimoire::after {
content: "";
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 50%;
width: 4px;
height: 4px;
background: var(--w-amber);
box-shadow:
0 -76px 0 var(--w-amber),
0 -38px 0 var(--w-amber),
0 38px 0 var(--w-amber),
0 76px 0 var(--w-amber);
z-index: 6;
pointer-events: none;
}
/* Each .grimoire-page is on a gr.Column inside the row. */
.grimoire-page {
background:
/* candlelight halo at the top of the page */
radial-gradient(ellipse 70% 40% at 50% 0%,
rgba(240, 192, 96, 0.12) 0%, transparent 70%),
/* faint horizontal ruled-paper lines (writing guides) */
repeating-linear-gradient(0deg,
transparent 0px, transparent 32px,
rgba(240, 192, 96, 0.045) 32px,
rgba(240, 192, 96, 0.045) 33px),
/* warm aged-page tint over the base */
linear-gradient(180deg, #2f2638 0%, #251d2e 100%) !important;
padding: 38px 36px 34px 36px !important;
color: var(--w-cream) !important;
font-size: 22px !important;
line-height: 1.7 !important;
min-height: 460px;
position: relative;
overflow: hidden;
flex: 1 1 50% !important;
min-width: 0 !important;
}
/* Gradio wraps gr.HTML output in its own div which resets font-size on the
inner content — these selectors win because they target the inner div
directly with !important. */
.grimoire-page .grimoire-text-body,
.grimoire-page .grimoire-text-body p,
.grimoire-page .grimoire-text-body li,
.grimoire-page .grimoire-text-body strong,
.grimoire-page .grimoire-text-body em,
.grimoire-page .grimoire-text-body blockquote {
font-size: 22px !important;
line-height: 1.7 !important;
}
/* Hand-drawn amber rule near the top of each page */
.grimoire-page::before {
content: "";
position: absolute;
top: 18px;
left: 30px;
right: 30px;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
rgba(240, 192, 96, 0.45) 18%,
rgba(240, 192, 96, 0.70) 50%,
rgba(240, 192, 96, 0.45) 82%,
transparent 100%);
pointer-events: none;
z-index: 1;
}
/* Per-step pixel-art sigil rendered inline (via _grimoire_left_html and
_grimoire_right_sigil_html). Positioned at the inner-bottom corner of
each page so the two sigils mirror across the spine. */
.grimoire-sigil {
position: absolute;
bottom: 14px;
width: 36px;
height: 36px;
opacity: 0.9;
pointer-events: none;
z-index: 2;
filter: drop-shadow(2px 2px 0 var(--w-shadow));
}
.grimoire-sigil-left { right: 28px; }
.grimoire-sigil-right { left: 28px; }
/* Illuminated drop-cap: first letter of the wizard's first paragraph
on the left page renders as a chunky pixel-block initial.
Chinese characters look forced as drop-caps — the rule is suppressed
when the inner div has lang="zh" (set by the renderer). */
.grimoire-text-body[lang="zh"] p:first-of-type::first-letter,
.grimoire-text-body[lang="zh"] p:first-of-type strong:first-of-type::first-letter {
font-size: inherit !important;
color: inherit !important;
text-shadow: none !important;
float: none !important;
margin: 0 !important;
padding: 0 !important;
font-family: inherit !important;
}
.grimoire-page.grimoire-left p:first-of-type::first-letter {
font-family: 'Press Start 2P', 'Courier New', monospace !important;
font-size: 40px !important;
line-height: 1 !important;
color: var(--w-amber) !important;
text-shadow: 3px 3px 0 var(--w-shadow);
float: left;
margin: 4px 8px 0 0;
padding: 2px 4px 0 0;
font-style: normal !important;
}
/* Parchment curl: deeper shadow at the spine edge of each page */
.grimoire-page.grimoire-left {
box-shadow:
inset -18px 0 24px rgba(0,0,0,0.55),
inset 0 0 28px rgba(0,0,0,0.25);
font-family: 'VT323', 'Courier New', monospace !important;
font-style: italic;
color: var(--w-cream) !important;
}
.grimoire-page.grimoire-right {
box-shadow:
inset 18px 0 24px rgba(0,0,0,0.55),
inset 0 0 28px rgba(0,0,0,0.25);
}
.grimoire-page.grimoire-left p {
margin: 8px 0;
color: var(--w-cream) !important;
}
.grimoire-page.grimoire-left strong {
color: var(--w-amber) !important;
font-style: normal;
font-weight: 600;
}
.grimoire-page.grimoire-left em {
color: var(--w-amber) !important;
font-style: italic;
}
.grimoire-page.grimoire-left blockquote {
border-left: 4px solid var(--w-amber);
margin: 12px 0;
padding: 6px 14px;
background: rgba(0, 0, 0, 0.3);
font-style: italic;
color: var(--w-cream) !important;
}
.grimoire-page.grimoire-left .grimoire-step-marker {
/* In normal flow now (was absolute), rendered AFTER the text body
so it sits BELOW the wizard's speech rather than overlapping. */
display: inline-block;
margin: 20px 0 0 0;
font-family: 'Press Start 2P', monospace !important;
font-size: 10px;
color: var(--w-amber);
letter-spacing: 1px;
opacity: 0.85;
background: rgba(24, 20, 31, 0.78);
padding: 4px 8px;
border: 1px solid rgba(240, 192, 96, 0.45);
pointer-events: none;
}
/* Reserve a right-side rectangle in the upper corner so the first
paragraph's first 1-2 lines wrap before the marker instead of
running underneath it. Done with a floating phantom block so the
text envelope flows naturally around both the drop-cap (left) AND
the marker zone (right). */
.grimoire-page.grimoire-left p:first-of-type::before {
content: "";
float: right;
width: 96px;
height: 36px;
margin: 0 0 6px 8px;
shape-outside: inset(0 0 0 0);
}
.grimoire-page.grimoire-right {
display: flex;
flex-direction: column;
justify-content: center;
}
.grimoire-page.grimoire-right .grimoire-right-helper {
font-family: 'VT323', monospace !important;
color: var(--w-mute);
font-size: 16px;
margin-bottom: 12px;
font-style: italic;
}
.grimoire-page.grimoire-right textarea,
.grimoire-page.grimoire-right input {
background: rgba(0,0,0,0.35) !important;
border: 2px solid var(--w-amber) !important;
color: var(--w-cream) !important;
font-family: 'VT323', monospace !important;
font-size: 19px !important;
padding: 12px !important;
box-shadow: 3px 3px 0 var(--w-shadow) !important;
}
.grimoire-summary {
color: var(--w-cream) !important;
font-family: 'VT323', monospace !important;
font-size: 17px;
line-height: 1.5;
background: rgba(0,0,0,0.3);
border-left: 4px solid var(--w-amber);
padding: 10px 14px;
margin: 8px 0;
}
.grimoire-summary strong {
color: var(--w-amber) !important;
}
.grimoire-summary ol {
margin: 6px 0 6px 18px;
padding: 0;
}
.grimoire-summary ol li {
color: var(--w-cream) !important;
margin: 4px 0;
}
.grimoire-progress {
text-align: center;
margin: 6px 0 0 0;
letter-spacing: 6px;
font-family: 'Press Start 2P', monospace !important;
font-size: 12px;
color: var(--w-amber);
}
.grimoire-progress .dot {
color: var(--w-mute);
}
.grimoire-progress .dot.done {
color: var(--w-amber);
}
@media (max-width: 720px) {
.oracle-grimoire {
flex-direction: column !important;
}
.grimoire-page {
flex: 1 1 100% !important;
min-height: 220px !important;
}
.grimoire-page.grimoire-left {
border-right: none !important;
border-bottom: 2px solid var(--w-amber);
}
}
/* ---------- Prologue (legacy, kept for any references) ---------- */
.oracle-prologue {
background: var(--w-panel);
border: 4px solid var(--w-amber);
box-shadow: 6px 6px 0 var(--w-shadow);
padding: 22px 26px;
margin: 0 0 18px 0;
color: var(--w-cream) !important;
font-size: 18px;
line-height: 1.6;
}
.oracle-prologue-title {
font-family: 'Press Start 2P', monospace !important;
font-size: 12px;
color: var(--w-amber);
letter-spacing: 2px;
text-shadow: 2px 2px 0 var(--w-shadow);
margin-bottom: 12px;
text-align: center;
}
.oracle-prologue p {
margin: 8px 0;
color: var(--w-cream) !important;
}
.oracle-prologue strong {
color: var(--w-amber) !important;
font-weight: 600;
}
.oracle-prologue em {
color: var(--w-mute);
font-style: italic;
}
.oracle-prologue-quote {
border-left: 4px solid var(--w-amber);
margin: 12px 0;
padding: 6px 14px;
background: rgba(0, 0, 0, 0.3);
font-style: italic;
color: var(--w-cream) !important;
}
/* ---------- Interlude (above the next trial's setup) ---------- */
.oracle-interlude {
background: rgba(42, 36, 56, 0.6);
border-left: 4px solid var(--w-moss);
padding: 14px 18px;
margin: 0 0 16px 0;
font-style: italic;
color: var(--w-cream) !important;
font-size: 17px;
line-height: 1.55;
}
.oracle-interlude p {
margin: 6px 0;
color: var(--w-cream) !important;
}
.oracle-interlude.oracle-interlude-dragon {
border-left-color: var(--w-dragon);
background: rgba(58, 14, 14, 0.45);
color: #ffd9c3 !important;
}
.oracle-interlude.oracle-interlude-dragon p {
color: #ffd9c3 !important;
}
/* ---------- Inscribe-phase figure row (wizard → 5 oracles → hero) ---------- */
.oracle-inscribe-figs {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 24px 18px;
margin: 12px 0 20px 0;
background: var(--w-panel);
border: 4px solid var(--w-amber);
box-shadow: 6px 6px 0 var(--w-shadow);
}
.oracle-inscribe-figs .oracle-fig {
display: flex;
flex-direction: column;
align-items: center;
flex: 0 0 auto;
}
.oracle-inscribe-figs .oracle-fig img {
width: 128px;
height: 128px;
image-rendering: pixelated;
filter: drop-shadow(4px 4px 0 var(--w-shadow));
}
.oracle-inscribe-figs .oracle-fig-label {
margin-top: 12px;
font-family: 'Press Start 2P', monospace !important;
font-size: 12px; /* was 10 */
color: var(--w-amber);
text-align: center;
letter-spacing: 1px;
line-height: 1.6;
}
.oracle-inscribe-figs .oracle-fig-label span {
display: block;
font-family: 'VT323', monospace !important;
font-size: 20px; /* was 16 */
color: var(--w-mute);
font-style: italic;
letter-spacing: 0;
margin-top: 6px;
}
.oracle-inscribe-figs .oracle-fig-arrow {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--w-cream);
padding: 0 16px;
min-width: 0;
}
.oracle-arrow-scrolls {
font-family: 'Press Start 2P', monospace !important;
font-size: 14px;
color: var(--w-amber);
letter-spacing: 1px;
margin-bottom: 6px;
text-shadow: 2px 2px 0 var(--w-shadow);
}
.oracle-arrow-line {
font-family: 'VT323', monospace !important;
font-size: 32px;
color: var(--w-amber);
line-height: 1;
letter-spacing: 0;
}
.oracle-arrow-caption {
font-family: 'VT323', monospace !important;
font-size: 22px; /* was 18 */
color: var(--w-mute);
font-style: italic;
margin-top: 6px;
}
/* ---------- Chronicle accordion text ---------- */
.gradio-container .label-wrap, .gradio-container details summary {
color: var(--w-amber) !important;
font-family: 'Press Start 2P', monospace !important;
font-size: 11px !important;
letter-spacing: 1px !important;
}
.gradio-container details h3, .gradio-container details strong {
color: var(--w-amber) !important;
font-family: 'Press Start 2P', monospace !important;
font-size: 12px !important;
}
.gradio-container details em {
color: var(--w-mute) !important;
}
/* ---------- Story-tree visualization (epilogue/chronicle) ---------- */
.story-tree {
background: linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.55) 100%);
border: 2px solid var(--w-amber);
box-shadow: 5px 5px 0 var(--w-shadow);
padding: 18px 16px 22px 16px;
margin: 0 0 18px 0;
font-family: 'VT323', monospace;
color: var(--w-cream);
}
.story-tree-header {
text-align: center;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px dashed rgba(240, 192, 96, 0.4);
}
.story-tree-title {
font-family: 'Press Start 2P', monospace;
color: var(--w-amber);
font-size: 14px;
letter-spacing: 2px;
margin-bottom: 4px;
}
.story-tree-hint {
color: var(--w-mute);
font-size: 14px;
font-style: italic;
}
.story-tree-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
align-items: start;
}
.story-tree-col {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.story-tree-col-label {
font-family: 'Press Start 2P', monospace;
color: var(--w-amber);
font-size: 10px;
text-align: center;
letter-spacing: 1px;
padding: 4px 0;
border-bottom: 1px solid rgba(240, 192, 96, 0.3);
margin-bottom: 4px;
}
.story-node {
border: 2px solid var(--w-shadow);
background: rgba(20, 14, 30, 0.7);
padding: 6px 8px;
min-height: 64px;
border-radius: 0;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.story-node.visited {
border-color: var(--w-amber);
background: linear-gradient(180deg, rgba(60, 42, 16, 0.95) 0%, rgba(36, 28, 18, 0.95) 100%);
box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25);
/* Subtle breath so the walked path looks alive vs. the static
masked nodes. Same keyframe-anchor pattern (0% = 100% = resting). */
animation: story-node-breath 5.5s ease-in-out infinite;
}
@keyframes story-node-breath {
0% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); }
50% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 18px rgba(240, 192, 96, 0.42); }
100% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); }
}
.story-node.visited:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--w-shadow), 0 0 22px rgba(240, 192, 96, 0.55);
}
.story-node.masked {
border-color: rgba(120, 110, 100, 0.35);
background: rgba(20, 14, 30, 0.45);
color: var(--w-mute);
filter: blur(0.5px);
opacity: 0.55;
}
.story-node.masked .story-node-title {
color: rgba(180, 168, 144, 0.6);
font-style: italic;
}
.story-node.ending.visited {
border-color: #ffd966;
background: linear-gradient(180deg, rgba(120, 80, 20, 0.95) 0%, rgba(60, 40, 16, 0.95) 100%);
box-shadow: 3px 3px 0 var(--w-shadow), 0 0 20px rgba(255, 217, 102, 0.5);
}
.story-node-title {
font-family: 'Press Start 2P', monospace;
color: var(--w-amber);
font-size: 9px;
letter-spacing: 0.5px;
margin-bottom: 4px;
line-height: 1.3;
word-break: break-word;
}
.story-node.masked .story-node-title {
font-family: 'VT323', monospace;
font-size: 16px;
color: rgba(180, 168, 144, 0.6);
}
.story-node-tag {
font-family: 'VT323', monospace;
color: var(--w-mute);
font-size: 12px;
margin-bottom: 4px;
}
.story-node-setup {
font-family: 'VT323', monospace;
color: var(--w-cream);
font-size: 13px;
line-height: 1.35;
opacity: 0.92;
}
@media (max-width: 900px) {
.story-tree-grid {
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.story-tree-col-label { font-size: 9px; }
.story-node-title { font-size: 8px; }
}
/* ---------- Ending banner on the epilogue screen ---------- */
.ending-banner {
background: linear-gradient(180deg, rgba(120, 80, 20, 0.92) 0%, rgba(36, 24, 14, 0.92) 100%);
border: 2px solid #ffd966;
box-shadow: 4px 4px 0 var(--w-shadow), 0 0 28px rgba(255, 217, 102, 0.35);
padding: 16px 18px 18px 18px;
margin: 12px 0 18px 0;
text-align: center;
color: var(--w-cream);
font-family: 'VT323', monospace;
position: relative;
}
.ending-eyebrow {
font-family: 'Press Start 2P', monospace;
color: #ffd966;
font-size: 10px;
letter-spacing: 2px;
margin-bottom: 8px;
text-shadow: 1px 1px 0 var(--w-shadow);
}
.ending-title {
font-family: 'Press Start 2P', monospace;
color: #ffd966;
font-size: 16px;
letter-spacing: 1.5px;
line-height: 1.5;
margin-bottom: 14px;
text-shadow: 2px 2px 0 var(--w-shadow);
}
.ending-pips {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
margin-top: 10px;
}
.ending-pip {
border: 2px solid var(--w-shadow);
padding: 6px 4px;
font-family: 'VT323', monospace;
font-size: 13px;
line-height: 1.25;
text-align: center;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
word-break: break-word;
}
.ending-pip.lit {
background: linear-gradient(180deg, rgba(255, 217, 102, 0.95) 0%, rgba(180, 130, 30, 0.95) 100%);
border-color: #ffd966;
color: #1a1018;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
letter-spacing: 0.5px;
text-shadow: 1px 1px 0 rgba(255, 240, 200, 0.7);
box-shadow: 0 0 12px rgba(255, 217, 102, 0.5);
/* Subtle pulse so the player's eye lands on the ending they reached.
Keyframes anchored at the resting glow (0% == 100%) so the
Gradio-yield restart never snaps to a darker state — the same
fix pattern used for every other animation in the app. */
animation: ending-pip-pulse 3.4s ease-in-out infinite;
}
@keyframes ending-pip-pulse {
0% { box-shadow: 0 0 12px rgba(255, 217, 102, 0.50);
transform: scale(1.00); }
50% { box-shadow: 0 0 22px rgba(255, 217, 102, 0.85);
transform: scale(1.025); }
100% { box-shadow: 0 0 12px rgba(255, 217, 102, 0.50);
transform: scale(1.00); }
}
.ending-pip.dim {
background: rgba(20, 14, 30, 0.7);
color: rgba(180, 168, 144, 0.55);
border-color: rgba(120, 110, 100, 0.35);
filter: blur(0.5px);
font-style: italic;
}
.ending-pip-tag {
font-size: 11px;
opacity: 0.7;
margin-left: 2px;
}
@media (max-width: 700px) {
.ending-pips { grid-template-columns: repeat(2, 1fr); }
.ending-title { font-size: 13px; }
}
/* ---------- Motion layer (overnight overhaul) ---------- */
/* Subtle decorative animations to make the static page feel alive.
All animations respect prefers-reduced-motion (see bottom of block). */
/* (1) Candle flicker on the grimoire pages.
We add a NEW pseudo-element to .grimoire-page (::after) that overlays a
candlelight halo at the top, then pulse its opacity irregularly.
We don't touch .grimoire-page::before (the hand-drawn rule) to avoid
conflicting with its existing transform/positioning. */
.grimoire-page::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40%;
background: radial-gradient(ellipse 70% 100% at 50% 0%,
rgba(240, 192, 96, 0.18) 0%,
rgba(240, 192, 96, 0.08) 40%,
transparent 75%);
pointer-events: none;
z-index: 0;
opacity: 0.95;
animation: grimoire-candle-flicker 2.5s ease-in-out infinite alternate;
}
@keyframes grimoire-candle-flicker {
0% { opacity: 0.88; }
23% { opacity: 1.00; }
47% { opacity: 0.92; }
71% { opacity: 0.97; }
88% { opacity: 0.85; }
100% { opacity: 0.96; }
}
/* Also expose an explicit class hook in case it's added on the element. */
.grimoire-candle-flicker {
animation: grimoire-candle-flicker 2.5s ease-in-out infinite alternate;
}
/* (2) Ribbon sway on the grimoire bookmark.
We need to preserve the existing translateX(-50%) positioning of
.oracle-grimoire::before, so we override transform-origin and add
the sway via animation that drives the rotation while keeping the
horizontal centering. */
.oracle-grimoire::before {
transform-origin: top center;
animation: grimoire-ribbon-sway 4s ease-in-out infinite alternate;
}
@keyframes grimoire-ribbon-sway {
0% { transform: translateX(-50%) rotate(-2deg); }
50% { transform: translateX(-50%) rotate(0deg); }
100% { transform: translateX(-50%) rotate(2deg); }
}
/* (3) Wax-seal pulse on grimoire progress dots.
Plays once each time a dot's class becomes .done (Gradio re-renders the
inner HTML so the animation fires fresh on each new sealed oracle). */
.grimoire-progress .dot.done {
display: inline-block;
animation: grimoire-seal-stamp 0.6s ease-out;
transform-origin: center;
}
@keyframes grimoire-seal-stamp {
0% { transform: scale(1.6) rotate(-8deg); opacity: 0.4; }
55% { transform: scale(0.9) rotate(3deg); opacity: 1; }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
/* (4) Sprite hover glow on the trial scene cards. */
.oracle-image-wrap img {
transition: transform 0.3s ease, filter 0.3s ease, box-shadow 0.3s ease;
}
.oracle-image-wrap img:hover {
transform: scale(1.04);
filter: drop-shadow(0 0 10px rgba(240, 192, 96, 0.55))
drop-shadow(0 0 18px rgba(240, 192, 96, 0.25));
}
.oracle-card[data-dragon="true"] .oracle-image-wrap img:hover {
filter: drop-shadow(0 0 10px rgba(200, 74, 58, 0.55))
drop-shadow(0 0 18px rgba(200, 74, 58, 0.25));
}
/* (5) Spread entrance fade — DISABLED.
This animation re-fired on every generator yield because Gradio swaps
inner HTML each tick, restarting the keyframes at opacity:0 +
translateX(-12px). The result was a visible 0.5s shift-and-fade on
every button click — what the user described as "all blocks
displaced for a second". The grimoire pages now render statically. */
@keyframes grimoire-spread-enter {
0% { opacity: 1; transform: translateX(0); }
100% { opacity: 1; transform: translateX(0); }
}
/* (6) Candlelight breathing on the trial scene cards.
Can't style internals of an <img> data-URI SVG, so we breathe the whole
card's brightness/filter instead. Skipped the per-rect twinkle for that
reason — documented in PIVOT_LOG. */
.oracle-image-wrap img {
/* Atmosphere preserved — keyframes now pin frame 0% and 100% to
the resting visual state and put the variation at 50%. Gradio's
inner-HTML rebuilds on each yield still restart the animation
at 0%, but 0% is now indistinguishable from steady-state, so
there's no visible snap. */
animation: oracle-card-breathe 5s ease-in-out infinite;
}
@keyframes oracle-card-breathe {
0% { filter: brightness(1.00) saturate(1.04); }
50% { filter: brightness(1.06) saturate(1.08); }
100% { filter: brightness(1.00) saturate(1.04); }
}
/* Don't override the hover filter glow — re-state the hover rule AFTER the
breathing keyframe so :hover wins precedence. */
.oracle-image-wrap img:hover {
animation: none;
transform: scale(1.04);
filter: drop-shadow(0 0 10px rgba(240, 192, 96, 0.55))
drop-shadow(0 0 18px rgba(240, 192, 96, 0.25));
}
.oracle-card[data-dragon="true"] .oracle-image-wrap img:hover {
filter: drop-shadow(0 0 10px rgba(200, 74, 58, 0.55))
drop-shadow(0 0 18px rgba(200, 74, 58, 0.25));
}
/* ---------- Wizard's Daydream — pipeline demo card on step 0 ---------- */
.oracle-pipeline-demo {
background: linear-gradient(180deg, #2f2638 0%, #251d2e 100%);
border: 4px solid var(--w-amber);
box-shadow: 6px 6px 0 var(--w-shadow);
padding: 18px 22px 14px 22px;
margin: 14px 0 18px 0;
color: var(--w-cream) !important;
position: relative;
}
.oracle-pipeline-demo::before {
/* Thought-bubble tail pointing up from the card's top-left corner,
suggesting these are the wizard's musings drifting up. */
content: "";
position: absolute;
top: -10px; left: 26px;
width: 14px; height: 14px;
background: var(--w-panel);
border: 4px solid var(--w-amber);
border-bottom: none;
border-right: none;
transform: rotate(45deg);
z-index: 1;
}
.pipeline-demo-header {
font-family: 'Press Start 2P', monospace !important;
font-size: 11px;
color: var(--w-amber) !important;
text-shadow: 2px 2px 0 var(--w-shadow);
letter-spacing: 1.5px;
margin-bottom: 14px;
text-align: center;
}
.pipeline-demo-header span.float {
/* a small wobbling thought-cloud emoji for charm */
display: inline-block;
margin-right: 8px;
animation: pipeline-cloud-bob 3.5s ease-in-out infinite alternate;
}
@keyframes pipeline-cloud-bob {
0% { transform: translateY(0); }
100% { transform: translateY(-3px); }
}
.pipeline-demo-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
@media (max-width: 820px) {
.pipeline-demo-grid { grid-template-columns: 1fr; }
}
.pipeline-demo-panel {
/* Bumped opacity 0.30 → 0.72 so the example-usage text on the
front page reads clearly regardless of what scene/texture sits
behind it. Same buffering treatment as the trial narration. */
background: rgba(20, 14, 30, 0.72);
border: 1px solid rgba(240, 192, 96, 0.25);
border-left: 3px solid var(--w-moss);
padding: 14px 16px;
font-size: 15px;
line-height: 1.55;
box-shadow: 2px 2px 0 rgba(0,0,0,0.4);
}
.pipeline-demo-panel:nth-child(1) { border-left-color: #f0c060; }
.pipeline-demo-panel:nth-child(2) { border-left-color: #7ea687; }
.pipeline-demo-panel:nth-child(3) { border-left-color: #c84a3a; }
/* ---- Sprite scene + mode row inside each demo panel ---- */
.pipeline-demo-scene {
position: relative;
width: 100%;
height: 130px;
margin-bottom: 8px;
background: linear-gradient(180deg,
rgba(40, 24, 56, 0.55) 0%,
rgba(40, 24, 56, 0.55) 62%,
/* horizon line */
rgba(120, 84, 48, 0.85) 64%,
rgba(78, 52, 26, 0.95) 80%,
rgba(40, 24, 8, 1) 100%);
border: 2px solid var(--w-amber);
box-shadow: 2px 2px 0 var(--w-shadow);
overflow: hidden;
image-rendering: pixelated;
}
/* Painted pixel-art ground tile (grass-on-dirt) anchored at the bottom
of the scene. Tiles horizontally so the panel can be any width. The
data URI is injected from a build-time helper so we don't bake an
800KB image into the static CSS string. */
.pipeline-demo-scene::after {
content: "";
position: absolute;
bottom: 0; left: 0; right: 0;
height: 36px;
background-image: var(--ground-tile-uri, none);
background-repeat: repeat-x;
background-size: auto 36px;
background-position: bottom left;
image-rendering: pixelated;
pointer-events: none;
z-index: 0;
}
.pipeline-demo-sprite {
position: absolute;
bottom: 4px;
width: 96px;
height: 96px;
image-rendering: pixelated;
filter: drop-shadow(2px 2px 0 var(--w-shadow));
}
.pipeline-demo-sprite-foe { left: 6px; transform: scaleX(1); }
.pipeline-demo-sprite-hero { right: 6px; }
.pipeline-demo-mode-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.pipeline-demo-mode-label {
font-family: 'VT323', monospace !important;
font-size: 16px;
color: var(--w-amber);
font-style: italic;
}
/* For demo card itself: layer the backdrop image (inline-styled by
_pipeline_demo_html) under the existing gradient so the painted backdrop
reads as ambience but text stays legible. */
.oracle-pipeline-demo {
background-blend-mode: normal !important;
background-size: cover !important;
background-position: center 30% !important;
background-repeat: no-repeat !important;
}
.oracle-pipeline-demo::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg,
rgba(24,20,31,0.25) 0%,
rgba(24,20,31,0.55) 100%);
pointer-events: none;
z-index: 0;
}
.oracle-pipeline-demo > * { position: relative; z-index: 1; }
.pipeline-demo-step {
margin: 5px 0;
color: var(--w-cream);
}
.pipeline-demo-tag {
font-family: 'Press Start 2P', monospace !important;
font-size: 9px;
color: var(--w-amber) !important;
letter-spacing: 1px;
margin-right: 6px;
display: inline-block;
}
.pipeline-demo-step code {
background: rgba(240,192,96,0.18);
color: var(--w-amber);
padding: 1px 7px;
font-family: 'VT323', monospace;
font-size: 18px;
border-radius: 2px;
border: 1px solid rgba(240,192,96,0.35);
}
.pipeline-demo-resolution {
border-top: 1px dashed var(--w-mute);
padding-top: 8px;
margin-top: 8px;
font-style: italic;
color: var(--w-cream);
}
.pipeline-demo-mode-badge {
display: inline-block;
font-family: 'Press Start 2P', monospace !important;
font-size: 8px;
color: var(--w-shadow);
background: var(--w-amber);
padding: 2px 6px;
margin-left: 6px;
letter-spacing: 1px;
vertical-align: middle;
}
.pipeline-demo-footer {
text-align: center;
font-size: 16px;
color: var(--w-cream);
border-top: 1px solid var(--w-amber);
padding-top: 12px;
margin-top: 6px;
line-height: 1.45;
}
.pipeline-demo-footer strong {
color: var(--w-amber) !important;
font-family: 'Press Start 2P', monospace !important;
font-size: 10px;
letter-spacing: 1.5px;
margin: 0 2px;
}
/* ---------- Walking-hero processing overlay ----------
Replaces Gradio's generic spinner with a pixel-art hero crossing a small
horizon strip — gives the LLM-wait a narrative feel ("the hero is
walking to the next obstacle") instead of a tech progress bar.
The data URI for the hero sprite is injected at app startup via a
<style> block in _processing_overlay_styles_html so we don't need to
bake it into the static CSS string. */
.gradio-container .progress,
.gradio-container .progress-text,
.gradio-container .progress-bar,
.gradio-container .progress-level,
.gradio-container .progress-level-inner {
color: var(--w-amber) !important;
}
.gradio-container .progress-text,
.gradio-container .meta-text {
font-family: 'Press Start 2P', monospace !important;
font-size: 11px !important;
letter-spacing: 1px !important;
}
/* Floating modal that dims the page while the hero walks across a
pixel-art horizon. Sits above all other UI via z-index, centered
regardless of scroll position. */
.oracle-processing-modal {
position: fixed;
inset: 0;
background: rgba(8, 6, 14, 0.55);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: oracle-modal-fade-in 0.25s ease-out;
}
@keyframes oracle-modal-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.oracle-processing-overlay {
position: relative;
width: 92%;
max-width: 540px;
height: 200px;
background-color: rgba(34, 22, 50, 1);
border: 4px solid var(--w-amber);
box-shadow:
8px 8px 0 var(--w-shadow),
0 0 60px rgba(240, 192, 96, 0.25);
overflow: hidden;
image-rendering: pixelated;
animation: oracle-modal-pop 0.35s cubic-bezier(.18,.89,.32,1.28);
}
/* ---- Rotating journey backdrops ----
4 stacked layers, each fades in for ~3.5s then out for 0.5s every 16s
(offset by 4s, matching the hero's walk cycle). Effect: a new landscape
appears each time the hero exits the right edge. */
.oracle-processing-bg {
position: absolute;
inset: 0;
background-position: center bottom;
background-size: cover;
background-repeat: no-repeat;
image-rendering: pixelated;
opacity: 0;
animation: oracle-bg-cycle 16s linear infinite;
z-index: 0;
}
/* Theme-scene backdrops are square Klein vignettes with chroma-keyed
transparent margins. `cover` would crop the centered subject + leave
transparent voids. `contain` keeps the subject whole, and the dark
solid fill replaces the magenta-keyed margins so nothing bleeds. */
.oracle-processing-bg.oracle-processing-bg-theme {
background-size: contain !important;
background-position: center center !important;
background-color: rgba(20, 14, 30, 0.95);
}
.oracle-processing-bg-1 { animation-delay: 0s; }
.oracle-processing-bg-2 { animation-delay: -4s; }
.oracle-processing-bg-3 { animation-delay: -8s; }
.oracle-processing-bg-4 { animation-delay: -12s; }
@keyframes oracle-bg-cycle {
0% { opacity: 0; }
2% { opacity: 1; } /* fade in over first 2% (0.32s) */
22% { opacity: 1; } /* visible for 20% (3.2s) */
25% { opacity: 0; } /* fade out over 3% (0.48s) */
100% { opacity: 0; } /* hidden for the remaining 75% (12s) */
}
@keyframes oracle-modal-pop {
0% { transform: scale(0.85); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.oracle-processing-overlay::before {
/* Distant tiny stars + tiny mountains drawn via repeating gradients */
content: "";
position: absolute;
top: 4px; left: 0; right: 0; height: 18px;
background:
radial-gradient(circle at 12% 30%, #ede4d3 0 1px, transparent 2px),
radial-gradient(circle at 28% 60%, #ede4d3 0 1px, transparent 2px),
radial-gradient(circle at 47% 25%, #ede4d3 0 1px, transparent 2px),
radial-gradient(circle at 68% 50%, #ede4d3 0 1px, transparent 2px),
radial-gradient(circle at 84% 30%, #ede4d3 0 1px, transparent 2px);
pointer-events: none;
opacity: 0.85;
}
.oracle-processing-hero {
position: absolute;
bottom: 4px;
width: 64px; height: 64px;
z-index: 2;
image-rendering: pixelated;
filter: drop-shadow(2px 2px 0 var(--w-shadow));
animation: hero-walk 4s linear infinite, hero-bob 0.45s steps(2) infinite;
}
@keyframes hero-walk {
0% { left: -64px; }
100% { left: 100%; }
}
@keyframes hero-bob {
0% { transform: translateY(0); }
50% { transform: translateY(-3px); }
100% { transform: translateY(0); }
}
.oracle-processing-caption {
position: absolute;
top: 6px; left: 10px; right: 10px;
font-family: 'Press Start 2P', monospace !important;
font-size: 10px !important;
color: var(--w-amber);
letter-spacing: 1px;
text-shadow: 2px 2px 0 var(--w-shadow), 0 0 8px rgba(0,0,0,0.8);
z-index: 3;
background: rgba(8, 6, 14, 0.45);
padding: 4px 8px;
border: 1px solid rgba(240, 192, 96, 0.55);
}
/* ---------- Open-book spread on the closing grimoire spread (step 8) ---------- */
.grimoire-summary-book {
display: block;
width: 132px;
height: auto;
margin: 0 auto 12px auto;
image-rendering: pixelated;
filter: drop-shadow(3px 3px 0 var(--w-shadow));
}
/* ---------- Per-phase ambient texture backdrops on the Group wrappers ---------- */
/* Each phase Group can opt into a tiled pixel-art ambient backdrop. The
data-URI fill is injected at render time via a Python helper so the CSS
stays static; the gradient overlay keeps text legible. */
.phase-bg {
position: relative;
background-repeat: repeat;
background-size: 320px;
image-rendering: pixelated;
/* Suppress the "blocks displaced for a second" click-flash by
isolating layout. ``contain: layout`` tells the browser the
phase-wrapper's children can't affect ancestor layout — so when
Gradio toggles visible=True/False on a sibling group, this
wrapper doesn't need to be re-measured. */
contain: layout style;
}
/* Reserve a stable height for whichever phase is mounted so toggling
sibling phase visibility doesn't cause vertical page jump. */
#oracle-inscribe-grp,
#oracle-send-off-grp,
#oracle-trial-grp,
#oracle-epilogue-grp {
min-height: 720px;
}
.phase-bg::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(24, 20, 31, 0.78) 0%,
rgba(24, 20, 31, 0.88) 100%
);
pointer-events: none;
z-index: 0;
}
.phase-bg > * { position: relative; z-index: 1; }
/* ---------- Composed trial scene — animated layered backdrop ---------- */
.oracle-composed-scene {
position: relative;
width: 100%;
/* Aspect-ratio-based height. The Klein-generated scene sprites are
square (1:1) but the trial card is wider than tall. With
`background-size: cover` the scene fills the panel and gets
lightly cropped top/bottom. Using a 16:9 panel keeps the
composition balanced and matches modern viewport norms. */
aspect-ratio: 16 / 9;
min-height: 320px;
max-height: 520px;
border: 4px solid var(--w-amber);
box-shadow: 8px 8px 0 var(--w-shadow);
overflow: hidden;
image-rendering: pixelated;
background: var(--w-night);
margin: 14px 0 18px 0;
}
.oracle-composed-scene[data-dragon="true"] {
border-color: var(--w-dragon);
box-shadow: 8px 8px 0 #260606;
}
/* Layer 1: env backdrop fills the frame and very slowly parallax-drifts.
Position-absolute + zoom slightly larger than the box so the drift
doesn't expose edges. */
.oracle-composed-scene .scene-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background-size: cover; /* fill the panel edge-to-edge */
background-position: center;
background-repeat: no-repeat;
background-color: var(--w-night);
image-rendering: pixelated;
z-index: 0;
/* NO animation. The previous drift caused flicker every time
Gradio replaced the inner HTML (yield) because animations
restart on element creation. */
}
/* Dim the backdrop a touch so foreground sprites pop. */
.oracle-composed-scene .scene-bg::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(24, 20, 31, 0.25) 0%,
rgba(24, 20, 31, 0.55) 100%
);
pointer-events: none;
}
/* Layer 2: pixel ground strip with grass tufts at the base of the frame.
Uses the same painted ground tile we already injected as
--ground-tile-uri (falls back to a CSS gradient). */
.oracle-composed-scene .scene-ground {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 56px;
background-image: var(--ground-tile-uri, none);
background-size: auto 56px;
background-repeat: repeat-x;
background-position: bottom left;
image-rendering: pixelated;
z-index: 1;
box-shadow: inset 0 24px 24px -16px rgba(0,0,0,0.5);
}
/* Layer 3: hero sprite on the LEFT — idle bob + slight bob-shadow. */
.oracle-composed-scene .scene-hero {
position: absolute;
bottom: 28px;
left: 10%;
width: 132px;
height: 132px;
image-rendering: pixelated;
filter: drop-shadow(3px 3px 0 var(--w-shadow));
z-index: 3;
/* Atmosphere preserved — keyframes anchor 0% and 100% to the
resting state so Gradio's per-yield rebuild lands on an
indistinguishable frame. Variation lives at 50%. */
animation: scene-hero-bob 1.2s ease-in-out infinite;
}
@keyframes scene-hero-bob {
0% { transform: translateY(0); }
50% { transform: translateY(-4px); }
100% { transform: translateY(0); }
}
/* Layer 4: obstacle sprite on the RIGHT — same flicker fix as scene-hero. */
.oracle-composed-scene .scene-obstacle {
position: absolute;
bottom: 22px;
right: 10%;
width: 156px;
height: 156px;
image-rendering: pixelated;
filter: drop-shadow(3px 3px 0 var(--w-shadow));
z-index: 2;
transform-origin: bottom center;
}
.oracle-composed-scene .scene-obstacle.anim-breathe {
animation: scene-monster-breathe 2.4s ease-in-out infinite;
}
.oracle-composed-scene .scene-obstacle.anim-breathe-slow {
animation: scene-monster-breathe 4.0s ease-in-out infinite;
}
.oracle-composed-scene .scene-obstacle.anim-stomp {
animation: scene-monster-stomp 1.6s ease-in-out infinite;
}
@keyframes scene-monster-breathe {
0% { transform: scale(1.00); }
50% { transform: scale(1.07); }
100% { transform: scale(1.00); }
}
@keyframes scene-monster-stomp {
0% { transform: translateY(0) scaleY(1.00); }
45% { transform: translateY(-3px) scaleY(0.97); }
55% { transform: translateY(-3px) scaleY(0.97); }
100% { transform: translateY(0) scaleY(1.00); }
}
/* Layer 5: force-trial atmospheric particles — small twinkling pixel
sparkles drifting across the scene, CSS-only via repeating radial
gradients animated with background-position. */
.oracle-composed-scene .scene-particles {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
background:
radial-gradient(circle 1px at 18% 28%, #ede4d3 0 1px, transparent 2px),
radial-gradient(circle 1px at 47% 52%, #ede4d3 0 1px, transparent 2px),
radial-gradient(circle 1px at 72% 18%, #ede4d3 0 1px, transparent 2px),
radial-gradient(circle 1px at 88% 65%, #ede4d3 0 1px, transparent 2px);
opacity: 0.7;
animation: scene-particles-drift 6s linear infinite;
}
@keyframes scene-particles-drift {
0% { background-position: 0 -60px, 0 -60px, 0 -60px, 0 -60px; opacity: 0.7; }
50% { background-position: 0 0, 0 0, 0 0, 0 0; opacity: 0.9; }
100% { background-position: 0 -60px, 0 -60px, 0 -60px, 0 -60px; opacity: 0.7; }
}
/* Caption strip at the very bottom of the scene — tiny pixel-font tag. */
.oracle-composed-scene .scene-caption {
position: absolute;
bottom: 4px;
left: 8px;
right: 8px;
z-index: 5;
font-family: 'VT323', monospace !important;
font-size: 16px;
color: var(--w-amber);
text-shadow: 2px 2px 0 var(--w-shadow);
background: rgba(8, 6, 14, 0.50);
padding: 4px 10px;
border-left: 3px solid var(--w-amber);
font-style: italic;
}
/* ---------- Compact banner during trial/epilogue phases ----------
Once the player leaves the inscribe phase, the banner collapses to a
~72px strip so the trial visual gets the vertical real estate. Title
shrinks proportionally so the brand still reads. A CSS transition
makes the resize smooth when the player advances. */
.oracle-banner {
transition: height 0.4s ease-out;
}
.oracle-banner.oracle-banner-compact {
height: 72px !important;
border-bottom-width: 2px !important;
}
.oracle-banner.oracle-banner-compact .oracle-banner-overlay {
background: linear-gradient(
180deg,
rgba(24, 20, 31, 0.20) 0%,
rgba(24, 20, 31, 0.65) 100%
) !important;
padding: 0 16px !important;
/* Keep title + subtitle on one line each, tightly stacked. */
justify-content: center !important;
}
.oracle-banner.oracle-banner-compact .oracle-banner-title {
font-size: 16px !important;
letter-spacing: 2px !important;
text-shadow: 2px 2px 0 var(--w-shadow) !important;
margin: 0 !important;
line-height: 1.0 !important;
}
.oracle-banner.oracle-banner-compact .oracle-banner-sub {
font-size: 13px !important;
margin-top: 4px !important;
line-height: 1.1 !important;
/* Don't let a long theme subtitle wrap to a 3rd line and balloon
the strip. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 95%;
}
/* Image inside the compact banner gets cropped tighter so the title
stays legible on top. */
.oracle-banner.oracle-banner-compact img {
object-position: center 40% !important;
filter: brightness(0.55) saturate(1.1) !important;
}
/* ---------- Bigger, more readable trial typography ---------- */
.oracle-card .oracle-trial-heading {
font-size: 22px !important;
margin-bottom: 18px !important;
letter-spacing: 2px !important;
}
.oracle-card .oracle-obstacle {
font-size: 21px !important;
line-height: 1.65 !important;
margin-bottom: 16px !important;
}
.oracle-card blockquote.oracle-quote {
font-size: 22px !important;
line-height: 1.55 !important;
padding: 14px 20px !important;
margin: 16px 0 !important;
}
.oracle-card .oracle-narration {
font-size: 22px !important;
line-height: 1.70 !important;
}
.oracle-card .oracle-narration p {
margin: 14px 0 !important;
}
.oracle-card .oracle-tactic {
font-size: 19px !important;
margin-top: 18px !important;
padding-top: 12px !important;
}
/* The reveal text card gets extra top breathing room since the image
sits above it now. */
.oracle-card.oracle-bg {
padding: 26px 30px !important;
margin-top: 14px !important;
}
/* Trial scene image — larger now that it sits at the top, with breathing
animation that's hover-pausable. */
.oracle-image-wrap img,
.oracle-image-wrap svg {
max-width: 100% !important;
max-height: 360px !important;
width: auto !important;
height: auto !important;
}
/* ---------- Finer ambient animations (parallax + slow drift) ---------- */
/* Each backdropped card gets a very slow background-position drift so the
pixel backdrop feels alive instead of static. Combined with the
existing breathing brightness cycle this gives "depth" without being
distracting. */
.oracle-card.oracle-bg,
.oracle-prologue.oracle-bg,
.oracle-narration.oracle-bg,
.oracle-interlude.oracle-bg {
/* card-breathe + bg-drift kept, but keyframes now anchor 0% and
100% to the same visual state so the per-yield restart is
invisible. Variation lives at 50%. */
animation:
oracle-card-breathe 6s ease-in-out infinite,
oracle-bg-drift 28s ease-in-out infinite !important;
}
@keyframes oracle-bg-drift {
0% { background-position: 65% 55%; }
50% { background-position: 50% 50%; }
100% { background-position: 65% 55%; }
}
.oracle-card.oracle-bg[data-dragon="true"] {
animation:
oracle-card-breathe-dragon 6s ease-in-out infinite,
oracle-bg-drift 28s ease-in-out infinite !important;
}
@keyframes oracle-card-breathe-dragon {
0% { filter: brightness(1.00) saturate(1.04) hue-rotate(0deg); }
50% { filter: brightness(1.08) saturate(1.10) hue-rotate(-3deg); }
100% { filter: brightness(1.00) saturate(1.04) hue-rotate(0deg); }
}
/* CASCADE FADE-IN REMOVED:
The cascade re-fired on every UI bundle update (Gradio replaces inner
HTML on each handler yield), causing visible flicker mid-trial.
The breathing + bg-drift animations alone are enough atmosphere. */
/* Faint amber-twinkle decoration sprinkled over the trial card edges */
.oracle-card.oracle-bg::before {
box-shadow:
20px 30px 0 1px rgba(240, 192, 96, 0.22),
180px 14px 0 1px rgba(240, 192, 96, 0.15),
300px 50px 0 1px rgba(240, 192, 96, 0.22);
animation: amber-twinkle 3.5s ease-in-out infinite alternate;
}
@keyframes amber-twinkle {
0% { opacity: 0.55; }
100% { opacity: 1.0; }
}
/* ---------- (removed) breathing animation on backdropped panels ----------
The keyframe-driven brightness cycle restarted every time Gradio
replaced the inner HTML on a generator yield (each click yields
2-3 times), producing visible flicker mid-trial. Cards now stay at
a steady brightness — backdrop is enough atmosphere. */
/* Pixel ground line + grass tufts on the bottom of every backdropped
panel — matches the demo-scene aesthetic so sprites have something
to stand on visually. */
.oracle-card.oracle-bg::after,
.oracle-prologue.oracle-bg::after,
.oracle-narration.oracle-bg::after,
.oracle-interlude.oracle-bg::after {
content: "";
position: absolute;
bottom: 0; left: 0; right: 0;
height: 28px;
background:
/* grass tufts (sparse) */
linear-gradient(90deg,
transparent 0 16px,
rgba(80, 110, 50, 0.85) 16px 22px,
transparent 22px 60px,
rgba(80, 110, 50, 0.85) 60px 64px,
transparent 64px 96px,
rgba(80, 110, 50, 0.85) 96px 104px,
transparent 104px),
/* dirt band */
linear-gradient(180deg,
transparent 0%,
rgba(78, 52, 26, 0.0) 0%,
rgba(78, 52, 26, 0.55) 40%,
rgba(40, 24, 8, 0.85) 100%);
background-size: 132px 6px, 100% 100%;
background-repeat: repeat-x, no-repeat;
background-position: bottom 8px left, bottom;
pointer-events: none;
z-index: 0;
}
/* The grass strip overlay would lay on TOP of text otherwise; the panel's
::before already sets up an overlay layer at z-index 0, so we lift it
to z-index 1 so ::after doesn't intercept content. */
.oracle-card.oracle-bg > *,
.oracle-prologue.oracle-bg > *,
.oracle-narration.oracle-bg > *,
.oracle-interlude.oracle-bg > * {
position: relative;
z-index: 2;
}
/* ---------- Panel backdrops (send-off, interludes, trials, epilogue) ---------- */
/* Each panel card can opt into a faint pixel-art backdrop image via these
utility classes. The inline `background-image` data URI is injected at
render time from a Python helper; the CSS handles overlay + blending so
text stays legible no matter how busy the image is. */
.oracle-card.oracle-bg,
.oracle-prologue.oracle-bg,
.oracle-interlude.oracle-bg,
.oracle-narration.oracle-bg {
position: relative;
background-size: cover !important;
background-position: center !important;
background-repeat: no-repeat !important;
image-rendering: pixelated;
}
.oracle-card.oracle-bg::before,
.oracle-prologue.oracle-bg::before,
.oracle-interlude.oracle-bg::before,
.oracle-narration.oracle-bg::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(24, 20, 31, 0.62) 0%,
rgba(24, 20, 31, 0.80) 100%
);
pointer-events: none;
z-index: 0;
}
.oracle-card.oracle-bg > *,
.oracle-prologue.oracle-bg > *,
.oracle-interlude.oracle-bg > *,
.oracle-narration.oracle-bg > * {
position: relative;
z-index: 1;
}
/* Dragon-trial backdrop overlay is slightly more red-tinted */
.oracle-card.oracle-bg[data-dragon="true"]::before {
background: linear-gradient(
180deg,
rgba(58, 14, 14, 0.55) 0%,
rgba(26, 6, 6, 0.85) 100%
);
}
/* Accessibility: honor prefers-reduced-motion. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
"""
# ---------------------------------------------------------------------------
# Render helpers
# ---------------------------------------------------------------------------
def _md_safe(text: str) -> str:
"""HTML-escape user-provided text. Newlines -> <br>."""
return (
(text or "")
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\n", "<br>")
)
def _md_block(text: str) -> str:
"""Escape and split into <p> blocks on blank lines (for narrations)."""
paragraphs = [p.strip() for p in (text or "").split("\n\n") if p.strip()]
if not paragraphs:
return ""
return "".join(f"<p>{_md_safe(p)}</p>" for p in paragraphs)
def _badge_html(state: GameState, trial_substate: str = TRIAL_STATE_AWAIT) -> str:
"""Small status chip shown under the banner — the title itself is in
the parallax banner so this is just a one-line state indicator.
Cached by (lang, mode, current_trial). On a grimoire-spread click
none of those change, so the cache hits every time.
"""
lang = _tlang(state)
cache_key = (lang, state.mode, state.current_trial)
if cache_key in _BADGE_CACHE:
return _BADGE_CACHE[cache_key]
if state.mode == "inscribe":
label = _t("badge_inscribing", lang)
elif state.mode == "send_off":
label = _t("badge_sealed", lang)
elif state.mode == "trial":
label = _t("badge_trial", lang).format(
current=state.current_trial, total=NUM_TRIALS
)
elif state.mode == "epilogue":
label = _t("badge_return", lang)
else:
label = _t("badge_done", lang)
result = (
"<div style='margin: 14px 0 4px 0;'>"
f"<span class='oracle-badge'>{label}</span>"
"</div>"
)
_BADGE_CACHE[cache_key] = result
return result
def _sealed_list_html(state: GameState) -> str:
"""Numbered, italicized list of the inscribed oracles for the send-off panel."""
if not state.oracles:
return ""
lang = _tlang(state)
silence_label = _t("blank_silence", lang)
items = []
for o in state.oracles:
text = (o.text or "").strip() or silence_label
items.append(
f"<li><em>{_md_safe(text)}</em></li>"
)
header = _md_safe(_t("sealed_list_header", lang))
return (
"<div class='oracle-sealed-list'>"
f"<p><strong>{header}</strong></p>"
"<ol>" + "".join(items) + "</ol>"
"</div>"
)
# Per-trial backdrop mapping: each of the 5 trials gets a distinct
# pixel-art landscape so the journey feels visually varied. Trial 5
# always uses the dragon throne room (its theme override is intentional).
_TRIAL_BACKDROPS = {
1: "trial_crossroads", # start of the journey
2: "trial_marsh",
3: "trial_storm_heath",
4: "trial_cave_entrance",
5: "dragon_throne_room", # finale; matches the existing fantasy art
}
def _bg_style(sprite_name: str) -> str:
"""Return an inline ``style="background-image:url(...)"`` for a panel
backdrop, or an empty string when the sprite isn't on disk."""
uri = _sprite_data_uri_app(sprite_name)
return f"style=\"background-image:url('{uri}');\"" if uri else ""
def _send_off_narration_html(state: GameState) -> str:
lang = _tlang(state)
hero = state.hero_name or ("the hero" if lang == "en" else "少年")
village = state.village_name or ("his village" if lang == "en" else "他的村庄")
para = _t("tmpl_send_off_narration", lang).format(hero=hero, village=village)
bg = _bg_style("send_off_road")
return f"<div class='oracle-narration oracle-bg' {bg}><p>{_md_safe(para)}</p></div>"
def _trial_heading_html(state: GameState) -> str:
n = state.current_trial
ob = state.current_obstacle()
lang = _tlang(state)
if ob is not None and ob.is_dragon:
# Theme-aware finale heading: use the theme's finale_descriptor
# (e.g. "the warlord-king of the Iron Plains") instead of always
# saying "Dragon". Fantasy still reads "Trial 5: The Dragon"
# because its finale_descriptor IS "the evil dragon".
from oracles.themes import get_theme as _get_theme
theme = _get_theme(getattr(state, "theme", "fantasy") or "fantasy")
finale_name = theme.localized_field("finale_descriptor", lang) \
or theme.finale_descriptor or ""
# Strip a leading "the " for the title case.
clean = finale_name.strip()
for prefix in ("the ", "The ", "THE "):
if clean.startswith(prefix):
clean = clean[len(prefix):]
break
title = (
(f"第 {n} 难:{clean}" if lang == "zh" else f"Trial {n}: The {clean}")
if clean else _t("trial_dragon_title", lang).format(n=n)
)
else:
title = _t("trial_normal_title", lang).format(n=n, total=NUM_TRIALS)
dragon_attr = ' data-dragon="true"' if (ob is not None and ob.is_dragon) else ""
# Per-trial pixel-art backdrop so each fight feels visually distinct.
bg_sprite = _TRIAL_BACKDROPS.get(n, "forest_path")
bg = _bg_style(bg_sprite)
return (
f"<div class='oracle-card oracle-bg'{dragon_attr} {bg}>"
f"<div class='oracle-trial-heading'>{_md_safe(title)}</div>"
+ (
f"<div class='oracle-obstacle'>{_md_safe(ob.setup)}</div>"
if ob is not None
else ""
)
+ "</div>"
)
def _interlude_html(state: GameState) -> str:
"""Interlude paragraph shown above the next trial's await view.
Indexed by `state.current_trial - 2` (interlude[0] sits above trial 2).
Returns empty HTML if no interlude is set for this transition (e.g. on
trial 1, before the first encounter)."""
idx = state.current_trial - 2
if idx < 0 or idx >= len(state.interludes):
return ""
text = (state.interludes[idx] or "").strip()
if not text:
return ""
is_dragon_lead = (state.current_trial == NUM_TRIALS)
extra_class = " oracle-interlude-dragon" if is_dragon_lead else ""
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] or [text]
body = "".join(f"<p>{_md_safe(p)}</p>" for p in paragraphs)
bg = _bg_style("interlude_path")
return f"<div class='oracle-interlude oracle-bg{extra_class}' {bg}>{body}</div>"
def _trial_await_html(state: GameState) -> str:
"""State A within a trial: oracle not yet drawn. Optionally preceded by
an interlude paragraph bridging from the previous trial.
On trial 1 specifically, the send-off narration ('Hero kneels by the
well at dawn…') sits at the very top — it's the first thing the player
reads after closing the grimoire, since we merged the old send_off
intermediate panel into the begin-journey transition."""
remaining = len(state.unopened())
lang = _tlang(state)
parts = []
if state.current_trial == 1:
# Replaces the now-removed send_off panel. Carries the painted
# `send_off_road` backdrop so the leave-the-village atmosphere
# opens the journey visually.
parts.append(_send_off_narration_html(state))
parts.append(_interlude_html(state))
if remaining == 1:
remaining_msg = _t("trial_remaining_one", lang).format(remaining=remaining)
else:
remaining_msg = _t("trial_remaining_many", lang).format(remaining=remaining)
parts.append(
"<div class='oracle-narration'>"
f"<p>{remaining_msg}</p>"
"</div>"
)
return "".join(parts)
def _trial_reveal_html(state: GameState) -> str:
"""State B within a trial: oracle drawn + resolution shown (no image — that's separate)."""
if not state.resolutions:
return ""
res = state.resolutions[-1]
oracle = res.oracle
lang = _tlang(state)
roman = _ROMAN.get(oracle.index, str(oracle.index))
blank_quote = _t("parchment_blank_quote", lang)
quote_safe = _md_safe(oracle.text or blank_quote)
narration_html = _md_block(res.narration)
tactic = (res.tactic or "").strip()
is_dragon = bool(res.obstacle.is_dragon)
dragon_attr = ' data-dragon="true"' if is_dragon else ""
oracle_label = _md_safe(
_t("oracle_label_sealed_since", lang).format(roman=roman)
)
tactic_html = ""
if tactic:
# Apply the localized template, then HTML-escape the whole rendered
# string so any < or > in the tactic itself is safe.
tactic_line = _t("tactic_prefix", lang).format(tactic=tactic)
tactic_html = f"<div class='oracle-tactic'>{_md_safe(tactic_line)}</div>"
return (
f"<div class='oracle-card'{dragon_attr}>"
f"<div style='font-weight:600; margin-bottom:6px;'>"
f"{oracle_label}</div>"
f"<blockquote class='oracle-quote'>{quote_safe}</blockquote>"
f"<div class='oracle-narration'>{narration_html}</div>"
+ tactic_html
+ "</div>"
)
def _image_html_for(path: str, caption: str) -> str:
"""Render the resolution image as inline HTML.
- PNG: read bytes, base64-embed.
- SVG (anything not .png): read content, base64-embed as image/svg+xml.
- Missing/empty path: caption-only fallback.
"""
cap_safe = _md_safe(caption or "")
if not path or not os.path.exists(path):
if not cap_safe:
return ""
return (
"<div class='oracle-image-wrap'>"
f"<p style='font-style:italic;color:#7a6535'>{cap_safe}</p>"
"</div>"
)
ext = os.path.splitext(path)[1].lower()
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return (
"<div class='oracle-image-wrap'>"
f"<p style='font-style:italic;color:#7a6535'>{cap_safe}</p>"
"</div>"
)
if ext == ".png":
mime = "image/png"
else:
mime = "image/svg+xml"
b64 = base64.b64encode(data).decode("ascii")
return (
"<div class='oracle-image-wrap'>"
f"<img src='data:{mime};base64,{b64}' alt='{cap_safe}' "
"style='max-width:100%; max-height:480px;'/>"
"</div>"
)
# ---------------------------------------------------------------------------
# Composed trial scene — replaces the old single-image trial card.
# Classifies the obstacle text by keyword, picks an environment backdrop +
# obstacle creature sprite + hero sprite, then composes them with CSS
# animations (hero idle bob, obstacle breathing scale, etc.) so the scene
# feels alive instead of static. Falls back to the old mock SVG when no
# good match is found.
# ---------------------------------------------------------------------------
# Each entry: list of (keyword_token, weight) — first match (after lowercasing
# the obstacle setup) classifies the scene. Order matters: more specific
# keywords go first so "dragon" beats "scaly thing" via the wider net.
_OBSTACLE_KEYWORDS = {
"dragon": ["dragon", "vraskar", "wyrm", "draconic", "scaled beast",
"warlord", "demon-king", "sovereign", "apep", "serpent",
"warden", "world-eater", "world-eating",
# zh
"巨龙", "龙王", "魔王", "至尊", "巨蛇", "看守", "霸主"],
"guardian": ["sphinx", "vizier", "witch", "knight", "judge", "sage",
"guardian", "bureaucrat", "official", "scribe of",
"monk", "priest", "toll", "beggar", "watcher",
"celestial", "scholar",
# zh
"斯芬克斯", "守卫", "守护", "判官", "贤者", "祭司", "僧",
"石像", "石狮", "石像鬼", "维齐尔", "巫女"],
"brute": ["giant", "troll", "ogre", "wolf", "bear", "swine", "sow",
"monster", "hornet", "wasp", "owl", "heron", "wraith",
"beast", "spider", "bandit", "archer", "fox-spirit",
# zh
"巨人", "山妖", "妖怪", "狼", "熊", "鬼", "怪兽",
"胡蜂", "黄蜂", "强盗", "弓手", "狐妖", "野兽"],
"water": ["river", "marsh", "swamp", "lake", "pond", "water",
"flood", "tide", "current", "yellow spring", "delta",
"reeds", "stream", "well",
# zh
"河", "湖", "潭", "沼", "泽", "水", "洪", "井",
"黄泉", "溪", "瀑"],
"stone": ["mountain", "boulder", "cliff", "rock", "stone",
"scree", "monolith", "pillar", "pillars", "ruin",
"monolithic", "obelisk", "wall", "fortress",
# zh
"山", "崖", "石", "岩", "巨石", "城墙", "废墟",
"石柱", "山岭"],
"force": ["storm", "lightning", "thunder", "wind", "gale",
"curse", "fog", "mist", "snow", "frost", "blizzard",
"fire", "ash", "famine", "plague", "radiation",
"EM cloud", "static",
# zh
"风暴", "雷", "电", "风", "诅咒", "雾", "霾",
"雪", "霜", "火", "灰", "瘟疫", "辐射"],
"barrier": ["chasm", "abyss", "bridge", "ford", "pass", "gate",
"doorway", "gap", "thicket", "thorns", "brambles",
"bunker door", "blast door",
# zh
"深渊", "鸿沟", "裂", "桥", "门", "关", "隘",
"荆棘", "丛"],
"cave": ["cave", "lair", "cavern", "tomb", "crypt", "vault",
"den", "burrow", "ruin",
# zh
"洞", "穴", "窟", "墓", "陵", "巢"],
}
def _classify_obstacle_scene(obstacle_setup: str, is_dragon_trial: bool) -> str:
"""Pick a scene category from the obstacle setup text. Returns one of
the keys of _OBSTACLE_KEYWORDS, or 'default' on no match. Trial-5
forces 'dragon' even if the theme overrode the final monster."""
if is_dragon_trial:
return "dragon"
text = (obstacle_setup or "").lower()
for category, kws in _OBSTACLE_KEYWORDS.items():
for kw in kws:
if kw in text:
return category
return "default"
# Category → (env_backdrop_sprite, obstacle_creature_sprite_or_None,
# animation_class_for_obstacle).
_SCENE_FOR_CATEGORY = {
"dragon": ("dragon_throne_room", "dragon", "anim-breathe-slow"),
"guardian": ("sphinx_temple_pillars", "sphinx", "anim-breathe"),
"brute": ("forest_path", "giant", "anim-stomp"),
"water": ("trial_marsh", None, ""),
"stone": ("mountain_pass", None, ""),
# `force` obstacle name `force` only resolves for non-fantasy themes
# (via the _theme_pick fallback to `force_<theme>`). For fantasy it
# silently degrades to env-only since no canonical `force.png` exists.
"force": ("trial_storm_heath", "force", "anim-breathe-slow"),
"barrier": ("trial_chasm", None, ""),
"cave": ("trial_cave_entrance", "troll", "anim-breathe"),
"default": ("forest_path", None, ""),
}
# Non-fantasy themes ship 4 per-theme env scenes (finale / arena /
# passage / trap). This maps the fantasy-flavored scene-category above
# to the closest themed slot — the runtime tries scene_<theme>_<slot>
# first and falls back to scene_<theme>_landscape if missing.
_THEMED_ENV_SLOT = {
"dragon": "finale",
"guardian": "arena",
"brute": "arena",
"water": "passage",
"stone": "passage",
"force": "arena",
"barrier": "passage",
"cave": "passage",
"default": "arena",
# Special raw category names (when env_name is already a sprite name,
# not a category) get their own slot via _categorize_env() below.
}
def _themed_env_slot(env_name: str) -> str:
"""Resolve a fantasy env sprite name to a themed-env slot key.
Handles both category-style names (``dragon``) and direct sprite
names (``dragon_throne_room``)."""
if env_name in _THEMED_ENV_SLOT:
return _THEMED_ENV_SLOT[env_name]
# Direct sprite-name fallback: classify by name fragment
if "dragon" in env_name or "throne" in env_name or "finale" in env_name:
return "finale"
if "sphinx" in env_name or "temple" in env_name or "arena" in env_name:
return "arena"
if "marsh" in env_name or "chasm" in env_name or "cave" in env_name or "passage" in env_name:
return "passage"
if "bazaar" in env_name or "market" in env_name or "trap" in env_name:
return "trap"
return "arena"
def _composed_scene_html(state: GameState) -> str:
"""Build a layered animated scene for the current trial reveal.
Layers (bottom→top):
1. Environment backdrop (full-frame pixel-art landscape)
2. CSS ground strip with grass tufts
3. Hero sprite on the left, idle-bobbing
4. Obstacle sprite (when applicable) on the right, breathing/stomping
5. Atmospheric particle overlay (CSS-only sparkles) for force trials
"""
if not state.resolutions:
return ""
res = state.resolutions[-1]
is_dragon = bool(res.obstacle and res.obstacle.is_dragon)
category = _classify_obstacle_scene(res.obstacle.setup or "", is_dragon)
env_name, ob_name, ob_anim = _SCENE_FOR_CATEGORY.get(
category, _SCENE_FOR_CATEGORY["default"]
)
# Theme-aware sprite picks. Lookup order per layer:
# 1. theme-specific category match (e.g. `giant_space_cowboy`)
# 2. for the ENV layer only: theme landscape (`scene_<theme>_landscape`)
# — universal backdrop for non-fantasy themes that don't ship
# per-category scenes
# 3. fantasy canonical name (e.g. `giant`)
theme_key = (getattr(state, "theme", "fantasy") or "fantasy")
def _theme_pick(category: str, is_env: bool = False) -> str:
themed = _sprite_data_uri_app(f"{category}_{theme_key}")
if themed:
return themed
if is_env and theme_key != "fantasy":
# Prefer the per-category themed env scene
# (scene_<theme>_<finale|arena|passage|trap>) over the
# single generic landscape backdrop. Falls through to the
# landscape if a slot-specific PNG isn't on disk.
slot = _themed_env_slot(category)
themed_slot = _sprite_data_uri_app(f"scene_{theme_key}_{slot}")
if themed_slot:
return themed_slot
landscape = _sprite_data_uri_app(f"scene_{theme_key}_landscape")
if landscape:
return landscape
return _sprite_data_uri_app(category) or ""
# Lean mode: drop the landscape backdrop only. Hero + obstacle sprites
# stay because they're the visual hook of every trial — the rest of
# the panel is a CSS-gradient sky in lean.
env_uri = _theme_pick(env_name, is_env=True) if _full_visual_mode(state) else ""
hero_uri = _theme_pick("hero")
ob_uri = _theme_pick(ob_name) if ob_name else ""
dragon_attr = ' data-dragon="true"' if is_dragon else ""
hero_img = (
f"<img class='scene-hero' src='{hero_uri}' alt='hero'/>"
if hero_uri else ""
)
ob_img = (
f"<img class='scene-obstacle {ob_anim}' src='{ob_uri}' "
f"alt='obstacle'/>"
if ob_uri else ""
)
# Atmospheric overlay only for "force" trials (storms etc.) so the
# standalone backdrops don't get over-decorated.
force_overlay = (
"<div class='scene-particles'></div>"
if category == "force" else ""
)
cap = _md_safe(res.image_caption or res.tactic or "")
# Empty env_uri ⇒ scene-bg falls back to its CSS gradient (defined in
# the main stylesheet) instead of trying to load a heavy PNG that
# doesn't exist at the empty URL.
bg_attr = f"background-image:url('{env_uri}');" if env_uri else ""
return (
f"<div class='oracle-image-wrap oracle-composed-scene'{dragon_attr}>"
f"<div class='scene-bg' style=\"{bg_attr}\"></div>"
"<div class='scene-ground'></div>"
f"{force_overlay}"
f"{hero_img}"
f"{ob_img}"
f"<div class='scene-caption'>{cap}</div>"
"</div>"
)
def _resolution_image_html(state: GameState) -> str:
if not state.resolutions:
return ""
return _composed_scene_html(state)
def _story_tree_html(state: GameState) -> str:
"""Render the branching story tree as an HTML grid with the player's
path highlighted and unexplored nodes masked as ???.
Empty when ``state.story_path`` is empty (non-fantasy themes still use
the old random-obstacle flow which has no tree to draw).
"""
if not state or not getattr(state, "story_path", None):
return ""
try:
from oracles.story_graph import STORY_GRAPH, ENDINGS, get_node
except Exception:
return ""
lang = _tlang(state)
visited = set(state.story_path)
leaf = get_node(state.story_path[-1]) if state.story_path else None
visited_ending_id = leaf.ending_id if leaf and leaf.ending_id else None
# Group nodes by trial column (1..5)
cols: list = [[] for _ in range(5)]
for n in STORY_GRAPH.values():
if 1 <= n.trial <= 5:
cols[n.trial - 1].append(n)
for col in cols:
col.sort(key=lambda n: n.id)
# Localized strings
masked_label = "未发掘" if lang == "zh" else "Undiscovered"
title_label = "故事之树" if lang == "zh" else "Story Tree"
hint_label = "走过的路 / 未走的路" if lang == "zh" else "Paths taken vs. paths untaken"
trial_lbl = "试炼" if lang == "zh" else "Trial"
ending_lbl = "结局" if lang == "zh" else "Ending"
html = ['<div class="story-tree">']
html.append(
f'<div class="story-tree-header">'
f'<div class="story-tree-title">{title_label}</div>'
f'<div class="story-tree-hint">{hint_label}</div>'
f'</div>'
)
html.append('<div class="story-tree-grid">')
# Trial columns 1..5
for ci, col in enumerate(cols):
html.append(f'<div class="story-tree-col">')
html.append(f'<div class="story-tree-col-label">{trial_lbl} {ci+1}</div>')
for node in col:
is_visited = node.id in visited
cls = "story-node visited" if is_visited else "story-node masked"
# Use the concept's first phrase as a theme-neutral title
# (e.g. "OPENING SWARM", "FIRST GATEKEEPER") so the tree viz
# works across themes without showing fantasy-flavored
# node-ids to the player.
raw_title = (node.concept or node.id).split(".")[0].strip()
if not raw_title or len(raw_title) > 40:
raw_title = node.id.replace("_", " ").title()
title_text = raw_title.title() if raw_title.isupper() else raw_title
if is_visited:
# Prefer the themed setup the player actually saw at runtime
# (stored on state during obstacle generation); fall back to
# the hand-authored setup for fantasy / mock runs.
themed = ""
idx_in_path = state.story_path.index(node.id) if node.id in state.story_path else -1
if 0 <= idx_in_path < len(state.obstacles):
themed = state.obstacles[idx_in_path].setup or ""
if not themed:
themed = node.setup(lang) or ""
setup = themed[:90]
if len(themed) > 90:
setup += "…"
inner = (
f'<div class="story-node-title">{_md_safe(title_text)}</div>'
f'<div class="story-node-tag">[{_md_safe(node.tag)}]</div>'
f'<div class="story-node-setup">{_md_safe(setup)}</div>'
)
else:
inner = (
f'<div class="story-node-title">???</div>'
f'<div class="story-node-tag">[{_md_safe(masked_label)}]</div>'
)
html.append(f'<div class="{cls}">{inner}</div>')
html.append('</div>')
# Endings column
html.append('<div class="story-tree-col story-tree-endings">')
html.append(f'<div class="story-tree-col-label">{ending_lbl}</div>')
from oracles.story_graph import ENDINGS as _ENDINGS
for ending in _ENDINGS.values():
is_visited = (ending.id == visited_ending_id)
cls = "story-node ending visited" if is_visited else "story-node ending masked"
if is_visited:
title_text = ending.title(lang)
inner = (
f'<div class="story-node-title">★ {_md_safe(title_text)}</div>'
f'<div class="story-node-tag">[{ending.id}]</div>'
)
else:
inner = (
f'<div class="story-node-title">???</div>'
f'<div class="story-node-tag">[{_md_safe(masked_label)}]</div>'
)
html.append(f'<div class="{cls}">{inner}</div>')
html.append('</div>')
html.append('</div>') # /grid
html.append('</div>') # /tree
return "".join(html)
def _chronicle_md(state: GameState) -> str:
lang = _tlang(state)
if not state.resolutions:
return _t("chronicle_empty", lang)
silence_label = _t("blank_silence", lang)
lines = []
for res in state.resolutions:
n = res.trial_index
ob_setup = (res.obstacle.setup or "").strip()
# Trim long obstacles for the chronicle line.
if len(ob_setup) > 140:
ob_setup = ob_setup[:140].rstrip() + "…"
oracle_text = (res.oracle.text or silence_label).strip().replace("\n", " ")
if len(oracle_text) > 80:
oracle_text = oracle_text[:80].rstrip() + "…"
roman = _ROMAN.get(res.oracle.index, str(res.oracle.index))
tactic = (res.tactic or "").strip()
heading = _t("chronicle_trial_heading", lang).format(n=n)
ob_line = _t("chronicle_obstacle", lang).format(ob_setup=ob_setup)
or_line = _t("chronicle_oracle", lang).format(
roman=roman, oracle_text=oracle_text
)
ta_line = _t("chronicle_tactic", lang).format(tactic=tactic)
lines.append(
f"{heading}\n"
f"{ob_line}\n\n"
f"{or_line}\n\n"
f"{ta_line}\n"
)
chronicle_text = "\n---\n".join(lines)
# Prepend the branching-story-tree viz when the player walked the
# fantasy graph. Other themes have no tree to draw; chronicle alone.
tree = _story_tree_html(state)
if tree:
return tree + "\n\n---\n\n" + chronicle_text
return chronicle_text
def _epilogue_html(state: GameState) -> str:
text = (state.epilogue or "").strip()
if not text:
return ""
lang = _tlang(state)
bg = _bg_style("epilogue_homecoming")
heading = _md_safe(_t("epilogue_heading_return", lang))
# If the player walked the story tree, look up the leaf node's
# ending and stamp the title prominently above the narration so the
# finale screen tells them WHICH of the 5 endings they earned.
ending_banner = ""
if getattr(state, "story_path", None):
try:
from oracles.story_graph import get_node, get_ending, ENDINGS
leaf = get_node(state.story_path[-1])
if leaf is not None and leaf.ending_id:
ending = get_ending(leaf.ending_id)
e_title = ending.title(lang)
eyebrow = "结局" if lang == "zh" else "Ending"
tag_label = "未发掘" if lang == "zh" else "undiscovered"
# Build the 5-of-5 progress strip — visited ending lit, others masked.
# We only know what THIS run produced; without cross-run persistence
# we can't show "ever discovered" — but we CAN show the player which
# slot in the gallery this run filled.
pip_html = []
for eid, e in ENDINGS.items():
is_this = (eid == leaf.ending_id)
cls = "ending-pip lit" if is_this else "ending-pip dim"
label = e.title(lang) if is_this else f"??? <span class='ending-pip-tag'>[{tag_label}]</span>"
pip_html.append(f"<div class='{cls}'>{label}</div>")
pips = "".join(pip_html)
ending_banner = (
"<div class='ending-banner'>"
f"<div class='ending-eyebrow'>{_md_safe(eyebrow)}</div>"
f"<div class='ending-title'>★ {_md_safe(e_title)}</div>"
f"<div class='ending-pips'>{pips}</div>"
"</div>"
)
except Exception:
pass
return (
f"<div class='oracle-card oracle-bg' {bg}>"
f"<div class='oracle-trial-heading'>{heading}</div>"
+ ending_banner +
f"<div class='oracle-narration'>{_md_block(text)}</div>"
"</div>"
)
# ---------------------------------------------------------------------------
# Visibility helper
# ---------------------------------------------------------------------------
def _visibility_for(state: GameState, trial_substate: str):
"""Return visibility updates for each panel in order:
inscribe_grp, send_off_grp, trial_grp, trial_await_grp, trial_reveal_grp,
epilogue_grp, epilogue_ending_grp, epilogue_summary_grp.
"""
in_trial = state.mode == "trial"
in_epilogue = state.mode == "epilogue"
sub = getattr(state, "epilogue_substate", "ending") or "ending"
return (
gr.update(visible=(state.mode == "inscribe")), # inscribe_grp
gr.update(visible=(state.mode == "send_off")), # send_off_grp
gr.update(visible=in_trial), # trial_grp (outer)
gr.update(visible=in_trial and trial_substate == TRIAL_STATE_AWAIT),
gr.update(visible=in_trial and trial_substate == TRIAL_STATE_REVEAL),
gr.update(visible=in_epilogue), # epilogue_grp (outer)
gr.update(visible=in_epilogue and sub == "ending"), # ending card
gr.update(visible=in_epilogue and sub == "summary"), # summary card
)
def _grimoire_right_visibility(state: GameState):
"""Return 9 visibility updates for the right-page sub-groups, indexed
by inscribe_step (0..8). Only one is visible at a time when the
inscribe panel is showing."""
in_inscribe = (state.mode == "inscribe")
step = state.inscribe_step
flags = [in_inscribe and step == i for i in range(_GRIMOIRE_NUM_STEPS)]
return tuple(gr.update(visible=v) for v in flags)
# ---------------------------------------------------------------------------
# Localized-chrome updates — the gradio components below are created with
# English defaults at build time; this helper returns gr.update() calls so
# every event re-emits their labels/info/placeholders/button text in the
# active language. Order matches the matching slice of the outputs list at
# the bottom of build_app().
# ---------------------------------------------------------------------------
def _localized_chrome_updates(state: GameState):
"""Return a tuple of gr.update() calls in the canonical order:
narration_length_dropdown, hero_name_box, grimoire_name_btn,
village_name_box, grimoire_village_btn,
oracle_box_1, grimoire_oracle1_btn,
oracle_box_2, grimoire_oracle2_btn,
oracle_box_3, grimoire_oracle3_btn,
oracle_box_4, grimoire_oracle4_btn,
oracle_box_5, grimoire_oracle5_btn,
grimoire_lang_btn, grimoire_begin_btn,
open_oracle_btn, continue_btn, restart_btn,
language_dropdown (info only), theme_dropdown (info only),
chronicle_accordion (label only).
Each entry is a gr.update() with only the localized props set so we
don't clobber values the user has just typed in.
"""
lang = _tlang(state)
nl_current_key = getattr(state, "narration_length", "medium") or "medium"
theme_key = getattr(state, "theme", "fantasy") or "fantasy"
# Cache by (lang, narration-length key, theme key) — the only fields
# the returned tuple of gr.update() objects actually depends on. The
# theme is in the key because _theme_dropdown_update inspects state.theme.
cache_key = (lang, nl_current_key, theme_key)
if cache_key in _LOCALIZED_CHROME_CACHE:
return _LOCALIZED_CHROME_CACHE[cache_key]
from oracles.state import NARRATION_LENGTHS as _NL
nl_choices = [v[0][lang] if isinstance(v[0], dict) else v[0]
for v in _NL.values()]
nl_default_label = _NL["medium"][0]
nl_default = (nl_default_label[lang] if isinstance(nl_default_label, dict)
else nl_default_label)
nl_current_entry = _NL.get(nl_current_key, _NL["medium"])
nl_current_label = nl_current_entry[0]
nl_current = (nl_current_label[lang] if isinstance(nl_current_label, dict)
else nl_current_label)
result = (
gr.update(
label=_t("label_narration_length", lang),
info=_t("info_narration_length", lang),
choices=nl_choices,
value=nl_current,
),
gr.update(
label=_t("label_hero_name", lang),
placeholder=_t("ph_hero_name", lang),
),
gr.update(value=_t("btn_name_him", lang)),
gr.update(
label=_t("label_village", lang),
placeholder=_t("ph_village", lang),
),
gr.update(value=_t("btn_place_him", lang)),
gr.update(
label=_t("label_oracle_1", lang),
placeholder=_t("ph_oracle_1", lang),
),
gr.update(value=_t("btn_seal_first", lang)),
gr.update(label=_t("label_oracle_2", lang)),
gr.update(value=_t("btn_seal_second", lang)),
gr.update(label=_t("label_oracle_3", lang)),
gr.update(value=_t("btn_seal_third", lang)),
gr.update(label=_t("label_oracle_4", lang)),
gr.update(value=_t("btn_seal_fourth", lang)),
gr.update(
label=_t("label_oracle_5", lang),
placeholder=_t("ph_oracle_5", lang),
),
gr.update(value=_t("btn_seal_last", lang)),
gr.update(value=_t("btn_inscribe_choice", lang)),
gr.update(value=_t("btn_let_journey_begin", lang)),
gr.update(value=_t("btn_open_oracle", lang)),
gr.update(value=_t("btn_continue", lang)),
gr.update(value=_t("btn_to_chronicle", lang)),
gr.update(value=_t("btn_new_tale", lang)),
gr.update(info=_t("info_language_dropdown", lang)),
_theme_dropdown_update(state, lang),
gr.update(label=_t("label_chronicle_accordion", lang)),
)
_LOCALIZED_CHROME_CACHE[cache_key] = result
return result
def _theme_dropdown_update(state: GameState, lang: str):
"""Rebuild the theme dropdown with localized labels. value uses the
same English ``display_name`` as the key (build_app's _fantasy.display_name)
so it stays stable across rerenders — but Gradio's value-matching is
against the label text, so we map the current theme key to its localized
display name."""
from oracles.themes import THEMES
choices = _theme_dropdown_choices(lang)
current_key = getattr(state, "theme", "fantasy") or "fantasy"
current_theme = THEMES.get(current_key, THEMES["fantasy"])
current_label = current_theme.localized_field("display_name", lang)
return gr.update(
info=_t("info_theme_dropdown", lang),
choices=choices,
value=current_label,
)
# ---------------------------------------------------------------------------
# UI bundle — every event handler returns the same shape (17 slots)
# ---------------------------------------------------------------------------
#
# Order:
# 0 state
# 1 trial_substate (str)
# 2 header_html
# 3 inscribe_error_md
# 4 send_off_sealed_html
# 5 send_off_narration_html
# 6 trial_heading_html
# 7 trial_await_html
# 8 trial_reveal_html
# 9 trial_image_html
# 10 epilogue_html
# 11 chronicle_md
# 12 inscribe_grp
# 13 send_off_grp
# 14 trial_grp
# 15 trial_await_grp
# 16 trial_reveal_grp
# 17 epilogue_grp
def _ui_bundle(
state: GameState,
trial_substate: str = TRIAL_STATE_AWAIT,
*,
inscribe_error: str = "",
):
(insc_v, send_v, trial_v, await_v, reveal_v,
epi_v, epi_end_v, epi_sum_v) = _visibility_for(state, trial_substate)
rg = _grimoire_right_visibility(state) # 9 visibility updates
new_bundle = (
# 0..17: existing slots (unchanged)
state,
trial_substate,
_badge_html(state, trial_substate),
inscribe_error,
_sealed_list_html(state) if state.mode == "send_off" else "",
_send_off_narration_html(state) if state.mode == "send_off" else "",
_trial_heading_html(state) if state.mode == "trial" else "",
_trial_await_html(state)
if state.mode == "trial" and trial_substate == TRIAL_STATE_AWAIT
else "",
_trial_reveal_html(state)
if state.mode == "trial" and trial_substate == TRIAL_STATE_REVEAL
else "",
_resolution_image_html(state)
if state.mode == "trial" and trial_substate == TRIAL_STATE_REVEAL
else "",
_epilogue_html(state) if state.mode == "epilogue" else "",
_chronicle_md(state),
insc_v,
send_v,
trial_v,
await_v,
reveal_v,
epi_v,
epi_end_v, # epilogue_ending_grp visibility
epi_sum_v, # epilogue_summary_grp visibility
# 18..21: grimoire content slots
_grimoire_left_html(state),
_grimoire_progress_html(state),
_grimoire_summary_html(state) if state.inscribe_step == 8 else "",
_grimoire_error_html(state),
# 22..30: visibility for the 9 right-page sub-groups (steps 0..8)
*rg,
# 31: right-page mirrored sigil
_grimoire_right_sigil_html(state),
# 32: wizard's-daydream pipeline-demo card (visible only on step 0
# of the inscribe phase).
gr.update(visible=(state.mode == "inscribe" and state.inscribe_step == 0)),
# 33..35: theme-driven chrome — palette CSS overrides, banner with
# theme title/subtitle, and inscribe-figs decoration with theme
# mentor/finale captions. All re-render on every event so picking
# a theme on grimoire spread 0 reskins the entire page.
_theme_style_overrides_html(state),
_banner_html(state),
_inscribe_decoration_html(state),
# 36: walking-hero processing overlay (visible only when a
# generator handler has set state.processing_msg before its slow
# work and cleared it after).
_processing_overlay_html(state),
# 37..58: localized chrome updates — labels/info/placeholders/button
# values for every component below spread 0 of the grimoire so the
# whole UI flips to the active language when the player picks
# Simplified Chinese.
*_localized_chrome_updates(state),
)
# NOTE: gr.skip() diff was tried here to reduce payload by sending
# only changed outputs. It broke multi-yield generator handlers like
# handle_grimoire_begin → handle_send_off (an internal _ui_bundle
# call updated the cache to a state that was never sent to the
# client, so the next yield diffed everything as "unchanged" and
# the client never received the new page). The memoized HTML helpers
# still provide most of the speedup; this path now just returns
# the full bundle.
return new_bundle
# ---------------------------------------------------------------------------
# Event handlers
# ---------------------------------------------------------------------------
def _label_to_lang_code(label: str) -> str:
return next(
(code for code, l in LANG_LABELS.items() if l == label), "en"
)
def _theme_dropdown_choices(lang: str):
"""Return the (label, value_key) tuples for the theme dropdown in the
given language. value_key is always the English ``display_name`` (used
as the stable key for lookup), label switches to the localized field
so the player sees themes in their tongue."""
from oracles.themes import THEMES
out = []
for t in THEMES.values():
name = t.localized_field("display_name", lang)
blurb = t.localized_field("blurb", lang)
out.append((f"{name}{blurb}", name))
return out
def _apply_spread0_choices(
state: GameState,
language_label: str,
theme_label: str = "",
narration_label: str = "",
visual_mode_label: str = "",
) -> GameState:
"""Mutate state from the four spread-0 dropdown values. Shared by
the live-preview .change() handler and the click-to-advance button."""
if visual_mode_label:
# Map the human label back to the internal key.
if "Full" in visual_mode_label:
state.visual_mode = "full"
elif "Lean" in visual_mode_label:
state.visual_mode = "lean"
from oracles.themes import THEMES, DEFAULT_THEME
from oracles.state import NARRATION_LENGTHS as _NL
state.lang = _label_to_lang_code(language_label)
chosen_key = DEFAULT_THEME
for key, theme in THEMES.items():
if theme_label and (
theme.display_name == theme_label
or theme.localized_field("display_name", "zh") == theme_label
):
chosen_key = key
break
state.theme = chosen_key
chosen_nl = "medium"
for key, (label, *_rest) in _NL.items():
if isinstance(label, dict):
if narration_label in label.values():
chosen_nl = key
break
elif label == narration_label:
chosen_nl = key
break
state.narration_length = chosen_nl
# Refresh theme defaults if the player hasn't customized yet. When
# fantasy + Chinese, swap to Chinese-friendly placeholder names so
# they don't look out of place in the wizard's Chinese monologue.
theme_obj = THEMES[chosen_key]
if state.hero_name in ("", "Tobin", "托宾"):
if chosen_key == "fantasy" and state.lang == "zh":
state.hero_name = "小明"
else:
state.hero_name = theme_obj.hero_default
if state.village_name in ("", "the Hollow", "凡人村", "洼谷"):
if chosen_key == "fantasy" and state.lang == "zh":
state.village_name = "凡人村"
else:
state.village_name = theme_obj.village_default
return state
def handle_lang_theme_preview(state: GameState, language_label: str,
theme_label: str = "",
narration_label: str = "",
visual_mode_label: str = ""):
"""Live-preview handler bound to the dropdowns' ``.change()`` event.
Re-renders every theme/lang-driven slot (banner title, palette,
inscribe-figs captions, grimoire wizard text on the left page,
pipeline-demo, etc.) without advancing past inscribe step 0. Lets
the player see the world reskin instantly when they switch theme
or language."""
if state.mode != "inscribe" or state.inscribe_step != 0:
return _ui_bundle(state)
_apply_spread0_choices(state, language_label, theme_label,
narration_label, visual_mode_label)
state.grimoire_error = ""
return _ui_bundle(state)
def handle_grimoire_lang(state: GameState, language_label: str,
theme_label: str = "",
narration_label: str = "",
visual_mode_label: str = ""):
"""Step 0 → 1: apply the language/theme/narration picks and advance.
The actual mutation lives in `_apply_spread0_choices` so the live-
preview .change() handler can share the same logic without advancing
the step."""
if state.mode != "inscribe" or state.inscribe_step != 0:
return _ui_bundle(state)
_apply_spread0_choices(state, language_label, theme_label,
narration_label, visual_mode_label)
state.grimoire_error = ""
state.inscribe_step = 1
return _ui_bundle(state)
def handle_grimoire_name(state: GameState, hero_name: str):
"""Step 1 → 2: inscribe the hero's name."""
if state.mode != "inscribe" or state.inscribe_step != 1:
return _ui_bundle(state)
cleaned = (hero_name or "").strip()
if not cleaned:
state.grimoire_error = _t("err_hero_blank", _tlang(state))
return _ui_bundle(state)
state.hero_name = cleaned
state.grimoire_error = ""
state.inscribe_step = 2
return _ui_bundle(state)
def handle_grimoire_village(state: GameState, village_name: str):
"""Step 2 → 3: name the village."""
if state.mode != "inscribe" or state.inscribe_step != 2:
return _ui_bundle(state)
cleaned = (village_name or "").strip()
if not cleaned:
state.grimoire_error = _t("err_village_blank", _tlang(state))
return _ui_bundle(state)
state.village_name = cleaned
state.grimoire_error = ""
state.inscribe_step = 3
return _ui_bundle(state)
def handle_grimoire_oracle(state: GameState, oracle_text: str):
"""Step N ∈ {3..7} → N+1: seal one oracle and advance.
Step 7 (the final oracle) advances to step 8 (the closing spread).
"""
if state.mode != "inscribe" or state.inscribe_step not in _GRIMOIRE_ORACLE_STEPS:
return _ui_bundle(state)
cleaned = (oracle_text or "").rstrip()
if not cleaned.strip():
lang = _tlang(state)
ordinal_keys = [
"ord_first", "ord_second", "ord_third", "ord_fourth", "ord_fifth",
]
idx = state.inscribe_step - 3
ord_key = ordinal_keys[idx] if 0 <= idx < len(ordinal_keys) else "ord_next"
ord_word = _t(ord_key, lang)
state.grimoire_error = _t("err_parchment_blank", lang).format(
ord_word=ord_word
)
return _ui_bundle(state)
oracle_idx = state.inscribe_step - 3 # 0..4
state.oracles[oracle_idx].text = cleaned
state.grimoire_error = ""
state.inscribe_step += 1
return _ui_bundle(state)
def handle_grimoire_begin(state: GameState):
"""Step 8 → trial 1 (one click, one wait).
Yields TWICE so the player sees a walking-hero overlay during the slow
obstacle-generation LLM call:
1. Immediate yield — UI gets the overlay shown via state.processing_msg.
2. After the work — UI gets the final trial-1 await view with the
overlay cleared.
Previously this flipped to a 'send_off' intermediate mode that
required a second button click to actually start. We now fold the two
actions together so the player goes directly from the closing grimoire
spread into trial 1 — obstacle generation + precompute happen during
the single waiting period. The send-off narration is rendered above
the trial-1 await screen instead of in its own panel."""
if state.mode != "inscribe" or state.inscribe_step != 8:
yield _ui_bundle(state)
return
state.grimoire_error = ""
# First yield — show the walking-hero overlay so the wait feels
# narrative, not technical.
state.processing_msg = _t("msg_walking_east", _tlang(state))
yield _ui_bundle(state)
# Slow work: obstacle generation + background precompute.
state.mode = "send_off" # transient — handle_send_off flips to "trial"
bundle = handle_send_off(state)
# The bundle's state reference is the same object, so mutating
# processing_msg before re-rendering clears the overlay.
state.processing_msg = ""
yield _ui_bundle(state, trial_substate=TRIAL_STATE_AWAIT)
def handle_send_off(state: GameState):
"""send_off → trial 1. Generates the 5 obstacles AND kicks off the
background precompute so trial reveals are instant in live mode."""
if state.mode != "send_off":
return _ui_bundle(state)
language = LANG_PROMPT_NAME.get(state.lang, "English")
try:
obstacles = generate_obstacles(state, CLIENT, language=language)
except Exception as e:
# LLM unreachable. Build placeholder obstacles so the player can see
# the error in-game instead of a blank screen, then continue. The
# message is visible in trial 1's setup.
from oracles.state import Obstacle as _Obstacle, DRAGON_TRIAL as _DRAGON
msg = (
f"[The {getattr(state, 'theme', 'fantasy')} world will not open: "
f"{e}. Check MODAL_URL / MODAL_KEY / MODAL_SECRET, then restart.]"
)
obstacles = [
_Obstacle(index=i, setup=msg, is_dragon=(i == _DRAGON))
for i in range(1, 6)
]
state.obstacles = obstacles
state.current_trial = 1
state.mode = "trial"
# Kick off background precompute. In mock mode this is a no-op.
# When live, this fills state.resolution_cache while the player reads
# the send-off paragraph and the trial-1 setup, so the first
# "open oracle" click reveals instantly.
try:
precompute_all_resolutions(state, CLIENT, language=language)
except Exception:
pass
return _ui_bundle(state, trial_substate=TRIAL_STATE_AWAIT)
def handle_open_oracle(state: GameState, trial_substate: str):
"""Within a trial (state A → state B). Draw oracle, resolve, render image.
Generator: yields once with the walking-hero overlay during the LLM
call (or instantly when the cache is hot), then yields the final
reveal state."""
if state.mode != "trial" or trial_substate != TRIAL_STATE_AWAIT:
yield _ui_bundle(state, trial_substate)
return
obstacle = state.current_obstacle()
if obstacle is None:
# No obstacle for this trial — shouldn't happen, but bail gracefully.
yield _ui_bundle(state, trial_substate)
return
# Draw a random unopened oracle and mark it.
try:
oracle = state.draw_random_oracle()
except RuntimeError:
# No oracles left — shouldn't happen since we only have 5 trials.
yield _ui_bundle(state, trial_substate)
return
oracle.opened = True
oracle.opened_at_trial = state.current_trial
# Cache lookup first — precompute_all_resolutions populates this in the
# background. Cache hit → instant reveal. Miss → synchronous LLM call.
cache_key = (oracle.index, obstacle.index)
resolution = state.resolution_cache.get(cache_key)
if resolution is None:
lang = _tlang(state)
# Show the walking-hero modal while the LLM cooks the resolution.
state.processing_msg = _t("msg_reading_oracle", lang)
yield _ui_bundle(state, trial_substate)
language = LANG_PROMPT_NAME.get(state.lang, "English")
try:
resolution = resolve_trial(
obstacle,
oracle,
state.hero_name,
state.village_name,
CLIENT,
language=language,
theme=getattr(state, "theme", "fantasy"),
narration_length=getattr(state, "narration_length", "medium"),
)
except Exception as e:
# LLM failure mid-trial: surface the error to the player instead
# of crashing the UI. They can restart once Modal is back up.
from oracles.state import Resolution as _Resolution
msg = _t("msg_oracle_quivers", lang).format(e=e)
resolution = _Resolution(
trial_index=obstacle.index,
obstacle=obstacle,
oracle=oracle,
narration=msg,
tactic=_t("msg_no_tactic", lang),
image_path="",
image_caption="LLM unreachable",
)
# Render scene image (mock; SVG card). use_klein=False per task spec.
try:
img = generate_scene_image(resolution, out_dir=OUT_DIR, use_klein=False)
resolution.image_path = img.path
resolution.image_caption = img.caption
except Exception:
# The mock path is supposed to be safe, but if anything explodes, keep
# the caption-only state set by resolve_trial.
resolution.image_path = ""
resolution.image_caption = resolution.image_caption or (
_t("msg_trial_caption_fallback", _tlang(state)).format(
n=resolution.trial_index, tactic=resolution.tactic
)
)
state.resolutions.append(resolution)
# Kick off background generation of the *next* bridge while the user
# reads this trial's narration. By the time they click Continue, the
# interlude (or epilogue, after trial 5) is already cached on state.
language = LANG_PROMPT_NAME.get(state.lang, "English")
theme_key = getattr(state, "theme", "fantasy") or "fantasy"
if state.current_trial < NUM_TRIALS:
kick_background_interlude(
state, state.current_trial,
CLIENT, language=language, theme=theme_key,
)
else:
kick_background_epilogue(
state, CLIENT, language=language, theme=theme_key,
)
# Clear the modal overlay before showing the reveal.
state.processing_msg = ""
yield _ui_bundle(state, trial_substate=TRIAL_STATE_REVEAL)
def handle_continue(state: GameState, trial_substate: str):
"""trial N → trial N+1, or trial 5 → epilogue.
Generator: yields the walking-hero overlay during interlude/epilogue
generation (skipping the overlay yield when the precompute cache is
already warm and the click resolves instantly).
"""
if state.mode != "trial" or trial_substate != TRIAL_STATE_REVEAL:
yield _ui_bundle(state, trial_substate)
return
language = LANG_PROMPT_NAME.get(state.lang, "English")
theme_key = getattr(state, "theme", "fantasy") or "fantasy"
# Decide whether the next bridge is already cached so we can skip
# the "walking hero" yield when it would just flicker for one frame.
next_idx_advance = state.current_trial + 1
if state.current_trial < NUM_TRIALS:
slot = state.current_trial - 1
cache_warm = (
0 <= slot < len(state.interludes)
and (state.interludes[slot] or "").strip() != ""
)
else:
cache_warm = (state.epilogue or "").strip() != ""
if not cache_warm:
lang_code = _tlang(state)
state.processing_msg = (
_t("msg_walking_between_trials", lang_code)
if state.current_trial < NUM_TRIALS
else _t("msg_climbing_final", lang_code)
)
yield _ui_bundle(state, trial_substate)
if state.current_trial < NUM_TRIALS:
# Generate the interlude bridging trial N → trial N+1.
# The previous trial's obstacle + tactic come from state.resolutions[-1].
prev_trial_idx = state.current_trial
next_trial_idx = state.current_trial + 1
prev_res = state.resolutions[-1] if state.resolutions else None
next_ob = next(
(ob for ob in state.obstacles if ob.index == next_trial_idx),
None,
)
if prev_res is not None and next_ob is not None:
trials_remaining = NUM_TRIALS - prev_trial_idx
slot = prev_trial_idx - 1
cached = ""
if 0 <= slot < len(state.interludes):
cached = (state.interludes[slot] or "").strip()
if cached:
# Background precompute already filled it. Instant.
text = cached
else:
# Precompute missed (still in flight, or never fired).
# Fall through to a synchronous call.
try:
if next_ob.is_dragon:
four_tactics = [
(r.tactic or "").strip() for r in state.resolutions
]
text = generate_dragon_interlude(
prev_res.obstacle, four_tactics,
state.hero_name, state.village_name,
CLIENT, language=language,
theme=theme_key,
)
else:
text = generate_interlude(
prev_res.obstacle, next_ob,
prev_res.tactic or "",
state.hero_name, state.village_name,
trials_remaining=trials_remaining,
client=CLIENT, language=language,
theme=theme_key,
)
except Exception as e:
text = _t("msg_road_silent", _tlang(state)).format(e=e)
if 0 <= slot < len(state.interludes):
state.interludes[slot] = text
state.current_trial = next_trial_idx
state.processing_msg = ""
yield _ui_bundle(state, trial_substate=TRIAL_STATE_AWAIT)
return
# Trial 5 just resolved — move to epilogue, opening on the
# cinematic ending-card substate. Player clicks Continue to see the
# chronicle/tree summary card.
cached_epi = (state.epilogue or "").strip()
if cached_epi:
pass
else:
try:
epi = generate_epilogue(state, CLIENT, language=language, theme=theme_key)
except Exception as e:
epi = _t("msg_mentor_silent", _tlang(state)).format(e=e)
state.epilogue = (epi or "").strip()
state.mode = "epilogue"
state.epilogue_substate = "ending"
state.current_trial = NUM_TRIALS + 1
state.processing_msg = ""
yield _ui_bundle(state)
def handle_view_summary(state: GameState):
"""On the ending card, the player clicks Continue → flip to the
summary card (chronicle list + story-tree viz)."""
if state.mode != "epilogue":
return _ui_bundle(state)
state.epilogue_substate = "summary"
return _ui_bundle(state)
def handle_restart(state: GameState):
"""epilogue → inscribe (fresh state, hero/village + theme preserved)."""
hero = state.hero_name or "Tobin"
village = state.village_name or "the Hollow"
theme = getattr(state, "theme", "fantasy") or "fantasy"
new_state = fresh_state(hero_name=hero, village_name=village, theme=theme)
return _ui_bundle(new_state, trial_substate=TRIAL_STATE_AWAIT)
# ---------------------------------------------------------------------------
# Blocks construction
# ---------------------------------------------------------------------------
def build_app() -> gr.Blocks:
initial_state = fresh_state()
# Compose CSS: the static block + the inline pixel-art book frame +
# an optional Klein parchment-texture block (empty if the PNG isn't on
# disk). The book frame CSS uses !important overrides so order doesn't
# strictly matter, but putting it AFTER the static block keeps the
# override semantics clearer in the rendered <style>.
composed_css = CSS + _grimoire_book_css() + _grimoire_texture_css()
# Custom Base theme — we override every visible token to match the
# pixel-art palette (--w-amber / --w-night / --w-cream / --w-shadow).
# ``Base`` ships with the least amount of opinionated default styling
# vs. ``Soft`` so our CSS sweep below has less stock-Gradio look to
# fight against. Off-Brand badge: a judge should not be able to
# identify this as a Gradio app at first glance.
_apprentice_theme = gr.themes.Base(
primary_hue=gr.themes.colors.amber,
secondary_hue=gr.themes.colors.amber,
neutral_hue=gr.themes.colors.stone,
text_size=gr.themes.sizes.text_md,
spacing_size=gr.themes.sizes.spacing_md,
radius_size=gr.themes.sizes.radius_none, # NES-style sharp corners
font=[gr.themes.GoogleFont("VT323"), "monospace"],
font_mono=[gr.themes.GoogleFont("Press Start 2P"), "monospace"],
).set(
body_background_fill="#18141f",
body_text_color="#ede4d3",
background_fill_primary="#18141f",
background_fill_secondary="#251d2e",
block_background_fill="#251d2e",
border_color_primary="#f0c060",
button_primary_background_fill="#f0c060",
button_primary_text_color="#251d2e",
button_secondary_background_fill="#251d2e",
button_secondary_text_color="#ede4d3",
input_background_fill="#251d2e",
)
with gr.Blocks(
theme=_apprentice_theme,
css=composed_css,
title="The Apprentice",
analytics_enabled=False,
) as demo:
state = gr.State(initial_state)
trial_substate = gr.State(TRIAL_STATE_AWAIT)
# Per-theme CSS variable overrides — injected dynamically so changing
# the theme on grimoire spread 0 reskins the entire page (banner,
# cards, buttons, accents).
theme_style_html = gr.HTML(_theme_style_overrides_html(initial_state))
# Parallax banner — title + subtitle pulled from the active theme.
banner_html = gr.HTML(_banner_html(initial_state))
# Tiny status chip beneath the banner (Trial N/5, Inscribing, etc.)
header_html = gr.HTML(_badge_html(initial_state))
# Walking-hero processing overlay — visible only while a generator
# handler has work in flight (state.processing_msg != "").
processing_overlay_html = gr.HTML(_processing_overlay_html(initial_state))
if CLIENT.using_mock:
gr.HTML(
"<div class='oracle-mock-banner'>"
"Running in mock mode — set MODAL_URL (and unset "
"ORACLES_FORCE_MOCK) to enable the live LLM."
"</div>"
)
# Static sprite-derived CSS — emits :root CSS variables (ground
# tile URI, etc) and #oracle-{phase}-grp backdrop rules so the
# main stylesheet stays free of big data URIs.
gr.HTML(_global_sprite_vars_html())
gr.HTML(_phase_backdrop_styles_html())
# ---- inscribe panel: the grimoire (book spread) ----------------
with gr.Group(visible=True, elem_id="oracle-inscribe-grp",
elem_classes=["phase-bg"]) as inscribe_grp:
# Inscribe-figs decoration (wizard ←→ hero figures) sits at
# the top so the journey framing is the first thing the player
# sees on every inscribe spread.
inscribe_decoration_html = gr.HTML(
_inscribe_decoration_html(initial_state)
)
# The book is a Gradio Row with two Column children — using
# Gradio's own layout primitives means our CSS classes apply
# to real wrapper elements (you can't wrap components with
# raw <div> tags — Gradio puts each HTML component inside its
# own wrapper, so the open tag self-closes immediately).
with gr.Row(elem_classes=["oracle-grimoire"]):
with gr.Column(elem_classes=["grimoire-page", "grimoire-left"]):
grimoire_left_html = gr.HTML(_grimoire_left_html(initial_state))
with gr.Column(elem_classes=["grimoire-page", "grimoire-right"]):
# Mirrored sigil for this spread on the right page.
grimoire_right_sigil_html = gr.HTML(
_grimoire_right_sigil_html(initial_state)
)
# The wizard's prompt asks for input on the right page.
# ONE sub-group per inscribe step (0..8); only one visible.
with gr.Group(visible=True) as right_lang_grp: # step 0
language_dropdown = gr.Dropdown(
label="Tongue / 语言",
choices=list(LANG_LABELS.values()),
value=LANG_LABELS["en"],
info=(
"The mentor will write in this tongue for the "
"rest of the tale."
),
)
# ---- Theme picker (new) ------------------------------
# Each theme reshapes the world: the mentor, the
# obstacles, the finale, the style of narration.
from oracles.themes import THEMES as _THEMES_FOR_UI, \
DEFAULT_THEME as _DEFAULT_THEME_FOR_UI
# Gradio Dropdown supports (display_label, value)
# tuples — the user sees the verbose label, the
# handler receives the bare display_name.
_theme_choices = [
(f"{t.display_name}{t.blurb}", t.display_name)
for t in _THEMES_FOR_UI.values()
]
_fantasy = _THEMES_FOR_UI[_DEFAULT_THEME_FOR_UI]
theme_dropdown = gr.Dropdown(
label="World / 世界",
choices=_theme_choices,
value=_fantasy.display_name,
info=(
"The shape of the world the hero walks. "
"Each setting has its own mentor, obstacles, "
"and final reckoning."
),
)
# ---- Narration length picker ---------------------
from oracles.state import NARRATION_LENGTHS as _NL
# Labels are now `{"en": ..., "zh": ...}` dicts; the
# initial render uses the English variant — the
# bundle's localized-chrome updates swap in the
# Chinese ones once the player picks zh.
_length_choices = [
(v[0]["en"] if isinstance(v[0], dict) else v[0])
for v in _NL.values()
]
_medium_label = _NL["medium"][0]
_medium_default = (
_medium_label["en"]
if isinstance(_medium_label, dict)
else _medium_label
)
narration_length_dropdown = gr.Dropdown(
label="Narration length per trial",
choices=_length_choices,
value=_medium_default,
info=(
"How many words you want the mentor to spin "
"per trial. Shorter = snappier, longer = more "
"vivid. You can rerun the journey with a "
"different choice."
),
)
# Visual-mode selector — flips the full-fat decorative
# PNGs (parallax banner, scene landscapes, etc.) on
# and off live. Default reads from the env var the
# process launched with (HF Space → lean, ./run.sh
# --full → full). The player can override either way.
_vm_initial = ("Full visuals (all backdrops)"
if _full_visual_mode() else
"Lean (fast on slow networks)")
visual_mode_dropdown = gr.Dropdown(
label="Visuals",
choices=[
"Lean (fast on slow networks)",
"Full visuals (all backdrops)",
],
value=_vm_initial,
info=(
"Lean skips the heavy decorative PNGs for "
"fast loading. Full adds the parallax banner, "
"scene landscapes, parchment, etc. The choice "
"applies instantly."
),
)
grimoire_lang_btn = gr.Button(
"Inscribe my choice →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_name_grp: # step 1
hero_name_box = gr.Textbox(
label="The hero's name",
value="Tobin", lines=1,
placeholder="A name a dragon would forget…",
)
grimoire_name_btn = gr.Button(
"Name him →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_village_grp: # step 2
village_name_box = gr.Textbox(
label="His village",
value="the Hollow", lines=1,
placeholder="A place with a well and a bell…",
)
grimoire_village_btn = gr.Button(
"Place him →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_oracle1_grp: # step 3
oracle_box_1 = gr.Textbox(
label="The first parchment",
lines=3, placeholder="Anything you write may save him.",
)
grimoire_oracle1_btn = gr.Button(
"Seal the first →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_oracle2_grp: # step 4
oracle_box_2 = gr.Textbox(
label="The second parchment", lines=3, placeholder="…",
)
grimoire_oracle2_btn = gr.Button(
"Seal the second →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_oracle3_grp: # step 5
oracle_box_3 = gr.Textbox(
label="The third parchment", lines=3, placeholder="…",
)
grimoire_oracle3_btn = gr.Button(
"Seal the third →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_oracle4_grp: # step 6
oracle_box_4 = gr.Textbox(
label="The fourth parchment", lines=3, placeholder="…",
)
grimoire_oracle4_btn = gr.Button(
"Seal the fourth →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_oracle5_grp: # step 7
oracle_box_5 = gr.Textbox(
label="The fifth (and final) parchment",
lines=3, placeholder="He will open this at the dragon.",
)
grimoire_oracle5_btn = gr.Button(
"Seal the last →", variant="primary", size="lg"
)
with gr.Group(visible=False) as right_closing_grp: # step 8
grimoire_summary_html = gr.HTML("")
grimoire_begin_btn = gr.Button(
"Let the journey begin →", variant="primary", size="lg"
)
grimoire_error_html = gr.HTML(
"", elem_classes=["grimoire-error-slot"]
)
grimoire_progress_html = gr.HTML(
_grimoire_progress_html(initial_state)
)
# Legacy slot — handlers still feed this for back-compat, hidden in UI.
inscribe_error_md = gr.HTML("", visible=False)
# Wizard's-daydream demonstration of the gameplay loop. Moved
# BELOW the grimoire so the player makes their language/theme/
# narration-length choices first, then reads the examples.
# Hidden via the bundle's pipeline_demo_grp visibility update.
with gr.Group(visible=True) as pipeline_demo_grp:
gr.HTML(_pipeline_demo_html())
# ---- send_off panel --------------------------------------------
with gr.Group(visible=False, elem_id="oracle-send-off-grp",
elem_classes=["phase-bg"]) as send_off_grp:
send_off_sealed_html = gr.HTML("")
send_off_narration_html = gr.HTML("")
send_off_btn = gr.Button(
"The hero leaves the village.", variant="primary", size="lg"
)
# ---- trial panel -----------------------------------------------
with gr.Group(visible=False, elem_id="oracle-trial-grp",
elem_classes=["phase-bg"]) as trial_grp:
trial_heading_html = gr.HTML("")
with gr.Group(visible=False) as trial_await_grp:
trial_await_html = gr.HTML("")
open_oracle_btn = gr.Button(
"He opens one of the mentor's oracles.",
variant="primary",
size="lg",
)
with gr.Group(visible=False) as trial_reveal_grp:
# Image at top + bigger, text below for legibility.
trial_image_html = gr.HTML("")
trial_reveal_html = gr.HTML("")
continue_btn = gr.Button(
"Continue.", variant="primary", size="lg"
)
# ---- epilogue panel — two stacked sub-cards --------------------
# Card 1 (epilogue_ending_grp): cinematic ENDING — title banner +
# wizard's narration + a 'Continue to the chronicle' button.
# Card 2 (epilogue_summary_grp): the CHRONICLE — story-tree viz +
# trial-by-trial recap + 'Begin a new tale' restart button.
# Substate ``state.epilogue_substate`` flips between them; the
# initial mount lands on the ending card.
with gr.Group(visible=False, elem_id="oracle-epilogue-grp",
elem_classes=["phase-bg"]) as epilogue_grp:
with gr.Group(visible=True) as epilogue_ending_grp:
epilogue_html = gr.HTML("")
continue_to_summary_btn = gr.Button(
"Continue to the chronicle", variant="primary"
)
with gr.Group(visible=False) as epilogue_summary_grp:
with gr.Accordion("Chronicle", open=True) as chronicle_accordion:
chronicle_md = gr.Markdown(_chronicle_md(initial_state))
restart_btn = gr.Button("Begin a new tale", variant="primary")
# ---- bound outputs (must match the order of _ui_bundle) --------
# Note: chronicle_md is used in both the visible epilogue accordion AND
# as a slot updated by every handler. Place it AFTER the epilogue group
# so its variable is defined before we reference it here.
outputs = [
state,
trial_substate,
header_html,
inscribe_error_md,
send_off_sealed_html,
send_off_narration_html,
trial_heading_html,
trial_await_html,
trial_reveal_html,
trial_image_html,
epilogue_html,
chronicle_md,
inscribe_grp,
send_off_grp,
trial_grp,
trial_await_grp,
trial_reveal_grp,
epilogue_grp,
epilogue_ending_grp,
epilogue_summary_grp,
# 18..21
grimoire_left_html,
grimoire_progress_html,
grimoire_summary_html,
grimoire_error_html,
# 22..30 — visibility for 9 right-page sub-groups (steps 0..8)
right_lang_grp,
right_name_grp,
right_village_grp,
right_oracle1_grp,
right_oracle2_grp,
right_oracle3_grp,
right_oracle4_grp,
right_oracle5_grp,
right_closing_grp,
grimoire_right_sigil_html,
# 32 — wizard's-daydream pipeline-demo group (visible only on
# inscribe step 0); the bundle's visibility update lives in
# the matching final slot.
pipeline_demo_grp,
# 33..35 — theme-driven chrome slots that re-render on every
# event so theme picks reskin the page immediately.
theme_style_html,
banner_html,
inscribe_decoration_html,
# 36 — walking-hero processing overlay, driven by generator
# handlers via state.processing_msg.
processing_overlay_html,
# 37..58 — localized chrome (labels/info/placeholders/button
# text) for every component below grimoire spread 0. Order
# MUST match the tuple returned by _localized_chrome_updates().
narration_length_dropdown,
hero_name_box,
grimoire_name_btn,
village_name_box,
grimoire_village_btn,
oracle_box_1,
grimoire_oracle1_btn,
oracle_box_2,
grimoire_oracle2_btn,
oracle_box_3,
grimoire_oracle3_btn,
oracle_box_4,
grimoire_oracle4_btn,
oracle_box_5,
grimoire_oracle5_btn,
grimoire_lang_btn,
grimoire_begin_btn,
open_oracle_btn,
continue_btn,
continue_to_summary_btn,
restart_btn,
language_dropdown,
theme_dropdown,
chronicle_accordion,
]
# ---- wire events -----------------------------------------------
# Spread 0 LIVE preview: every dropdown change re-renders the
# banner, palette, captions, grimoire wizard text, and demo card
# in the new theme/language WITHOUT advancing the step. The
# actual step advance still requires clicking the button.
_spread0_inputs = [state, language_dropdown, theme_dropdown,
narration_length_dropdown, visual_mode_dropdown]
for _dd in (language_dropdown, theme_dropdown, narration_length_dropdown,
visual_mode_dropdown):
_dd.change(
handle_lang_theme_preview,
inputs=_spread0_inputs,
outputs=outputs,
)
# Grimoire steps: one handler per spread.
grimoire_lang_btn.click(
handle_grimoire_lang,
inputs=_spread0_inputs,
outputs=outputs,
show_progress="hidden",
)
grimoire_name_btn.click(
handle_grimoire_name,
inputs=[state, hero_name_box],
outputs=outputs,
show_progress="hidden",
)
grimoire_village_btn.click(
handle_grimoire_village,
inputs=[state, village_name_box],
outputs=outputs,
show_progress="hidden",
)
grimoire_oracle1_btn.click(
handle_grimoire_oracle,
inputs=[state, oracle_box_1],
outputs=outputs,
show_progress="hidden",
)
grimoire_oracle2_btn.click(
handle_grimoire_oracle,
inputs=[state, oracle_box_2],
outputs=outputs,
show_progress="hidden",
)
grimoire_oracle3_btn.click(
handle_grimoire_oracle,
inputs=[state, oracle_box_3],
outputs=outputs,
show_progress="hidden",
)
grimoire_oracle4_btn.click(
handle_grimoire_oracle,
inputs=[state, oracle_box_4],
outputs=outputs,
show_progress="hidden",
)
grimoire_oracle5_btn.click(
handle_grimoire_oracle,
inputs=[state, oracle_box_5],
outputs=outputs,
show_progress="hidden",
)
grimoire_begin_btn.click(
handle_grimoire_begin,
inputs=[state],
outputs=outputs,
show_progress="hidden",
)
send_off_btn.click(
handle_send_off,
inputs=[state],
outputs=outputs,
show_progress="hidden",
)
open_oracle_btn.click(
handle_open_oracle,
inputs=[state, trial_substate],
outputs=outputs,
show_progress="hidden",
)
continue_btn.click(
handle_continue,
inputs=[state, trial_substate],
outputs=outputs,
show_progress="hidden",
)
continue_to_summary_btn.click(
handle_view_summary,
inputs=[state],
outputs=outputs,
show_progress="hidden",
)
restart_btn.click(
handle_restart,
inputs=[state],
outputs=outputs,
show_progress="hidden",
)
return demo
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
#
# On HF Spaces, Gradio's launcher imports app.py as a module and looks for a
# top-level ``demo`` variable to serve. If it doesn't find one, it falls back
# to its own ``.launch()`` with DEFAULT settings — which means everything we
# pass to ``demo.launch(...)`` below would be ignored (no theme, no css, no
# ssr_mode=False, no allowed_paths). So we build the demo at module scope.
# Register the assets directory with Gradio's static file router so
# /gradio_api/file=<absolute-path> URLs served at runtime resolve to actual
# files. ``set_static_paths`` is more reliable than ``allowed_paths`` on
# launch() because it takes effect at module load, before any request hits.
try:
gr.set_static_paths(paths=[_HERE / "assets"])
except Exception:
# Gradio versions without set_static_paths fall back to ``allowed_paths``
# via demo.launch(...) — harmless to skip here.
pass
demo = build_app()
def main() -> int:
# Local entry point — uses the module-level ``demo`` so the local run
# matches the HF Space behavior exactly.
# ssr_mode is Gradio 5+ only. The local dev env may still be on
# Gradio 4.44 (the version this app was originally built against),
# which would TypeError on the unknown kwarg. Try-with-ssr first,
# fall back to without on TypeError.
launch_kwargs = dict(
server_name="0.0.0.0",
server_port=7860,
allowed_paths=[str(_HERE / "assets")],
show_error=True,
)
try:
demo.launch(ssr_mode=False, **launch_kwargs)
except TypeError:
demo.launch(**launch_kwargs)
return 0
if __name__ == "__main__":
sys.exit(main())