""" 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=. 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"""
[ System Notification — Star Stream Access Granted ]
Omniscient Reader
Scenario Simulator
You have read the novel 3,149 times.
The Dokkaebi knows you know everything.
Reality has been redesigned — specifically for you.
Kim Dokja
Incarnation
Dokkaebi
Dokkaebi
Jung Heewon
Jung Heewon
Lee Jihye
Lee Jihye
Powered by Llama 3.1  ·  Build Small Hackathon 2026
""" 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"""
Main Scenario  {scenario_num}
âš” {name} âš”
{subtitle} — {location}
Rank {difficulty} Turn {turn} / {max_turns}
""" 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'{t}' for t in titles ) if titles else f'No titles earned yet' 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'
' f"âš” {name}{cd_txt}" f'
' ) skills_html = "".join(skills_parts) or f'None' else: skills_html = "" debuffs_html = "" if debuffs: debuff_badges = "".join( f'
âš  {d}
' for d in debuffs ) debuffs_html = f"""
Active Afflictions
{debuff_badges}
""" 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"""
✦ Sponsor: {sponsor}
""" if sponsor else "" return f"""
Kim Dokja
Kim Dokja
Incarnation  Â·  {phase_icon} {phase}
Vitality{hp}/{max_hp}
â—ˆ {coins:,} Coins
{debuffs_html}
Titles Earned
{titles_html}
Combat Skills
{skills_html}
{sponsor_html}
""" 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"""
âš  Meta Exposure{meta}%
â—ˆ Probability{stability}%
✦ Entertainment  {entertainment}
""" 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"""
⚠ Probability Distortion — {reality_subversion}
""" return f"""
{narrative}
{subversion_html}
{dokkaebi_comment}
""" 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"""
""" 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""" """ return f'''
{buttons}
''' 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'
+ {coins} Coins
' if coins > 0 else "" messages_html += f"""
{modifier} {reaction} {coin_html}
""" if not messages_html: messages_html = """
The Constellations observe in silence...
""" return f"""
✦ Star Stream
{messages_html}
""" def render_overlay(overlay_type: str = "", data: dict = None) -> str: """Fullscreen overlays — Elder Scrolls stone tablet style.""" if not overlay_type: return '' if overlay_type == "scenario_announcement": scenario = data or {} announcement = get_scenario_announcement_text(scenario) intro = scenario.get("intro_narrative", "") return f"""
MAIN SCENARIO #{scenario.get('number', '?')}
SCENARIO BEGINS
{announcement}
{intro}
✦ Click to Continue ✦
""" 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"""
SCENARIO COMPLETE
{rank}
RANK {rank} — BATTLE CONCLUDED
""" elif overlay_type == "reader_noticed": return f"""
PROBABILITY DISTORTION DETECTED
THE READER
HAS BEEN NOTICED
⚠ Hidden Scenario Unlocked — Difficulty: Impossible
""" elif overlay_type == "game_over": reason = data.get("reason", "You have died.") if data else "You have died." return f"""
INCARNATION ELIMINATED
{reason}
The Constellations have lost interest.
""" return '' def render_state_data(state: dict) -> str: """Hidden element that passes state JSON to JavaScript for effects.""" return f'' 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'' ) 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""" """ # Cinematic overlay HTML — injected as gr.HTML in the body so it actually renders _CINEMATIC_HTML = f"""
Entering the Star Stream
""" # 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('
') narrative_display = gr.HTML( '
' 'Waiting for scenario to begin...
' ) 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( '', 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