aether-garden / app.py
kavyabhand's picture
Deploy Aether Garden application
c68d910 verified
Raw
History Blame Contribute Delete
119 kB
"""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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
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:
# Fallback to greeting if AI unavailable
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()))
# Pickers at root — must stay mounted (Gradio 5 skips visible=False from DOM)
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 button ──
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])
# ── Soul chat API ──
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 API ──
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",
)
# ── Diorama multiplayer presence API ──
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 heartbeat (every 10s) ──
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)