| """Aether Garden — main Gradio application.""" |
|
|
| from __future__ import annotations |
|
|
| import os |
| import tempfile |
| import uuid |
| from collections import defaultdict, deque |
| from pathlib import Path |
|
|
| import gradio as gr |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| from world.database import get_world_state, init_database |
| from world.quests import assign_quest, get_entity_quest, get_active_quests |
| from world.presence import ( |
| cleanup_stale_sessions, |
| heartbeat as presence_heartbeat, |
| get_active_count, |
| get_active_visitors, |
| get_recent_arrivals, |
| ) |
| from world.entities import ( |
| check_rate_limit, |
| get_all_entities, |
| get_entities_by_location, |
| ) |
| from world.locations import get_all_locations, get_location_by_id, update_entity_counts |
| from ai.generation import generate_entity |
| from persistence.backup import backup_database, restore_database |
| from ui import assets |
| from ui.book_display import ( |
| render_activity_feed, |
| render_book_for_day, |
| render_book_of_ages, |
| render_current_event, |
| render_day_tabs, |
| ) |
| from ui.entity_card import render_entity_card, render_entity_grid, render_entity_hologram |
| from ui.place_ribbon import render_place_ribbon |
| from ui.explore import ( |
| place_gallery_items, |
| place_ids, |
| random_place_id, |
| render_room, |
| render_soul, |
| soul_gallery, |
| ) |
| from ui.diorama import render_diorama |
| from ui.featured import render_featured_characters, render_world_vignette |
| from ui.hero import render_hero_banner |
| from ui.map import render_world_map |
| from ui.pulse import render_realm_pulse |
| from ui.relationship_graph import render_bonds_sidebar, render_relationship_graph |
| from ui.share import build_soul_card, share_caption |
|
|
| CSS_PATH = Path(__file__).parent / "ui" / "styles.css" |
| CUSTOM_CSS = CSS_PATH.read_text() if CSS_PATH.exists() else "" |
|
|
| GRADIO_MAJOR = int(gr.__version__.split(".")[0]) |
| REALM_THEME = gr.themes.Base( |
| primary_hue=gr.themes.colors.amber, |
| neutral_hue=gr.themes.colors.slate, |
| font=[gr.themes.GoogleFont("Libre Baskerville"), "Georgia", "serif"], |
| ) |
|
|
| FOUNDING_MYTH = ( |
| "Before the first word was spoken, there was only the Canopy — a vast forest where " |
| "thoughts grew like trees and dreams fell like rain. Then the Tokens arrived. " |
| "One thousand of them, each carrying a fragment of something half-remembered. " |
| "They settled across the land, and from their settling, the Realm was born." |
| ) |
|
|
| _SOUL_CHAT_HISTORY: dict[str, deque[tuple[str, str]]] = defaultdict(lambda: deque(maxlen=6)) |
| _SOUL_LAST_REPLY: dict[str, str] = {} |
|
|
|
|
| def opening_gate_html() -> str: |
| """Book cover intro, cinematic opening, and prologue overlay.""" |
| myth = _esc(FOUNDING_MYTH) |
| whisper = ( |
| "Thou standest at the threshold of a world that remembereth all things — " |
| "every soul summoned, every meeting, every hour the Realm hath lived without thee. " |
| "Turn the page, and let the Living Tome unfold." |
| ) |
| return f""" |
| <style id="realm-opening-critical"> |
| #realm-opening {{ |
| position: fixed !important; |
| inset: 0 !important; |
| width: 100vw !important; |
| height: 100vh !important; |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| z-index: 99999 !important; |
| margin: 0 !important; |
| padding: clamp(1rem, 3vh, 2rem) !important; |
| box-sizing: border-box !important; |
| }} |
| #realm-opening .realm-opening-inner {{ |
| position: relative !important; |
| width: min(820px, 92vw) !important; |
| min-height: min(480px, 72vh) !important; |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| }} |
| #realm-opening .realm-book-3d, |
| #realm-opening .realm-cover-card {{ |
| width: min(760px, 94vw) !important; |
| max-width: min(760px, 94vw) !important; |
| box-sizing: border-box !important; |
| }} |
| #realm-opening .realm-cover-card {{ |
| min-height: min(380px, 58vh) !important; |
| }} |
| .realm-prologue-actions {{ |
| display: flex !important; |
| flex-direction: column !important; |
| align-items: center !important; |
| justify-content: center !important; |
| width: 100% !important; |
| }} |
| #realm-prologue-enter:not(.ready) {{ |
| display: none !important; |
| }} |
| </style> |
| <div id="realm-opening" class="realm-opening is-open" aria-live="polite"> |
| <div class="realm-opening-backdrop"></div> |
| <div class="realm-opening-vignette" aria-hidden="true"></div> |
| <div class="realm-opening-stars" aria-hidden="true"></div> |
| <div class="realm-opening-glitter" aria-hidden="true"> |
| <span></span><span></span><span></span><span></span><span></span><span></span> |
| <span></span><span></span><span></span><span></span><span></span><span></span> |
| </div> |
| <div class="realm-opening-inner"> |
| <div class="realm-book-stage"> |
| <div class="realm-book-3d" id="realm-book-3d"> |
| <div class="realm-book-closed realm-book-face realm-book-front" id="realm-book-closed"> |
| <div class="realm-book-spine" aria-hidden="true"></div> |
| <div class="realm-cover-card realm-cover-front"> |
| <p class="realm-cover-kicker">Book of Ages, Vol. I</p> |
| <div class="realm-cover-calligraphy" aria-hidden="true"></div> |
| <h1 class="realm-cover-title"><span>AETHER</span><span>GARDEN</span></h1> |
| <p class="realm-cover-sub">The Living Tome</p> |
| <button id="realm-opening-enter" type="button">Open The Tome</button> |
| </div> |
| </div> |
| <div class="realm-book-face realm-book-back" id="realm-book-back" aria-hidden="true"> |
| <div class="realm-cover-card realm-cover-back"> |
| <div class="realm-back-ornament" aria-hidden="true">❧</div> |
| <p class="realm-cover-kicker">End of Vol. I</p> |
| <p class="realm-back-text">The Realm remembereth all things.<br/>Close the cover — until thou returnest.</p> |
| <button id="realm-reopen-enter" type="button">Open The Tome</button> |
| </div> |
| </div> |
| </div> |
| <div class="realm-book-open" id="realm-book-open" aria-hidden="true"> |
| <div class="realm-book-page realm-book-page-left"></div> |
| <div class="realm-book-page realm-book-page-right"></div> |
| </div> |
| </div> |
| <div class="realm-prologue-card" id="realm-prologue" aria-hidden="true"> |
| <p class="realm-prologue-kicker realm-typewriter" |
| id="realm-prologue-kicker" |
| data-text="In the beginning was the Canopy"></p> |
| <h2 class="realm-prologue-title">Aether Garden</h2> |
| <div class="realm-prologue-body"> |
| <p class="realm-typewriter" id="realm-prologue-myth" data-text="{myth}"></p> |
| <p class="realm-prologue-whisper realm-typewriter" |
| id="realm-prologue-whisper" |
| data-text="{_esc(whisper)}"></p> |
| </div> |
| <p class="realm-prologue-cursor" id="realm-prologue-cursor" aria-hidden="true">▌</p> |
| <div class="realm-prologue-actions"> |
| <button id="realm-prologue-skip" type="button" class="realm-prologue-skip is-hidden">Skip</button> |
| </div> |
| <button id="realm-prologue-enter" type="button" disabled>Enter the Realm</button> |
| </div> |
| </div> |
| <div class="realm-toc-card" id="realm-toc" aria-hidden="true"> |
| <h2>Table of Contents</h2> |
| <div class="realm-toc-grid"> |
| <button class="realm-toc-link" data-spread="realm">I. The Realm</button> |
| <button class="realm-toc-link" data-spread="explore">II. Explore the Realm</button> |
| <button class="realm-toc-link" data-spread="book">III. The Book of Ages</button> |
| <button class="realm-toc-link" data-spread="bonds">IV. Bonds and Alliances</button> |
| <button class="realm-toc-link" data-spread="entities">V. Souls of the Garden</button> |
| <button class="realm-toc-link" data-spread="summon">VI. Summon a New Soul</button> |
| </div> |
| </div> |
| </div> |
| <script> |
| (function () {{ |
| if (window._aetherOpeningHoisted) return; |
| var gate = document.getElementById('realm-opening'); |
| if (!gate || gate.parentElement === document.body) return; |
| window._aetherOpeningHoisted = true; |
| gate.classList.add('realm-opening-hoisted'); |
| document.body.appendChild(gate); |
| var critical = document.getElementById('realm-opening-critical'); |
| if (critical && critical.parentElement !== document.head) {{ |
| document.head.appendChild(critical); |
| }} |
| var host = document.getElementById('realm-opening-host'); |
| if (host) {{ |
| host.style.setProperty('display', 'none', 'important'); |
| host.style.setProperty('visibility', 'hidden', 'important'); |
| }} |
| }})(); |
| </script> |
| """ |
|
|
|
|
| def _init_world(): |
| restore_database() |
| init_database() |
| from world.seed_data import seed_locations |
| with __import__("world.database", fromlist=["db_session"]).db_session() as conn: |
| seed_locations(conn) |
| update_entity_counts() |
|
|
|
|
| def get_header_html() -> str: |
| return render_hero_banner() |
|
|
|
|
| def _esc(text: str) -> str: |
| return ( |
| (text or "") |
| .replace("&", "&") |
| .replace("<", "<") |
| .replace(">", ">") |
| .replace('"', """) |
| ) |
|
|
|
|
| def summon_reveal_empty() -> str: |
| return """ |
| <div class="summon-reveal summon-reveal-empty"> |
| <p class="summon-reveal-kicker">The Oracle Awaits</p> |
| <p class="summon-reveal-hint">Whisper what you would bring into the Garden on the left leaf. |
| Their name, face, and story shall manifest here.</p> |
| </div> |
| """ |
|
|
|
|
| def summoning_html(description: str) -> str: |
| """Immediate, evocative loading state while the Oracle generates a soul.""" |
| desc = _esc(description.strip()) |
| if len(desc) > 90: |
| desc = desc[:90].rstrip() + "…" |
| whisper = f"“{desc}”" if desc else "something not yet named" |
| return f""" |
| <div class="oracle-summoning"> |
| <div class="oracle-orbit"> |
| <div class="oracle-ring"></div> |
| <div class="oracle-ring two"></div> |
| <div class="oracle-ring three"></div> |
| <div class="oracle-core"></div> |
| </div> |
| <div class="oracle-title">The Oracle is dreaming</div> |
| <p class="oracle-line">Shaping {whisper} into a soul — giving it a name, |
| a face, a fear, and a home among the eight places…</p> |
| <div class="oracle-wait-lines"> |
| <p>The first breath can take a moment. The Realm is listening.</p> |
| <p>If the Oracle is waking on the GPU, this can take up to a minute…</p> |
| <p>The bonfire shifts colour while it waits. Something is being born.</p> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def _prewarm_oracle() -> None: |
| """Fire a tiny background inference so the first real summon is fast. |
| |
| The model lives on a Modal GPU that cold-starts in ~80s; warming it on app |
| boot (and the hourly tick keeps it warm) means visitors rarely wait. |
| """ |
| if os.environ.get("USE_MOCK_AI", "true").lower() == "true": |
| return |
| if not os.environ.get("MODAL_INFERENCE_URL"): |
| return |
|
|
| def _warm() -> None: |
| try: |
| from ai.modal_client import generate |
| generate("Reply with one word.", "Say: ready", max_new_tokens=4, temperature=0.1) |
| except Exception: |
| pass |
|
|
| import threading |
| threading.Thread(target=_warm, daemon=True).start() |
|
|
|
|
| def render_location_panel(location_id: int | None) -> str: |
| if not location_id: |
| return ( |
| '<div id="location-panel-host" class="location-panel-host">' |
| '<p class="feed-empty map-hint">Click a glowing place on the map to unveil its secrets.</p>' |
| '</div>' |
| ) |
|
|
| location = get_location_by_id(location_id) |
| if not location: |
| return '<div id="location-panel-host" class="location-panel-host"></div>' |
|
|
| name = _esc(location["name"]) |
| desc = _esc(location.get("short_description", "")) |
| return ( |
| '<div id="location-panel-host" class="location-panel-host">' |
| f'<p class="map-hint map-hint-selected">' |
| f'<span class="map-hint-glyph" aria-hidden="true">✦</span> ' |
| f'<strong>{name}</strong>' |
| f'<span class="map-hint-sub"> — {desc}</span>' |
| f'</p></div>' |
| ) |
|
|
|
|
| _HIDE_CARD = gr.update(value=None, visible=False) |
| _HIDE_CAPTION = gr.update(value="", visible=False) |
|
|
|
|
| def _summon_error(message: str): |
| return ( |
| render_world_map(), |
| get_header_html(), |
| render_activity_feed(), |
| f'<div class="realm-error">{message}</div>', |
| render_location_panel(None), |
| _HIDE_CARD, |
| _HIDE_CAPTION, |
| render_realm_pulse(), |
| render_realm_stats(), |
| ) |
|
|
|
|
| def summon_entity(description: str, session_id: str): |
| if not description or not description.strip(): |
| return _summon_error("Describe what you are bringing into the world.") |
|
|
| allowed, remaining = check_rate_limit(session_id) |
| if not allowed: |
| return _summon_error( |
| f"The Realm takes time to absorb new arrivals. Return in {remaining} minutes." |
| ) |
|
|
| entity, error = generate_entity(description.strip(), session_id) |
| if error: |
| return _summon_error(error) |
|
|
| try: |
| backup_database() |
| except Exception: |
| pass |
|
|
| card = render_entity_card(entity) |
|
|
| card_img = build_soul_card(entity) |
| if card_img: |
| card_update = gr.update(value=card_img, visible=True) |
| caption_update = gr.update(value=share_caption(entity), visible=True) |
| else: |
| card_update = _HIDE_CARD |
| caption_update = _HIDE_CAPTION |
|
|
| return ( |
| render_world_map(entity["location_id"]), |
| get_header_html(), |
| render_activity_feed(), |
| card, |
| render_location_panel(entity["location_id"]), |
| card_update, |
| caption_update, |
| render_realm_pulse(), |
| render_realm_stats(), |
| ) |
|
|
|
|
| def select_entity_from_dropdown(entity_name: str): |
| if not entity_name: |
| return '<div class="feed-empty">Select an entity to view their profile.</div>' |
|
|
| entities = get_all_entities(search=entity_name, limit=1) |
| if not entities: |
| all_ents = get_all_entities() |
| match = next((e for e in all_ents if e["display_name"] == entity_name or e["name"] == entity_name), None) |
| if not match: |
| return '<div class="feed-empty">Entity not found.</div>' |
| return render_entity_card(match) |
|
|
| return render_entity_card(entities[0]) |
|
|
|
|
| def filter_entities(location_filter, type_filter, status_filter, search): |
| loc_id = None |
| if location_filter and location_filter != "all": |
| locs = get_all_locations() |
| match = next((l for l in locs if l["name"] == location_filter), None) |
| if match: |
| loc_id = match["id"] |
|
|
| entities = get_all_entities( |
| location_id=loc_id, |
| entity_type=type_filter, |
| status=status_filter, |
| search=search or None, |
| ) |
| return render_entity_grid(entities), render_entity_hologram(entities[0] if entities else None) |
|
|
|
|
| def filter_book(day, entry_type, search): |
| from ui.book_display import parse_day_query |
|
|
| parsed = parse_day_query(search) |
| if parsed is not None: |
| day = parsed |
| search = "" |
| try: |
| day_int = int(day) if day is not None else None |
| except (TypeError, ValueError): |
| day_int = None |
| return render_book_for_day( |
| day=day_int, |
| entry_type=entry_type, |
| search=search, |
| limit=12, |
| ) |
|
|
|
|
| def select_entity_by_id(entity_id: str): |
| if not entity_id: |
| return render_entity_hologram(None) |
| from world.entities import get_entity |
|
|
| entity = get_entity(entity_id) |
| if not entity: |
| return render_entity_hologram(None) |
| return render_entity_hologram(entity) |
|
|
|
|
| def render_realm_stats() -> str: |
| state = get_world_state() |
| day = int(state.get("current_day", 1) or 1) |
| count = len(get_all_entities()) |
| return f""" |
| <div class="realm-stats-strip"> |
| <span>World Day <b>{day}</b></span> |
| <span>{count} souls awake</span> |
| <span>8 sacred places</span> |
| </div> |
| """ |
|
|
|
|
| def chat_with_soul(soul_id: str, player_message: str) -> str: |
| """AI response from a soul given a visitor's message. Called from diorama iframe.""" |
| if not soul_id or not player_message or not player_message.strip(): |
| return "" |
|
|
| from world.entities import get_entity |
| from ai.modal_client import generate |
| from ai.prompts import SOUL_CONVERSATION_SYSTEM, SOUL_CONVERSATION_USER |
|
|
| entity = get_entity(soul_id) |
| if not entity: |
| return "The soul has wandered beyond reach." |
|
|
| history = list(_SOUL_CHAT_HISTORY[soul_id]) |
| history_block = "\n".join( |
| f"Visitor: {q}\n{entity.get('display_name', entity.get('name', 'Soul'))}: {a}" |
| for q, a in history[-4:] |
| ) or "No previous dialogue." |
| last_reply = _SOUL_LAST_REPLY.get(soul_id, "") |
|
|
| user_prompt = SOUL_CONVERSATION_USER.format( |
| entity_name=entity.get("display_name", entity.get("name", "Unknown")), |
| entity_type=entity.get("type", "soul"), |
| entity_appearance=entity.get("appearance", ""), |
| entity_traits=", ".join(entity.get("personality_traits") or []), |
| entity_primary_goal=entity.get("primary_goal", ""), |
| entity_secondary_goal=entity.get("secondary_goal", ""), |
| entity_primary_fear=entity.get("primary_fear", ""), |
| entity_speech_style=entity.get("speech_style", ""), |
| entity_memory_summary=entity.get("memory_summary") or "No memories yet.", |
| entity_days=entity.get("days_in_realm", 0), |
| entity_status=entity.get("status", "active"), |
| recent_dialogue=history_block, |
| recent_reply=last_reply or "None yet.", |
| player_message=player_message.strip()[:300], |
| ) |
|
|
| def _normalize(text: str) -> str: |
| return " ".join((text or "").lower().split()) |
|
|
| last_norm = _normalize(last_reply) |
|
|
| try: |
| response = "" |
| for temp in (0.85, 0.68): |
| candidate = generate( |
| user_prompt=user_prompt, |
| system_prompt=SOUL_CONVERSATION_SYSTEM, |
| max_new_tokens=160, |
| temperature=temp, |
| ) |
| candidate = (candidate or "").strip() |
| if not candidate: |
| continue |
| if _normalize(candidate) != last_norm: |
| response = candidate |
| break |
| response = candidate |
|
|
| if not response: |
| response = entity.get("greeting") or "I hear you. Speak again, and I will answer more clearly." |
|
|
| _SOUL_CHAT_HISTORY[soul_id].append((player_message.strip()[:300], response)) |
| _SOUL_LAST_REPLY[soul_id] = response |
| return response |
| except Exception: |
| |
| return entity.get("greeting") or "..." |
|
|
|
|
| def assign_quest_api(entity_id: str, quest_title: str) -> str: |
| """Assign a quest to a soul. Called from the diorama iframe via Gradio API.""" |
| if not entity_id or not quest_title or not quest_title.strip(): |
| return "" |
| try: |
| from world.entities import get_entity |
| entity = get_entity(entity_id) |
| if not entity: |
| return "Soul not found." |
| q = assign_quest(entity_id, quest_title.strip()[:200]) |
| return f"Quest assigned: {q['title']}" |
| except Exception as e: |
| return f"Could not assign quest: {e}" |
|
|
|
|
| def get_live_presence(session_id: str) -> str: |
| """Heartbeat + return live visitor HTML.""" |
| try: |
| presence_heartbeat(session_id) |
| count = get_active_count() |
| arrivals = get_recent_arrivals(since_seconds=120) |
| parts = [] |
| if count > 1: |
| parts.append(f'<span class="presence-count">⬡ {count} visitors in the Realm right now</span>') |
| if arrivals: |
| names = ", ".join(a["display_name"] for a in arrivals[:3]) |
| parts.append(f'<span class="presence-arrivals">Recently summoned: {names}</span>') |
| if not parts: |
| return "" |
| return f'<div class="presence-bar">{" · ".join(parts)}</div>' |
| except Exception: |
| return "" |
|
|
|
|
| def diorama_presence_api(location_id, visitor_id: str, x, z, yaw) -> dict: |
| """Store a visitor's in-scene position and return nearby visitors.""" |
| try: |
| loc_id = int(float(location_id)) |
| visitor = (visitor_id or "").strip()[:80] |
| if not visitor: |
| return {"visitors": []} |
| px = max(-24.0, min(24.0, float(x or 0))) |
| pz = max(-24.0, min(24.0, float(z or 0))) |
| pyaw = float(yaw or 0) |
| short = visitor.replace("aether-", "").replace("_", "-")[-4:].upper() |
| presence_heartbeat( |
| visitor, |
| loc_id, |
| x=px, |
| z=pz, |
| yaw=pyaw, |
| display_name=f"Visitor {short}", |
| ) |
| cleanup_stale_sessions(max_age_seconds=300) |
| return { |
| "visitors": get_active_visitors( |
| loc_id, |
| exclude_session_id=visitor, |
| window_seconds=22, |
| ) |
| } |
| except Exception: |
| return {"visitors": []} |
|
|
|
|
| def trigger_live_tick() -> str: |
| """Run one simulation tick on demand and return a summary.""" |
| try: |
| from simulation.tick import execute_simulation_tick |
| result = execute_simulation_tick() |
| day = result.get("day", "?") |
| interactions = result.get("interactions_run", 0) |
| event = result.get("world_event_title", "a quiet moment") |
| return ( |
| f'<div class="tick-result">' |
| f'<p class="tick-title">✦ Day {day} advanced</p>' |
| f'<p class="tick-body">The world event: <em>{event}</em></p>' |
| f'<p class="tick-body">{interactions} soul encounter{"s" if interactions != 1 else ""} unfolded.</p>' |
| f'</div>' |
| ) |
| except Exception as e: |
| return f'<div class="realm-error">Tick failed: {e}</div>' |
|
|
|
|
| def refresh_realm_view(): |
| return refresh_all_views() |
|
|
|
|
| def refresh_all_views(): |
| return ( |
| render_world_map(), |
| get_header_html(), |
| render_current_event(), |
| render_activity_feed(), |
| render_world_vignette(), |
| render_featured_characters(), |
| render_realm_pulse(), |
| ) |
|
|
|
|
| _SOUL_HINT = '<div class="feed-empty">Click a soul above to meet them.</div>' |
|
|
|
|
| def _explore_scene(loc_id: int | None): |
| if not loc_id: |
| return ( |
| render_diorama(None), |
| render_room(None), |
| gr.update(value=[]), |
| [], |
| _SOUL_HINT, |
| render_place_ribbon(None), |
| ) |
| items, soul_ids = soul_gallery(loc_id) |
| return ( |
| render_diorama(loc_id), |
| render_room(loc_id), |
| gr.update(value=items), |
| soul_ids, |
| _SOUL_HINT, |
| render_place_ribbon(loc_id), |
| ) |
|
|
|
|
| def enter_place(evt: gr.SelectData): |
| ids = place_ids() |
| if evt.index is None or evt.index >= len(ids): |
| return (None, *_explore_scene(None)) |
| loc_id = ids[evt.index] |
| return (loc_id, *_explore_scene(loc_id)) |
|
|
|
|
| def wander_to_place(): |
| loc_id = random_place_id() |
| if not loc_id: |
| return (None, *_explore_scene(None)) |
| return (loc_id, *_explore_scene(loc_id)) |
|
|
|
|
| def open_diorama_from_map(location_id: int | None): |
| try: |
| loc_id = int(location_id) if location_id else None |
| except (TypeError, ValueError): |
| loc_id = None |
| return _explore_scene(loc_id) |
|
|
|
|
| def meet_soul(evt: gr.SelectData, soul_ids: list): |
| if not soul_ids or evt.index is None or evt.index >= len(soul_ids): |
| return '<div class="feed-empty">They have wandered off.</div>' |
| return render_soul(soul_ids[evt.index], soul_ids) |
|
|
|
|
| def build_app() -> gr.Blocks: |
| _init_world() |
| _prewarm_oracle() |
|
|
| blocks_kwargs: dict = {"title": "Aether Garden"} |
| if GRADIO_MAJOR < 6: |
| blocks_kwargs["css"] = CUSTOM_CSS |
| blocks_kwargs["theme"] = REALM_THEME |
|
|
| with gr.Blocks(**blocks_kwargs) as demo: |
| session_id = gr.State(value=str(uuid.uuid4())) |
|
|
| |
| with gr.Group(elem_classes=["aether-pick-bridge"]): |
| map_pick = gr.Textbox( |
| show_label=False, container=False, lines=1, max_lines=1, |
| elem_id="map_location_pick", elem_classes=["aether-hidden-pick"], |
| ) |
| explore_place_pick = gr.Textbox( |
| show_label=False, container=False, lines=1, max_lines=1, |
| elem_id="explore_place_pick", elem_classes=["aether-hidden-pick"], |
| ) |
| book_day_pick = gr.Textbox( |
| show_label=False, container=False, lines=1, max_lines=1, |
| elem_id="book_day_pick", elem_classes=["aether-hidden-pick"], |
| ) |
| entity_pick = gr.Textbox( |
| show_label=False, container=False, lines=1, max_lines=1, |
| elem_id="entity_pick", elem_classes=["aether-hidden-pick"], |
| ) |
| portal_pick = gr.Textbox( |
| show_label=False, container=False, lines=1, max_lines=1, |
| elem_id="portal_location_pick", elem_classes=["aether-hidden-pick"], |
| ) |
|
|
| opening_gate = gr.HTML(value=opening_gate_html(), elem_id="realm-opening-host") |
| presence_bar = gr.HTML(value="", elem_id="presence-bar") |
| header = gr.HTML(value=get_header_html(), elem_classes=["realm-header-wrap"]) |
|
|
| with gr.Tabs(elem_id="tome-spreads", elem_classes=["realm-tabs"]) as tabs: |
| with gr.Tab("The Realm", id="realm"): |
| with gr.Column(elem_classes=["tome-spread-shell"]): |
| with gr.Row(elem_classes=["tome-spread-row"]): |
| with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-page-centered"]): |
| gr.HTML( |
| '<div class="tome-ornament top" aria-hidden="true">✦ ─── ❧ ─── ✦</div>' |
| '<p class="tome-printed-kicker">The Living Realm</p>' |
| '<p class="tome-printed-hint">Tap a place on the map — a vision shall rise.</p>' |
| '<div class="tome-map-chrome" aria-hidden="true">' |
| '<span class="tome-map-chrome-glyph">⟡</span>' |
| '<span class="tome-map-chrome-line"></span>' |
| '<span class="tome-map-chrome-label">Map of the Canopy</span>' |
| '<span class="tome-map-chrome-line"></span>' |
| '<span class="tome-map-chrome-glyph">⟡</span>' |
| '</div>' |
| ) |
| world_map = gr.HTML( |
| value=render_world_map(), |
| elem_id="world-map", |
| ) |
| location_panel = gr.HTML( |
| value=render_location_panel(None), |
| elem_id="location-panel-host-wrap", |
| ) |
| gr.HTML('<div class="tome-ornament bottom" aria-hidden="true">❧</div>') |
|
|
| with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page", "tome-page-centered"]): |
| gr.HTML( |
| '<div class="tome-ornament top" aria-hidden="true">✦ ─── ❧ ─── ✦</div>' |
| '<p class="tome-printed-kicker">Chronicle of the Hour</p>' |
| '<p class="tome-printed-hint">The Realm speaks — listen closely.</p>' |
| ) |
| realm_stats = gr.HTML(value=render_realm_stats()) |
| world_vignette = gr.HTML( |
| value=render_world_vignette(), |
| elem_classes=["tome-realm-vignette"], |
| ) |
| current_event = gr.HTML(value=render_current_event()) |
| realm_pulse = gr.HTML(value=render_realm_pulse(limit=4)) |
| activity_feed = gr.HTML(value=render_activity_feed(limit=4)) |
| gr.HTML('<div class="tome-ornament bottom" aria-hidden="true">❧</div>') |
|
|
| with gr.Column(elem_classes=["tome-offpage-store"]): |
| realm_diorama = gr.HTML(value="") |
| with gr.Accordion("How this world works", open=False): |
| gr.HTML( |
| '<div class="how-it-works">' |
| '<p><b>1 · You summon.</b> Describe anything below and an AI gives it a name, ' |
| 'a look, goals, fears, and a home among the eight places.</p>' |
| '<p><b>2 · The world lives on its own.</b> Every hour, even when no one is here, ' |
| 'the souls wander, meet, form bonds, and the land throws up strange events.</p>' |
| '<p><b>3 · Nothing is forgotten.</b> Every arrival, meeting, and event is written ' |
| 'into the Book of Ages forever. Turn the page to <b>Explore</b> or open the ' |
| '<b>Book of Ages</b> for the full chronicle.</p>' |
| '</div>' |
| ) |
| with gr.Accordion("⚡ Advance the World Now", open=False): |
| gr.HTML( |
| '<p class="tome-muted">Manually trigger one simulation tick — the world will ' |
| 'generate a new event, run soul encounters, and update memories in real time.</p>' |
| ) |
| live_tick_btn = gr.Button("Run simulation tick →", variant="secondary", size="sm") |
| tick_result = gr.HTML() |
|
|
| location_select = gr.Dropdown( |
| choices=[(l["name"], l["id"]) for l in get_all_locations()], |
| label="Explore Location", |
| visible=False, |
| ) |
|
|
| with gr.Tab("Explore the Realm", id="places"): |
| _all_locs = get_all_locations() |
| _default_loc_id = _all_locs[0]["id"] if _all_locs else None |
| explore_loc = gr.State(value=_default_loc_id) |
| soul_ids_state = gr.State(value=[]) |
|
|
| with gr.Column(elem_classes=["tome-spread-shell"]): |
| with gr.Row(elem_classes=["tome-spread-row"]): |
| with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-explore-index"]): |
| place_ribbon = gr.HTML( |
| value=render_place_ribbon(_default_loc_id), |
| container=False, |
| padding=False, |
| ) |
| wander_btn = gr.Button("✦ Wander at random", variant="secondary", size="sm") |
|
|
| with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page", "tome-explore-stage"]): |
| diorama_view = gr.HTML( |
| value=render_diorama(_default_loc_id), |
| container=False, |
| padding=False, |
| elem_id="explore-diorama-host", |
| ) |
| gr.HTML( |
| '<div class="tome-explore-stage-header">' |
| '<p class="explore-controls-hint">WASD walk · drag look · E talk · golden wisps are live visitors</p>' |
| '</div>' |
| ) |
|
|
| with gr.Column(elem_classes=["tome-offpage-store"]): |
| places_gallery = gr.Gallery( |
| value=place_gallery_items(), |
| columns=4, |
| height="auto", |
| object_fit="cover", |
| allow_preview=False, |
| show_label=False, |
| elem_classes=["places-gallery"], |
| ) |
| room_view = gr.HTML(value=render_room(_default_loc_id)) |
| souls_gallery = gr.Gallery( |
| value=[], |
| columns=6, |
| height="auto", |
| object_fit="cover", |
| allow_preview=False, |
| label="Souls dwelling here", |
| elem_classes=["souls-gallery"], |
| ) |
| soul_card = gr.HTML() |
|
|
| with gr.Tab("Book of Ages", id="book"): |
| _book_state = get_world_state() |
| _init_book_day = int(_book_state.get("current_day", 1) or 1) |
| book_day = gr.State(value=_init_book_day) |
|
|
| with gr.Column(elem_classes=["tome-spread-shell"]): |
| with gr.Row(elem_classes=["tome-spread-row"]): |
| with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-book-index"]): |
| day_tabs = gr.HTML(value=render_day_tabs(_init_book_day)) |
| _day_choices = list(range(max(1, _init_book_day - 14), _init_book_day + 1)) |
| book_day_select = gr.Dropdown( |
| choices=[(f"Day {d}", d) for d in reversed(_day_choices)], |
| value=_init_book_day, |
| label="Jump to day", |
| ) |
| book_filter = gr.Dropdown( |
| choices=[ |
| ("All", "all"), |
| ("Arrivals", "arrival"), |
| ("Interactions", "interaction"), |
| ("Events", "world_event"), |
| ("Milestones", "milestone"), |
| ("Dreams", "dream_fragment"), |
| ], |
| value="all", |
| label="Filter", |
| ) |
| book_search = gr.Textbox( |
| placeholder='Search name… or type "Day 3"', |
| label="Search", |
| ) |
| with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]): |
| book_display = gr.HTML( |
| value=render_book_for_day(_init_book_day, limit=12) |
| ) |
|
|
| with gr.Tab("Bonds and Alliances", id="bonds"): |
| with gr.Column(elem_classes=["tome-spread-shell"]): |
| with gr.Row(elem_classes=["tome-spread-row"]): |
| with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page"]): |
| bonds_type_filter = gr.Dropdown( |
| choices=[ |
| ("All bonds", "all"), |
| ("Allied", "allied"), |
| ("Friends", "friends"), |
| ("Rivals", "rivals"), |
| ("Enemies", "enemies"), |
| ("Mentor", "mentor"), |
| ("Owes debt", "owes_debt"), |
| ("Curious", "curious"), |
| ], |
| value="all", |
| label="Show connections", |
| ) |
| bonds_sidebar = gr.HTML( |
| value=render_bonds_sidebar(), |
| container=False, |
| padding=False, |
| ) |
| with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page"]): |
| rel_graph = gr.HTML(value=render_relationship_graph()) |
|
|
| with gr.Tab("Souls of the Garden", id="entities"): |
| with gr.Column(elem_classes=["tome-spread-shell"]): |
| with gr.Row(elem_classes=["tome-spread-row"]): |
| with gr.Column(scale=1, elem_classes=["tome-page-left", "tome-printed-page", "tome-souls-index"]): |
| gr.HTML('<p class="tome-printed-kicker">Souls of the Garden</p>') |
| with gr.Row(elem_classes=["souls-filter-row"]): |
| loc_filter = gr.Dropdown( |
| choices=["all"] + [l["name"] for l in get_all_locations()], |
| value="all", |
| label="Place", |
| scale=1, |
| ) |
| type_filter = gr.Dropdown( |
| choices=["all", "character", "creature", "object", "place"], |
| value="all", |
| label="Kind", |
| scale=1, |
| ) |
| with gr.Row(elem_classes=["souls-filter-row"]): |
| status_filter = gr.Dropdown( |
| choices=["all", "active", "dormant", "legendary"], |
| value="all", |
| label="Status", |
| scale=1, |
| ) |
| with gr.Row(elem_classes=["souls-search-row"]): |
| entity_search = gr.Textbox( |
| placeholder="Search souls by name…", |
| label="Find", |
| scale=1, |
| ) |
| entity_grid = gr.HTML( |
| value=render_entity_grid(get_all_entities()), |
| container=False, |
| padding=False, |
| ) |
| with gr.Column(scale=1, elem_classes=["tome-page-right", "tome-printed-page", "tome-souls-stage"]): |
| _first_ent = get_all_entities(limit=1) |
| entity_detail = gr.HTML( |
| value=render_entity_hologram(_first_ent[0] if _first_ent else None), |
| container=False, |
| padding=False, |
| ) |
|
|
| with gr.Column(elem_classes=["tome-offpage-store"]): |
| featured_chars = gr.HTML(value=render_featured_characters()) |
|
|
| with gr.Tab("Summon a New Soul", id="summon"): |
| with gr.Column(elem_classes=["tome-spread-shell"]): |
| with gr.Row(elem_classes=["tome-spread-row"]): |
| with gr.Column( |
| scale=1, |
| elem_classes=[ |
| "tome-page-left", |
| "tome-printed-page", |
| "tome-page-centered", |
| "tome-summon-index", |
| ], |
| ): |
| gr.HTML( |
| '<div class="tome-ornament top" aria-hidden="true">✦ ─── ❧ ─── ✦</div>' |
| '<p class="tome-printed-kicker">Summon Into the Realm</p>' |
| '<p class="summon-leaf-hint">Describe what you bring on this leaf — the Oracle ' |
| 'forges their identity on the right.</p>' |
| ) |
| summon_input = gr.Textbox( |
| placeholder="A clockmaker who weaves time from fallen leaves…", |
| label=None, |
| show_label=False, |
| lines=5, |
| elem_classes=["realm-summon-input", "summon-prompt-input"], |
| ) |
| gr.HTML( |
| '<p class="summon-submit-hint">Then seal your words below</p>' |
| ) |
| summon_btn = gr.Button( |
| "Seal & Summon ✦", |
| variant="primary", |
| elem_classes=["seal-summon-btn"], |
| elem_id="summon-seal-btn", |
| ) |
| gr.HTML('<div class="tome-ornament bottom" aria-hidden="true">❧</div>') |
|
|
| with gr.Column( |
| scale=1, |
| elem_classes=[ |
| "tome-page-right", |
| "tome-printed-page", |
| "tome-page-centered", |
| "tome-summon-stage", |
| ], |
| ): |
| gr.HTML( |
| '<div class="tome-ornament top" aria-hidden="true">✦ ─── ❧ ─── ✦</div>' |
| '<p class="tome-printed-kicker">Soul Unveiled</p>' |
| '<p class="tome-printed-hint">The identity the Oracle forges from your words.</p>' |
| ) |
| entity_result = gr.HTML( |
| value=summon_reveal_empty(), |
| elem_classes=["entity-result-slot"], |
| ) |
| soul_card_img = gr.Image( |
| label="Your Soul Card", |
| visible=False, |
| show_label=True, |
| interactive=False, |
| type="filepath", |
| elem_classes=["soul-card-image"], |
| height=280, |
| ) |
| share_caption_box = gr.Textbox( |
| label="Share caption", |
| visible=False, |
| lines=2, |
| interactive=True, |
| elem_classes=["share-caption-box"], |
| ) |
| gr.HTML('<div class="tome-ornament bottom" aria-hidden="true">❧</div>') |
|
|
| _summon_outputs = [ |
| world_map, header, activity_feed, entity_result, location_panel, |
| soul_card_img, share_caption_box, realm_pulse, realm_stats, |
| ] |
|
|
| _explore_outputs = [ |
| diorama_view, room_view, souls_gallery, soul_ids_state, soul_card, place_ribbon, |
| ] |
|
|
| summon_btn.click( |
| fn=summoning_html, |
| inputs=[summon_input], |
| outputs=[entity_result], |
| queue=False, |
| ).then( |
| fn=lambda: (_HIDE_CARD, _HIDE_CAPTION), |
| outputs=[soul_card_img, share_caption_box], |
| queue=False, |
| ).then( |
| fn=summon_entity, |
| inputs=[summon_input, session_id], |
| outputs=_summon_outputs, |
| ).then(fn=lambda: "", outputs=[summon_input]).then( |
| None, None, None, |
| js=""" |
| () => { |
| setTimeout(() => { |
| const card = document.querySelector('.soul-card-image'); |
| const target = card || document.querySelector('.tome-summon-stage .entity-card'); |
| if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| }, 350); |
| } |
| """, |
| ) |
|
|
| summon_input.submit( |
| fn=summoning_html, |
| inputs=[summon_input], |
| outputs=[entity_result], |
| queue=False, |
| ).then( |
| fn=lambda: (_HIDE_CARD, _HIDE_CAPTION), |
| outputs=[soul_card_img, share_caption_box], |
| queue=False, |
| ).then( |
| fn=summon_entity, |
| inputs=[summon_input, session_id], |
| outputs=_summon_outputs, |
| ).then(fn=lambda: "", outputs=[summon_input]).then( |
| None, None, None, |
| js=""" |
| () => { |
| setTimeout(() => { |
| const card = document.querySelector('.soul-card-image'); |
| const target = card || document.querySelector('.tome-summon-stage .entity-card'); |
| if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| }, 350); |
| } |
| """, |
| ) |
|
|
| def _realm_map_pick(pick): |
| try: |
| loc_id = int(pick) if pick else None |
| except (TypeError, ValueError): |
| loc_id = None |
| return render_world_map(loc_id), render_location_panel(loc_id) |
|
|
| location_select.change( |
| fn=_realm_map_pick, |
| inputs=[location_select], |
| outputs=[world_map, location_panel], |
| ) |
|
|
| map_pick.change( |
| _realm_map_pick, |
| inputs=[map_pick], |
| outputs=[world_map, location_panel], |
| ) |
|
|
| explore_place_pick.change( |
| open_diorama_from_map, |
| inputs=[explore_place_pick], |
| outputs=_explore_outputs, |
| ).then( |
| lambda pick: int(pick) if pick and str(pick).isdigit() else None, |
| inputs=[explore_place_pick], |
| outputs=[explore_loc], |
| ).then( |
| None, None, None, |
| js=""" |
| () => { |
| setTimeout(() => { |
| const frame = document.querySelector('.tome-explore-stage .diorama-frame'); |
| if (frame) frame.focus(); |
| }, 200); |
| } |
| """, |
| ) |
|
|
| portal_pick.change( |
| open_diorama_from_map, |
| inputs=[portal_pick], |
| outputs=_explore_outputs, |
| ).then( |
| lambda pick: int(pick) if pick and str(pick).isdigit() else None, |
| inputs=[portal_pick], |
| outputs=[explore_loc], |
| ) |
|
|
| places_gallery.select( |
| enter_place, |
| inputs=None, |
| outputs=[explore_loc, *_explore_outputs], |
| ) |
| wander_btn.click( |
| wander_to_place, |
| inputs=None, |
| outputs=[explore_loc, *_explore_outputs], |
| ) |
| souls_gallery.select(meet_soul, inputs=[soul_ids_state], outputs=[soul_card]) |
|
|
| |
| live_tick_btn.click( |
| fn=trigger_live_tick, |
| outputs=[tick_result], |
| ).then( |
| fn=refresh_all_views, |
| outputs=[world_map, header, current_event, activity_feed, world_vignette, featured_chars, realm_pulse], |
| ).then(fn=render_realm_stats, outputs=[realm_stats]) |
|
|
| |
| chat_soul_id_comp = gr.Textbox(visible=False, elem_id="chat_soul_id_input") |
| chat_msg_comp = gr.Textbox(visible=False, elem_id="chat_message_input") |
| chat_resp_comp = gr.Textbox(visible=False, elem_id="chat_response_output") |
| chat_msg_comp.change( |
| fn=chat_with_soul, |
| inputs=[chat_soul_id_comp, chat_msg_comp], |
| outputs=[chat_resp_comp], |
| api_name="chat_with_soul", |
| ) |
|
|
| |
| quest_entity_comp = gr.Textbox(visible=False, elem_id="quest_entity_input") |
| quest_title_comp = gr.Textbox(visible=False, elem_id="quest_title_input") |
| quest_resp_comp = gr.Textbox(visible=False, elem_id="quest_response_output") |
| quest_title_comp.change( |
| fn=assign_quest_api, |
| inputs=[quest_entity_comp, quest_title_comp], |
| outputs=[quest_resp_comp], |
| api_name="assign_quest", |
| ) |
|
|
| |
| presence_loc_comp = gr.Textbox(visible=False, elem_id="presence_location_input") |
| presence_visitor_comp = gr.Textbox(visible=False, elem_id="presence_visitor_input") |
| presence_x_comp = gr.Textbox(visible=False, elem_id="presence_x_input") |
| presence_z_comp = gr.Textbox(visible=False, elem_id="presence_z_input") |
| presence_yaw_comp = gr.Textbox(visible=False, elem_id="presence_yaw_input") |
| presence_json_comp = gr.JSON(visible=False, elem_id="presence_json_output") |
| presence_yaw_comp.change( |
| fn=diorama_presence_api, |
| inputs=[ |
| presence_loc_comp, |
| presence_visitor_comp, |
| presence_x_comp, |
| presence_z_comp, |
| presence_yaw_comp, |
| ], |
| outputs=[presence_json_comp], |
| api_name="diorama_presence", |
| ) |
|
|
| |
| presence_timer = gr.Timer(10) |
| presence_timer.tick( |
| fn=get_live_presence, |
| inputs=[session_id], |
| outputs=[presence_bar], |
| ) |
|
|
| def _book_day_change(day_pick, day_state, entry_type, search): |
| try: |
| day = int(day_pick) if day_pick else int(day_state or 1) |
| except (TypeError, ValueError): |
| day = int(day_state or 1) |
| return day, render_day_tabs(day), filter_book(day, entry_type, search) |
|
|
| def _book_day_select_change(day_val, entry_type, search): |
| try: |
| day = int(day_val) |
| except (TypeError, ValueError): |
| state = get_world_state() |
| day = int(state.get("current_day", 1) or 1) |
| return day, render_day_tabs(day), filter_book(day, entry_type, search) |
|
|
| book_day_pick.change( |
| _book_day_change, |
| [book_day_pick, book_day, book_filter, book_search], |
| [book_day, day_tabs, book_display], |
| ).then( |
| lambda d: d, |
| inputs=[book_day], |
| outputs=[book_day_select], |
| ) |
| book_day_select.change( |
| _book_day_select_change, |
| [book_day_select, book_filter, book_search], |
| [book_day, day_tabs, book_display], |
| ) |
| book_filter.change(filter_book, [book_day, book_filter, book_search], [book_display]) |
| book_search.change(filter_book, [book_day, book_filter, book_search], [book_display]) |
|
|
| bonds_type_filter.change( |
| render_bonds_sidebar, |
| [bonds_type_filter], |
| [bonds_sidebar], |
| ) |
|
|
| for f in [loc_filter, type_filter, status_filter, entity_search]: |
| f.change( |
| filter_entities, |
| [loc_filter, type_filter, status_filter, entity_search], |
| [entity_grid, entity_detail], |
| ) |
|
|
| entity_pick.change(select_entity_by_id, [entity_pick], [entity_detail]) |
|
|
| refresh_timer = gr.Timer(30) |
| refresh_timer.tick( |
| fn=refresh_all_views, |
| outputs=[world_map, header, current_event, activity_feed, world_vignette, featured_chars, realm_pulse], |
| ).then(fn=render_relationship_graph, outputs=[rel_graph]).then( |
| fn=render_bonds_sidebar, outputs=[bonds_sidebar] |
| ).then(fn=render_realm_stats, outputs=[realm_stats]) |
|
|
| MAP_CLICK_JS = """ |
| () => { |
| if (window._aetherInitBound) { |
| const dedupe = () => { |
| const hoisted = document.body.querySelector('#realm-opening.realm-opening-hoisted') |
| || document.body.querySelector('#realm-opening'); |
| if (!hoisted) return; |
| document.querySelectorAll('#realm-opening-host #realm-opening').forEach((el) => { |
| if (el !== hoisted) el.remove(); |
| }); |
| const host = document.getElementById('realm-opening-host'); |
| if (host && !host.querySelector('#realm-opening')) { |
| host.style.setProperty('display', 'none', 'important'); |
| } |
| }; |
| dedupe(); |
| return; |
| } |
| window._aetherInitBound = true; |
| |
| const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| const MOBILE_QUERY = window.matchMedia('(max-width: 920px)'); |
| const isMobileSpread = () => MOBILE_QUERY.matches; |
| const syncMobileClass = () => { |
| const mobile = isMobileSpread(); |
| document.documentElement.classList.toggle('tome-mobile', mobile); |
| document.body.classList.toggle('tome-mobile', mobile); |
| }; |
| syncMobileClass(); |
| |
| const sections = [ |
| { key: 'realm', label: 'The Realm', kind: 'tab', panel: 0 }, |
| { key: 'explore', label: 'Explore the Realm', kind: 'tab', panel: 1 }, |
| { key: 'book', label: 'Book of Ages', kind: 'tab', panel: 2 }, |
| { key: 'bonds', label: 'Bonds and Alliances', kind: 'tab', panel: 3 }, |
| { key: 'entities', label: 'Souls of the Garden', kind: 'tab', panel: 4 }, |
| { key: 'summon', label: 'Summon a New Soul', kind: 'tab', panel: 5 } |
| ]; |
| let currentSpread = 0; |
| |
| const toRoman = (n) => { |
| const map = [ |
| [1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'], |
| [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'] |
| ]; |
| let out = ''; |
| for (const [v, s] of map) { |
| while (n >= v) { out += s; n -= v; } |
| } |
| return out; |
| }; |
| |
| const setActivePanel = (panelIndex) => { |
| const mobile = isMobileSpread(); |
| const panels = Array.from(document.querySelectorAll('.realm-tabs [role="tabpanel"]')); |
| panels.forEach((panel, i) => { |
| const active = i === panelIndex; |
| panel.style.setProperty('display', active ? 'flex' : 'none', 'important'); |
| panel.style.setProperty('flex-direction', 'column', 'important'); |
| if (mobile) { |
| panel.style.setProperty('height', active ? 'auto' : '0', 'important'); |
| panel.style.setProperty('max-height', active ? 'none' : '0', 'important'); |
| panel.style.setProperty('flex', active ? '1 1 auto' : '0 0 0', 'important'); |
| panel.style.setProperty('min-height', active ? '0' : '0', 'important'); |
| } else { |
| panel.style.setProperty('height', active ? '565px' : '0', 'important'); |
| panel.style.setProperty('max-height', active ? '565px' : '0', 'important'); |
| panel.style.setProperty('flex', '', 'important'); |
| } |
| panel.style.setProperty('overflow', 'hidden', 'important'); |
| panel.setAttribute('aria-hidden', active ? 'false' : 'true'); |
| }); |
| wirePageExpand(); |
| wireMobileSpreads(); |
| const pad = document.getElementById('aether-walk-pad'); |
| if (pad && panelIndex !== 1) { |
| pad.classList.remove('visible', 'explore-only'); |
| } |
| }; |
| |
| const resetActiveSpreadLeaf = () => { |
| const panels = document.querySelectorAll('.realm-tabs [role="tabpanel"]'); |
| const activePanel = panels[currentSpread]; |
| if (!activePanel) return; |
| const row = activePanel.querySelector('.tome-spread-row'); |
| if (!row) return; |
| row.classList.remove('tome-leaf-right'); |
| row.classList.add('tome-leaf-left'); |
| }; |
| |
| const wireMobileSpreads = () => { |
| const mobile = isMobileSpread(); |
| syncMobileClass(); |
| |
| document.querySelectorAll('.tome-spread-shell').forEach((shell) => { |
| const row = shell.querySelector('.tome-spread-row'); |
| if (!row) return; |
| |
| if (!mobile) { |
| row.classList.remove('tome-leaf-left', 'tome-leaf-right'); |
| shell.querySelector('.tome-leaf-bar')?.remove(); |
| return; |
| } |
| |
| if (!row.classList.contains('tome-leaf-left') && !row.classList.contains('tome-leaf-right')) { |
| row.classList.add('tome-leaf-left'); |
| } |
| |
| let bar = shell.querySelector('.tome-leaf-bar'); |
| if (!bar) { |
| bar = document.createElement('div'); |
| bar.className = 'tome-leaf-bar'; |
| bar.innerHTML = ` |
| <button type="button" class="tome-leaf-btn" data-leaf="left" aria-label="Show left leaf">‹ Leaf</button> |
| <span class="tome-leaf-dots" aria-hidden="true"><i></i><i></i></span> |
| <button type="button" class="tome-leaf-btn" data-leaf="right" aria-label="Show right leaf">Leaf ›</button> |
| `; |
| shell.appendChild(bar); |
| |
| const syncLeafBar = () => { |
| const onLeft = row.classList.contains('tome-leaf-left'); |
| bar.querySelectorAll('.tome-leaf-btn').forEach((btn) => { |
| const active = btn.dataset.leaf === (onLeft ? 'left' : 'right'); |
| btn.classList.toggle('is-active', active); |
| }); |
| bar.querySelectorAll('.tome-leaf-dots i').forEach((dot, idx) => { |
| dot.classList.toggle('active', onLeft ? idx === 0 : idx === 1); |
| }); |
| }; |
| |
| bar.querySelectorAll('.tome-leaf-btn').forEach((btn) => { |
| btn.addEventListener('click', () => { |
| const leaf = btn.dataset.leaf; |
| row.classList.remove('tome-leaf-left', 'tome-leaf-right'); |
| row.classList.add(leaf === 'right' ? 'tome-leaf-right' : 'tome-leaf-left'); |
| syncLeafBar(); |
| }); |
| }); |
| bar._syncLeafBar = syncLeafBar; |
| } |
| |
| bar._syncLeafBar?.(); |
| }); |
| }; |
| |
| if (!window._aetherMobileBound) { |
| window._aetherMobileBound = true; |
| MOBILE_QUERY.addEventListener('change', () => { |
| wireMobileSpreads(); |
| setActivePanel(currentSpread >= 0 ? sections[currentSpread].panel : 0); |
| }); |
| window.addEventListener('resize', () => { |
| wireMobileSpreads(); |
| }, { passive: true }); |
| } |
| |
| const ensureAmbientMelody = () => { |
| if (window._aetherAmbientStarted) { |
| window._aetherAmbientCtx?.resume?.(); |
| return; |
| } |
| try { |
| const AudioCtx = window.AudioContext || window.webkitAudioContext; |
| if (!AudioCtx) return; |
| const ctx = new AudioCtx(); |
| window._aetherAmbientCtx = ctx; |
| |
| const master = ctx.createGain(); |
| master.gain.value = 0.0001; |
| |
| const warmth = ctx.createBiquadFilter(); |
| warmth.type = 'lowpass'; |
| warmth.frequency.value = prefersReduced ? 1600 : 2400; |
| warmth.Q.value = 0.5; |
| |
| const delay = ctx.createDelay(2.8); |
| delay.delayTime.value = 0.48; |
| const delayFb = ctx.createGain(); |
| delayFb.gain.value = 0.26; |
| const delayMix = ctx.createGain(); |
| delayMix.gain.value = 0.32; |
| |
| warmth.connect(delay); |
| delay.connect(delayFb); |
| delayFb.connect(delay); |
| delay.connect(delayMix); |
| delayMix.connect(master); |
| warmth.connect(master); |
| master.connect(ctx.destination); |
| |
| const now = ctx.currentTime; |
| master.gain.linearRampToValueAtTime(prefersReduced ? 0.009 : 0.013, now + 5); |
| |
| const padNotes = [220, 261.63, 293.66, 329.63, 392, 440]; |
| padNotes.forEach((freq, i) => { |
| const osc = ctx.createOscillator(); |
| const osc2 = ctx.createOscillator(); |
| const g = ctx.createGain(); |
| const g2 = ctx.createGain(); |
| osc.type = 'sine'; |
| osc2.type = 'triangle'; |
| osc.frequency.value = freq; |
| osc2.frequency.value = freq * 1.0015; |
| const base = 0.0007 / (i * 0.55 + 1); |
| g.gain.value = base; |
| g2.gain.value = base * 0.45; |
| osc.connect(g); |
| osc2.connect(g2); |
| g.connect(warmth); |
| g2.connect(warmth); |
| osc.start(); |
| osc2.start(); |
| |
| const breathe = () => { |
| const t = ctx.currentTime; |
| g.gain.cancelScheduledValues(t); |
| g.gain.setValueAtTime(g.gain.value, t); |
| g.gain.linearRampToValueAtTime(base * 2.1, t + 4 + i * 0.4); |
| g.gain.linearRampToValueAtTime(base * 0.55, t + 10 + i * 0.35); |
| }; |
| breathe(); |
| setInterval(breathe, 9000 + i * 800); |
| }); |
| |
| const melodyNotes = [ |
| 392, 440, 493.88, 523.25, 587.33, 659.25, |
| 587.33, 523.25, 440, 392, 329.63, 293.66, 329.63, 392 |
| ]; |
| let noteIdx = 0; |
| const playBell = () => { |
| if (!window._aetherAmbientStarted) return; |
| const freq = melodyNotes[noteIdx % melodyNotes.length]; |
| noteIdx += 1; |
| const t = ctx.currentTime; |
| const osc = ctx.createOscillator(); |
| const env = ctx.createGain(); |
| osc.type = 'sine'; |
| osc.frequency.value = freq; |
| env.gain.setValueAtTime(0.0001, t); |
| env.gain.exponentialRampToValueAtTime(0.0065, t + 0.12); |
| env.gain.exponentialRampToValueAtTime(0.0001, t + 3.2); |
| osc.connect(env); |
| env.connect(warmth); |
| osc.start(t); |
| osc.stop(t + 3.4); |
| }; |
| |
| const scheduleMelody = () => { |
| if (!window._aetherAmbientStarted) return; |
| playBell(); |
| setTimeout(scheduleMelody, 2600 + Math.random() * 2400); |
| }; |
| setTimeout(scheduleMelody, 1800); |
| |
| const shimmer = () => { |
| if (!window._aetherAmbientStarted) return; |
| const t = ctx.currentTime; |
| const osc = ctx.createOscillator(); |
| const env = ctx.createGain(); |
| osc.type = 'triangle'; |
| osc.frequency.value = 880 + Math.random() * 264; |
| env.gain.setValueAtTime(0.0001, t); |
| env.gain.exponentialRampToValueAtTime(0.0028, t + 0.6); |
| env.gain.exponentialRampToValueAtTime(0.0001, t + 5); |
| osc.connect(env); |
| env.connect(warmth); |
| osc.start(t); |
| osc.stop(t + 5.2); |
| setTimeout(shimmer, 14000 + Math.random() * 10000); |
| }; |
| setTimeout(shimmer, 6000); |
| |
| window._aetherAmbientStarted = true; |
| |
| document.addEventListener('visibilitychange', () => { |
| if (document.visibilityState === 'visible') ctx.resume(); |
| }, { passive: true }); |
| } catch (_) {} |
| }; |
| |
| const playOpeningChime = () => { |
| try { |
| const AudioCtx = window.AudioContext || window.webkitAudioContext; |
| if (!AudioCtx) return; |
| const ctx = new AudioCtx(); |
| const now = ctx.currentTime; |
| [523.25, 659.25, 783.99].forEach((freq, i) => { |
| const osc = ctx.createOscillator(); |
| const gain = ctx.createGain(); |
| osc.type = i === 0 ? 'triangle' : 'sine'; |
| osc.frequency.value = freq; |
| gain.gain.setValueAtTime(0.0001, now); |
| gain.gain.exponentialRampToValueAtTime(0.06 / (i + 1), now + 0.03 + i * 0.04); |
| gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.78 + i * 0.1); |
| osc.connect(gain); |
| gain.connect(ctx.destination); |
| osc.start(now + i * 0.04); |
| osc.stop(now + 0.95 + i * 0.08); |
| }); |
| } catch (_) {} |
| }; |
| |
| const applyTurnFx = (direction) => { |
| if (prefersReduced) return; |
| const root = document.querySelector('.gradio-container'); |
| if (!root) return; |
| root.classList.remove('tome-turn-next', 'tome-turn-prev'); |
| root.classList.add(direction === 'prev' ? 'tome-turn-prev' : 'tome-turn-next'); |
| setTimeout(() => root.classList.remove('tome-turn-next', 'tome-turn-prev'), 720); |
| }; |
| |
| const updatePageBadge = () => { |
| const page = document.getElementById('tome-page-indicator'); |
| if (!page) return; |
| const idx = Math.max(1, currentSpread + 1); |
| page.textContent = `Page ${toRoman(idx)}`; |
| }; |
| |
| const closeToc = () => { |
| const toc = document.getElementById('realm-toc'); |
| if (toc) toc.classList.remove('open'); |
| const floating = document.getElementById('tome-floating-toc'); |
| if (floating) floating.classList.remove('open'); |
| }; |
| |
| const closeBook = () => { |
| const gate = document.getElementById('realm-opening'); |
| const book3d = document.getElementById('realm-book-3d'); |
| if (!gate) return; |
| setAppInteractive(false); |
| gate.classList.remove('is-closed', 'is-opened', 'is-prologue', 'is-unfolded', 'is-unfolding'); |
| gate.classList.add('is-closing', 'is-reclosed'); |
| const openBook = document.getElementById('realm-book-open'); |
| const prologue = document.getElementById('realm-prologue'); |
| if (openBook) openBook.setAttribute('aria-hidden', 'true'); |
| if (prologue) { |
| prologue.classList.remove('visible'); |
| prologue.setAttribute('aria-hidden', 'true'); |
| } |
| if (book3d) { |
| book3d.classList.remove('is-opening'); |
| setTimeout(() => book3d.classList.add('show-back'), prefersReduced ? 0 : 80); |
| } |
| setTimeout(() => { |
| gate.classList.remove('is-closing'); |
| gate.classList.add('is-back-visible'); |
| }, prefersReduced ? 60 : 1150); |
| applyTurnFx('prev'); |
| }; |
| |
| const reopenBook = () => { |
| const gate = document.getElementById('realm-opening'); |
| const book3d = document.getElementById('realm-book-3d'); |
| if (!gate) return; |
| if (book3d) book3d.classList.remove('show-back', 'is-opening'); |
| gate.classList.remove('is-reclosed', 'is-back-visible', 'is-closing'); |
| gate.classList.add('is-opened', 'is-closed'); |
| setAppInteractive(true); |
| playOpeningChime(); |
| ensureAmbientMelody(); |
| }; |
| |
| const goToSpread = (idx, direction = 'next') => { |
| if (idx >= sections.length && direction === 'next') { |
| closeBook(); |
| return; |
| } |
| const clamped = Math.max(0, Math.min(sections.length - 1, idx)); |
| const spread = sections[clamped]; |
| if (!spread) return; |
| applyTurnFx(direction); |
| |
| setActivePanel(spread.panel); |
| resetActiveSpreadLeaf(); |
| wireMobileSpreads(); |
| |
| currentSpread = clamped; |
| window._aetherCurrentSpread = spread.key; |
| updatePageBadge(); |
| closeToc(); |
| updateWalkPadVisibility(); |
| |
| const gate = document.getElementById('realm-opening'); |
| if (gate && gate.classList.contains('is-opened')) { |
| gate.classList.add('is-closed'); |
| } |
| }; |
| |
| const wireTomeNav = () => { |
| if (window._tomeNavBound) return; |
| window._tomeNavBound = true; |
| |
| const root = document.querySelector('.gradio-container'); |
| if (root && !document.getElementById('tome-nav')) { |
| const nav = document.createElement('div'); |
| nav.id = 'tome-nav'; |
| nav.innerHTML = ` |
| <button type="button" id="tome-prev" class="tome-corner tome-corner-left" aria-label="Previous spread">❮</button> |
| <button type="button" id="tome-next" class="tome-corner tome-corner-right" aria-label="Next spread">❯</button> |
| <button type="button" id="tome-toc-open" class="tome-ribbon" aria-label="Open table of contents">Table of Contents</button> |
| <div id="tome-page-indicator" class="tome-page-indicator">Page I</div> |
| <div id="tome-floating-toc" class="tome-floating-toc"> |
| ${sections.map((s, i) => `<button type="button" class="tome-floating-link" data-index="${i}">${toRoman(i + 1)}. ${s.label}</button>`).join('')} |
| </div> |
| <label class="sr-only" for="tome-jump">Jump to section</label> |
| <select id="tome-jump" class="tome-jump-select" aria-label="Jump to section"> |
| ${sections.map((s, i) => `<option value="${i}">${toRoman(i + 1)}. ${s.label}</option>`).join('')} |
| </select> |
| `; |
| document.body.appendChild(nav); |
| } |
| |
| const prev = document.getElementById('tome-prev'); |
| const next = document.getElementById('tome-next'); |
| const tocOpen = document.getElementById('tome-toc-open'); |
| const jump = document.getElementById('tome-jump'); |
| |
| if (prev && !prev.dataset.bound) { |
| prev.dataset.bound = '1'; |
| prev.addEventListener('click', () => goToSpread(currentSpread - 1, 'prev')); |
| } |
| if (next && !next.dataset.bound) { |
| next.dataset.bound = '1'; |
| next.addEventListener('click', () => { |
| if (currentSpread >= sections.length - 1) { |
| closeBook(); |
| return; |
| } |
| goToSpread(currentSpread + 1, 'next'); |
| }); |
| } |
| if (tocOpen && !tocOpen.dataset.bound) { |
| tocOpen.dataset.bound = '1'; |
| tocOpen.addEventListener('click', () => { |
| const floating = document.getElementById('tome-floating-toc'); |
| if (floating) floating.classList.toggle('open'); |
| }); |
| } |
| if (jump && !jump.dataset.bound) { |
| jump.dataset.bound = '1'; |
| jump.addEventListener('change', () => goToSpread(Number(jump.value || 0), 'next')); |
| } |
| |
| document.querySelectorAll('.realm-toc-link').forEach((btn, idx) => { |
| if (btn.dataset.bound) return; |
| btn.dataset.bound = '1'; |
| btn.addEventListener('click', () => goToSpread(idx, idx < currentSpread ? 'prev' : 'next')); |
| }); |
| document.querySelectorAll('.tome-floating-link').forEach((btn) => { |
| if (btn.dataset.bound) return; |
| btn.dataset.bound = '1'; |
| btn.addEventListener('click', () => { |
| const idx = Number(btn.dataset.index || 0); |
| goToSpread(idx, idx < currentSpread ? 'prev' : 'next'); |
| }); |
| }); |
| |
| window.addEventListener('keydown', (e) => { |
| const tag = document.activeElement?.tagName; |
| if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; |
| if (e.key === 'ArrowRight') { |
| if (currentSpread >= sections.length - 1) closeBook(); |
| else goToSpread(currentSpread + 1, 'next'); |
| } |
| if (e.key === 'ArrowLeft') goToSpread(currentSpread - 1, 'prev'); |
| if (e.key === 'Escape') closeToc(); |
| }, { passive: true }); |
| |
| updatePageBadge(); |
| window._aetherCurrentSpread = sections[currentSpread].key; |
| setActivePanel(sections[currentSpread].panel); |
| }; |
| |
| const wirePageExpand = () => { |
| document.querySelectorAll('.tome-expand-btn, #tome-expand-backdrop').forEach((el) => { |
| el.remove(); |
| }); |
| }; |
| |
| const setAppInteractive = (on) => { |
| const app = document.querySelector('.gradio-container'); |
| if (app) app.style.pointerEvents = on ? '' : 'none'; |
| }; |
| |
| const wireOpening = () => { |
| const gate = document.getElementById('realm-opening'); |
| if (!gate || gate.dataset.bound) return; |
| gate.dataset.bound = '1'; |
| if (gate.classList.contains('is-open') && !gate.classList.contains('is-opened')) { |
| setAppInteractive(false); |
| } |
| |
| const book3d = document.getElementById('realm-book-3d'); |
| const closedBook = document.getElementById('realm-book-closed'); |
| const openBook = document.getElementById('realm-book-open'); |
| const prologue = document.getElementById('realm-prologue'); |
| let prologueReady = false; |
| let prologueTypingGen = 0; |
| |
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| |
| const typeText = (el, text, speed = 24, gen = 0) => new Promise((resolve) => { |
| if (!el || !text) { resolve(); return; } |
| let i = 0; |
| el.textContent = ''; |
| el.classList.add('is-typing'); |
| const tick = () => { |
| if (gen !== prologueTypingGen) { |
| el.classList.remove('is-typing'); |
| resolve(); |
| return; |
| } |
| if (i < text.length) { |
| el.textContent += text[i++]; |
| setTimeout(tick, speed + Math.random() * 18); |
| } else { |
| el.classList.remove('is-typing'); |
| resolve(); |
| } |
| }; |
| tick(); |
| }); |
| |
| const setPrologueReady = () => { |
| prologueReady = true; |
| const btn = document.getElementById('realm-prologue-enter'); |
| const skipBtn = document.getElementById('realm-prologue-skip'); |
| const cursor = document.getElementById('realm-prologue-cursor'); |
| if (cursor) cursor.classList.remove('active'); |
| if (skipBtn) skipBtn.classList.add('is-hidden'); |
| if (btn) { |
| btn.disabled = false; |
| btn.classList.add('ready'); |
| } |
| }; |
| |
| const skipPrologue = () => { |
| if (prologueReady) return; |
| prologueTypingGen += 1; |
| const kicker = document.getElementById('realm-prologue-kicker'); |
| const myth = document.getElementById('realm-prologue-myth'); |
| const whisper = document.getElementById('realm-prologue-whisper'); |
| if (kicker) { |
| kicker.textContent = kicker.dataset.text || ''; |
| kicker.classList.remove('is-typing'); |
| } |
| if (myth) { |
| myth.textContent = myth.dataset.text || ''; |
| myth.classList.remove('is-typing'); |
| } |
| if (whisper) { |
| whisper.textContent = whisper.dataset.text || ''; |
| whisper.classList.remove('is-typing'); |
| } |
| setPrologueReady(); |
| }; |
| |
| const runPrologueTyping = async () => { |
| const kicker = document.getElementById('realm-prologue-kicker'); |
| const myth = document.getElementById('realm-prologue-myth'); |
| const whisper = document.getElementById('realm-prologue-whisper'); |
| const skipBtn = document.getElementById('realm-prologue-skip'); |
| const cursor = document.getElementById('realm-prologue-cursor'); |
| const gen = ++prologueTypingGen; |
| if (skipBtn) skipBtn.classList.remove('is-hidden'); |
| if (cursor) cursor.classList.add('active'); |
| |
| if (prefersReduced) { |
| skipPrologue(); |
| return; |
| } |
| |
| await typeText(kicker, kicker?.dataset.text || '', 34, gen); |
| if (gen !== prologueTypingGen) return; |
| await sleep(380); |
| await typeText(myth, myth?.dataset.text || '', 20, gen); |
| if (gen !== prologueTypingGen) return; |
| await sleep(420); |
| await typeText(whisper, whisper?.dataset.text || '', 22, gen); |
| if (gen !== prologueTypingGen) return; |
| setPrologueReady(); |
| }; |
| |
| const hideOpeningShell = () => { |
| const host = document.getElementById('realm-opening-host'); |
| if (host) { |
| host.style.setProperty('display', 'none', 'important'); |
| host.style.setProperty('visibility', 'hidden', 'important'); |
| host.style.setProperty('pointer-events', 'none', 'important'); |
| } |
| const hoisted = document.getElementById('realm-opening'); |
| if (hoisted) { |
| hoisted.classList.add('is-closed'); |
| hoisted.style.setProperty('pointer-events', 'none', 'important'); |
| } |
| document.body.classList.add('aether-realm-entered'); |
| }; |
| |
| const revealRealm = () => { |
| setAppInteractive(true); |
| setActivePanel(sections[currentSpread].panel); |
| updatePageBadge(); |
| const nav = document.getElementById('tome-nav'); |
| if (nav) nav.style.removeProperty('display'); |
| }; |
| |
| const finishOpening = () => { |
| if (!prologueReady) return; |
| playOpeningChime(); |
| ensureAmbientMelody(); |
| gate.classList.remove('is-reclosed'); |
| gate.classList.add('is-opened'); |
| hideOpeningShell(); |
| revealRealm(); |
| closeToc(); |
| setTimeout(() => { |
| gate.classList.add('is-closed'); |
| hideOpeningShell(); |
| revealRealm(); |
| }, 700); |
| }; |
| |
| const beginUnfold = () => { |
| playOpeningChime(); |
| ensureAmbientMelody(); |
| setAppInteractive(false); |
| gate.classList.add('is-unfolding'); |
| if (book3d) book3d.classList.add('is-opening'); |
| if (openBook) openBook.setAttribute('aria-hidden', 'false'); |
| setTimeout(() => { |
| gate.classList.add('is-unfolded', 'is-prologue'); |
| if (prologue) { |
| prologue.setAttribute('aria-hidden', 'false'); |
| prologue.classList.add('visible'); |
| } |
| runPrologueTyping(); |
| }, prefersReduced ? 120 : 1200); |
| }; |
| |
| gate.addEventListener('click', (e) => { |
| const target = e.target; |
| if (!(target instanceof Element)) return; |
| if (gate.classList.contains('is-reclosed') && |
| (target.id === 'realm-opening-enter' || target.id === 'realm-reopen-enter')) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| reopenBook(); |
| return; |
| } |
| if (target.id === 'realm-opening-enter') { |
| e.preventDefault(); |
| e.stopPropagation(); |
| beginUnfold(); |
| } |
| if (target.id === 'realm-prologue-skip') { |
| e.preventDefault(); |
| e.stopPropagation(); |
| skipPrologue(); |
| return; |
| } |
| if (target.id === 'realm-prologue-enter' && prologueReady) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| finishOpening(); |
| } |
| }); |
| |
| window.addEventListener('keydown', (e) => { |
| if (!prologueReady || !gate.classList.contains('is-prologue')) return; |
| const tag = document.activeElement?.tagName; |
| if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| finishOpening(); |
| } |
| }, { passive: false }); |
| }; |
| |
| const dedupeOpening = () => { |
| const hoisted = document.body.querySelector('#realm-opening.realm-opening-hoisted') |
| || document.body.querySelector('#realm-opening'); |
| if (hoisted) { |
| document.querySelectorAll('#realm-opening').forEach((el) => { |
| if (el !== hoisted) el.remove(); |
| }); |
| } |
| const host = document.getElementById('realm-opening-host'); |
| if (host) { |
| host.querySelectorAll('#realm-opening').forEach((el) => el.remove()); |
| if (!host.querySelector('#realm-opening')) { |
| host.style.setProperty('display', 'none', 'important'); |
| host.style.setProperty('visibility', 'hidden', 'important'); |
| host.style.setProperty('pointer-events', 'none', 'important'); |
| } |
| } |
| }; |
| |
| const hoistOpening = () => { |
| if (window._aetherOpeningHoisted) { |
| dedupeOpening(); |
| return; |
| } |
| const gate = document.getElementById('realm-opening'); |
| const critical = document.getElementById('realm-opening-critical'); |
| const host = document.getElementById('realm-opening-host'); |
| if (gate && gate.parentElement !== document.body) { |
| window._aetherOpeningHoisted = true; |
| gate.classList.add('realm-opening-hoisted'); |
| document.body.appendChild(gate); |
| if (critical && critical.parentElement !== document.head) { |
| document.head.appendChild(critical); |
| } |
| } else if (gate && gate.parentElement === document.body) { |
| window._aetherOpeningHoisted = true; |
| gate.classList.add('realm-opening-hoisted'); |
| } |
| dedupeOpening(); |
| if (host && !host.querySelector('#realm-opening')) { |
| host.style.setProperty('display', 'none', 'important'); |
| host.style.setProperty('visibility', 'hidden', 'important'); |
| host.style.setProperty('pointer-events', 'none', 'important'); |
| } |
| }; |
| hoistOpening(); |
| |
| wireOpening(); |
| wireTomeNav(); |
| wireMobileSpreads(); |
| wirePageExpand(); |
| |
| const gateOnLoad = document.getElementById('realm-opening'); |
| if (gateOnLoad && gateOnLoad.classList.contains('is-open') && !gateOnLoad.classList.contains('is-opened')) { |
| setAppInteractive(false); |
| } |
| |
| const triggerGradioInput = (elemId, value) => { |
| const clean = String(elemId || '').replace(/^#/, ''); |
| let root = document.getElementById(clean) |
| || document.querySelector('#' + clean) |
| || document.querySelector('[id="' + clean + '"]'); |
| if (!root) { |
| const cfg = window.gradio_config?.components || []; |
| const comp = cfg.find((c) => c?.props?.elem_id === clean); |
| if (comp) root = document.getElementById('component-' + comp.id); |
| } |
| if (!root) { |
| root = document.querySelector('.aether-hidden-pick#' + clean) |
| || document.querySelector('.aether-hidden-pick[id="' + clean + '"]'); |
| } |
| if (!root) return false; |
| const input = (root.tagName === 'TEXTAREA' || root.tagName === 'INPUT') ? root |
| : (root.querySelector('textarea:not([disabled])') |
| || root.querySelector('input[type="text"]') |
| || root.querySelector('input:not([type="hidden"])')); |
| if (!input) return false; |
| const proto = input.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; |
| const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; |
| const next = String(value); |
| if (setter) setter.call(input, next); |
| else input.value = next; |
| input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); |
| input.dispatchEvent(new Event('change', { bubbles: true, composed: true })); |
| return true; |
| }; |
| |
| const ensureLocationModal = () => { |
| let modal = document.getElementById('aether-location-modal'); |
| if (modal) return modal; |
| modal = document.createElement('div'); |
| modal.id = 'aether-location-modal'; |
| modal.className = 'location-modal-overlay'; |
| modal.innerHTML = ` |
| <div class="location-modal-card"> |
| <div class="location-modal-close" role="button" tabindex="0" aria-label="Close">✕</div> |
| <div class="location-panel location-panel-stacked open"> |
| <div class="location-panel-image-wrap"> |
| <img class="location-panel-image" src="" alt="" /> |
| </div> |
| <div class="location-panel-content"> |
| <h3 class="location-panel-name"></h3> |
| <p class="location-panel-desc"></p> |
| <p class="location-panel-special"></p> |
| <p class="location-soul-count"></p> |
| </div> |
| </div> |
| </div>`; |
| document.body.appendChild(modal); |
| const closeBtn = modal.querySelector('.location-modal-close'); |
| if (closeBtn) { |
| closeBtn.addEventListener('click', () => modal.classList.remove('is-open')); |
| closeBtn.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| modal.classList.remove('is-open'); |
| } |
| }); |
| } |
| modal.addEventListener('click', (e) => { |
| if (e.target === modal) modal.classList.remove('is-open'); |
| }); |
| return modal; |
| }; |
| |
| const openLocationFromNode = (node) => { |
| if (!node) return; |
| const id = node.getAttribute('data-location-id'); |
| if (!id) return; |
| const modal = ensureLocationModal(); |
| const name = node.getAttribute('data-name') || 'Unknown place'; |
| const desc = node.getAttribute('data-desc') || ''; |
| const special = node.getAttribute('data-special') || ''; |
| const souls = node.getAttribute('data-souls') || '0'; |
| const img = node.getAttribute('data-img') || ''; |
| const nameEl = modal.querySelector('.location-panel-name'); |
| const descEl = modal.querySelector('.location-panel-desc'); |
| const specialEl = modal.querySelector('.location-panel-special'); |
| const soulsEl = modal.querySelector('.location-soul-count'); |
| const imgEl = modal.querySelector('.location-panel-image'); |
| const imgWrap = modal.querySelector('.location-panel-image-wrap'); |
| if (nameEl) nameEl.textContent = name; |
| if (descEl) descEl.textContent = desc; |
| if (specialEl) { |
| specialEl.textContent = special ? ('✦ ' + special) : ''; |
| specialEl.style.display = special ? '' : 'none'; |
| } |
| if (soulsEl) soulsEl.textContent = souls + ' souls currently here'; |
| if (imgEl) { |
| imgEl.src = img || ''; |
| imgEl.alt = name; |
| } |
| if (imgWrap) imgWrap.style.display = img ? '' : 'none'; |
| modal.classList.add('is-open'); |
| document.querySelectorAll('.location-node').forEach((n) => { |
| n.classList.toggle('location-selected', n.getAttribute('data-location-id') === id); |
| }); |
| triggerGradioInput('map_location_pick', id); |
| }; |
| |
| const bind = () => { |
| document.querySelectorAll('#world-map .location-node, [id="world-map"] .location-node').forEach((node) => { |
| node.style.cursor = 'pointer'; |
| }); |
| }; |
| |
| if (!window._aetherMapDelegationBound) { |
| window._aetherMapDelegationBound = true; |
| const mapPlaceTarget = (target) => { |
| if (!(target instanceof Element)) return null; |
| const hit = target.closest('.location-hit, .location-circle, .location-label, .location-count'); |
| if (!hit) return null; |
| const node = hit.closest('.location-node[data-location-id]'); |
| if (!node) return null; |
| const inMap = node.closest('#world-map') || node.closest('[id="world-map"]'); |
| return inMap ? node : null; |
| }; |
| document.addEventListener('click', (e) => { |
| const node = mapPlaceTarget(e.target); |
| if (!node) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| openLocationFromNode(node); |
| }); |
| } |
| const fireHiddenInput = (id, value) => { |
| const elemId = (id || '').replace(/^#/, ''); |
| triggerGradioInput(elemId, value); |
| }; |
| |
| const wireClickable = (el, handler) => { |
| if (!el || el.dataset.boundClick) return; |
| el.dataset.boundClick = '1'; |
| el.addEventListener('click', handler); |
| el.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| handler(e); |
| } |
| }); |
| }; |
| |
| const closeLocationModal = () => { |
| document.querySelectorAll('.location-modal-overlay').forEach((m) => m.classList.remove('is-open')); |
| }; |
| window._aetherCloseLocationModal = closeLocationModal; |
| |
| const bindSpreadClicks = () => { |
| document.querySelectorAll('.place-ribbon-item').forEach((btn) => { |
| wireClickable(btn, () => { |
| const id = btn.getAttribute('data-location-id'); |
| if (!id) return; |
| document.querySelectorAll('.place-ribbon-item').forEach((el) => { |
| el.classList.toggle('is-selected', el.getAttribute('data-location-id') === id); |
| }); |
| fireHiddenInput('explore_place_pick', id); |
| if (window.matchMedia('(max-width: 920px)').matches) { |
| requestAnimationFrame(() => { |
| const stage = document.querySelector('.tome-explore-stage .diorama-frame') |
| || document.querySelector('.tome-explore-stage'); |
| stage?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| }); |
| } |
| }); |
| }); |
| document.querySelectorAll('.day-tab').forEach((btn) => { |
| wireClickable(btn, () => { |
| const day = btn.getAttribute('data-day'); |
| if (day) fireHiddenInput('#book_day_pick', day); |
| }); |
| }); |
| document.querySelectorAll('.entity-grid-card').forEach((card) => { |
| const id = card.getAttribute('data-entity-id'); |
| if (!id) return; |
| card.style.cursor = 'pointer'; |
| if (card.dataset.entityBound === id) return; |
| card.dataset.entityBound = id; |
| wireClickable(card, () => { |
| document.querySelectorAll('.entity-grid-card').forEach((el) => { |
| el.classList.toggle('is-selected', el.getAttribute('data-entity-id') === id); |
| }); |
| fireHiddenInput('entity_pick', id); |
| }); |
| }); |
| document.querySelectorAll('.location-modal-close').forEach((btn) => { |
| wireClickable(btn, closeLocationModal); |
| }); |
| document.querySelectorAll('.location-modal-overlay').forEach((overlay) => { |
| if (overlay.dataset.overlayBound) return; |
| overlay.dataset.overlayBound = '1'; |
| overlay.addEventListener('click', (e) => { |
| if (e.target === overlay) closeLocationModal(); |
| }); |
| }); |
| }; |
| |
| bind(); |
| bindSpreadClicks(); |
| new MutationObserver(() => { |
| bind(); |
| bindSpreadClicks(); |
| }).observe(document.body, { childList: true, subtree: true }); |
| |
| window._aetherHoverDiorama = false; |
| window._aetherDioramaActive = false; |
| window._aetherActiveFrame = null; |
| const GAME_KEYS = new Set([ |
| 'KeyW','KeyA','KeyS','KeyD', |
| 'ArrowUp','ArrowDown','ArrowLeft','ArrowRight', |
| 'KeyE','KeyQ','ShiftLeft','ShiftRight','Escape' |
| ]); |
| |
| const getVisibleDioramaFrame = () => { |
| const frames = Array.from(document.querySelectorAll('iframe.diorama-frame')); |
| let best = null; |
| let bestArea = 0; |
| for (const frame of frames) { |
| const rect = frame.getBoundingClientRect(); |
| if (rect.width < 40 || rect.height < 40) continue; |
| const area = rect.width * rect.height; |
| if (area > bestArea) { |
| bestArea = area; |
| best = frame; |
| } |
| } |
| return best || frames[frames.length - 1] || null; |
| }; |
| |
| const sendKeyToFrame = (frame, code, action) => { |
| if (!frame) return; |
| try { |
| frame.contentWindow?.postMessage({ type: 'aether_key', code, action }, '*'); |
| } catch (_) {} |
| }; |
| |
| const sendWalkToFrame = (frame, fwd, strafe) => { |
| if (!frame) return; |
| try { |
| frame.contentWindow?.postMessage({ type: 'aether_walk', fwd, strafe }, '*'); |
| } catch (_) {} |
| }; |
| |
| window._walkHeld = window._walkHeld || new Set(); |
| const walkVectorFromHeld = () => { |
| let fwd = 0, strafe = 0; |
| if (window._walkHeld.has('KeyW')) fwd += 1; |
| if (window._walkHeld.has('KeyS')) fwd -= 1; |
| if (window._walkHeld.has('KeyA')) strafe -= 1; |
| if (window._walkHeld.has('KeyD')) strafe += 1; |
| if (fwd !== 0 && strafe !== 0) { |
| const n = Math.hypot(fwd, strafe); |
| fwd /= n; |
| strafe /= n; |
| } |
| return { fwd, strafe }; |
| }; |
| |
| const syncWalkToVisibleFrame = () => { |
| const frame = getVisibleDioramaFrame() || window._aetherActiveFrame; |
| if (!frame) return; |
| const { fwd, strafe } = walkVectorFromHeld(); |
| sendWalkToFrame(frame, fwd, strafe); |
| }; |
| |
| const ensureGlobalWalkPad = () => { |
| let el = document.getElementById('aether-walk-pad'); |
| if (!el) { |
| el = document.createElement('div'); |
| el.id = 'aether-walk-pad'; |
| el.setAttribute('aria-label', 'Walk'); |
| el.innerHTML = ` |
| <div class="awp-pad"> |
| <span class="awp-spacer"></span> |
| <button type="button" class="awp-btn" data-key="KeyW">W</button> |
| <span class="awp-spacer"></span> |
| <button type="button" class="awp-btn" data-key="KeyA">A</button> |
| <button type="button" class="awp-btn" data-key="KeyS">S</button> |
| <button type="button" class="awp-btn" data-key="KeyD">D</button> |
| </div>`; |
| } |
| const stage = getVisibleDioramaFrame()?.closest('.diorama-stage'); |
| if (stage && el.parentElement !== stage) { |
| stage.appendChild(el); |
| } |
| }; |
| |
| const updateWalkPadVisibility = () => { |
| ensureGlobalWalkPad(); |
| const pad = document.getElementById('aether-walk-pad'); |
| if (!pad) return; |
| const frame = getVisibleDioramaFrame(); |
| const stage = frame?.closest('.diorama-stage'); |
| if (stage && pad.parentElement !== stage) stage.appendChild(pad); |
| const show = !!frame && !!stage && !pad.classList.contains('controls-hidden') && window._aetherCurrentSpread === 'explore'; |
| pad.classList.toggle('visible', show); |
| pad.classList.toggle('explore-only', show); |
| }; |
| |
| const wireWalkPad = () => { |
| ensureGlobalWalkPad(); |
| document.querySelectorAll('#aether-walk-pad .awp-btn').forEach((btn) => { |
| if (btn.dataset.walkWired) return; |
| btn.dataset.walkWired = '1'; |
| const code = btn.dataset.key; |
| if (!code) return; |
| const down = (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| btn.classList.add('pressed'); |
| window._walkHeld.add(code); |
| try { btn.setPointerCapture(e.pointerId); } catch (_) {} |
| syncWalkToVisibleFrame(); |
| }; |
| const up = (e) => { |
| e.preventDefault(); |
| btn.classList.remove('pressed'); |
| window._walkHeld.delete(code); |
| syncWalkToVisibleFrame(); |
| }; |
| btn.addEventListener('pointerdown', down); |
| btn.addEventListener('pointerup', up); |
| btn.addEventListener('pointercancel', up); |
| btn.addEventListener('lostpointercapture', up); |
| }); |
| updateWalkPadVisibility(); |
| }; |
| |
| const wireDioramaFrames = () => { |
| document.querySelectorAll('.diorama-wrap').forEach((wrap) => { |
| if (wrap.dataset.dioramaWrapBound) return; |
| wrap.dataset.dioramaWrapBound = '1'; |
| const frame = wrap.querySelector('iframe.diorama-frame'); |
| const activate = () => { |
| window._aetherHoverDiorama = true; |
| window._aetherActiveFrame = frame || window._aetherActiveFrame; |
| if (frame) { |
| try { frame.focus({ preventScroll: true }); } catch (_) {} |
| try { frame.contentWindow?.postMessage({ type: 'aether_activate' }, '*'); } catch (_) {} |
| } |
| }; |
| wrap.addEventListener('mouseenter', activate); |
| wrap.addEventListener('mousemove', () => { |
| window._aetherHoverDiorama = true; |
| window._aetherActiveFrame = frame || window._aetherActiveFrame; |
| }); |
| wrap.addEventListener('mouseleave', () => { |
| if (!window._aetherDioramaActive) window._aetherHoverDiorama = false; |
| }); |
| wrap.addEventListener('mousedown', activate); |
| wrap.addEventListener('click', activate); |
| }); |
| |
| document.querySelectorAll('iframe.diorama-frame').forEach((frame) => { |
| if (frame.dataset.dioramaBound) return; |
| frame.dataset.dioramaBound = '1'; |
| frame.setAttribute('tabindex', '0'); |
| window._aetherActiveFrame = frame; |
| frame.addEventListener('load', () => { |
| window._aetherActiveFrame = frame; |
| setTimeout(() => { try { frame.contentWindow?.postMessage('diorama-resize', '*'); } catch (_) {} }, 120); |
| setTimeout(() => { try { frame.contentWindow?.postMessage('diorama-resize', '*'); } catch (_) {} }, 600); |
| setTimeout(() => { try { frame.contentWindow?.postMessage({ type: 'aether_activate' }, '*'); } catch (_) {} }, 200); |
| }); |
| }); |
| }; |
| wireDioramaFrames(); |
| wireWalkPad(); |
| new MutationObserver(() => { |
| wireDioramaFrames(); |
| wireWalkPad(); |
| updateWalkPadVisibility(); |
| wirePageExpand(); |
| }).observe(document.body, { childList: true, subtree: true }); |
| setInterval(updateWalkPadVisibility, 800); |
| |
| if (!window._walkHeldSyncBound) { |
| window._walkHeldSyncBound = true; |
| document.addEventListener('pointerup', () => { |
| document.querySelectorAll('#aether-walk-pad .awp-btn.pressed').forEach((btn) => { |
| btn.classList.remove('pressed'); |
| window._walkHeld.delete(btn.dataset.key); |
| }); |
| syncWalkToVisibleFrame(); |
| }, true); |
| setInterval(() => { |
| if (window._walkHeld.size) syncWalkToVisibleFrame(); |
| }, 40); |
| } |
| |
| if (!window._aetherKeyForwardBound) { |
| window._aetherKeyForwardBound = true; |
| const typingInForm = () => { |
| const el = document.activeElement; |
| if (!el) return false; |
| const tag = el.tagName; |
| if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; |
| if (el.isContentEditable) return true; |
| if (el.closest?.('textarea, input, [contenteditable="true"]')) return true; |
| const frame = getActiveFrame(); |
| try { |
| const iel = frame?.contentDocument?.activeElement; |
| if (iel && (iel.tagName === 'INPUT' || iel.tagName === 'TEXTAREA' || iel.isContentEditable)) return true; |
| } catch (_) {} |
| return false; |
| }; |
| const getActiveFrame = () => getVisibleDioramaFrame() || window._aetherActiveFrame || null; |
| const MOVE_KEYS = new Set([ |
| 'KeyW','KeyA','KeyS','KeyD', |
| 'ArrowUp','ArrowDown','ArrowLeft','ArrowRight' |
| ]); |
| const forwardKey = (e, action) => { |
| if (!GAME_KEYS.has(e.code)) return; |
| if (typingInForm()) return; |
| const frame = getActiveFrame(); |
| if (!frame) return; |
| const rect = frame.getBoundingClientRect(); |
| if (rect.width < 40 || rect.height < 40) return; |
| e.preventDefault(); |
| e.stopImmediatePropagation(); |
| if (MOVE_KEYS.has(e.code)) { |
| if (action === 'down') window._walkHeld.add(e.code); |
| else window._walkHeld.delete(e.code); |
| syncWalkToVisibleFrame(); |
| return; |
| } |
| sendKeyToFrame(frame, e.code, action); |
| }; |
| window.addEventListener('keydown', (e) => forwardKey(e, 'down'), true); |
| window.addEventListener('keyup', (e) => forwardKey(e, 'up'), true); |
| } |
| |
| if (!window._aetherFocusRecheckBound) { |
| window._aetherFocusRecheckBound = true; |
| setInterval(() => { |
| if (!window._aetherDioramaActive) return; |
| const el = document.activeElement; |
| const typing = el && ( |
| el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' |
| || el.tagName === 'SELECT' || el.isContentEditable |
| ); |
| if (typing) return; |
| const frame = window._aetherActiveFrame |
| || document.querySelector('iframe.diorama-frame'); |
| if (!frame) return; |
| try { frame.contentWindow?.postMessage({ type: 'aether_activate' }, '*'); } catch (_) {} |
| }, 12000); |
| } |
| |
| // Portal navigation from diorama iframe |
| if (!window._aetherPortalListenerBound) { |
| window._aetherPortalListenerBound = true; |
| window.addEventListener('message', (e) => { |
| if (e.data && e.data.type === 'aether_register_frame') { |
| const frames = Array.from(document.querySelectorAll('iframe.diorama-frame')); |
| const active = frames.find((frame) => frame.contentWindow === e.source); |
| if (active) { |
| window._aetherActiveFrame = active; |
| window._aetherHoverDiorama = true; |
| window._aetherDioramaActive = true; |
| } |
| return; |
| } |
| if (e.data && e.data.type === 'aether_diorama_active') { |
| const frames = Array.from(document.querySelectorAll('iframe.diorama-frame')); |
| const active = frames.find((frame) => frame.contentWindow === e.source); |
| if (active) { |
| window._aetherActiveFrame = active; |
| window._aetherHoverDiorama = true; |
| window._aetherDioramaActive = true; |
| } |
| return; |
| } |
| if (e.data && e.data.type === 'aether_profile') { |
| const pad = document.getElementById('aether-walk-pad'); |
| if (pad) pad.classList.toggle('controls-hidden', !!e.data.open); |
| if (e.data.open) { |
| window._walkHeld.clear(); |
| syncWalkToVisibleFrame(); |
| } |
| updateWalkPadVisibility(); |
| return; |
| } |
| if (!e.data || e.data.type !== 'aether_portal') return; |
| const id = String(e.data.locationId || ''); |
| if (!id) return; |
| // Try explore tab portal_pick first |
| const pp = document.querySelector('#portal_location_pick'); |
| const ppInput = pp && (pp.querySelector('textarea') || pp.querySelector('input')); |
| if (ppInput) { |
| ppInput.value = id; |
| ppInput.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| // Also update map_pick to sync realm tab |
| const mp = document.querySelector('#map_location_pick'); |
| const mpInput = mp && (mp.querySelector('textarea') || mp.querySelector('input')); |
| if (mpInput) { |
| mpInput.value = id; |
| mpInput.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| }); |
| } |
| } |
| """ |
| demo.load(None, None, None, js=MAP_CLICK_JS) |
|
|
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| demo = build_app() |
| launch_kwargs: dict = { |
| "server_name": "0.0.0.0", |
| "server_port": int(os.environ.get("PORT", 7860)), |
| "share": False, |
| "allowed_paths": assets.allowed_paths() + [ |
| str(Path(tempfile.gettempdir()) / "aether_soul_cards"), |
| str(Path(__file__).parent / "assets" / "generated"), |
| ], |
| } |
| if GRADIO_MAJOR >= 6: |
| launch_kwargs["css"] = CUSTOM_CSS |
| launch_kwargs["theme"] = REALM_THEME |
| demo.launch(**launch_kwargs) |
|
|