Aswini-Kumar's picture
Initial commit — Omniscient Reader Scenario Simulator
7d99fde
Raw
History Blame Contribute Delete
58.8 kB
"""
app.py — Omniscient Reader: Scenario Simulator
================================================
Main Gradio application entry point.
Connects the Dokkaebi AI (Qwen 2.5 14B via Modal), the game engine,
scenario definitions, and the custom dark-theme game UI.
Part of the ORV (Omniscient Reader's Viewpoint) Scenario Simulator.
Build Small Hackathon 2026 — Track 2: Thousand Token Wood.
"""
from __future__ import annotations
import json
import os
from urllib.parse import quote
import gradio as gr
from game_engine import GameState, process_turn
from scenarios import SCENARIOS, get_scenario_announcement_text, get_next_scenario
from prompts import build_prompt, build_intro_prompt
from modal_client import call_dokkaebi, call_dokkaebi_cinematic
# ---------------------------------------------------------------------------
# Asset paths
# ---------------------------------------------------------------------------
_ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
# Cache-busting: timestamp changes every server restart, forcing browser to fetch fresh images
_CACHE_BUST = int(os.path.getmtime(_ASSET_DIR))
SCENE_IMAGES = {
"Subway": "seoul_subway.png",
"Seoul Street": "seoul_street.png",
"Destroyed Office": "destroyed_office.png",
"Han River Bridge": "han_river_bridge.png",
"Reader Noticed": "reader_noticed.png",
}
def _asset_path(filename: str) -> str:
return os.path.join(_ASSET_DIR, filename)
def _asset_url(filename: str) -> str:
"""Return a Gradio file URL that works on Windows + HF Spaces.
Gradio 6.x serves allowed files at /gradio_api/file=<abs_path>.
We encode path segments so spaces become %20.
"""
abs_path = os.path.abspath(os.path.join(_ASSET_DIR, filename))
fwd = abs_path.replace("\\", "/")
parts = fwd.split("/")
encoded = "/".join(quote(p, safe='') if p else p for p in parts)
encoded = encoded.replace("%3A", ":", 1)
return f"/gradio_api/file={encoded}?v={_CACHE_BUST}"
# ---------------------------------------------------------------------------
# Load CSS / JS
# ---------------------------------------------------------------------------
def _read_file(path: str) -> str:
with open(os.path.join(os.path.dirname(__file__), path), "r", encoding="utf-8") as f:
return f.read()
_STYLE_CSS = _read_file("style.css")
_ANIMATIONS_CSS = _read_file("animations.css")
_GAME_JS = _read_file("game.js")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# HTML RENDERERS — Build the game UI from state + AI response
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def render_start_screen() -> str:
"""Dramatic ORV manhwa-accurate start screen."""
return f"""
<style>
#start-screen {{
background-image: url('{_asset_url('title_banner.png')}') !important;
background-size: cover !important;
background-position: center center !important;
background-repeat: no-repeat !important;
background-color: #030305 !important;
}}
</style>
<div class="start-screen" id="start-screen" style="
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;">
<!-- Multi-layer dark overlay for readability -->
<div style="position:absolute;inset:0;
background: linear-gradient(
180deg,
rgba(3,3,5,0.50) 0%,
rgba(3,3,5,0.60) 35%,
rgba(3,3,5,0.82) 70%,
rgba(3,3,5,0.96) 100%
);"></div>
<!-- Subtle vignette edges -->
<div style="position:absolute;inset:0;
background: radial-gradient(ellipse at 50% 50%, transparent 40%, rgba(3,3,5,0.7) 100%);
pointer-events:none;"></div>
<!-- Top crimson accent line -->
<div style="position:absolute;top:0;left:0;right:0;height:2px;
background:linear-gradient(90deg,transparent,#7a1a22,#904858,#7a1a22,transparent);"></div>
<div class="start-content" style="position:relative;z-index:2;text-align:center;padding:0 40px;max-width:760px;">
<!-- Label above title -->
<div style="
font-family:'Cinzel',serif;
font-size:10px;
color:#904858;
letter-spacing:6px;
text-transform:uppercase;
margin-bottom:18px;
opacity:0.85;
">[ System Notification — Star Stream Access Granted ]</div>
<!-- Main Title -->
<div style="
font-family:'Cinzel Decorative',serif;
font-size:clamp(2.2em,5.5vw,4em);
font-weight:900;
color:#d8cbb0;
letter-spacing:0.18em;
text-transform:uppercase;
text-shadow:
0 0 60px rgba(144,72,88,0.35),
0 0 120px rgba(122,26,34,0.15),
0 3px 12px rgba(0,0,0,0.95);
margin-bottom:6px;
line-height:1.1;
">Omniscient Reader</div>
<!-- Subtitle -->
<div style="
font-family:'Cinzel',serif;
font-size:0.95em;
color:#904858;
letter-spacing:0.55em;
text-transform:uppercase;
opacity:0.75;
margin-bottom:28px;
">Scenario Simulator</div>
<!-- Divider -->
<div style="width:360px;margin:0 auto 28px;height:1px;
background:linear-gradient(90deg,transparent,#5a1828,#904858,#5a1828,transparent);"></div>
<!-- Tagline -->
<div style="
font-family:'IM Fell English',Georgia,serif;
font-size:1em;
font-style:italic;
color:#a09078;
line-height:2.2;
margin-bottom:36px;
text-shadow:0 2px 6px rgba(0,0,0,0.9);
">
You have read the novel 3,149 times.<br>
The Dokkaebi knows you know everything.<br>
<span style="color:#c06878;">Reality has been redesigned — specifically for you.</span>
</div>
<!-- Portraits row -->
<div style="display:flex;justify-content:center;gap:20px;margin-bottom:36px;">
<!-- Kim Dokja -->
<div style="text-align:center;">
<div style="
width:88px;height:114px;
border:1px solid #5a1828;
position:relative;overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
">
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
<img src="{_asset_url('dokja_portrait.png')}" alt="Kim Dokja"
style="width:100%;height:100%;object-fit:cover;object-position:top;
filter:contrast(1.15) brightness(0.8) saturate(0.8);">
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
</div>
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Incarnation</div>
</div>
<!-- Dokkaebi -->
<div style="text-align:center;">
<div style="
width:88px;height:114px;
border:1px solid #5a1828;
position:relative;overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
">
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
<img src="{_asset_url('dokkaebi_character.png')}" alt="Dokkaebi"
style="width:100%;height:100%;object-fit:cover;
filter:contrast(1.15) brightness(0.8) saturate(0.75);">
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
</div>
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Dokkaebi</div>
</div>
<!-- Yoo Sangah -->
<div style="text-align:center;">
<div style="
width:88px;height:114px;
border:1px solid #5a1828;
position:relative;overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
">
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
<img src="{_asset_url('yoo_sangah_portrait.png')}" alt="Jung Heewon"
style="width:100%;height:100%;object-fit:cover;object-position:top;
filter:contrast(1.15) brightness(0.8) saturate(0.75);">
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
</div>
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Jung Heewon</div>
</div>
<!-- Jung Heewon -->
<div style="text-align:center;">
<div style="
width:88px;height:114px;
border:1px solid #5a1828;
position:relative;overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
">
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
<img src="{_asset_url('jung_heewon_portrait.png')}" alt="Lee Jihye"
style="width:100%;height:100%;object-fit:cover;object-position:top;
filter:contrast(1.15) brightness(0.8) saturate(0.75);">
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
</div>
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Lee Jihye</div>
</div>
</div>
<!-- Divider -->
<div style="width:260px;margin:0 auto 24px;height:1px;
background:linear-gradient(90deg,transparent,#5a1828,#904858,#5a1828,transparent);"></div>
<!-- Attribution -->
<div style="font-family:'Cinzel',serif;color:#3a2832;font-size:9px;letter-spacing:3px;text-transform:uppercase;margin-bottom:28px;">
Powered by Llama 3.1 &nbsp;·&nbsp; Build Small Hackathon 2026
</div>
<!-- Spacer so background fills screen (real button is position:fixed) -->
<div style="height:120px;"></div>
</div>
</div>
"""
def render_scenario_header(state: dict) -> str:
"""Top bar — Elder Scrolls quest log style."""
scenario_num = state.get("scenario_num", 1)
scenario = SCENARIOS.get(scenario_num, SCENARIOS[1])
name = scenario["name"]
subtitle = scenario.get("subtitle", "")
location = scenario.get("location", "")
difficulty = scenario.get("difficulty", "?")
turn = state.get("turn", 1)
max_turns = scenario.get("turns", 8)
diff_colors = {"F": "#90c060", "E": "#904858", "D": "#9b6a30", "C": "#c97040",
"B": "#c04030", "A": "#8b1a1a", "S": "#f0c840", "SS": "#cc3333"}
diff_color = diff_colors.get(difficulty, "#904858")
return f"""
<div class="scenario-header-inner">
<div class="scenario-header-left">
<span style="font-family:'Cinzel',serif;color:#6a3040;font-size:11px;letter-spacing:1px;">Main Scenario &nbsp;{scenario_num}</span>
</div>
<div class="scenario-header-center" style="text-align:center;">
<div class="scenario-header-text" style="font-family:'Cinzel Decorative',serif;font-size:15px;font-weight:700;color:#f0c840;letter-spacing:4px;text-shadow:0 0 20px rgba(240,200,64,0.45);">âš” {name} âš”</div>
<div style="font-family:'Cinzel',serif;font-size:10px;color:#8a7050;letter-spacing:2px;margin-top:2px;">{subtitle} — {location}</div>
</div>
<div class="scenario-header-right" style="text-align:right;">
<span style="font-family:'Cinzel',serif;color:{diff_color};font-size:11px;letter-spacing:1px;">Rank {difficulty}</span>
<span style="font-family:'Cinzel',serif;color:#5a4a35;font-size:11px;margin-left:14px;letter-spacing:1px;">Turn {turn} / {max_turns}</span>
</div>
</div>
"""
def render_player_panel(state: dict) -> str:
"""Left panel — Elder Scrolls character sheet style."""
hp = state.get("hp", 100)
max_hp = state.get("max_hp", 100)
hp_pct = max(0, min(100, (hp / max_hp) * 100)) if max_hp > 0 else 0
coins = state.get("coins", 0)
titles = state.get("titles", [])
skills = state.get("skills", {})
sponsor = state.get("sponsor")
debuffs = state.get("active_debuffs", [])
phase = state.get("current_phase", "Exploration")
titles_html = "".join(
f'<span class="title-badge">{t}</span>' for t in titles
) if titles else f'<span style="color:#5a4a35;font-family:\'Cinzel\',serif;font-size:10px;font-style:italic;">No titles earned yet</span>'
if isinstance(skills, dict):
skills_parts = []
for name, d in skills.items():
cd = d.get("cooldown", 0)
opacity = "0.35" if cd > 0 else "1"
cd_txt = f" · CD:{cd}" if cd > 0 else ""
skills_parts.append(
f'<div class="skill-name" style="opacity:{opacity};margin-bottom:3px;">'
f"âš” {name}{cd_txt}"
f'</div>'
)
skills_html = "".join(skills_parts) or f'<span style="color:#5a4a35;font-size:10px;font-style:italic;">None</span>'
else:
skills_html = ""
debuffs_html = ""
if debuffs:
debuff_badges = "".join(
f'<div style="background:rgba(139,26,26,0.15);border:1px solid #5a0808;'
f'color:#cc3333;padding:3px 8px;margin:2px 0;'
f'font-family:\'Cinzel\',serif;font-size:9px;letter-spacing:1px;">âš  {d}</div>'
for d in debuffs
)
debuffs_html = f"""
<div>
<div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#8b1a1a;text-transform:uppercase;margin-bottom:4px;">Active Afflictions</div>
{debuff_badges}
</div>"""
phase_colors = {"Combat": "#cc3333", "Safe Zone": "#90c060", "Exploration": "#904858"}
phase_color = phase_colors.get(phase, "#904858")
phase_icons = {"Combat": "⚔", "Safe Zone": "✦", "Exploration": "◈"}
phase_icon = phase_icons.get(phase, "â—ˆ")
sponsor_html = f"""
<div style="margin-top:6px;font-family:'Cinzel',serif;color:#904858;font-size:10px;letter-spacing:1px;text-shadow:0 0 8px rgba(201,168,76,0.3);">
✦ Sponsor: {sponsor}
</div>
""" if sponsor else ""
return f"""
<div style="display:flex;flex-direction:column;gap:12px;width:100%;">
<!-- Portrait with ornate corners -->
<div class="player-portrait">
<img src="{_asset_url('dokja_portrait.png')}" alt="Kim Dokja">
</div>
<!-- Name + Phase -->
<div style="text-align:center;">
<div style="font-family:'Cinzel Decorative',serif;color:#f0c840;font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:3px;text-shadow:0 0 16px rgba(240,200,64,0.4);">
Kim Dokja
</div>
<div style="font-family:'Cinzel',serif;font-size:10px;color:#8a7050;letter-spacing:2px;margin-top:4px;">
Incarnation &nbsp;·&nbsp; <span style="color:{phase_color};">{phase_icon} {phase}</span>
</div>
</div>
<!-- Divider -->
<div style="height:1px;background:linear-gradient(90deg,transparent,#6a3040,transparent);"></div>
<!-- HP Bar -->
<div class="stat-bar stat-bar-hp">
<div class="stat-bar-label"><span>Vitality</span><span>{hp}/{max_hp}</span></div>
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:{hp_pct}%"></div></div>
</div>
<!-- Coins -->
<div style="font-family:'Cinzel',serif;color:#904858;font-size:13px;text-shadow:0 0 8px rgba(201,168,76,0.4);letter-spacing:1px;">
â—ˆ {coins:,} Coins
</div>
{debuffs_html}
<!-- Divider -->
<div style="height:1px;background:linear-gradient(90deg,transparent,#3a1828,transparent);"></div>
<!-- Titles -->
<div>
<div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#6a3040;text-transform:uppercase;margin-bottom:6px;">Titles Earned</div>
<div>{titles_html}</div>
</div>
<!-- Skills -->
<div>
<div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#6a3040;text-transform:uppercase;margin-bottom:6px;">Combat Skills</div>
<div style="font-family:'Cinzel',serif;font-size:11px;color:#b8a07a;">{skills_html}</div>
</div>
{sponsor_html}
</div>
"""
def render_meters(state: dict) -> str:
"""Render meters in Elder Scrolls style."""
meta = state.get("meta_exposure", 0)
stability = state.get("prob_stability", 100)
entertainment = state.get("entertainment", 0)
meta_class = ""
if meta >= 75:
meta_class = "meter-danger"
elif meta >= 50:
meta_class = "meter-pulse"
return f"""
<div class="meters-container">
<!-- Divider -->
<div style="height:1px;background:linear-gradient(90deg,transparent,#3a1828,transparent);margin-bottom:8px;"></div>
<!-- Meta Exposure -->
<div class="stat-bar stat-bar-meta {meta_class}">
<div class="stat-bar-label"><span>âš  Meta Exposure</span><span>{meta}%</span></div>
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:{meta}%"></div></div>
</div>
<!-- Probability Stability -->
<div class="stat-bar stat-bar-stability">
<div class="stat-bar-label"><span>â—ˆ Probability</span><span>{stability}%</span></div>
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:{stability}%"></div></div>
</div>
<!-- Entertainment -->
<div style="text-align:center;margin-top:8px;font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:2px;">
✦ Entertainment &nbsp;{entertainment}
</div>
</div>
"""
def render_narrative(narrative: str, dokkaebi_comment: str,
reality_subversion: str = None) -> str:
"""Main game area narrative — tome reading style."""
subversion_html = ""
if reality_subversion:
subversion_html = f"""
<div style="margin:12px 24px;padding:12px 16px;
background:rgba(90,42,138,0.12);border-left:2px solid #5a2a8a;
font-family:'Cinzel',serif;color:#9b59b6;font-size:11px;letter-spacing:1px;">
⚠ Probability Distortion — {reality_subversion}
</div>
"""
return f"""
<div class="narrative-container system-window"
style="overflow-y:auto;max-height:280px;min-height:100px;flex-shrink:0;"
>
<div class="narrative-text">{narrative}</div>
{subversion_html}
<div class="dokkaebi-text">{dokkaebi_comment}</div>
</div>
"""
def render_scene(state: dict) -> str:
"""Scene background image — cinematic dark vignette."""
scenario_num = state.get("scenario_num", 1)
scenario = SCENARIOS.get(scenario_num, SCENARIOS[1])
scene_img = scenario.get("scene_image", "seoul_subway.png")
return f"""
<div style="width:100%;height:160px;overflow:hidden;margin-bottom:0;
background:url('{_asset_url(scene_img)}') center/cover no-repeat;
border-bottom:1px solid rgba(106,48,64,0.4);
position:relative;flex-shrink:0;"
>
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.95) 0%,rgba(6,5,8,0.3) 50%,transparent 100%);"></div>
<div style="position:absolute;inset:0;background:linear-gradient(to right,rgba(6,5,8,0.4) 0%,transparent 30%,transparent 70%,rgba(6,5,8,0.4) 100%);"></div>
</div>
"""
def render_suggestions(suggestions: list) -> str:
"""Three action suggestions — Elder Scrolls decision scroll."""
if not suggestions or len(suggestions) < 3:
suggestions = ["Look around carefully", "Search for something useful", "Do something unexpected"]
buttons = ""
for i, sug in enumerate(suggestions):
roman = ["I", "II", "III", "IV", "V"][i] if i < 5 else str(i+1)
buttons += f"""
<button class="suggestion-btn" data-action="{sug}"
style="display:flex;align-items:flex-start;gap:10px;width:100%;text-align:left;margin:0 0 6px 0;padding:10px 14px;
background:rgba(18,12,20,0.7);border:1px solid #5a3040;border-left:2px solid #904050;color:#d0b0a0;cursor:pointer;transition:all 0.2s;
font-family:'IM Fell English',serif;font-style:italic;font-size:13px;line-height:1.5;"
onmouseover="this.style.borderColor='#c06070'; this.style.backgroundColor='rgba(40,18,28,0.8)'; this.style.color='#e0c0b0';"
onmouseout="this.style.borderColor='#5a3040'; this.style.backgroundColor='rgba(18,12,20,0.7)'; this.style.color='#d0b0a0';"
>
<span style="color:#904050;min-width:18px;font-size:10px;margin-top:1px;font-family:'Cinzel',serif;">{roman}.</span>
<span>{sug}</span>
</button>
"""
return f'''<div style="width:100%;">
<div class="suggestion-section-label">— Choose Your Action —</div>
<div style="padding:6px 14px 10px;display:flex;flex-direction:column;gap:4px;">{buttons}</div>
</div>'''
def render_star_stream(state: dict, new_reactions: list = None) -> str:
"""Right panel — Star Stream as an ancient chronicle scroll."""
history = state.get("star_stream_history", [])
display_reactions = history[-15:]
messages_html = ""
for r in display_reactions:
modifier = r.get("modifier", "Unknown Constellation")
reaction = r.get("reaction", "...")
coins = r.get("coins", 0)
mood = "neutral"
if any(w in modifier for w in ["Demonic", "Abyssal", "Dark", "Death"]): mood = "negative"
if any(w in modifier for w in ["Judge", "Salvation", "Light", "Star"]): mood = "positive"
coin_html = f'<div class="chat-donation" style="font-family:\'Cinzel\',serif;font-size:10px;letter-spacing:1px;">+ {coins} Coins</div>' if coins > 0 else ""
messages_html += f"""
<div class="chat-message" data-mood="{mood}">
<span class="constellation-name">{modifier}</span>
<span style="color:#8a7050;font-family:\'IM Fell English\',serif;font-style:italic;font-size:12px;line-height:1.5;">{reaction}</span>
{coin_html}
</div>
"""
if not messages_html:
messages_html = """
<div class="chat-message" data-mood="neutral" style="opacity:0.5;">
<span style="font-family:'IM Fell English',serif;font-style:italic;font-size:12px;color:#5a4a35;">The Constellations observe in silence...</span>
</div>
"""
return f"""
<div style="width:100%;display:flex;flex-direction:column;height:100%;">
<div style="font-family:'Cinzel Decorative',serif;color:#904858;font-size:11px;letter-spacing:3px;text-transform:uppercase;margin-bottom:10px;text-shadow:0 0 12px rgba(201,168,76,0.4);">
✦ Star Stream
</div>
<div style="height:1px;background:linear-gradient(90deg,#6a3040,transparent);margin-bottom:12px;"></div>
<div class="constellation-chat" id="star-stream-chat">
{messages_html}
</div>
</div>
"""
def render_overlay(overlay_type: str = "", data: dict = None) -> str:
"""Fullscreen overlays — Elder Scrolls stone tablet style."""
if not overlay_type:
return '<div class="fullscreen-overlay" style="display:none;"></div>'
if overlay_type == "scenario_announcement":
scenario = data or {}
announcement = get_scenario_announcement_text(scenario)
intro = scenario.get("intro_narrative", "")
return f"""
<div class="fullscreen-overlay scenario-flash" id="game-overlay" data-auto-dismiss="6000">
<div class="scenario-announcement fade-in-up" style="max-width:640px;">
<div style="font-family:'Cinzel',serif;color:#6a3040;font-size:11px;letter-spacing:3px;margin-bottom:16px;">MAIN SCENARIO #{scenario.get('number', '?')}</div>
<div style="font-family:'Cinzel Decorative',serif;color:#f0c840;font-size:1.8em;font-weight:700;letter-spacing:4px;text-shadow:0 0 30px rgba(240,200,64,0.4);margin-bottom:20px;">SCENARIO BEGINS</div>
<div style="height:1px;background:linear-gradient(90deg,transparent,#904858,transparent);margin-bottom:20px;"></div>
<pre style="font-family:'IM Fell English',serif;color:#d4c090;text-align:left;white-space:pre-wrap;max-width:500px;margin:0 auto 20px;font-style:italic;font-size:13px;line-height:1.8;">{announcement}</pre>
<div style="font-family:'IM Fell English',serif;font-style:italic;color:#b8a07a;font-size:14px;line-height:1.8;max-width:560px;margin:0 auto 24px;">{intro}</div>
<div style="font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:3px;">✦ Click to Continue ✦</div>
</div>
</div>
"""
elif overlay_type == "ranking":
rank = data.get("rank", "C") if data else "C"
rank_colors = {"S": "#f0c840", "A": "#904858", "B": "#7a8fbb", "C": "#8a7050"}
color = rank_colors.get(rank, "#8a7050")
bg_img = _asset_url("rank_s_bg.png") if rank == "S" else ""
bg_style = f"background:url('{bg_img}') center/cover no-repeat,#060402;" if bg_img else "background:#060402;"
return f"""
<div class="fullscreen-overlay" id="game-overlay" data-auto-dismiss="4000" style="{bg_style}">
<div class="ranking-display rank-{rank.lower()}" style="text-align:center;">
<div style="font-family:'Cinzel',serif;color:#6a3040;font-size:12px;letter-spacing:4px;margin-bottom:16px;">SCENARIO COMPLETE</div>
<div style="height:1px;width:200px;margin:0 auto 20px;background:linear-gradient(90deg,transparent,#904858,transparent);"></div>
<div style="font-size:7em;font-family:'Cinzel Decorative',serif;font-weight:900;color:{color};text-shadow:0 0 60px {color},0 0 120px {color}40;">{rank}</div>
<div style="font-family:'Cinzel',serif;font-size:14px;letter-spacing:4px;color:#904858;margin-top:16px;">RANK {rank} — BATTLE CONCLUDED</div>
</div>
</div>
"""
elif overlay_type == "reader_noticed":
return f"""
<div class="fullscreen-overlay reader-noticed glitch-effect static-noise" id="game-overlay">
<div style="text-align:center;max-width:600px;padding:40px;border:1px solid #8b1a1a;background:linear-gradient(160deg,#1a0808,#0e0404);position:relative;">
<div style="position:absolute;top:-1px;left:-1px;width:24px;height:24px;border-top:2px solid #cc3333;border-left:2px solid #cc3333;"></div>
<div style="position:absolute;bottom:-1px;right:-1px;width:24px;height:24px;border-bottom:2px solid #cc3333;border-right:2px solid #cc3333;"></div>
<div class="glitch-text" style="font-family:'Cinzel',serif;font-size:11px;letter-spacing:3px;color:#8b1a1a;margin-bottom:20px;">PROBABILITY DISTORTION DETECTED</div>
<div class="glitch-text" style="font-family:'Cinzel Decorative',serif;font-size:2.4em;font-weight:900;color:#e8d5a3;margin:16px 0;text-shadow:0 0 30px rgba(139,26,26,0.6);">THE READER<br>HAS BEEN NOTICED</div>
<div style="height:1px;background:linear-gradient(90deg,transparent,#8b1a1a,transparent);margin:20px 0;"></div>
<div style="font-family:'Cinzel',serif;font-size:12px;color:#cc3333;letter-spacing:3px;">⚠ Hidden Scenario Unlocked — Difficulty: Impossible</div>
</div>
</div>
"""
elif overlay_type == "game_over":
reason = data.get("reason", "You have died.") if data else "You have died."
return f"""
<div class="fullscreen-overlay" id="game-overlay">
<div style="text-align:center;max-width:520px;padding:48px;border:1px solid #5a0808;background:linear-gradient(160deg,#150808,#0a0404);position:relative;">
<div style="position:absolute;top:-1px;left:-1px;width:22px;height:22px;border-top:2px solid #8b1a1a;border-left:2px solid #8b1a1a;"></div>
<div style="position:absolute;bottom:-1px;right:-1px;width:22px;height:22px;border-bottom:2px solid #8b1a1a;border-right:2px solid #8b1a1a;"></div>
<div style="font-family:'Cinzel Decorative',serif;font-size:2em;color:#8b1a1a;font-weight:900;text-shadow:0 0 30px rgba(139,26,26,0.5);margin-bottom:20px;">INCARNATION ELIMINATED</div>
<div style="height:1px;background:linear-gradient(90deg,transparent,#5a0808,transparent);margin-bottom:20px;"></div>
<div style="font-family:'IM Fell English',serif;font-style:italic;color:#b8a07a;font-size:15px;line-height:1.8;margin-bottom:20px;">{reason}</div>
<div style="font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:2px;">The Constellations have lost interest.</div>
</div>
</div>
"""
return '<div class="fullscreen-overlay" style="display:none;"></div>'
def render_state_data(state: dict) -> str:
"""Hidden element that passes state JSON to JavaScript for effects."""
return f'<div id="game-state-data" style="display:none;" data-state=\'{json.dumps(state)}\'></div>'
def buy_item_action(item_selection: str, state: dict):
if not item_selection or not state:
return state, render_player_panel(state)
item_map = {
"Minor HP Potion (15c)": ("Minor HP Potion", 15),
"HP Potion (30c)": ("HP Potion", 30),
"Major HP Potion (60c)": ("Major HP Potion", 60),
"Star Stream Flare (100c)": ("Star Stream Flare", 100),
"Unbroken Faith (150c)": ("Unbroken Faith", 150),
"Probability Nullifier (200c)": ("Probability Nullifier", 200),
"Random Attribute Box (250c)": ("Random Attribute Box", 250)
}
if item_selection not in item_map:
return state, render_player_panel(state)
item_name, cost = item_map[item_selection]
gs = GameState.from_dict(state)
if gs.buy_item(item_name, cost):
return gs.to_dict(), render_player_panel(gs.to_dict())
return state, render_player_panel(state)
def use_skill_action(skill_selection: str, current_input: str, state: dict):
if not skill_selection or not state:
return state, current_input, gr.update()
gs = GameState.from_dict(state)
skill_data = gs.state["skills"].get(skill_selection)
if skill_data and skill_data.get("cooldown", 0) <= 0:
skill_data["cooldown"] = skill_data.get("max_cooldown", 3)
new_text = f"[Activating Skill: {skill_selection}] - " + (current_input or "")
available_skills = [s for s, d in gs.state["skills"].items() if d.get("cooldown", 0) <= 0]
return gs.to_dict(), new_text, gr.update(choices=available_skills)
return state, current_input, gr.update()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# GAME LOGIC — Event handlers
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def start_game():
"""Initialize game state and trigger Scenario 1 intro."""
gs = GameState()
state = gs.to_dict()
scenario = SCENARIOS[1]
# Generate intro via AI (using Groq for speed)
system_prompt = build_intro_prompt(state, scenario)
ai_response = call_dokkaebi(system_prompt, "The scenario begins. Introduce yourself and set the scene.")
# Build initial UI
narrative = ai_response.get("narrative", scenario["intro_narrative"])
dokkaebi_comment = ai_response.get("dokkaebi_comment", "Incarnation. Your scenario begins now.")
suggestions = ai_response.get("suggestions", [
"Look around the subway car",
"Try to calm the panicking passengers",
"Search for an exit immediately"
])
constellation_reactions = ai_response.get("constellation_reactions", [])
# Add initial reactions to star stream
if constellation_reactions:
for r in constellation_reactions[-5:]:
state["star_stream_history"].append(r)
available_skills = [s for s, d in state.get("skills", {}).items() if d.get("cooldown", 0) <= 0]
return (
state, # game state
gr.update(visible=False), # hide start screen
gr.update(visible=False), # hide start button
gr.update(visible=True), # show game header
gr.update(visible=True), # show game main
gr.update(visible=True), # show input bar
render_scenario_header(state), # scenario header
render_player_panel(state), # player panel
render_meters(state), # meters
render_scene(state), # scene image
render_narrative(narrative, dokkaebi_comment), # narrative
render_suggestions(suggestions), # suggestions
render_star_stream(state, constellation_reactions), # star stream
render_overlay("scenario_announcement", scenario), # show scenario announcement
render_state_data(state), # state for JS
"", # clear input
gr.update(choices=available_skills), # update active skills dropdown
)
def take_action(stance: str, player_action: str, state: dict):
"""Process a player action and update the game."""
if not state:
# Game not started yet — return no-ops for all 11 outputs
return [state] + [gr.update()] * 9 + ["", gr.update()]
if not player_action or not player_action.strip():
# Empty input — do nothing
return (
state,
render_scenario_header(state),
render_player_panel(state),
render_meters(state),
render_scene(state),
gr.update(), # keep current narrative
gr.update(), # keep current suggestions
render_star_stream(state),
render_overlay(), # no overlay
render_state_data(state),
"",
gr.update()
)
player_action = player_action.strip()
if stance and stance != "Neutral":
action_for_prompt = f"[{stance} Stance] {player_action}"
else:
action_for_prompt = player_action
# Get current scenario
scenario_num = state.get("scenario_num", 1)
scenario = SCENARIOS.get(scenario_num, SCENARIOS[1])
# Build prompt and call AI
system_prompt = build_prompt(state, action_for_prompt, scenario)
# Payload note: {"max_tokens": 500} for speed
ai_response = call_dokkaebi(system_prompt, action_for_prompt)
# Process the turn (update game state)
prev_hp = state.get("hp", 100)
prev_meta = state.get("meta_exposure", 0)
updated_state = process_turn(state, action_for_prompt, ai_response, stance)
# Determine overlay
overlay_html = render_overlay() # default: no overlay
# Check for Reader Noticed
if updated_state.get("meta_exposure", 0) >= 100 and prev_meta < 100:
overlay_html = render_overlay("reader_noticed")
# Check for scenario completion
elif ai_response.get("scenario_complete"):
rank = ai_response.get("scenario_rank", "C")
overlay_html = render_overlay("ranking", {"rank": rank})
# After a moment, show next scenario announcement
next_num = updated_state.get("scenario_num", scenario_num)
if next_num != scenario_num and next_num in SCENARIOS:
# The ranking overlay will auto-dismiss, then we need the next scenario
pass # Handled on next turn start
# Check for game over
elif updated_state.get("game_over"):
overlay_html = render_overlay("game_over", {
"reason": updated_state.get("game_over_reason", "You have died.")
})
# Check for big moment
elif ai_response.get("big_moment"):
# For now, use reader_noticed image as a "big moment" visual
pass
# Extract display data from AI response
narrative = ai_response.get("narrative", "Something happens...")
dokkaebi_comment = ai_response.get("dokkaebi_comment", "...")
reality_subversion = ai_response.get("reality_subversion")
suggestions = ai_response.get("suggestions", [])
constellation_reactions = ai_response.get("constellation_reactions", [])
# Add damage flash trigger data
took_damage = updated_state.get("hp", 100) < prev_hp
meta_increased = updated_state.get("meta_exposure", 0) > prev_meta
# Build JS trigger data
js_triggers = {
"damage": took_damage,
"meta_up": meta_increased,
"coins_gained": ai_response.get("entertainment_score", 0) > 5,
"big_moment": ai_response.get("big_moment", False),
}
trigger_data = json.dumps(js_triggers)
# Inject JS triggers into state data element
state_with_triggers = (
f'<div id="game-state-data" style="display:none;" '
f'data-state=\'{json.dumps(updated_state)}\' '
f'data-triggers=\'{trigger_data}\'></div>'
)
available_skills = [s for s, d in updated_state.get("skills", {}).items() if d.get("cooldown", 0) <= 0]
return (
updated_state, # updated state
render_scenario_header(updated_state), # scenario header
render_player_panel(updated_state), # player panel
render_meters(updated_state), # meters
render_scene(updated_state), # scene
render_narrative(narrative, dokkaebi_comment, reality_subversion), # narrative
render_suggestions(suggestions), # suggestions
render_star_stream(updated_state, constellation_reactions), # star stream
overlay_html, # overlay
state_with_triggers, # state data for JS
"", # clear input
gr.update(choices=available_skills), # update active skills dropdown
)
# ——————————————————————————————————————————————————————————————————————————————
# GRADIO APP — The UI
# ——————————————————————————————————————————————————————————————————————————————
# Combine all CSS (no duplicates)
_ALL_CSS = _STYLE_CSS + "\n" + _ANIMATIONS_CSS
# Head injection: only the game JS script (CSS already in _ALL_CSS)
_HEAD_HTML = f"""
<script>{_GAME_JS}</script>
"""
# Cinematic overlay HTML — injected as gr.HTML in the body so it actually renders
_CINEMATIC_HTML = f"""
<div id="orv-cinematic-overlay">
<!-- Video layer -->
<video id="orv-intro-video" muted playsinline preload="auto"
src="{_asset_url('intro_video.mp4')}">
</video>
<!-- Dark vignette over video -->
<div id="orv-cinematic-vignette"></div>
<!-- Skip button (top-right) -->
<button id="orv-skip-btn" onclick="window._skipCinematic()">
SKIP <span style="opacity:0.5;margin-left:4px;">&#9654;&#9654;</span>
</button>
<!-- Progress bar (bottom) -->
<div id="orv-cinematic-progress">
<div id="orv-cinematic-progress-bar"></div>
</div>
<!-- Loading text (bottom-center) -->
<div id="orv-cinematic-text">
Entering the Star Stream
</div>
</div>
"""
# This runs AFTER Gradio mounts all Svelte components — guaranteed timing
_INIT_JS = """
function() {
var _gameReady = false;
var _cinematicDone = false;
// ── STYLE THE START BUTTON WITH CINZEL FONT ──
function styleStartButton() {
var btn = document.querySelector('#gradio-start-btn button');
if (!btn) { setTimeout(styleStartButton, 100); return; }
var applyFont = function(el) {
el.style.setProperty('font-family', "'Cinzel', serif", 'important');
el.style.setProperty('font-size', '14px', 'important');
el.style.setProperty('font-weight', '700', 'important');
el.style.setProperty('letter-spacing', '5px', 'important');
el.style.setProperty('text-transform', 'uppercase', 'important');
};
applyFont(btn);
btn.querySelectorAll('*').forEach(applyFont);
btn.style.setProperty('color', '#c06878', 'important');
btn.style.setProperty('background', 'linear-gradient(160deg,#1a0c12 0%,#2a101a 50%,#150a10 100%)', 'important');
btn.style.setProperty('border', '1px solid #904858', 'important');
btn.style.setProperty('border-radius', '0', 'important');
btn.style.setProperty('padding', '18px 64px', 'important');
btn.style.setProperty('box-shadow', '0 0 30px rgba(144,72,88,0.3)', 'important');
btn.addEventListener('mouseenter', function() {
this.style.setProperty('letter-spacing', '7px', 'important');
this.style.setProperty('color', '#e8c8d0', 'important');
});
btn.addEventListener('mouseleave', function() {
this.style.setProperty('letter-spacing', '5px', 'important');
this.style.setProperty('color', '#c06878', 'important');
});
// ── CLICK: START CINEMATIC ──
btn.addEventListener('click', function() {
startCinematic();
});
}
styleStartButton();
// ── CINEMATIC FLOW ──
function startCinematic() {
var overlay = document.getElementById('orv-cinematic-overlay');
var video = document.getElementById('orv-intro-video');
var progressBar = document.getElementById('orv-cinematic-progress-bar');
var skipBtn = document.getElementById('orv-skip-btn');
if (!overlay) return;
// Show the overlay
overlay.classList.add('active');
// Try to play video
if (video && video.src && !video.error) {
video.currentTime = 0;
var playPromise = video.play();
if (playPromise) {
playPromise.catch(function() {
// Video failed — just show the overlay as loading screen
});
}
// Update progress bar
video.addEventListener('timeupdate', function() {
if (video.duration) {
var pct = (video.currentTime / video.duration) * 100;
if (progressBar) progressBar.style.width = pct + '%';
}
});
// Video ended naturally
video.addEventListener('ended', function() {
if (progressBar) progressBar.style.width = '100%';
finishCinematic();
});
// Show skip button after 2 seconds
setTimeout(function() {
if (skipBtn) {
skipBtn.classList.add('visible');
}
}, 2000);
} else {
// No video available — just show loading text, auto-dismiss when game ready
if (skipBtn) skipBtn.style.display = 'none';
}
}
// ── SKIP / FINISH ──
window._skipCinematic = function() {
var video = document.getElementById('orv-intro-video');
if (video) { video.pause(); }
finishCinematic();
};
function finishCinematic() {
if (_cinematicDone) return;
_cinematicDone = true;
var overlay = document.getElementById('orv-cinematic-overlay');
if (!overlay) return;
if (_gameReady) {
// Game already loaded — fade out immediately
fadeOutOverlay(overlay);
} else {
// Game still loading — show "loading" state, wait
var text = document.getElementById('orv-cinematic-text');
if (text) text.textContent = 'Loading Scenario...';
var skipBtn = document.getElementById('orv-skip-btn');
if (skipBtn) skipBtn.classList.remove('visible');
// Poll until game is ready
var poll = setInterval(function() {
if (_gameReady) {
clearInterval(poll);
fadeOutOverlay(overlay);
}
}, 200);
}
}
function fadeOutOverlay(overlay) {
overlay.style.transition = 'opacity 1s ease';
overlay.style.opacity = '0';
setTimeout(function() {
overlay.classList.remove('active');
overlay.style.opacity = '';
overlay.style.transition = '';
}, 1000);
}
// ── DETECT WHEN GAME IS LOADED ──
var observer = new MutationObserver(function() {
var header = document.querySelector('.game-header');
if (header && getComputedStyle(header).display !== 'none') {
_gameReady = true;
// If cinematic already done (skipped/ended), fade out now
if (_cinematicDone) {
var overlay = document.getElementById('orv-cinematic-overlay');
if (overlay && overlay.classList.contains('active')) {
fadeOutOverlay(overlay);
}
}
}
});
observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style', 'class'] });
}
"""
with gr.Blocks(
title="Omniscient Reader — Scenario Simulator",
elem_classes="game-container",
) as demo:
# ----- Session State -----
game_state = gr.State(value=None)
# ===== START SCREEN =====
start_screen_html = gr.HTML(
render_start_screen(),
elem_classes="start-screen-container",
)
start_btn = gr.Button(
"⚡ Begin the Scenario",
elem_id="gradio-start-btn",
elem_classes="start-btn-visible",
variant="primary",
)
# ===== CINEMATIC OVERLAY — must be a gr.HTML so it renders in the body =====
cinematic_overlay = gr.HTML(_CINEMATIC_HTML, elem_id="cinematic-overlay-wrapper")
# ===== GAME HEADER (hidden initially) =====
with gr.Row(elem_classes="game-header", visible=False) as game_header:
scenario_header = gr.HTML(render_scenario_header({"scenario_num": 1, "turn": 1}))
# ===== MAIN GAME LAYOUT (hidden initially) =====
with gr.Row(elem_classes="game-main", visible=False) as game_main:
# --- LEFT: Player Panel ---
with gr.Column(scale=1, min_width=260, elem_classes="player-panel"):
player_panel = gr.HTML(render_player_panel(GameState().to_dict()))
meters_panel = gr.HTML(render_meters(GameState().to_dict()))
with gr.Accordion("Dokkaebi Bag", open=False, elem_classes=["system-window", "shop-accordion"]):
shop_item = gr.Dropdown(
choices=[
"Minor HP Potion (15c)",
"HP Potion (30c)",
"Major HP Potion (60c)",
"Star Stream Flare (100c)",
"Unbroken Faith (150c)",
"Probability Nullifier (200c)",
"Random Attribute Box (250c)"
],
label="Select Item",
show_label=False
)
shop_buy_btn = gr.Button("💰 Buy Item", elem_classes="suggestion-btn")
# --- CENTER: Game Area ---
with gr.Column(scale=3, min_width=400, elem_classes="game-area"):
scene_display = gr.HTML('<div class="scene-placeholder"></div>')
narrative_display = gr.HTML(
'<div class="system-window"><div class="narrative-text">'
'Waiting for scenario to begin...</div></div>'
)
suggestions_display = gr.HTML(render_suggestions([]))
# --- RIGHT: Constellation Sidebar ---
with gr.Column(scale=1, min_width=280, elem_classes="constellation-panel"):
star_stream_display = gr.HTML(
render_star_stream(GameState().to_dict())
)
# ===== BOTTOM INPUT BAR (hidden initially) =====
with gr.Row(elem_classes="input-bar", visible=False) as input_bar:
with gr.Column(scale=1, min_width=150):
active_skills_dd = gr.Dropdown(choices=[], label="", show_label=False, elem_classes="game-input", container=False)
skill_btn = gr.Button("Use Skill", elem_classes="suggestion-btn")
with gr.Column(scale=4):
stance_radio = gr.Radio(["Neutral", "Aggressive", "Deceptive", "Empathetic", "Observant"], label="", show_label=False, value="Neutral", container=False)
player_input = gr.Textbox(
placeholder="What do you do, Incarnation?",
label="",
elem_classes="game-input",
max_lines=2,
)
submit_btn = gr.Button(
"âš¡ Act",
elem_classes="act-button",
variant="primary",
scale=1,
)
# ===== FULLSCREEN OVERLAY =====
overlay_display = gr.HTML(
render_overlay(),
elem_classes="overlay-container",
)
# ===== HIDDEN STATE DATA (for JS) =====
state_data = gr.HTML(
'<div id="game-state-data" style="display:none;"></div>',
visible=False,
)
# ----- Event Wiring -----
# Start button → initialize game
start_btn.click(
fn=start_game,
inputs=[],
outputs=[
game_state,
start_screen_html,
start_btn,
game_header,
game_main,
input_bar,
scenario_header,
player_panel,
meters_panel,
scene_display,
narrative_display,
suggestions_display,
star_stream_display,
overlay_display,
state_data,
player_input,
active_skills_dd,
],
)
# Submit button → process action
submit_btn.click(
fn=take_action,
inputs=[stance_radio, player_input, game_state],
outputs=[
game_state,
scenario_header,
player_panel,
meters_panel,
scene_display,
narrative_display,
suggestions_display,
star_stream_display,
overlay_display,
state_data,
player_input,
active_skills_dd,
],
)
# Enter key also submits
player_input.submit(
fn=take_action,
inputs=[stance_radio, player_input, game_state],
outputs=[
game_state,
scenario_header,
player_panel,
meters_panel,
scene_display,
narrative_display,
suggestions_display,
star_stream_display,
overlay_display,
state_data,
player_input,
active_skills_dd,
],
)
# Buy Item logic
shop_buy_btn.click(
fn=buy_item_action,
inputs=[shop_item, game_state],
outputs=[game_state, player_panel]
)
# Use Skill logic
skill_btn.click(
fn=use_skill_action,
inputs=[active_skills_dd, player_input, game_state],
outputs=[game_state, player_input, active_skills_dd]
)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# INJECT CSS & JS — Works on both local dev and HF Spaces
# In Gradio 6, css and head are set on the demo object or passed to launch().
# Setting them here ensures HF Spaces (which calls launch() internally) picks them up.
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
demo.css = _ALL_CSS
demo.head = _HEAD_HTML
if __name__ == "__main__":
import time
# allowed_paths: include both forward-slash and native paths
# Gradio 6 resolves to forward slashes internally on some routes
_abs_assets = os.path.abspath(_ASSET_DIR)
_fwd_assets = _abs_assets.replace("\\", "/")
demo.launch(
server_name="0.0.0.0",
server_port=7860,
allowed_paths=[_abs_assets, _fwd_assets, os.path.dirname(_abs_assets)],
show_error=True,
prevent_thread_lock=True,
css=_ALL_CSS,
head=_HEAD_HTML,
js=_INIT_JS,
)
print("ORV Scenario Simulator running at http://localhost:7860")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass