"""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"""
Book of Ages, Vol. I
AETHERGARDEN
The Living Tome
❧
End of Vol. I
The Realm remembereth all things. Close the cover — until thou returnest.
Whisper what you would bring into the Garden on the left leaf.
Their name, face, and story shall manifest here.
"""
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"""
The Oracle is dreaming
Shaping {whisper} into a soul — giving it a name,
a face, a fear, and a home among the eight places…
The first breath can take a moment. The Realm is listening.
If the Oracle is waking on the GPU, this can take up to a minute…
The bonfire shifts colour while it waits. Something is being born.
"""
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 (
'
'
'
Click a glowing place on the map to unveil its secrets.
'
'
'
)
location = get_location_by_id(location_id)
if not location:
return ''
name = _esc(location["name"])
desc = _esc(location.get("short_description", ""))
return (
'
',
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 '
Select an entity to view their profile.
'
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 '
Entity not found.
'
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"""
World Day {day}{count} souls awake8 sacred places
"""
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'⬡ {count} visitors in the Realm right now')
if arrivals:
names = ", ".join(a["display_name"] for a in arrivals[:3])
parts.append(f'Recently summoned: {names}')
if not parts:
return ""
return f'
{" · ".join(parts)}
'
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'
'
f'
✦ Day {day} advanced
'
f'
The world event: {event}
'
f'
{interactions} soul encounter{"s" if interactions != 1 else ""} unfolded.
')
with gr.Column(elem_classes=["tome-offpage-store"]):
realm_diorama = gr.HTML(value="")
with gr.Accordion("How this world works", open=False):
gr.HTML(
'
'
'
1 · You summon. Describe anything below and an AI gives it a name, '
'a look, goals, fears, and a home among the eight places.
'
'
2 · The world lives on its own. Every hour, even when no one is here, '
'the souls wander, meet, form bonds, and the land throws up strange events.
'
'
3 · Nothing is forgotten. Every arrival, meeting, and event is written '
'into the Book of Ages forever. Turn the page to Explore or open the '
'Book of Ages for the full chronicle.
'
'
'
)
with gr.Accordion("⚡ Advance the World Now", open=False):
gr.HTML(
'
Manually trigger one simulation tick — the world will '
'generate a new event, run soul encounters, and update memories in real time.
'
)
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(
'
'
'
WASD walk · drag look · E talk · golden wisps are live visitors
'
'
'
)
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('
Souls of the Garden
')
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(
'
✦ ─── ❧ ─── ✦
'
'
Summon Into the Realm
'
'
Describe what you bring on this leaf — the Oracle '
'forges their identity on the right.
'
)
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(
'