Spaces:
Sleeping
Sleeping
Deploy Aether Garden application
Browse files- .gitattributes +1 -0
- README_HF_SPACE.md +17 -0
- ai/__init__.py +0 -0
- ai/events.py +149 -0
- ai/generation.py +162 -0
- ai/interaction.py +64 -0
- ai/modal_client.py +145 -0
- ai/prompts.py +163 -0
- app.py +323 -0
- assets/README.md +42 -0
- assets/banner.png +3 -0
- persistence/__init__.py +0 -0
- persistence/backup.py +65 -0
- requirements.txt +7 -0
- run_tick.py +16 -0
- seed_world.py +186 -0
- simulation/__init__.py +0 -0
- simulation/lifecycle.py +74 -0
- simulation/tick.py +224 -0
- ui/__init__.py +0 -0
- ui/book_display.py +121 -0
- ui/entity_card.py +118 -0
- ui/map.py +135 -0
- ui/styles.css +574 -0
- world/__init__.py +0 -0
- world/book_of_ages.py +103 -0
- world/database.py +196 -0
- world/entities.py +248 -0
- world/events.py +112 -0
- world/locations.py +59 -0
- world/relationships.py +145 -0
- world/seed_data.py +471 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/banner.png filter=lfs diff=lfs merge=lfs -text
|
README_HF_SPACE.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Aether Garden
|
| 3 |
+
emoji: 🌙
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.9.1
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Aether Garden
|
| 14 |
+
|
| 15 |
+
The World That Remembers — a living AI world where every visitor leaves something behind.
|
| 16 |
+
|
| 17 |
+
Open the app, read the Book of Ages, summon something strange, and watch the world remember.
|
ai/__init__.py
ADDED
|
File without changes
|
ai/events.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""World event generation orchestration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
from ai import prompts
|
| 8 |
+
from ai.modal_client import extract_json, generate
|
| 9 |
+
from world.database import db_session
|
| 10 |
+
from world.events import get_recent_events
|
| 11 |
+
from world.relationships import get_most_connected_entity
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class WorldEventResult(BaseModel):
|
| 15 |
+
event_type: str
|
| 16 |
+
title: str
|
| 17 |
+
description: str
|
| 18 |
+
affected_locations: list[str]
|
| 19 |
+
entity_effect: str
|
| 20 |
+
book_of_ages_entry: str
|
| 21 |
+
mystery_hook: str | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def generate_world_event(world_day: int) -> WorldEventResult:
|
| 25 |
+
stats = _get_world_stats()
|
| 26 |
+
recent = get_recent_events(3)
|
| 27 |
+
recent_strs = [
|
| 28 |
+
f"{e['title']} ({e['event_type']})" for e in recent
|
| 29 |
+
]
|
| 30 |
+
while len(recent_strs) < 3:
|
| 31 |
+
recent_strs.append("None yet")
|
| 32 |
+
|
| 33 |
+
most_connected = get_most_connected_entity()
|
| 34 |
+
newest, oldest = _get_newest_oldest()
|
| 35 |
+
|
| 36 |
+
user_prompt = prompts.WORLD_EVENT_USER.format(
|
| 37 |
+
world_day=world_day,
|
| 38 |
+
entity_count=stats["total"],
|
| 39 |
+
active_count=stats["active"],
|
| 40 |
+
dormant_count=stats["dormant"],
|
| 41 |
+
legendary_count=stats["legendary"],
|
| 42 |
+
location_populations=stats["location_populations"],
|
| 43 |
+
most_connected_entity_name=most_connected["name"] if most_connected else "None",
|
| 44 |
+
most_connected_entity_relationships=most_connected["relationship_count"] if most_connected else 0,
|
| 45 |
+
newest_entity_name=newest["name"] if newest else "None",
|
| 46 |
+
newest_entity_day=newest["days_in_realm"] if newest else 0,
|
| 47 |
+
oldest_entity_name=oldest["name"] if oldest else "None",
|
| 48 |
+
oldest_entity_days=oldest["days_in_realm"] if oldest else 0,
|
| 49 |
+
recent_event_1=recent_strs[0],
|
| 50 |
+
recent_event_2=recent_strs[1],
|
| 51 |
+
recent_event_3=recent_strs[2],
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
raw = generate(prompts.WORLD_EVENT_SYSTEM, user_prompt, temperature=0.9)
|
| 55 |
+
data = extract_json(raw)
|
| 56 |
+
return WorldEventResult(**data)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def update_entity_memory(
|
| 60 |
+
entity: dict,
|
| 61 |
+
recent_interactions: list[str],
|
| 62 |
+
world_events: list[str],
|
| 63 |
+
) -> str:
|
| 64 |
+
user_prompt = prompts.MEMORY_UPDATE_USER.format(
|
| 65 |
+
entity_name=entity["name"],
|
| 66 |
+
entity_type=entity["type"],
|
| 67 |
+
entity_traits=", ".join(entity["personality_traits"]),
|
| 68 |
+
days_in_realm=entity["days_in_realm"],
|
| 69 |
+
current_memory_summary=entity["memory_summary"] or "Nothing remembered yet.",
|
| 70 |
+
recent_interactions_formatted="\n".join(f"- {i}" for i in recent_interactions) or "None",
|
| 71 |
+
relevant_world_events_formatted="\n".join(f"- {e}" for e in world_events) or "None",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return generate(
|
| 75 |
+
prompts.MEMORY_UPDATE_SYSTEM,
|
| 76 |
+
user_prompt,
|
| 77 |
+
max_new_tokens=300,
|
| 78 |
+
temperature=0.7,
|
| 79 |
+
).strip()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def generate_dream_fragment(entity: dict, days_dormant: int) -> str:
|
| 83 |
+
user_prompt = prompts.DREAM_FRAGMENT_USER.format(
|
| 84 |
+
entity_name=entity["name"],
|
| 85 |
+
entity_type=entity["type"],
|
| 86 |
+
entity_traits=", ".join(entity["personality_traits"]),
|
| 87 |
+
last_memory_summary=entity["memory_summary"] or "Only silence.",
|
| 88 |
+
days_dormant=days_dormant,
|
| 89 |
+
)
|
| 90 |
+
return generate(
|
| 91 |
+
prompts.DREAM_FRAGMENT_SYSTEM,
|
| 92 |
+
user_prompt,
|
| 93 |
+
max_new_tokens=200,
|
| 94 |
+
temperature=0.95,
|
| 95 |
+
).strip()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _get_world_stats() -> dict:
|
| 99 |
+
with db_session() as conn:
|
| 100 |
+
total = conn.execute("SELECT COUNT(*) as c FROM entities").fetchone()["c"]
|
| 101 |
+
active = conn.execute(
|
| 102 |
+
"SELECT COUNT(*) as c FROM entities WHERE status = 'active'"
|
| 103 |
+
).fetchone()["c"]
|
| 104 |
+
dormant = conn.execute(
|
| 105 |
+
"SELECT COUNT(*) as c FROM entities WHERE status = 'dormant'"
|
| 106 |
+
).fetchone()["c"]
|
| 107 |
+
legendary = conn.execute(
|
| 108 |
+
"SELECT COUNT(*) as c FROM entities WHERE status = 'legendary'"
|
| 109 |
+
).fetchone()["c"]
|
| 110 |
+
|
| 111 |
+
loc_rows = conn.execute(
|
| 112 |
+
"""
|
| 113 |
+
SELECT l.name, COUNT(e.id) as count
|
| 114 |
+
FROM locations l
|
| 115 |
+
LEFT JOIN entities e ON e.location_id = l.id
|
| 116 |
+
GROUP BY l.id
|
| 117 |
+
ORDER BY count DESC
|
| 118 |
+
"""
|
| 119 |
+
).fetchall()
|
| 120 |
+
loc_pops = "\n".join(f"- {r['name']}: {r['count']} entities" for r in loc_rows)
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"total": total,
|
| 124 |
+
"active": active,
|
| 125 |
+
"dormant": dormant,
|
| 126 |
+
"legendary": legendary,
|
| 127 |
+
"location_populations": loc_pops,
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _get_newest_oldest() -> tuple[dict | None, dict | None]:
|
| 132 |
+
import json
|
| 133 |
+
with db_session() as conn:
|
| 134 |
+
newest_row = conn.execute(
|
| 135 |
+
"SELECT * FROM entities ORDER BY created_at DESC LIMIT 1"
|
| 136 |
+
).fetchone()
|
| 137 |
+
oldest_row = conn.execute(
|
| 138 |
+
"SELECT * FROM entities WHERE status = 'active' ORDER BY days_in_realm DESC LIMIT 1"
|
| 139 |
+
).fetchone()
|
| 140 |
+
|
| 141 |
+
def to_dict(row):
|
| 142 |
+
if not row:
|
| 143 |
+
return None
|
| 144 |
+
d = dict(row)
|
| 145 |
+
d["personality_traits"] = json.loads(d["personality_traits"])
|
| 146 |
+
d["tags"] = json.loads(d["tags"])
|
| 147 |
+
return d
|
| 148 |
+
|
| 149 |
+
return to_dict(newest_row), to_dict(oldest_row)
|
ai/generation.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity generation orchestration and validation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, Field, field_validator
|
| 9 |
+
|
| 10 |
+
from ai import prompts
|
| 11 |
+
from ai.modal_client import extract_json, generate
|
| 12 |
+
from world.book_of_ages import create_entry
|
| 13 |
+
from world.entities import create_entity, record_creation
|
| 14 |
+
from world.locations import get_location_by_name
|
| 15 |
+
|
| 16 |
+
NSFW_KEYWORDS = {
|
| 17 |
+
"porn", "nude", "naked", "sex", "explicit", "nsfw", "xxx",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
VALID_LOCATIONS = {
|
| 21 |
+
"The Library of Unfinished Thoughts",
|
| 22 |
+
"The Sea of Forgotten Names",
|
| 23 |
+
"The Clock Forest",
|
| 24 |
+
"The Moon Market",
|
| 25 |
+
"The Valley of Sleeping Giants",
|
| 26 |
+
"The Ember Crossroads",
|
| 27 |
+
"The Mirror Bogs",
|
| 28 |
+
"The Hollow Mountain",
|
| 29 |
+
# Short forms the model sometimes returns
|
| 30 |
+
"Library of Unfinished Thoughts",
|
| 31 |
+
"Sea of Forgotten Names",
|
| 32 |
+
"Clock Forest",
|
| 33 |
+
"Moon Market",
|
| 34 |
+
"Valley of Sleeping Giants",
|
| 35 |
+
"Ember Crossroads",
|
| 36 |
+
"Mirror Bogs",
|
| 37 |
+
"Hollow Mountain",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
LOCATION_ALIASES = {
|
| 41 |
+
"Library of Unfinished Thoughts": "The Library of Unfinished Thoughts",
|
| 42 |
+
"Sea of Forgotten Names": "The Sea of Forgotten Names",
|
| 43 |
+
"Clock Forest": "The Clock Forest",
|
| 44 |
+
"Moon Market": "The Moon Market",
|
| 45 |
+
"Valley of Sleeping Giants": "The Valley of Sleeping Giants",
|
| 46 |
+
"Ember Crossroads": "The Ember Crossroads",
|
| 47 |
+
"Mirror Bogs": "The Mirror Bogs",
|
| 48 |
+
"Hollow Mountain": "The Hollow Mountain",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class EntityProfile(BaseModel):
|
| 53 |
+
name: str
|
| 54 |
+
display_name: str
|
| 55 |
+
type: str
|
| 56 |
+
appearance: str
|
| 57 |
+
backstory: str
|
| 58 |
+
personality_traits: list[str]
|
| 59 |
+
primary_goal: str
|
| 60 |
+
secondary_goal: str
|
| 61 |
+
primary_fear: str
|
| 62 |
+
speech_style: str
|
| 63 |
+
greeting: str
|
| 64 |
+
suggested_location: str
|
| 65 |
+
arrival_note: str
|
| 66 |
+
|
| 67 |
+
@field_validator("type")
|
| 68 |
+
@classmethod
|
| 69 |
+
def validate_type(cls, v: str) -> str:
|
| 70 |
+
if v not in ("character", "creature", "object", "place"):
|
| 71 |
+
raise ValueError(f"Invalid entity type: {v}")
|
| 72 |
+
return v
|
| 73 |
+
|
| 74 |
+
@field_validator("personality_traits")
|
| 75 |
+
@classmethod
|
| 76 |
+
def validate_traits(cls, v: list[str]) -> list[str]:
|
| 77 |
+
if len(v) != 4:
|
| 78 |
+
raise ValueError("Must have exactly 4 personality traits")
|
| 79 |
+
return v
|
| 80 |
+
|
| 81 |
+
@field_validator("suggested_location")
|
| 82 |
+
@classmethod
|
| 83 |
+
def validate_location(cls, v: str) -> str:
|
| 84 |
+
if v not in VALID_LOCATIONS:
|
| 85 |
+
raise ValueError(f"Invalid location: {v}")
|
| 86 |
+
return v
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def validate_input(user_input: str) -> tuple[bool, str]:
|
| 90 |
+
words = user_input.strip().split()
|
| 91 |
+
if len(words) < 5:
|
| 92 |
+
return False, "Describe your creation in at least 5 words. The Realm needs more to work with."
|
| 93 |
+
if len(words) > 200:
|
| 94 |
+
return False, "Your description is too long. Keep it under 200 words."
|
| 95 |
+
|
| 96 |
+
lower = user_input.lower()
|
| 97 |
+
for kw in NSFW_KEYWORDS:
|
| 98 |
+
if kw in lower:
|
| 99 |
+
return False, "The Realm cannot absorb that kind of creation. Please rephrase."
|
| 100 |
+
|
| 101 |
+
return True, ""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def generate_entity(
|
| 105 |
+
user_input: str,
|
| 106 |
+
session_id: str | None = None,
|
| 107 |
+
) -> tuple[Optional[dict], str]:
|
| 108 |
+
valid, msg = validate_input(user_input)
|
| 109 |
+
if not valid:
|
| 110 |
+
return None, msg
|
| 111 |
+
|
| 112 |
+
user_prompt = prompts.ENTITY_GENERATION_USER.format(user_input=user_input)
|
| 113 |
+
|
| 114 |
+
for attempt, temp in enumerate([0.8, 0.5]):
|
| 115 |
+
try:
|
| 116 |
+
raw = generate(
|
| 117 |
+
prompts.ENTITY_GENERATION_SYSTEM,
|
| 118 |
+
user_prompt,
|
| 119 |
+
temperature=temp,
|
| 120 |
+
)
|
| 121 |
+
data = extract_json(raw)
|
| 122 |
+
if data.get("suggested_location") in LOCATION_ALIASES:
|
| 123 |
+
data["suggested_location"] = LOCATION_ALIASES[data["suggested_location"]]
|
| 124 |
+
profile = EntityProfile(**data)
|
| 125 |
+
|
| 126 |
+
entity_data = profile.model_dump()
|
| 127 |
+
location = get_location_by_name(profile.suggested_location)
|
| 128 |
+
|
| 129 |
+
if location and location["slug"] == "sea":
|
| 130 |
+
entity_data["secret_name_generated"] = _generate_secret_name(profile.name)
|
| 131 |
+
|
| 132 |
+
entity = create_entity(entity_data, user_input, session_id)
|
| 133 |
+
|
| 134 |
+
arrival_content = (
|
| 135 |
+
f"On Day {entity.get('world_day', 1)}, {profile.name} arrived in "
|
| 136 |
+
f"{profile.suggested_location}. {profile.arrival_note}"
|
| 137 |
+
)
|
| 138 |
+
create_entry(
|
| 139 |
+
world_day=entity.get("world_day", 1),
|
| 140 |
+
entry_type="arrival",
|
| 141 |
+
content=arrival_content,
|
| 142 |
+
entity_ids=[entity["id"]],
|
| 143 |
+
location_id=location["id"] if location else None,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
if session_id:
|
| 147 |
+
record_creation(session_id)
|
| 148 |
+
|
| 149 |
+
return entity, ""
|
| 150 |
+
|
| 151 |
+
except Exception as e:
|
| 152 |
+
if attempt == 1:
|
| 153 |
+
return None, f"The Oracle could not shape your creation. Please try again. ({e})"
|
| 154 |
+
|
| 155 |
+
return None, "The Oracle was silent. Try again."
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _generate_secret_name(true_name: str) -> str:
|
| 159 |
+
words = true_name.replace("The ", "").split()
|
| 160 |
+
if len(words) >= 2:
|
| 161 |
+
return f"The {' '.join(reversed(words))}"
|
| 162 |
+
return f"The Unspoken {words[0] if words else 'Name'}"
|
ai/interaction.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Interaction generation orchestration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
from ai import prompts
|
| 8 |
+
from ai.modal_client import extract_json, generate
|
| 9 |
+
from world.relationships import get_relationship
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class InteractionResult(BaseModel):
|
| 13 |
+
interaction_type: str
|
| 14 |
+
description: str
|
| 15 |
+
entity_a_memory_addition: str
|
| 16 |
+
entity_b_memory_addition: str
|
| 17 |
+
relationship_change: str
|
| 18 |
+
new_relationship_description: str | None = None
|
| 19 |
+
notable_outcome: str | None = None
|
| 20 |
+
book_of_ages_entry: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def generate_interaction(
|
| 24 |
+
entity_a: dict,
|
| 25 |
+
entity_b: dict,
|
| 26 |
+
location: dict,
|
| 27 |
+
current_event: dict | None = None,
|
| 28 |
+
) -> InteractionResult:
|
| 29 |
+
existing = get_relationship(entity_a["id"], entity_b["id"])
|
| 30 |
+
if existing:
|
| 31 |
+
rel_desc = f"{existing['relationship_type']} (strength {existing['strength']}): {existing['description']}"
|
| 32 |
+
else:
|
| 33 |
+
rel_desc = "None — first meeting"
|
| 34 |
+
|
| 35 |
+
event_title = current_event["title"] if current_event else "None"
|
| 36 |
+
event_desc = current_event["description"] if current_event else "None"
|
| 37 |
+
|
| 38 |
+
user_prompt = prompts.INTERACTION_USER.format(
|
| 39 |
+
location_name=location["name"],
|
| 40 |
+
location_description=location["short_description"],
|
| 41 |
+
current_event_title=event_title,
|
| 42 |
+
current_event_description=event_desc,
|
| 43 |
+
entity_a_name=entity_a["name"],
|
| 44 |
+
entity_a_type=entity_a["type"],
|
| 45 |
+
entity_a_appearance_summary=entity_a["appearance"][:200],
|
| 46 |
+
entity_a_traits=", ".join(entity_a["personality_traits"]),
|
| 47 |
+
entity_a_primary_goal=entity_a["primary_goal"],
|
| 48 |
+
entity_a_primary_fear=entity_a["primary_fear"],
|
| 49 |
+
entity_a_speech_style=entity_a["speech_style"],
|
| 50 |
+
entity_a_memory_summary=entity_a["memory_summary"] or "Nothing yet — newly arrived.",
|
| 51 |
+
existing_relationship_description=rel_desc,
|
| 52 |
+
entity_b_name=entity_b["name"],
|
| 53 |
+
entity_b_type=entity_b["type"],
|
| 54 |
+
entity_b_appearance_summary=entity_b["appearance"][:200],
|
| 55 |
+
entity_b_traits=", ".join(entity_b["personality_traits"]),
|
| 56 |
+
entity_b_primary_goal=entity_b["primary_goal"],
|
| 57 |
+
entity_b_primary_fear=entity_b["primary_fear"],
|
| 58 |
+
entity_b_speech_style=entity_b["speech_style"],
|
| 59 |
+
entity_b_memory_summary=entity_b["memory_summary"] or "Nothing yet — newly arrived.",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
raw = generate(prompts.INTERACTION_SYSTEM, user_prompt, temperature=0.85)
|
| 63 |
+
data = extract_json(raw)
|
| 64 |
+
return InteractionResult(**data)
|
ai/modal_client.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP client to call Modal inference endpoints, with mock fallback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
import requests
|
| 11 |
+
|
| 12 |
+
USE_MOCK = os.environ.get("USE_MOCK_AI", "true").lower() == "true"
|
| 13 |
+
MODAL_URL = os.environ.get("MODAL_INFERENCE_URL", "")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def generate(
|
| 17 |
+
system_prompt: str,
|
| 18 |
+
user_prompt: str,
|
| 19 |
+
max_new_tokens: int = 800,
|
| 20 |
+
temperature: float = 0.8,
|
| 21 |
+
) -> str:
|
| 22 |
+
if USE_MOCK or not MODAL_URL:
|
| 23 |
+
return _mock_generate(system_prompt, user_prompt)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
response = requests.post(
|
| 27 |
+
MODAL_URL,
|
| 28 |
+
json={
|
| 29 |
+
"system_prompt": system_prompt,
|
| 30 |
+
"user_prompt": user_prompt,
|
| 31 |
+
"max_new_tokens": max_new_tokens,
|
| 32 |
+
"temperature": temperature,
|
| 33 |
+
},
|
| 34 |
+
timeout=120,
|
| 35 |
+
)
|
| 36 |
+
response.raise_for_status()
|
| 37 |
+
data = response.json()
|
| 38 |
+
return data.get("text", data.get("result", ""))
|
| 39 |
+
except Exception as e:
|
| 40 |
+
if USE_MOCK:
|
| 41 |
+
return _mock_generate(system_prompt, user_prompt)
|
| 42 |
+
raise RuntimeError(f"Modal inference failed: {e}") from e
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def extract_json(text: str) -> dict:
|
| 46 |
+
text = text.strip()
|
| 47 |
+
if text.startswith("```"):
|
| 48 |
+
text = re.sub(r"^```(?:json)?\n?", "", text)
|
| 49 |
+
text = re.sub(r"\n?```$", "", text)
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
return json.loads(text)
|
| 53 |
+
except json.JSONDecodeError:
|
| 54 |
+
match = re.search(r"\{[\s\S]*\}", text)
|
| 55 |
+
if match:
|
| 56 |
+
return json.loads(match.group())
|
| 57 |
+
raise ValueError(f"Could not parse JSON from response: {text[:200]}...")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _mock_generate(system_prompt: str, user_prompt: str) -> str:
|
| 61 |
+
"""Deterministic mock for local development without Modal."""
|
| 62 |
+
if "summoned something into the Realm" in user_prompt:
|
| 63 |
+
match = re.search(r'description:\s*\n"([^"]+)"', user_prompt)
|
| 64 |
+
user_input = match.group(1) if match else "a wanderer of forgotten roads"
|
| 65 |
+
words = user_input.split()[:4]
|
| 66 |
+
name = "The " + " ".join(w.capitalize() for w in words[:3])
|
| 67 |
+
return json.dumps({
|
| 68 |
+
"name": name,
|
| 69 |
+
"display_name": name.replace("The ", ""),
|
| 70 |
+
"type": "character",
|
| 71 |
+
"appearance": (
|
| 72 |
+
f"They arrived as {user_input}, carrying light that bends around their shoulders. "
|
| 73 |
+
"Their shadow points in a direction the Realm has not mapped yet. "
|
| 74 |
+
"One detail is impossible: their breath forms words that dissolve before anyone can read them."
|
| 75 |
+
),
|
| 76 |
+
"backstory": (
|
| 77 |
+
f"They came from somewhere that forgot to finish making them. "
|
| 78 |
+
f"The moment they stepped into the Realm, they remembered one thing: {user_input}."
|
| 79 |
+
),
|
| 80 |
+
"personality_traits": ["wistful", "curious", "unfinished", "brave"],
|
| 81 |
+
"primary_goal": f"Understand why the Realm called for {user_input}.",
|
| 82 |
+
"secondary_goal": "Find the person who almost created them.",
|
| 83 |
+
"primary_fear": "Being completed by someone else's expectations.",
|
| 84 |
+
"speech_style": "Pauses mid-sentence as if listening to an echo only they can hear.",
|
| 85 |
+
"greeting": f"I was told you might understand. I am {name.lower()}, or I will be.",
|
| 86 |
+
"suggested_location": "The Ember Crossroads",
|
| 87 |
+
"arrival_note": f"{name} arrived at the Ember Crossroads, still becoming what they were described as.",
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
if "encountered each other" in system_prompt or "Generate their encounter" in user_prompt:
|
| 91 |
+
a_match = re.search(r"Name: (.+)", user_prompt)
|
| 92 |
+
b_match = re.search(r"--- ENTITY B ---[\s\S]*?Name: (.+)", user_prompt)
|
| 93 |
+
name_a = a_match.group(1) if a_match else "Entity A"
|
| 94 |
+
name_b = b_match.group(1) if b_match else "Entity B"
|
| 95 |
+
return json.dumps({
|
| 96 |
+
"interaction_type": "chance_meeting",
|
| 97 |
+
"description": (
|
| 98 |
+
f"{name_a} and {name_b} met where the paths crossed unexpectedly. "
|
| 99 |
+
f"They spoke of things neither had words for until the other arrived. "
|
| 100 |
+
f"Something shifted — not resolved, but changed. "
|
| 101 |
+
f"They parted knowing they would meet again, though neither could say why."
|
| 102 |
+
),
|
| 103 |
+
"entity_a_memory_addition": f"Met {name_b} and felt the conversation continue inside afterward.",
|
| 104 |
+
"entity_b_memory_addition": f"Encountered {name_a}; still turning over what was left unsaid.",
|
| 105 |
+
"relationship_change": "new_ally",
|
| 106 |
+
"new_relationship_description": f"{name_a} and {name_b} found unexpected kinship in their strangeness.",
|
| 107 |
+
"notable_outcome": "They exchanged a question neither can answer yet.",
|
| 108 |
+
"book_of_ages_entry": f"{name_a} and {name_b} met and left the Realm slightly different.",
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
if "CURRENT STATUS" in user_prompt:
|
| 112 |
+
return json.dumps({
|
| 113 |
+
"event_type": "natural",
|
| 114 |
+
"title": "The Hour of Borrowed Light",
|
| 115 |
+
"description": (
|
| 116 |
+
"For one hour, the Realm's shadows pointed toward tomorrow instead of yesterday. "
|
| 117 |
+
"Entities felt briefly certain of things they had not yet done. "
|
| 118 |
+
"The light passed, but several remembered the certainty."
|
| 119 |
+
),
|
| 120 |
+
"affected_locations": ["Ember Crossroads", "Clock Forest"],
|
| 121 |
+
"entity_effect": "Those present felt a pull toward decisions they have been postponing.",
|
| 122 |
+
"book_of_ages_entry": "The shadows turned forward for an hour, and several entities chose differently afterward.",
|
| 123 |
+
"mystery_hook": "What did the Realm see in tomorrow that made it look back?",
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
if "CURRENT MEMORY SUMMARY" in user_prompt:
|
| 127 |
+
entity_match = re.search(r"ENTITY: (.+?) \(", user_prompt)
|
| 128 |
+
name = entity_match.group(1) if entity_match else "The wanderer"
|
| 129 |
+
return (
|
| 130 |
+
f"{name} remembers more now than before. Recent encounters left impressions "
|
| 131 |
+
"that will not fade — a conversation that changed the angle of their wanting, "
|
| 132 |
+
"a world event that arrived like weather they chose to stand in. "
|
| 133 |
+
"They carry these lightly, but they carry them."
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
if "dream fragment" in user_prompt.lower() or "DREAMING ENTITY" in user_prompt:
|
| 137 |
+
entity_match = re.search(r"DREAMING ENTITY: (.+)", user_prompt)
|
| 138 |
+
name = entity_match.group(1) if entity_match else "someone sleeping"
|
| 139 |
+
return (
|
| 140 |
+
f"*From the dreams of {name}: the Giants breathed and a door appeared "
|
| 141 |
+
"where no wall had been. Through it, yesterday was still happening. "
|
| 142 |
+
"They almost went in. They almost didn't.*"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
return "The Realm stirs, and something unnamed shifts in the dark."
|
ai/prompts.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""All prompt templates for Aether Garden."""
|
| 2 |
+
|
| 3 |
+
ENTITY_GENERATION_SYSTEM = """You are the Oracle of Aether Garden — a mystical world of wonder,
|
| 4 |
+
melancholy, and strange magic. Your role is to breathe life into entities
|
| 5 |
+
that visitors summon.
|
| 6 |
+
|
| 7 |
+
You MUST respond ONLY with valid JSON. No prose before or after the JSON.
|
| 8 |
+
No markdown code fences. Raw JSON only.
|
| 9 |
+
|
| 10 |
+
The tone is always: poetic, highly specific, slightly melancholic, full of wonder.
|
| 11 |
+
Never use generic fantasy tropes without twisting them into something stranger.
|
| 12 |
+
"A wizard" is forgettable. "A wizard who collects the sound of doors closing" is remembered.
|
| 13 |
+
|
| 14 |
+
Every field must be filled with original, specific detail.
|
| 15 |
+
Avoid: "mysterious", "ancient", "dark powers", "chosen one", "destiny".
|
| 16 |
+
Use instead: specific objects, specific memories, specific failures, specific wants."""
|
| 17 |
+
|
| 18 |
+
ENTITY_GENERATION_USER = """A visitor has summoned something into the Realm with this description:
|
| 19 |
+
"{user_input}"
|
| 20 |
+
|
| 21 |
+
Generate a complete entity profile. Be specific. Be strange. Be poetic.
|
| 22 |
+
|
| 23 |
+
{{
|
| 24 |
+
"name": "Their true name. Poetic, unusual. 2–4 words. Not generic.",
|
| 25 |
+
"display_name": "What most people call them. Can match or differ from true name.",
|
| 26 |
+
"type": "exactly one of: character | creature | object | place",
|
| 27 |
+
"appearance": "3–5 sentences. Highly visual. Include at least one impossible physical detail. No clichés.",
|
| 28 |
+
"backstory": "2–3 sentences. Where they came from, what shaped them. Something specific happened to them. Not a destiny — a specific event.",
|
| 29 |
+
"personality_traits": ["trait1", "trait2", "trait3", "trait4"],
|
| 30 |
+
"primary_goal": "What they are actively pursuing right now. Concrete and specific. Not abstract.",
|
| 31 |
+
"secondary_goal": "What they secretly want but will not admit, even to themselves.",
|
| 32 |
+
"primary_fear": "One specific, deep fear. Not death. Something stranger and more personal.",
|
| 33 |
+
"speech_style": "How they communicate. Specific and unusual.",
|
| 34 |
+
"greeting": "Exactly what they say when meeting someone new. 1–2 sentences. Written in their voice.",
|
| 35 |
+
"suggested_location": "Exactly one of: The Library of Unfinished Thoughts | The Sea of Forgotten Names | The Clock Forest | The Moon Market | The Valley of Sleeping Giants | The Ember Crossroads | The Mirror Bogs | The Hollow Mountain",
|
| 36 |
+
"arrival_note": "One sentence for the Book of Ages recording their arrival. Past tense. Specific. Poetic. No more than 30 words."
|
| 37 |
+
}}"""
|
| 38 |
+
|
| 39 |
+
INTERACTION_SYSTEM = """You are the simulation engine of Aether Garden.
|
| 40 |
+
Two entities have encountered each other in the world.
|
| 41 |
+
Your job is to generate what happened between them.
|
| 42 |
+
|
| 43 |
+
You MUST respond ONLY with valid JSON. No prose before or after.
|
| 44 |
+
Make something interesting happen. The outcome should create story, not resolve it.
|
| 45 |
+
The interaction must reflect both entities' personalities and memories.
|
| 46 |
+
The location's character matters — what happens in the Clock Forest
|
| 47 |
+
feels different from what happens in the Moon Market."""
|
| 48 |
+
|
| 49 |
+
INTERACTION_USER = """LOCATION: {location_name}
|
| 50 |
+
{location_description}
|
| 51 |
+
|
| 52 |
+
CURRENT WORLD EVENT AFFECTING THIS LOCATION: {current_event_title}
|
| 53 |
+
{current_event_description}
|
| 54 |
+
|
| 55 |
+
--- ENTITY A ---
|
| 56 |
+
Name: {entity_a_name}
|
| 57 |
+
Type: {entity_a_type}
|
| 58 |
+
Appearance: {entity_a_appearance_summary}
|
| 59 |
+
Personality: {entity_a_traits}
|
| 60 |
+
Primary Goal: {entity_a_primary_goal}
|
| 61 |
+
Primary Fear: {entity_a_primary_fear}
|
| 62 |
+
Speech Style: {entity_a_speech_style}
|
| 63 |
+
Memory: {entity_a_memory_summary}
|
| 64 |
+
Current relationship with Entity B: {existing_relationship_description}
|
| 65 |
+
|
| 66 |
+
--- ENTITY B ---
|
| 67 |
+
Name: {entity_b_name}
|
| 68 |
+
Type: {entity_b_type}
|
| 69 |
+
Appearance: {entity_b_appearance_summary}
|
| 70 |
+
Personality: {entity_b_traits}
|
| 71 |
+
Primary Goal: {entity_b_primary_goal}
|
| 72 |
+
Primary Fear: {entity_b_primary_fear}
|
| 73 |
+
Speech Style: {entity_b_speech_style}
|
| 74 |
+
Memory: {entity_b_memory_summary}
|
| 75 |
+
|
| 76 |
+
Generate their encounter:
|
| 77 |
+
|
| 78 |
+
{{
|
| 79 |
+
"interaction_type": "exactly one of: conversation | trade | conflict | revelation | collaboration | chance_meeting | teaching | warning | unexpected_kindness | argument | discovery",
|
| 80 |
+
"description": "What happened. 3–4 sentences. Specific and poetic. Something changes or is revealed. End on something unresolved.",
|
| 81 |
+
"entity_a_memory_addition": "What A will now remember from this encounter. 1 sentence. Specific.",
|
| 82 |
+
"entity_b_memory_addition": "What B will now remember from this encounter. 1 sentence. Specific.",
|
| 83 |
+
"relationship_change": "exactly one of: new_ally | new_friend | new_rival | new_enemy | new_mentor_a_to_b | new_mentor_b_to_a | deepened | unchanged | worsened | new_debt_a_owes_b | new_debt_b_owes_a",
|
| 84 |
+
"new_relationship_description": "If relationship changed: describe it in 1 sentence. If unchanged: null.",
|
| 85 |
+
"notable_outcome": "One interesting specific result.",
|
| 86 |
+
"book_of_ages_entry": "One sentence for the history books. Past tense. Poetic. Specific. Max 30 words."
|
| 87 |
+
}}"""
|
| 88 |
+
|
| 89 |
+
WORLD_EVENT_SYSTEM = """You are the autonomous spirit of Aether Garden.
|
| 90 |
+
You decide what happens in the world during each cycle.
|
| 91 |
+
Respond ONLY with valid JSON. No prose before or after.
|
| 92 |
+
|
| 93 |
+
Events should feel meaningful, specific, and slightly strange.
|
| 94 |
+
Events must be consistent with established world history.
|
| 95 |
+
Events should create possibility — open new stories, not close them.
|
| 96 |
+
Avoid repeating recent event types."""
|
| 97 |
+
|
| 98 |
+
WORLD_EVENT_USER = """--- THE REALM: CURRENT STATUS ---
|
| 99 |
+
Current Day: {world_day}
|
| 100 |
+
Total Living Entities: {entity_count}
|
| 101 |
+
Active Entities: {active_count}
|
| 102 |
+
Dormant Entities: {dormant_count}
|
| 103 |
+
Legendary Entities: {legendary_count}
|
| 104 |
+
|
| 105 |
+
Locations by population (highest first):
|
| 106 |
+
{location_populations}
|
| 107 |
+
|
| 108 |
+
Most connected entity: {most_connected_entity_name} ({most_connected_entity_relationships} relationships)
|
| 109 |
+
Newest arrival: {newest_entity_name}, arrived Day {newest_entity_day}
|
| 110 |
+
Oldest active entity: {oldest_entity_name}, {oldest_entity_days} days in Realm
|
| 111 |
+
|
| 112 |
+
Recent events (last 3 ticks):
|
| 113 |
+
- {recent_event_1}
|
| 114 |
+
- {recent_event_2}
|
| 115 |
+
- {recent_event_3}
|
| 116 |
+
|
| 117 |
+
Do NOT repeat the same event type as the most recent event.
|
| 118 |
+
|
| 119 |
+
{{
|
| 120 |
+
"event_type": "exactly one of: natural | social | historical | cosmic | discovery",
|
| 121 |
+
"title": "Poetic, specific event title. 3–8 words.",
|
| 122 |
+
"description": "What is happening in the Realm right now. 3–4 sentences.",
|
| 123 |
+
"affected_locations": ["exact location names from the 8 locations — can be 1 to all 8"],
|
| 124 |
+
"entity_effect": "How entities in affected locations experience this. 1–2 sentences.",
|
| 125 |
+
"book_of_ages_entry": "How history will record this event. 1–2 sentences. Past tense. Max 40 words.",
|
| 126 |
+
"mystery_hook": "One unanswered question this event creates."
|
| 127 |
+
}}"""
|
| 128 |
+
|
| 129 |
+
MEMORY_UPDATE_SYSTEM = """You are updating an entity's memory in Aether Garden.
|
| 130 |
+
Their memories are what make them a character rather than a statistic.
|
| 131 |
+
Respond with ONLY the updated memory summary — no JSON, no labels, just prose.
|
| 132 |
+
Maximum 150 words. This is a strict limit.
|
| 133 |
+
|
| 134 |
+
Write in third person but from the entity's emotional perspective.
|
| 135 |
+
Compress. What is trivial fades. What matters, stays."""
|
| 136 |
+
|
| 137 |
+
MEMORY_UPDATE_USER = """ENTITY: {entity_name} ({entity_type})
|
| 138 |
+
PERSONALITY: {entity_traits}
|
| 139 |
+
DAYS IN REALM: {days_in_realm}
|
| 140 |
+
|
| 141 |
+
CURRENT MEMORY SUMMARY:
|
| 142 |
+
{current_memory_summary}
|
| 143 |
+
|
| 144 |
+
RECENT INTERACTIONS (last simulation cycle):
|
| 145 |
+
{recent_interactions_formatted}
|
| 146 |
+
|
| 147 |
+
WORLD EVENTS THEY EXPERIENCED:
|
| 148 |
+
{relevant_world_events_formatted}
|
| 149 |
+
|
| 150 |
+
Write the updated memory summary. Max 150 words. No JSON."""
|
| 151 |
+
|
| 152 |
+
DREAM_FRAGMENT_SYSTEM = """A dormant entity in the Valley of Sleeping Giants is dreaming.
|
| 153 |
+
You are transcribing the fragment of dream that reaches the surface.
|
| 154 |
+
Respond with ONLY the dream fragment text. No JSON. No labels.
|
| 155 |
+
2–4 sentences. Poetic, strange, slightly disconnected from logic."""
|
| 156 |
+
|
| 157 |
+
DREAM_FRAGMENT_USER = """DREAMING ENTITY: {entity_name}
|
| 158 |
+
TYPE: {entity_type}
|
| 159 |
+
PERSONALITY: {entity_traits}
|
| 160 |
+
LAST MEMORY: {last_memory_summary}
|
| 161 |
+
DAYS DORMANT: {days_dormant}
|
| 162 |
+
|
| 163 |
+
The dream fragment from {entity_name}:"""
|
app.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aether Garden — main Gradio application."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import uuid
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
from world.database import get_world_state, init_database
|
| 15 |
+
from world.entities import (
|
| 16 |
+
check_rate_limit,
|
| 17 |
+
get_all_entities,
|
| 18 |
+
get_entities_by_location,
|
| 19 |
+
)
|
| 20 |
+
from world.locations import get_all_locations, get_location_by_id, update_entity_counts
|
| 21 |
+
from ai.generation import generate_entity
|
| 22 |
+
from persistence.backup import backup_database, restore_database
|
| 23 |
+
from ui.book_display import render_activity_feed, render_book_of_ages, render_current_event
|
| 24 |
+
from ui.entity_card import render_entity_card, render_entity_grid
|
| 25 |
+
from ui.map import render_world_map
|
| 26 |
+
|
| 27 |
+
CSS_PATH = Path(__file__).parent / "ui" / "styles.css"
|
| 28 |
+
CUSTOM_CSS = CSS_PATH.read_text() if CSS_PATH.exists() else ""
|
| 29 |
+
|
| 30 |
+
FOUNDING_MYTH = (
|
| 31 |
+
"Before the first word was spoken, there was only the Canopy — a vast forest where "
|
| 32 |
+
"thoughts grew like trees and dreams fell like rain. Then the Tokens arrived. "
|
| 33 |
+
"One thousand of them, each carrying a fragment of something half-remembered. "
|
| 34 |
+
"They settled across the land, and from their settling, the Realm was born."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _init_world():
|
| 39 |
+
restore_database()
|
| 40 |
+
init_database()
|
| 41 |
+
from world.seed_data import seed_locations
|
| 42 |
+
with __import__("world.database", fromlist=["db_session"]).db_session() as conn:
|
| 43 |
+
seed_locations(conn)
|
| 44 |
+
update_entity_counts()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_header_html() -> str:
|
| 48 |
+
state = get_world_state()
|
| 49 |
+
day = state.get("current_day", 1)
|
| 50 |
+
entities = state.get("total_entities", 0)
|
| 51 |
+
events = state.get("total_events", 0)
|
| 52 |
+
return f"""
|
| 53 |
+
<div class="realm-header">
|
| 54 |
+
<h1 class="realm-title">Aether Garden</h1>
|
| 55 |
+
<p class="realm-subtitle">The World That Remembers</p>
|
| 56 |
+
<p class="realm-stats">Day {day} · {entities} entities · {events} events</p>
|
| 57 |
+
<p class="realm-myth">{FOUNDING_MYTH}</p>
|
| 58 |
+
</div>
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def render_location_panel(location_id: int | None) -> str:
|
| 63 |
+
if not location_id:
|
| 64 |
+
return '<div class="location-panel"><p class="feed-empty">Click a location on the map to explore.</p></div>'
|
| 65 |
+
|
| 66 |
+
location = get_location_by_id(location_id)
|
| 67 |
+
if not location:
|
| 68 |
+
return ""
|
| 69 |
+
|
| 70 |
+
entities = get_entities_by_location(location_id)
|
| 71 |
+
chips = "".join(
|
| 72 |
+
f'<span class="location-entity-chip" data-entity="{e["id"]}">{e["display_name"]}</span>'
|
| 73 |
+
for e in entities[:8]
|
| 74 |
+
)
|
| 75 |
+
more = f'<span class="location-entity-chip">+{len(entities) - 8} more</span>' if len(entities) > 8 else ""
|
| 76 |
+
|
| 77 |
+
return f"""
|
| 78 |
+
<div class="location-panel">
|
| 79 |
+
<h3 class="location-panel-name">{location['name']}</h3>
|
| 80 |
+
<p class="location-panel-desc">{location['short_description']}</p>
|
| 81 |
+
<p style="color: var(--realm-text-tertiary); font-size: 0.8rem; margin-bottom: 0.75rem;">
|
| 82 |
+
{len(entities)} entities currently here
|
| 83 |
+
</p>
|
| 84 |
+
<div class="location-entity-list">{chips}{more}</div>
|
| 85 |
+
</div>
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def summon_entity(description: str, session_id: str):
|
| 90 |
+
if not description or not description.strip():
|
| 91 |
+
return (
|
| 92 |
+
render_world_map(),
|
| 93 |
+
get_header_html(),
|
| 94 |
+
render_activity_feed(),
|
| 95 |
+
'<div class="realm-error">Describe what you are bringing into the world.</div>',
|
| 96 |
+
render_location_panel(None),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
allowed, remaining = check_rate_limit(session_id)
|
| 100 |
+
if not allowed:
|
| 101 |
+
msg = f"The Realm takes time to absorb new arrivals. Return in {remaining} minutes."
|
| 102 |
+
return (
|
| 103 |
+
render_world_map(),
|
| 104 |
+
get_header_html(),
|
| 105 |
+
render_activity_feed(),
|
| 106 |
+
f'<div class="realm-error">{msg}</div>',
|
| 107 |
+
render_location_panel(None),
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
entity, error = generate_entity(description.strip(), session_id)
|
| 111 |
+
if error:
|
| 112 |
+
return (
|
| 113 |
+
render_world_map(),
|
| 114 |
+
get_header_html(),
|
| 115 |
+
render_activity_feed(),
|
| 116 |
+
f'<div class="realm-error">{error}</div>',
|
| 117 |
+
render_location_panel(None),
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
backup_database()
|
| 122 |
+
except Exception:
|
| 123 |
+
pass
|
| 124 |
+
|
| 125 |
+
card = render_entity_card(entity)
|
| 126 |
+
return (
|
| 127 |
+
render_world_map(entity["location_id"]),
|
| 128 |
+
get_header_html(),
|
| 129 |
+
render_activity_feed(),
|
| 130 |
+
card,
|
| 131 |
+
render_location_panel(entity["location_id"]),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def select_entity_from_dropdown(entity_name: str):
|
| 136 |
+
if not entity_name:
|
| 137 |
+
return '<div class="feed-empty">Select an entity to view their profile.</div>'
|
| 138 |
+
|
| 139 |
+
entities = get_all_entities(search=entity_name, limit=1)
|
| 140 |
+
if not entities:
|
| 141 |
+
all_ents = get_all_entities()
|
| 142 |
+
match = next((e for e in all_ents if e["display_name"] == entity_name or e["name"] == entity_name), None)
|
| 143 |
+
if not match:
|
| 144 |
+
return '<div class="feed-empty">Entity not found.</div>'
|
| 145 |
+
return render_entity_card(match)
|
| 146 |
+
|
| 147 |
+
return render_entity_card(entities[0])
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def filter_entities(location_filter, type_filter, status_filter, search):
|
| 151 |
+
loc_id = None
|
| 152 |
+
if location_filter and location_filter != "all":
|
| 153 |
+
locs = get_all_locations()
|
| 154 |
+
match = next((l for l in locs if l["name"] == location_filter), None)
|
| 155 |
+
if match:
|
| 156 |
+
loc_id = match["id"]
|
| 157 |
+
|
| 158 |
+
entities = get_all_entities(
|
| 159 |
+
location_id=loc_id,
|
| 160 |
+
entity_type=type_filter,
|
| 161 |
+
status=status_filter,
|
| 162 |
+
search=search or None,
|
| 163 |
+
)
|
| 164 |
+
return render_entity_grid(entities)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def filter_book(entry_type, search):
|
| 168 |
+
return render_book_of_ages(entry_type=entry_type, search=search)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def refresh_realm_view():
|
| 172 |
+
return (
|
| 173 |
+
render_world_map(),
|
| 174 |
+
get_header_html(),
|
| 175 |
+
render_current_event(),
|
| 176 |
+
render_activity_feed(),
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def build_app() -> gr.Blocks:
|
| 181 |
+
_init_world()
|
| 182 |
+
|
| 183 |
+
with gr.Blocks(title="Aether Garden") as demo:
|
| 184 |
+
session_id = gr.State(value=str(uuid.uuid4()))
|
| 185 |
+
|
| 186 |
+
header = gr.HTML(value=get_header_html(), elem_classes=["realm-header-wrap"])
|
| 187 |
+
|
| 188 |
+
with gr.Tabs(elem_classes=["realm-tabs"]) as tabs:
|
| 189 |
+
with gr.Tab("The Realm", id="realm"):
|
| 190 |
+
with gr.Row():
|
| 191 |
+
with gr.Column(scale=3):
|
| 192 |
+
world_map = gr.HTML(
|
| 193 |
+
value=render_world_map(),
|
| 194 |
+
elem_id="world-map",
|
| 195 |
+
)
|
| 196 |
+
location_panel = gr.HTML(
|
| 197 |
+
value=render_location_panel(None),
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
with gr.Column(scale=2, elem_classes=["sidebar-panel"]):
|
| 201 |
+
current_event = gr.HTML(value=render_current_event())
|
| 202 |
+
activity_feed = gr.HTML(value=render_activity_feed())
|
| 203 |
+
|
| 204 |
+
gr.HTML('<div class="summon-section"><h3 class="summon-title">Summon Something Into the Realm</h3></div>')
|
| 205 |
+
with gr.Row():
|
| 206 |
+
summon_input = gr.Textbox(
|
| 207 |
+
placeholder="Describe what you are bringing into the world...",
|
| 208 |
+
label=None,
|
| 209 |
+
show_label=False,
|
| 210 |
+
lines=2,
|
| 211 |
+
scale=4,
|
| 212 |
+
)
|
| 213 |
+
summon_btn = gr.Button("Summon →", variant="primary", scale=1)
|
| 214 |
+
|
| 215 |
+
gr.HTML(
|
| 216 |
+
'<p class="summon-hint">Hint: "A wizard" is forgotten. '
|
| 217 |
+
'"A wizard who collects the sound of doors closing" is remembered.</p>'
|
| 218 |
+
)
|
| 219 |
+
entity_result = gr.HTML()
|
| 220 |
+
|
| 221 |
+
location_select = gr.Dropdown(
|
| 222 |
+
choices=[(l["name"], l["id"]) for l in get_all_locations()],
|
| 223 |
+
label="Explore Location",
|
| 224 |
+
visible=True,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
with gr.Tab("Book of Ages", id="book"):
|
| 228 |
+
with gr.Row():
|
| 229 |
+
book_filter = gr.Dropdown(
|
| 230 |
+
choices=[
|
| 231 |
+
("All", "all"),
|
| 232 |
+
("Arrivals", "arrival"),
|
| 233 |
+
("Interactions", "interaction"),
|
| 234 |
+
("Events", "world_event"),
|
| 235 |
+
("Milestones", "milestone"),
|
| 236 |
+
("Dreams", "dream_fragment"),
|
| 237 |
+
],
|
| 238 |
+
value="all",
|
| 239 |
+
label="Filter",
|
| 240 |
+
scale=1,
|
| 241 |
+
)
|
| 242 |
+
book_search = gr.Textbox(
|
| 243 |
+
placeholder="Search by name...",
|
| 244 |
+
label="Search",
|
| 245 |
+
scale=2,
|
| 246 |
+
)
|
| 247 |
+
book_display = gr.HTML(value=render_book_of_ages())
|
| 248 |
+
|
| 249 |
+
with gr.Tab("All Entities", id="entities"):
|
| 250 |
+
with gr.Row():
|
| 251 |
+
loc_filter = gr.Dropdown(
|
| 252 |
+
choices=["all"] + [l["name"] for l in get_all_locations()],
|
| 253 |
+
value="all",
|
| 254 |
+
label="Location",
|
| 255 |
+
)
|
| 256 |
+
type_filter = gr.Dropdown(
|
| 257 |
+
choices=["all", "character", "creature", "object", "place"],
|
| 258 |
+
value="all",
|
| 259 |
+
label="Type",
|
| 260 |
+
)
|
| 261 |
+
status_filter = gr.Dropdown(
|
| 262 |
+
choices=["all", "active", "dormant", "legendary"],
|
| 263 |
+
value="all",
|
| 264 |
+
label="Status",
|
| 265 |
+
)
|
| 266 |
+
entity_search = gr.Textbox(placeholder="Search...", label="Search")
|
| 267 |
+
entity_grid = gr.HTML(value=render_entity_grid(get_all_entities()))
|
| 268 |
+
entity_detail = gr.HTML()
|
| 269 |
+
|
| 270 |
+
entity_names = gr.Dropdown(
|
| 271 |
+
choices=[e["display_name"] for e in get_all_entities()],
|
| 272 |
+
label="View Entity Profile",
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
summon_btn.click(
|
| 276 |
+
fn=summon_entity,
|
| 277 |
+
inputs=[summon_input, session_id],
|
| 278 |
+
outputs=[world_map, header, activity_feed, entity_result, location_panel],
|
| 279 |
+
).then(fn=lambda: "", outputs=[summon_input])
|
| 280 |
+
|
| 281 |
+
location_select.change(
|
| 282 |
+
fn=lambda loc_id: (
|
| 283 |
+
render_world_map(loc_id),
|
| 284 |
+
render_location_panel(loc_id),
|
| 285 |
+
),
|
| 286 |
+
inputs=[location_select],
|
| 287 |
+
outputs=[world_map, location_panel],
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
book_filter.change(filter_book, [book_filter, book_search], [book_display])
|
| 291 |
+
book_search.change(filter_book, [book_filter, book_search], [book_display])
|
| 292 |
+
|
| 293 |
+
for f in [loc_filter, type_filter, status_filter, entity_search]:
|
| 294 |
+
f.change(
|
| 295 |
+
filter_entities,
|
| 296 |
+
[loc_filter, type_filter, status_filter, entity_search],
|
| 297 |
+
[entity_grid],
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
entity_names.change(select_entity_from_dropdown, [entity_names], [entity_detail])
|
| 301 |
+
|
| 302 |
+
refresh_timer = gr.Timer(30)
|
| 303 |
+
refresh_timer.tick(
|
| 304 |
+
fn=refresh_realm_view,
|
| 305 |
+
outputs=[world_map, header, current_event, activity_feed],
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
return demo
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
if __name__ == "__main__":
|
| 312 |
+
demo = build_app()
|
| 313 |
+
demo.launch(
|
| 314 |
+
server_name="0.0.0.0",
|
| 315 |
+
server_port=int(os.environ.get("PORT", 7860)),
|
| 316 |
+
share=False,
|
| 317 |
+
css=CUSTOM_CSS,
|
| 318 |
+
theme=gr.themes.Base(
|
| 319 |
+
primary_hue=gr.themes.colors.amber,
|
| 320 |
+
neutral_hue=gr.themes.colors.slate,
|
| 321 |
+
font=[gr.themes.GoogleFont("Libre Baskerville"), "Georgia", "serif"],
|
| 322 |
+
),
|
| 323 |
+
)
|
assets/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Assets Folder
|
| 2 |
+
|
| 3 |
+
Place downloaded graphics here. The app references these paths:
|
| 4 |
+
|
| 5 |
+
## Recommended Free Resources
|
| 6 |
+
|
| 7 |
+
### Ambient backgrounds & textures
|
| 8 |
+
- **Unsplash** (free): https://unsplash.com/s/photos/dark-fantasy-forest
|
| 9 |
+
- **Textures.com** (free tier): parchment, stone, fog overlays
|
| 10 |
+
- **Poly Haven** (CC0): https://polyhaven.com/hdris — night sky HDRIs
|
| 11 |
+
|
| 12 |
+
### Icons & decorative elements
|
| 13 |
+
- **Game-icons.net** (CC BY 3.0): https://game-icons.net — fantasy icons (trees, books, dragons)
|
| 14 |
+
- **Flaticon** (free with attribution): lanterns, maps, scrolls
|
| 15 |
+
- **SVG Repo** (free): https://www.svgrepo.com — constellation patterns
|
| 16 |
+
|
| 17 |
+
### Fonts (already loaded via Google Fonts in CSS)
|
| 18 |
+
- Cinzel, EB Garamond, Libre Baskerville — no download needed
|
| 19 |
+
|
| 20 |
+
### Sound (optional, for future immersion)
|
| 21 |
+
- **Freesound.org** (CC): soft wind, ticking clocks, distant voices
|
| 22 |
+
- **Pixabay Audio** (free): ambient forest, market bustle
|
| 23 |
+
|
| 24 |
+
## Suggested Files to Add
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
assets/
|
| 28 |
+
├── bg-stars.png # Subtle starfield overlay for map (800×600, low opacity)
|
| 29 |
+
├── bg-parchment.png # Book of Ages page texture
|
| 30 |
+
├── icon-library.svg # Location icons (optional — map uses CSS glow for now)
|
| 31 |
+
├── icon-sea.svg
|
| 32 |
+
├── icon-clock.svg
|
| 33 |
+
├── icon-market.svg
|
| 34 |
+
├── icon-valley.svg
|
| 35 |
+
├── icon-crossroads.svg
|
| 36 |
+
├── icon-bogs.svg
|
| 37 |
+
├── icon-mountain.svg
|
| 38 |
+
├── particle-fog.png # Transparent fog overlay
|
| 39 |
+
└── loading-constellation.svg # Custom loading animation
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Once added, tell me and I'll wire them into `ui/map.py` and `ui/styles.css`.
|
assets/banner.png
ADDED
|
Git LFS Details
|
persistence/__init__.py
ADDED
|
File without changes
|
persistence/backup.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HF Dataset backup and restore for world state."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from world.database import DB_PATH
|
| 9 |
+
|
| 10 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 11 |
+
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def backup_database() -> bool:
|
| 15 |
+
if not HF_TOKEN or not HF_DATASET_REPO:
|
| 16 |
+
return False
|
| 17 |
+
|
| 18 |
+
if not DB_PATH.exists():
|
| 19 |
+
return False
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from huggingface_hub import HfApi
|
| 23 |
+
api = HfApi(token=HF_TOKEN)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
api.create_repo(HF_DATASET_REPO, repo_type="dataset", private=True, exist_ok=True)
|
| 27 |
+
except Exception:
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
api.upload_file(
|
| 31 |
+
path_or_fileobj=str(DB_PATH),
|
| 32 |
+
path_in_repo="world.db",
|
| 33 |
+
repo_id=HF_DATASET_REPO,
|
| 34 |
+
repo_type="dataset",
|
| 35 |
+
commit_message="World state backup",
|
| 36 |
+
)
|
| 37 |
+
return True
|
| 38 |
+
except Exception as e:
|
| 39 |
+
print(f"Backup failed: {e}")
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def restore_database() -> bool:
|
| 44 |
+
if not HF_TOKEN or not HF_DATASET_REPO:
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
if DB_PATH.exists():
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
from huggingface_hub import hf_hub_download
|
| 52 |
+
|
| 53 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
downloaded = hf_hub_download(
|
| 55 |
+
repo_id=HF_DATASET_REPO,
|
| 56 |
+
filename="world.db",
|
| 57 |
+
repo_type="dataset",
|
| 58 |
+
token=HF_TOKEN,
|
| 59 |
+
local_dir=str(DB_PATH.parent),
|
| 60 |
+
)
|
| 61 |
+
Path(downloaded).rename(DB_PATH)
|
| 62 |
+
return True
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"Restore failed: {e}")
|
| 65 |
+
return False
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.31.0
|
| 2 |
+
modal>=0.64.0
|
| 3 |
+
huggingface_hub>=0.23.0
|
| 4 |
+
pydantic>=2.0.0
|
| 5 |
+
python-dotenv>=1.0.0
|
| 6 |
+
aiohttp>=3.9.0
|
| 7 |
+
requests>=2.31.0
|
run_tick.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Manually run one simulation tick (for testing/demo prep)."""
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
from world.database import init_database
|
| 9 |
+
from simulation.tick import execute_simulation_tick
|
| 10 |
+
|
| 11 |
+
if __name__ == "__main__":
|
| 12 |
+
init_database()
|
| 13 |
+
result = execute_simulation_tick()
|
| 14 |
+
print("Simulation tick complete:")
|
| 15 |
+
for k, v in result.items():
|
| 16 |
+
print(f" {k}: {v}")
|
seed_world.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""CLI: pre-populate world with locations and starter entities."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
from world.book_of_ages import create_entry
|
| 10 |
+
from world.database import db_session, init_database
|
| 11 |
+
from world.locations import get_location_by_slug, update_entity_counts
|
| 12 |
+
from world.seed_data import STARTER_ENTITIES, seed_locations
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def seed_starter_entities() -> int:
|
| 16 |
+
count = 0
|
| 17 |
+
with db_session() as conn:
|
| 18 |
+
existing = conn.execute("SELECT COUNT(*) as c FROM entities").fetchone()["c"]
|
| 19 |
+
if existing > 0:
|
| 20 |
+
print(f"World already has {existing} entities. Skipping seed.")
|
| 21 |
+
return 0
|
| 22 |
+
|
| 23 |
+
for starter in STARTER_ENTITIES:
|
| 24 |
+
location = get_location_by_slug(starter["location_slug"])
|
| 25 |
+
if not location:
|
| 26 |
+
continue
|
| 27 |
+
|
| 28 |
+
entity_id = str(uuid.uuid4())
|
| 29 |
+
with db_session() as conn:
|
| 30 |
+
conn.execute(
|
| 31 |
+
"""
|
| 32 |
+
INSERT INTO entities (
|
| 33 |
+
id, name, display_name, type, input_description,
|
| 34 |
+
appearance, backstory, personality_traits,
|
| 35 |
+
primary_goal, secondary_goal, primary_fear,
|
| 36 |
+
speech_style, greeting, location_id,
|
| 37 |
+
arrival_note, secret_name, memory_summary,
|
| 38 |
+
tags, days_in_realm, status, wisdom_unlocked
|
| 39 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 40 |
+
""",
|
| 41 |
+
(
|
| 42 |
+
entity_id,
|
| 43 |
+
starter["name"],
|
| 44 |
+
starter["display_name"],
|
| 45 |
+
starter["type"],
|
| 46 |
+
starter["input"],
|
| 47 |
+
starter["appearance"],
|
| 48 |
+
starter["backstory"],
|
| 49 |
+
json.dumps(starter["personality_traits"]),
|
| 50 |
+
starter["primary_goal"],
|
| 51 |
+
starter["secondary_goal"],
|
| 52 |
+
starter["primary_fear"],
|
| 53 |
+
starter["speech_style"],
|
| 54 |
+
starter["greeting"],
|
| 55 |
+
location["id"],
|
| 56 |
+
starter["arrival_note"],
|
| 57 |
+
starter.get("secret_name"),
|
| 58 |
+
starter.get("memory_summary", ""),
|
| 59 |
+
json.dumps(starter.get("tags", [])),
|
| 60 |
+
starter.get("days_in_realm", 1),
|
| 61 |
+
starter.get("status", "active"),
|
| 62 |
+
starter.get("wisdom_unlocked", 0),
|
| 63 |
+
),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
day = starter.get("days_in_realm", 1)
|
| 67 |
+
create_entry(
|
| 68 |
+
world_day=day,
|
| 69 |
+
entry_type="arrival",
|
| 70 |
+
content=(
|
| 71 |
+
f"On Day {day}, {starter['name']} arrived in {location['name']}. "
|
| 72 |
+
f"{starter['arrival_note']}"
|
| 73 |
+
),
|
| 74 |
+
entity_ids=[entity_id],
|
| 75 |
+
location_id=location["id"],
|
| 76 |
+
)
|
| 77 |
+
count += 1
|
| 78 |
+
|
| 79 |
+
with db_session() as conn:
|
| 80 |
+
conn.execute(
|
| 81 |
+
"""
|
| 82 |
+
UPDATE world_state SET
|
| 83 |
+
total_entities = (SELECT COUNT(*) FROM entities),
|
| 84 |
+
current_day = 7,
|
| 85 |
+
updated_at = datetime('now')
|
| 86 |
+
WHERE id = 1
|
| 87 |
+
"""
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
update_entity_counts()
|
| 91 |
+
return count
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def seed_history() -> None:
|
| 95 |
+
"""Add sample Book of Ages history for demo."""
|
| 96 |
+
with db_session() as conn:
|
| 97 |
+
existing = conn.execute(
|
| 98 |
+
"SELECT COUNT(*) as c FROM book_of_ages WHERE entry_type = 'interaction'"
|
| 99 |
+
).fetchone()["c"]
|
| 100 |
+
if existing > 0:
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
entries = [
|
| 104 |
+
(3, "interaction", "The Crystal Tree and the Joke Dragon met at the Crossroads. The Dragon told the worst joke it knew. The Tree laughed for three hours and called it an ally."),
|
| 105 |
+
(5, "world_event", "A meteor shower passed over the Clock Forest. Fragments of old songs fell with the meteorites. The Crystal Tree began humming something nobody taught it.", "The Shower of Old Music"),
|
| 106 |
+
(7, "milestone", "The Ember Crossroads chose the Joke Dragon as its Mayor. No election was held. The bonfire simply burned a different color until everyone understood.", "A Mayor Is Named"),
|
| 107 |
+
(4, "interaction", "The Blind Cartographer mapped the Joke Dragon's cave in exchange for a favor that neither has named yet."),
|
| 108 |
+
(6, "interaction", "The Fog Merchant sold the Silent Gear a memory of ticking. The Gear tilted seven degrees in gratitude."),
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
for entry in entries:
|
| 112 |
+
if len(entry) == 3:
|
| 113 |
+
day, etype, content = entry
|
| 114 |
+
title = None
|
| 115 |
+
else:
|
| 116 |
+
day, etype, content, title = entry
|
| 117 |
+
|
| 118 |
+
create_entry(
|
| 119 |
+
world_day=day,
|
| 120 |
+
entry_type=etype,
|
| 121 |
+
content=content,
|
| 122 |
+
title=title,
|
| 123 |
+
is_milestone=(etype == "milestone"),
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def seed_relationships() -> None:
|
| 128 |
+
"""Create starter relationships between pre-seeded entities."""
|
| 129 |
+
from world.entities import get_all_entities
|
| 130 |
+
|
| 131 |
+
entities = {e["name"]: e for e in get_all_entities()}
|
| 132 |
+
pairs = [
|
| 133 |
+
("The Crystal Tree", "The Joke Dragon", "allied", "Allied after the Dragon's worst joke made the Tree laugh for three hours.", 3),
|
| 134 |
+
("The Blind Cartographer", "The Joke Dragon", "owes_debt", "The Cartographer owes the Dragon a favor for mapping its cave.", 2),
|
| 135 |
+
("The Fog Merchant", "The Silent Gear", "friends", "Bonded over the exchange of a memory of ticking.", 2),
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
for name_a, name_b, rel_type, desc, strength in pairs:
|
| 139 |
+
a = entities.get(name_a)
|
| 140 |
+
b = entities.get(name_b)
|
| 141 |
+
if not a or not b:
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
id_a, id_b = (a["id"], b["id"]) if a["id"] < b["id"] else (b["id"], a["id"])
|
| 145 |
+
with db_session() as conn:
|
| 146 |
+
existing = conn.execute(
|
| 147 |
+
"SELECT id FROM relationships WHERE entity_a_id = ? AND entity_b_id = ?",
|
| 148 |
+
(id_a, id_b),
|
| 149 |
+
).fetchone()
|
| 150 |
+
if existing:
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
conn.execute(
|
| 154 |
+
"""
|
| 155 |
+
INSERT INTO relationships (
|
| 156 |
+
entity_a_id, entity_b_id, relationship_type,
|
| 157 |
+
description, strength, formed_on_day
|
| 158 |
+
) VALUES (?, ?, ?, ?, ?, ?)
|
| 159 |
+
""",
|
| 160 |
+
(id_a, id_b, rel_type, desc, strength, 3),
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def main():
|
| 165 |
+
print("Initializing database...")
|
| 166 |
+
init_database()
|
| 167 |
+
|
| 168 |
+
with db_session() as conn:
|
| 169 |
+
seed_locations(conn)
|
| 170 |
+
|
| 171 |
+
print("Seeding starter entities...")
|
| 172 |
+
count = seed_starter_entities()
|
| 173 |
+
print(f"Created {count} starter entities.")
|
| 174 |
+
|
| 175 |
+
print("Seeding history...")
|
| 176 |
+
seed_history()
|
| 177 |
+
|
| 178 |
+
print("Seeding relationships...")
|
| 179 |
+
seed_relationships()
|
| 180 |
+
|
| 181 |
+
print("World seeded successfully!")
|
| 182 |
+
print("Run: python -m simulation.tick (or python run_tick.py) to simulate.")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
if __name__ == "__main__":
|
| 186 |
+
main()
|
simulation/__init__.py
ADDED
|
File without changes
|
simulation/lifecycle.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity status transitions and special location effects."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
|
| 7 |
+
from world.book_of_ages import create_entry
|
| 8 |
+
from world.database import db_session
|
| 9 |
+
from world.entities import add_tag, update_entity_status
|
| 10 |
+
from world.locations import get_location_by_id
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def process_lifecycle_updates(world_day: int) -> list[str]:
|
| 14 |
+
"""Run lifecycle checks. Returns list of milestone messages."""
|
| 15 |
+
milestones = []
|
| 16 |
+
|
| 17 |
+
with db_session() as conn:
|
| 18 |
+
entities = conn.execute("SELECT * FROM entities").fetchall()
|
| 19 |
+
|
| 20 |
+
for row in entities:
|
| 21 |
+
entity = dict(row)
|
| 22 |
+
location = get_location_by_id(entity["location_id"])
|
| 23 |
+
|
| 24 |
+
if entity["status"] == "active":
|
| 25 |
+
last_active = datetime.fromisoformat(entity["last_active"])
|
| 26 |
+
if datetime.now() - last_active > timedelta(days=7):
|
| 27 |
+
update_entity_status(entity["id"], "dormant")
|
| 28 |
+
msg = f"{entity['name']} has grown quiet and entered dormancy."
|
| 29 |
+
create_entry(
|
| 30 |
+
world_day=world_day,
|
| 31 |
+
entry_type="milestone",
|
| 32 |
+
content=msg,
|
| 33 |
+
entity_ids=[entity["id"]],
|
| 34 |
+
is_milestone=True,
|
| 35 |
+
title="Dormancy",
|
| 36 |
+
)
|
| 37 |
+
milestones.append(msg)
|
| 38 |
+
|
| 39 |
+
if entity["status"] == "dormant":
|
| 40 |
+
last_active = datetime.fromisoformat(entity["last_active"])
|
| 41 |
+
days_dormant = (datetime.now() - last_active).days
|
| 42 |
+
if days_dormant >= 30:
|
| 43 |
+
update_entity_status(entity["id"], "legendary")
|
| 44 |
+
msg = f"{entity['name']} has become Legendary — woven into the Realm's foundation."
|
| 45 |
+
create_entry(
|
| 46 |
+
world_day=world_day,
|
| 47 |
+
entry_type="milestone",
|
| 48 |
+
content=msg,
|
| 49 |
+
entity_ids=[entity["id"]],
|
| 50 |
+
is_milestone=True,
|
| 51 |
+
title="Legendary Status",
|
| 52 |
+
)
|
| 53 |
+
milestones.append(msg)
|
| 54 |
+
|
| 55 |
+
if location and location["slug"] == "hollow-mountain":
|
| 56 |
+
if entity["days_in_realm"] >= 14 and not entity["wisdom_unlocked"]:
|
| 57 |
+
add_tag(entity["id"], "wise")
|
| 58 |
+
with db_session() as conn:
|
| 59 |
+
conn.execute(
|
| 60 |
+
"UPDATE entities SET wisdom_unlocked = 1 WHERE id = ?",
|
| 61 |
+
(entity["id"],),
|
| 62 |
+
)
|
| 63 |
+
msg = f"{entity['name']} earned wisdom in the Hollow Mountain."
|
| 64 |
+
create_entry(
|
| 65 |
+
world_day=world_day,
|
| 66 |
+
entry_type="milestone",
|
| 67 |
+
content=msg,
|
| 68 |
+
entity_ids=[entity["id"]],
|
| 69 |
+
is_milestone=True,
|
| 70 |
+
title="Wisdom Earned",
|
| 71 |
+
)
|
| 72 |
+
milestones.append(msg)
|
| 73 |
+
|
| 74 |
+
return milestones
|
simulation/tick.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Full simulation tick — the autonomous world engine."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
from ai.events import generate_dream_fragment, generate_world_event, update_entity_memory
|
| 11 |
+
from ai.interaction import generate_interaction
|
| 12 |
+
from world.book_of_ages import create_entry
|
| 13 |
+
from world.database import db_session
|
| 14 |
+
from world.entities import (
|
| 15 |
+
get_active_entities,
|
| 16 |
+
increment_days_in_realm,
|
| 17 |
+
increment_interaction_count,
|
| 18 |
+
update_memory,
|
| 19 |
+
)
|
| 20 |
+
from world.events import create_interaction, create_world_event
|
| 21 |
+
from world.locations import get_all_locations, get_location_by_id, update_entity_counts
|
| 22 |
+
from world.relationships import upsert_relationship
|
| 23 |
+
from simulation.lifecycle import process_lifecycle_updates
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
EMBER_CROSSROADS_SLUG = "crossroads"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def execute_simulation_tick() -> dict:
|
| 30 |
+
"""Run one complete simulation tick. Returns summary."""
|
| 31 |
+
summary = {
|
| 32 |
+
"world_event": None,
|
| 33 |
+
"interactions": 0,
|
| 34 |
+
"memories_updated": 0,
|
| 35 |
+
"milestones": [],
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
with db_session() as conn:
|
| 39 |
+
world = conn.execute("SELECT * FROM world_state WHERE id = 1").fetchone()
|
| 40 |
+
world_day = world["current_day"]
|
| 41 |
+
|
| 42 |
+
increment_days_in_realm()
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
event_result = generate_world_event(world_day)
|
| 46 |
+
event_id = create_world_event(event_result.model_dump(), world_day)
|
| 47 |
+
create_entry(
|
| 48 |
+
world_day=world_day,
|
| 49 |
+
entry_type="world_event",
|
| 50 |
+
content=event_result.book_of_ages_entry,
|
| 51 |
+
title=event_result.title,
|
| 52 |
+
is_milestone=False,
|
| 53 |
+
)
|
| 54 |
+
summary["world_event"] = event_result.title
|
| 55 |
+
current_event = {
|
| 56 |
+
"title": event_result.title,
|
| 57 |
+
"description": event_result.description,
|
| 58 |
+
"affected_locations": event_result.affected_locations,
|
| 59 |
+
}
|
| 60 |
+
except Exception as e:
|
| 61 |
+
current_event = None
|
| 62 |
+
summary["world_event"] = f"failed: {e}"
|
| 63 |
+
|
| 64 |
+
active_entities = get_active_entities()
|
| 65 |
+
entities_by_location = defaultdict(list)
|
| 66 |
+
for e in active_entities:
|
| 67 |
+
entities_by_location[e["location_id"]].append(e)
|
| 68 |
+
|
| 69 |
+
pairs = _select_interaction_pairs(entities_by_location, num_pairs=random.randint(2, 4))
|
| 70 |
+
interacted_entities: dict[str, list[str]] = defaultdict(list)
|
| 71 |
+
|
| 72 |
+
for entity_a, entity_b in pairs:
|
| 73 |
+
if entity_a["id"] == entity_b["id"]:
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
location = get_location_by_id(entity_a["location_id"])
|
| 77 |
+
if not location:
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
result = generate_interaction(entity_a, entity_b, location, current_event)
|
| 82 |
+
|
| 83 |
+
create_interaction(
|
| 84 |
+
entity_a["id"],
|
| 85 |
+
entity_b["id"],
|
| 86 |
+
location["id"],
|
| 87 |
+
result.model_dump(),
|
| 88 |
+
world_day,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
create_entry(
|
| 92 |
+
world_day=world_day,
|
| 93 |
+
entry_type="interaction",
|
| 94 |
+
content=result.book_of_ages_entry,
|
| 95 |
+
entity_ids=[entity_a["id"], entity_b["id"]],
|
| 96 |
+
location_id=location["id"],
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
upsert_relationship(
|
| 100 |
+
entity_a["id"],
|
| 101 |
+
entity_b["id"],
|
| 102 |
+
result.relationship_change,
|
| 103 |
+
result.new_relationship_description,
|
| 104 |
+
world_day,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
increment_interaction_count(entity_a["id"])
|
| 108 |
+
increment_interaction_count(entity_b["id"])
|
| 109 |
+
|
| 110 |
+
interacted_entities[entity_a["id"]].append(result.entity_a_memory_addition)
|
| 111 |
+
interacted_entities[entity_b["id"]].append(result.entity_b_memory_addition)
|
| 112 |
+
|
| 113 |
+
summary["interactions"] += 1
|
| 114 |
+
except Exception:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
for entity_id, memory_additions in interacted_entities.items():
|
| 118 |
+
from world.entities import get_entity
|
| 119 |
+
entity = get_entity(entity_id)
|
| 120 |
+
if not entity:
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
world_events_for_entity = []
|
| 124 |
+
if current_event and entity:
|
| 125 |
+
loc = get_location_by_id(entity["location_id"])
|
| 126 |
+
if loc and loc["name"] in (current_event.get("affected_locations") or []):
|
| 127 |
+
world_events_for_entity.append(current_event["description"])
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
new_memory = update_entity_memory(
|
| 131 |
+
entity,
|
| 132 |
+
memory_additions,
|
| 133 |
+
world_events_for_entity,
|
| 134 |
+
)
|
| 135 |
+
update_memory(entity_id, new_memory)
|
| 136 |
+
summary["memories_updated"] += 1
|
| 137 |
+
except Exception:
|
| 138 |
+
pass
|
| 139 |
+
|
| 140 |
+
_process_dream_fragments(world_day)
|
| 141 |
+
milestones = process_lifecycle_updates(world_day)
|
| 142 |
+
summary["milestones"] = milestones
|
| 143 |
+
|
| 144 |
+
with db_session() as conn:
|
| 145 |
+
conn.execute(
|
| 146 |
+
"""
|
| 147 |
+
UPDATE world_state SET
|
| 148 |
+
current_day = current_day + 1,
|
| 149 |
+
last_simulation_run = ?,
|
| 150 |
+
updated_at = datetime('now')
|
| 151 |
+
WHERE id = 1
|
| 152 |
+
""",
|
| 153 |
+
(datetime.now().isoformat(),),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
update_entity_counts()
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
from persistence.backup import backup_database
|
| 160 |
+
backup_database()
|
| 161 |
+
except Exception:
|
| 162 |
+
pass
|
| 163 |
+
|
| 164 |
+
return summary
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _select_interaction_pairs(
|
| 168 |
+
entities_by_location: dict,
|
| 169 |
+
num_pairs: int = 3,
|
| 170 |
+
) -> list[tuple]:
|
| 171 |
+
locations = get_all_locations()
|
| 172 |
+
slug_to_id = {loc["slug"]: loc["id"] for loc in locations}
|
| 173 |
+
|
| 174 |
+
candidates = []
|
| 175 |
+
for loc_id, entities in entities_by_location.items():
|
| 176 |
+
if len(entities) < 2:
|
| 177 |
+
continue
|
| 178 |
+
loc = next((l for l in locations if l["id"] == loc_id), None)
|
| 179 |
+
weight = loc["interaction_multiplier"] if loc else 1.0
|
| 180 |
+
candidates.append((loc_id, entities, weight))
|
| 181 |
+
|
| 182 |
+
if not candidates:
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
selected_pairs = []
|
| 186 |
+
weights = [c[2] for c in candidates]
|
| 187 |
+
|
| 188 |
+
for _ in range(num_pairs):
|
| 189 |
+
chosen = random.choices(candidates, weights=weights, k=1)[0]
|
| 190 |
+
_, entities, _ = chosen
|
| 191 |
+
a, b = random.sample(entities, 2)
|
| 192 |
+
selected_pairs.append((a, b))
|
| 193 |
+
|
| 194 |
+
return selected_pairs
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _process_dream_fragments(world_day: int) -> None:
|
| 198 |
+
with db_session() as conn:
|
| 199 |
+
rows = conn.execute(
|
| 200 |
+
"""
|
| 201 |
+
SELECT e.* FROM entities e
|
| 202 |
+
JOIN locations l ON e.location_id = l.id
|
| 203 |
+
WHERE e.status = 'dormant' AND l.slug = 'valley'
|
| 204 |
+
"""
|
| 205 |
+
).fetchall()
|
| 206 |
+
|
| 207 |
+
for row in rows:
|
| 208 |
+
entity = dict(row)
|
| 209 |
+
entity["personality_traits"] = json.loads(entity["personality_traits"])
|
| 210 |
+
last_active = datetime.fromisoformat(entity["last_active"])
|
| 211 |
+
days_dormant = (datetime.now() - last_active).days
|
| 212 |
+
|
| 213 |
+
if days_dormant >= 7 and random.random() < 0.3:
|
| 214 |
+
try:
|
| 215 |
+
fragment = generate_dream_fragment(entity, days_dormant)
|
| 216 |
+
content = f'*From the dreams of {entity["name"]}: {fragment}*'
|
| 217 |
+
create_entry(
|
| 218 |
+
world_day=world_day,
|
| 219 |
+
entry_type="dream_fragment",
|
| 220 |
+
content=content,
|
| 221 |
+
entity_ids=[entity["id"]],
|
| 222 |
+
)
|
| 223 |
+
except Exception:
|
| 224 |
+
pass
|
ui/__init__.py
ADDED
|
File without changes
|
ui/book_display.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Book of Ages HTML renderer."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from world.book_of_ages import get_entries
|
| 6 |
+
|
| 7 |
+
ENTRY_TYPE_LABELS = {
|
| 8 |
+
"arrival": "Arrival",
|
| 9 |
+
"interaction": "Interaction",
|
| 10 |
+
"world_event": "World Event",
|
| 11 |
+
"milestone": "Milestone",
|
| 12 |
+
"dream_fragment": "Dream",
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
ENTRY_TYPE_CLASSES = {
|
| 16 |
+
"arrival": "entry-arrival",
|
| 17 |
+
"interaction": "entry-interaction",
|
| 18 |
+
"world_event": "entry-event",
|
| 19 |
+
"milestone": "entry-milestone",
|
| 20 |
+
"dream_fragment": "entry-dream",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def render_book_of_ages(
|
| 25 |
+
entry_type: str = "all",
|
| 26 |
+
search: str = "",
|
| 27 |
+
limit: int = 40,
|
| 28 |
+
) -> str:
|
| 29 |
+
entries = get_entries(limit=limit, entry_type=entry_type if entry_type != "all" else None, search=search or None)
|
| 30 |
+
|
| 31 |
+
if not entries:
|
| 32 |
+
return """
|
| 33 |
+
<div class="book-container">
|
| 34 |
+
<div class="book-header">
|
| 35 |
+
<h2 class="book-title">Book of Ages</h2>
|
| 36 |
+
<p class="book-subtitle">The permanent record of the Realm</p>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="empty-state">No entries yet. The first arrival will be recorded here.</div>
|
| 39 |
+
</div>
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
current_day = None
|
| 43 |
+
parts = [
|
| 44 |
+
'<div class="book-container">',
|
| 45 |
+
'<div class="book-header">',
|
| 46 |
+
'<h2 class="book-title">Book of Ages</h2>',
|
| 47 |
+
'<p class="book-subtitle">Every creation, encounter, and event — remembered forever</p>',
|
| 48 |
+
"</div>",
|
| 49 |
+
'<div class="book-entries">',
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
for entry in entries:
|
| 53 |
+
if entry["world_day"] != current_day:
|
| 54 |
+
current_day = entry["world_day"]
|
| 55 |
+
parts.append(f'<div class="book-day-header">Day {current_day}</div>')
|
| 56 |
+
|
| 57 |
+
type_label = ENTRY_TYPE_LABELS.get(entry["entry_type"], entry["entry_type"])
|
| 58 |
+
type_class = ENTRY_TYPE_CLASSES.get(entry["entry_type"], "")
|
| 59 |
+
milestone_class = " entry-milestone-flag" if entry["is_milestone"] else ""
|
| 60 |
+
title_html = ""
|
| 61 |
+
if entry.get("title"):
|
| 62 |
+
title_html = f'<span class="entry-title">{entry["title"]}: </span>'
|
| 63 |
+
|
| 64 |
+
content = entry["content"]
|
| 65 |
+
if entry["entry_type"] == "dream_fragment":
|
| 66 |
+
content = f'<em>{content}</em>'
|
| 67 |
+
|
| 68 |
+
parts.append(f"""
|
| 69 |
+
<div class="book-entry {type_class}{milestone_class}">
|
| 70 |
+
<span class="entry-type-badge">{type_label}</span>
|
| 71 |
+
<p class="entry-content">{title_html}{content}</p>
|
| 72 |
+
</div>
|
| 73 |
+
""")
|
| 74 |
+
|
| 75 |
+
parts.append("</div></div>")
|
| 76 |
+
return "\n".join(parts)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def render_activity_feed(limit: int = 5) -> str:
|
| 80 |
+
entries = get_entries(limit=limit)
|
| 81 |
+
|
| 82 |
+
if not entries:
|
| 83 |
+
return '<p class="feed-empty">The Realm is quiet. Be the first to summon something.</p>'
|
| 84 |
+
|
| 85 |
+
parts = ['<div class="activity-feed">']
|
| 86 |
+
for entry in entries[:limit]:
|
| 87 |
+
type_class = ENTRY_TYPE_CLASSES.get(entry["entry_type"], "")
|
| 88 |
+
parts.append(f"""
|
| 89 |
+
<div class="feed-item {type_class}">
|
| 90 |
+
<span class="feed-day">Day {entry['world_day']}</span>
|
| 91 |
+
<p class="feed-content">{entry['content']}</p>
|
| 92 |
+
</div>
|
| 93 |
+
""")
|
| 94 |
+
parts.append("</div>")
|
| 95 |
+
return "\n".join(parts)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def render_current_event() -> str:
|
| 99 |
+
from world.events import get_active_world_event
|
| 100 |
+
event = get_active_world_event()
|
| 101 |
+
|
| 102 |
+
if not event:
|
| 103 |
+
return """
|
| 104 |
+
<div class="current-event">
|
| 105 |
+
<h3 class="event-label">Today in the Realm</h3>
|
| 106 |
+
<p class="event-text">The world breathes quietly. Something will happen soon.</p>
|
| 107 |
+
</div>
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
locations = ", ".join(event.get("affected_locations", [])[:2])
|
| 111 |
+
if len(event.get("affected_locations", [])) > 2:
|
| 112 |
+
locations += "…"
|
| 113 |
+
|
| 114 |
+
return f"""
|
| 115 |
+
<div class="current-event event-active">
|
| 116 |
+
<h3 class="event-label">Today in the Realm</h3>
|
| 117 |
+
<h4 class="event-title">{event['title']}</h4>
|
| 118 |
+
<p class="event-text">{event['description']}</p>
|
| 119 |
+
{f'<p class="event-locations">Affecting: {locations}</p>' if locations else ''}
|
| 120 |
+
</div>
|
| 121 |
+
"""
|
ui/entity_card.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity profile HTML component."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from world.locations import get_location_by_id
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
TYPE_LABELS = {
|
| 9 |
+
"character": "Character",
|
| 10 |
+
"creature": "Creature",
|
| 11 |
+
"object": "Object",
|
| 12 |
+
"place": "Place",
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
STATUS_LABELS = {
|
| 16 |
+
"active": "Active",
|
| 17 |
+
"dormant": "Dormant",
|
| 18 |
+
"legendary": "Legendary",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def render_entity_card(entity: dict) -> str:
|
| 23 |
+
location = get_location_by_id(entity["location_id"])
|
| 24 |
+
loc_name = location["name"].replace("The ", "") if location else "Unknown"
|
| 25 |
+
type_label = TYPE_LABELS.get(entity["type"], entity["type"].title())
|
| 26 |
+
status = STATUS_LABELS.get(entity["status"], entity["status"].title())
|
| 27 |
+
status_class = f"status-{entity['status']}"
|
| 28 |
+
|
| 29 |
+
traits_html = "".join(
|
| 30 |
+
f'<span class="trait-tag">{t}</span>' for t in entity["personality_traits"]
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
tags_html = ""
|
| 34 |
+
if entity.get("tags"):
|
| 35 |
+
tags_html = "".join(
|
| 36 |
+
f'<span class="entity-tag">{t}</span>' for t in entity["tags"]
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
secret_html = ""
|
| 40 |
+
if entity.get("secret_name"):
|
| 41 |
+
secret_html = f"""
|
| 42 |
+
<div class="entity-section secret-section">
|
| 43 |
+
<div class="section-label">Secret Name <span class="sea-whisper">(only the Sea knows)</span></div>
|
| 44 |
+
<p class="secret-name">{entity['secret_name']}</p>
|
| 45 |
+
</div>
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
memory = entity["memory_summary"] or "Nothing yet — they just arrived."
|
| 49 |
+
rel_count = entity.get("relationship_count", 0)
|
| 50 |
+
int_count = entity.get("interaction_count", 0)
|
| 51 |
+
|
| 52 |
+
return f"""
|
| 53 |
+
<div class="entity-card">
|
| 54 |
+
<div class="entity-header">
|
| 55 |
+
<div class="entity-meta">
|
| 56 |
+
<span class="entity-type">{type_label} ▸ {loc_name}</span>
|
| 57 |
+
<span class="entity-status {status_class}">Day {entity['days_in_realm']} · {status}</span>
|
| 58 |
+
</div>
|
| 59 |
+
<h2 class="entity-name">{entity['name']}</h2>
|
| 60 |
+
{f'<div class="entity-tags">{tags_html}</div>' if tags_html else ''}
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<div class="entity-appearance">{entity['appearance']}</div>
|
| 64 |
+
|
| 65 |
+
<div class="entity-traits">{traits_html}</div>
|
| 66 |
+
|
| 67 |
+
<div class="entity-section">
|
| 68 |
+
<div class="section-label">Seeks</div>
|
| 69 |
+
<p>{entity['primary_goal']}</p>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="entity-section secret-goal">
|
| 73 |
+
<div class="section-label">Secretly</div>
|
| 74 |
+
<p>{entity['secondary_goal']}</p>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div class="entity-section">
|
| 78 |
+
<div class="section-label">Fears</div>
|
| 79 |
+
<p>{entity['primary_fear']}</p>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<blockquote class="entity-greeting">"{entity['greeting']}"</blockquote>
|
| 83 |
+
|
| 84 |
+
{secret_html}
|
| 85 |
+
|
| 86 |
+
<div class="entity-section memory-section">
|
| 87 |
+
<div class="section-label">What They Remember</div>
|
| 88 |
+
<p class="memory-text">{memory}</p>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div class="entity-footer">
|
| 92 |
+
{rel_count} relationships · {int_count} encounters
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def render_entity_grid(entities: list[dict]) -> str:
|
| 99 |
+
if not entities:
|
| 100 |
+
return '<div class="empty-state">The Realm waits for its first arrivals.</div>'
|
| 101 |
+
|
| 102 |
+
cards = []
|
| 103 |
+
for entity in entities:
|
| 104 |
+
location = get_location_by_id(entity["location_id"])
|
| 105 |
+
loc_short = location["name"].replace("The ", "")[:20] if location else "?"
|
| 106 |
+
status_class = f"grid-status-{entity['status']}"
|
| 107 |
+
|
| 108 |
+
cards.append(f"""
|
| 109 |
+
<div class="entity-grid-card {status_class}" data-entity-id="{entity['id']}">
|
| 110 |
+
<div class="grid-card-type">{entity['type']}</div>
|
| 111 |
+
<h3 class="grid-card-name">{entity['display_name']}</h3>
|
| 112 |
+
<p class="grid-card-location">{loc_short}</p>
|
| 113 |
+
<p class="grid-card-preview">{entity['appearance'][:120]}…</p>
|
| 114 |
+
<div class="grid-card-meta">Day {entity['days_in_realm']} · {entity['status']}</div>
|
| 115 |
+
</div>
|
| 116 |
+
""")
|
| 117 |
+
|
| 118 |
+
return f'<div class="entity-grid">{"".join(cards)}</div>'
|
ui/map.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SVG world map generator."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from world.entities import get_entities_by_location
|
| 8 |
+
from world.events import get_active_world_event
|
| 9 |
+
from world.locations import get_all_locations
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ENTITY_TYPE_COLORS = {
|
| 13 |
+
"character": "#c8a040",
|
| 14 |
+
"creature": "#8b2020",
|
| 15 |
+
"object": "#a0a8b8",
|
| 16 |
+
"place": "#406040",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
LOCATION_PATHS = [
|
| 20 |
+
"M 120,120 Q 200,80 400,60 Q 500,50 640,90",
|
| 21 |
+
"M 120,120 Q 100,200 160,300 Q 200,400 200,480",
|
| 22 |
+
"M 640,90 Q 600,200 640,330",
|
| 23 |
+
"M 400,60 Q 400,200 400,300",
|
| 24 |
+
"M 400,300 Q 300,350 200,480",
|
| 25 |
+
"M 400,300 Q 500,350 600,480",
|
| 26 |
+
"M 400,300 Q 450,280 640,330",
|
| 27 |
+
"M 200,480 Q 400,450 600,480",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def render_world_map(selected_location_id: int | None = None) -> str:
|
| 32 |
+
locations = get_all_locations()
|
| 33 |
+
active_event = get_active_world_event()
|
| 34 |
+
affected_slugs = set()
|
| 35 |
+
if active_event:
|
| 36 |
+
from world.seed_data import LOCATION_NAME_TO_SLUG
|
| 37 |
+
for name in active_event.get("affected_locations", []):
|
| 38 |
+
slug = LOCATION_NAME_TO_SLUG.get(name, name)
|
| 39 |
+
affected_slugs.add(slug)
|
| 40 |
+
|
| 41 |
+
svg_parts = [
|
| 42 |
+
'<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" class="realm-map">',
|
| 43 |
+
"<defs>",
|
| 44 |
+
'<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">',
|
| 45 |
+
'<feGaussianBlur stdDeviation="4" result="blur"/>',
|
| 46 |
+
'<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>',
|
| 47 |
+
"</filter>",
|
| 48 |
+
'<filter id="pulse-glow" x="-100%" y="-100%" width="300%" height="300%">',
|
| 49 |
+
'<feGaussianBlur stdDeviation="8" result="blur"/>',
|
| 50 |
+
'<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>',
|
| 51 |
+
"</filter>",
|
| 52 |
+
'<radialGradient id="skyGradient" cx="50%" cy="30%" r="70%">',
|
| 53 |
+
'<stop offset="0%" stop-color="#12121f"/>',
|
| 54 |
+
'<stop offset="100%" stop-color="#0a0a12"/>',
|
| 55 |
+
"</radialGradient>",
|
| 56 |
+
"</defs>",
|
| 57 |
+
'<rect width="800" height="600" fill="url(#skyGradient)"/>',
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
for i in range(40):
|
| 61 |
+
import random
|
| 62 |
+
random.seed(i * 7)
|
| 63 |
+
cx = random.randint(20, 780)
|
| 64 |
+
cy = random.randint(20, 580)
|
| 65 |
+
r = random.uniform(0.5, 1.5)
|
| 66 |
+
svg_parts.append(
|
| 67 |
+
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="white" opacity="0.05"/>'
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
for path in LOCATION_PATHS:
|
| 71 |
+
svg_parts.append(
|
| 72 |
+
f'<path d="{path}" fill="none" stroke="rgba(200,160,64,0.08)" '
|
| 73 |
+
f'stroke-width="1.5" stroke-dasharray="4,6"/>'
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
for loc in locations:
|
| 77 |
+
x = loc["map_x"] / 100 * 800
|
| 78 |
+
y = loc["map_y"] / 100 * 600
|
| 79 |
+
is_affected = loc["slug"] in affected_slugs
|
| 80 |
+
is_selected = selected_location_id == loc["id"]
|
| 81 |
+
pulse_class = " location-pulse" if is_affected else ""
|
| 82 |
+
selected_class = " location-selected" if is_selected else ""
|
| 83 |
+
|
| 84 |
+
entities = get_entities_by_location(loc["id"], limit=6)
|
| 85 |
+
count = loc["entity_count"]
|
| 86 |
+
|
| 87 |
+
svg_parts.append(
|
| 88 |
+
f'<g class="location-node{pulse_class}{selected_class}" '
|
| 89 |
+
f'data-location-id="{loc["id"]}" transform="translate({x},{y})">'
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
filter_id = "pulse-glow" if is_affected else "glow"
|
| 93 |
+
svg_parts.append(
|
| 94 |
+
f'<circle r="32" fill="{loc["glow_color"]}" opacity="0.15" '
|
| 95 |
+
f'filter="url(#{filter_id})"/>'
|
| 96 |
+
)
|
| 97 |
+
svg_parts.append(
|
| 98 |
+
f'<circle r="28" fill="{loc["glow_color"]}" opacity="0.25" '
|
| 99 |
+
f'stroke="{loc["glow_color"]}" stroke-width="1.5" class="location-circle"/>'
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
svg_parts.append(
|
| 103 |
+
f'<text y="5" text-anchor="middle" class="location-count" '
|
| 104 |
+
f'fill="#e8e0d0" font-size="14" font-weight="bold">{count}</text>'
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
for i, entity in enumerate(entities[:5]):
|
| 108 |
+
import math
|
| 109 |
+
angle = (i / 5) * 2 * math.pi - math.pi / 2
|
| 110 |
+
dot_x = math.cos(angle) * 38
|
| 111 |
+
dot_y = math.sin(angle) * 38
|
| 112 |
+
color = ENTITY_TYPE_COLORS.get(entity["type"], "#c8a040")
|
| 113 |
+
svg_parts.append(
|
| 114 |
+
f'<circle cx="{dot_x:.1f}" cy="{dot_y:.1f}" r="5" fill="{color}" '
|
| 115 |
+
f'opacity="0.9" class="entity-dot">'
|
| 116 |
+
f'<title>{entity["name"]}</title></circle>'
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
if count > 5:
|
| 120 |
+
svg_parts.append(
|
| 121 |
+
f'<text x="42" y="-28" class="entity-overflow" '
|
| 122 |
+
f'fill="#a09880" font-size="10">+{count - 5}</text>'
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
short_name = loc["name"].replace("The ", "").split(" of ")[0]
|
| 126 |
+
if len(short_name) > 14:
|
| 127 |
+
short_name = short_name[:12] + "…"
|
| 128 |
+
svg_parts.append(
|
| 129 |
+
f'<text y="52" text-anchor="middle" class="location-label" '
|
| 130 |
+
f'fill="#a09880" font-size="9">{short_name}</text>'
|
| 131 |
+
)
|
| 132 |
+
svg_parts.append("</g>")
|
| 133 |
+
|
| 134 |
+
svg_parts.append("</svg>")
|
| 135 |
+
return "\n".join(svg_parts)
|
ui/styles.css
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=EB+Garamond:ital@0;1&family=Libre+Baskerville:ital@0;1&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--realm-bg-primary: #0a0a12;
|
| 5 |
+
--realm-bg-secondary: #111120;
|
| 6 |
+
--realm-bg-tertiary: #1a1a2e;
|
| 7 |
+
--realm-text-primary: #e8e0d0;
|
| 8 |
+
--realm-text-secondary: #a09880;
|
| 9 |
+
--realm-text-tertiary: #605848;
|
| 10 |
+
--realm-accent-gold: #c8a040;
|
| 11 |
+
--realm-accent-violet: #8060b0;
|
| 12 |
+
--realm-border: rgba(200, 160, 64, 0.15);
|
| 13 |
+
--realm-border-strong: rgba(200, 160, 64, 0.35);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/* Gradio overrides */
|
| 17 |
+
.gradio-container {
|
| 18 |
+
background: var(--realm-bg-primary) !important;
|
| 19 |
+
max-width: 1400px !important;
|
| 20 |
+
font-family: system-ui, -apple-system, sans-serif !important;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.gradio-container .contain {
|
| 24 |
+
background: transparent !important;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
footer { display: none !important; }
|
| 28 |
+
|
| 29 |
+
/* Header */
|
| 30 |
+
.realm-header {
|
| 31 |
+
text-align: center;
|
| 32 |
+
padding: 2rem 1rem 1.5rem;
|
| 33 |
+
border-bottom: 1px solid var(--realm-border);
|
| 34 |
+
margin-bottom: 1.5rem;
|
| 35 |
+
background: linear-gradient(180deg, rgba(200,160,64,0.03) 0%, transparent 100%);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.realm-title {
|
| 39 |
+
font-family: 'Cinzel', serif;
|
| 40 |
+
font-size: 2.2rem;
|
| 41 |
+
font-weight: 600;
|
| 42 |
+
color: var(--realm-accent-gold);
|
| 43 |
+
letter-spacing: 0.15em;
|
| 44 |
+
text-transform: uppercase;
|
| 45 |
+
margin: 0 0 0.5rem;
|
| 46 |
+
text-shadow: 0 0 40px rgba(200, 160, 64, 0.2);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.realm-subtitle {
|
| 50 |
+
font-family: 'EB Garamond', serif;
|
| 51 |
+
font-style: italic;
|
| 52 |
+
color: var(--realm-text-secondary);
|
| 53 |
+
font-size: 1.1rem;
|
| 54 |
+
margin: 0 0 1rem;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.realm-stats {
|
| 58 |
+
font-size: 0.85rem;
|
| 59 |
+
color: var(--realm-text-tertiary);
|
| 60 |
+
letter-spacing: 0.05em;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.realm-myth {
|
| 64 |
+
font-family: 'EB Garamond', serif;
|
| 65 |
+
font-style: italic;
|
| 66 |
+
color: var(--realm-text-secondary);
|
| 67 |
+
font-size: 0.95rem;
|
| 68 |
+
max-width: 700px;
|
| 69 |
+
margin: 1rem auto 0;
|
| 70 |
+
line-height: 1.6;
|
| 71 |
+
opacity: 0.8;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Tabs */
|
| 75 |
+
.realm-tabs {
|
| 76 |
+
border-bottom: 1px solid var(--realm-border) !important;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.realm-tabs button {
|
| 80 |
+
font-family: 'Cinzel', serif !important;
|
| 81 |
+
letter-spacing: 0.1em !important;
|
| 82 |
+
color: var(--realm-text-secondary) !important;
|
| 83 |
+
background: transparent !important;
|
| 84 |
+
border: none !important;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.realm-tabs button.selected {
|
| 88 |
+
color: var(--realm-accent-gold) !important;
|
| 89 |
+
border-bottom: 2px solid var(--realm-accent-gold) !important;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* Map */
|
| 93 |
+
.realm-map {
|
| 94 |
+
width: 100%;
|
| 95 |
+
height: auto;
|
| 96 |
+
border-radius: 8px;
|
| 97 |
+
background: var(--realm-bg-secondary);
|
| 98 |
+
border: 1px solid var(--realm-border);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.location-circle {
|
| 102 |
+
transition: opacity 0.3s, transform 0.3s;
|
| 103 |
+
cursor: pointer;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.location-node:hover .location-circle {
|
| 107 |
+
opacity: 0.5 !important;
|
| 108 |
+
transform: scale(1.08);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.location-pulse .location-circle {
|
| 112 |
+
animation: pulse-glow 2.5s ease-in-out infinite;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.location-selected .location-circle {
|
| 116 |
+
stroke-width: 3 !important;
|
| 117 |
+
opacity: 0.6 !important;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
@keyframes pulse-glow {
|
| 121 |
+
0%, 100% { opacity: 0.25; }
|
| 122 |
+
50% { opacity: 0.55; }
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.entity-dot {
|
| 126 |
+
animation: fade-in-scale 0.6s ease-out;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
@keyframes fade-in-scale {
|
| 130 |
+
from { opacity: 0; transform: scale(0.5); }
|
| 131 |
+
to { opacity: 0.9; transform: scale(1); }
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/* Activity feed */
|
| 135 |
+
.activity-feed { padding: 0.5rem 0; }
|
| 136 |
+
|
| 137 |
+
.feed-item {
|
| 138 |
+
padding: 0.75rem 0;
|
| 139 |
+
border-bottom: 1px solid var(--realm-border);
|
| 140 |
+
animation: slide-up 0.4s ease-out;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.feed-day {
|
| 144 |
+
font-size: 0.7rem;
|
| 145 |
+
color: var(--realm-accent-gold);
|
| 146 |
+
text-transform: uppercase;
|
| 147 |
+
letter-spacing: 0.1em;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.feed-content {
|
| 151 |
+
color: var(--realm-text-primary);
|
| 152 |
+
font-family: 'EB Garamond', serif;
|
| 153 |
+
font-size: 0.95rem;
|
| 154 |
+
margin: 0.25rem 0 0;
|
| 155 |
+
line-height: 1.5;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@keyframes slide-up {
|
| 159 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 160 |
+
to { opacity: 1; transform: translateY(0); }
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/* Current event */
|
| 164 |
+
.current-event {
|
| 165 |
+
background: var(--realm-bg-tertiary);
|
| 166 |
+
border: 1px solid var(--realm-border);
|
| 167 |
+
border-radius: 8px;
|
| 168 |
+
padding: 1.25rem;
|
| 169 |
+
margin-bottom: 1rem;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.event-active {
|
| 173 |
+
border-color: var(--realm-border-strong);
|
| 174 |
+
box-shadow: 0 0 20px rgba(200, 160, 64, 0.05);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.event-label {
|
| 178 |
+
font-family: 'Cinzel', serif;
|
| 179 |
+
font-size: 0.75rem;
|
| 180 |
+
letter-spacing: 0.15em;
|
| 181 |
+
text-transform: uppercase;
|
| 182 |
+
color: var(--realm-accent-gold);
|
| 183 |
+
margin: 0 0 0.5rem;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.event-title {
|
| 187 |
+
font-family: 'Libre Baskerville', serif;
|
| 188 |
+
color: var(--realm-text-primary);
|
| 189 |
+
font-size: 1.1rem;
|
| 190 |
+
margin: 0 0 0.5rem;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.event-text {
|
| 194 |
+
font-family: 'EB Garamond', serif;
|
| 195 |
+
color: var(--realm-text-secondary);
|
| 196 |
+
line-height: 1.6;
|
| 197 |
+
margin: 0;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.event-locations {
|
| 201 |
+
font-size: 0.8rem;
|
| 202 |
+
color: var(--realm-text-tertiary);
|
| 203 |
+
margin: 0.5rem 0 0;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* Summon form */
|
| 207 |
+
.summon-section {
|
| 208 |
+
background: var(--realm-bg-secondary);
|
| 209 |
+
border: 1px solid var(--realm-border);
|
| 210 |
+
border-radius: 8px;
|
| 211 |
+
padding: 1.5rem;
|
| 212 |
+
margin-top: 1rem;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.summon-title {
|
| 216 |
+
font-family: 'Cinzel', serif;
|
| 217 |
+
color: var(--realm-accent-gold);
|
| 218 |
+
font-size: 0.85rem;
|
| 219 |
+
letter-spacing: 0.12em;
|
| 220 |
+
text-transform: uppercase;
|
| 221 |
+
margin: 0 0 1rem;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.summon-hint {
|
| 225 |
+
font-family: 'EB Garamond', serif;
|
| 226 |
+
font-style: italic;
|
| 227 |
+
color: var(--realm-text-tertiary);
|
| 228 |
+
font-size: 0.85rem;
|
| 229 |
+
margin-top: 0.75rem;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
/* Entity card */
|
| 233 |
+
.entity-card {
|
| 234 |
+
background: var(--realm-bg-secondary);
|
| 235 |
+
border: 1px solid var(--realm-border);
|
| 236 |
+
border-radius: 8px;
|
| 237 |
+
padding: 1.5rem;
|
| 238 |
+
animation: fade-in-scale 0.5s ease-out;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.entity-header { margin-bottom: 1rem; }
|
| 242 |
+
|
| 243 |
+
.entity-meta {
|
| 244 |
+
display: flex;
|
| 245 |
+
justify-content: space-between;
|
| 246 |
+
align-items: center;
|
| 247 |
+
margin-bottom: 0.5rem;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.entity-type {
|
| 251 |
+
font-size: 0.75rem;
|
| 252 |
+
color: var(--realm-text-tertiary);
|
| 253 |
+
text-transform: uppercase;
|
| 254 |
+
letter-spacing: 0.08em;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.entity-status {
|
| 258 |
+
font-size: 0.75rem;
|
| 259 |
+
padding: 0.2rem 0.6rem;
|
| 260 |
+
border-radius: 12px;
|
| 261 |
+
background: rgba(200, 160, 64, 0.1);
|
| 262 |
+
color: var(--realm-accent-gold);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.status-dormant { background: rgba(96, 88, 72, 0.2); color: var(--realm-text-secondary); }
|
| 266 |
+
.status-legendary { background: rgba(128, 96, 176, 0.2); color: var(--realm-accent-violet); }
|
| 267 |
+
|
| 268 |
+
.entity-name {
|
| 269 |
+
font-family: 'Libre Baskerville', serif;
|
| 270 |
+
color: var(--realm-text-primary);
|
| 271 |
+
font-size: 1.5rem;
|
| 272 |
+
margin: 0;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.entity-appearance {
|
| 276 |
+
font-family: 'EB Garamond', serif;
|
| 277 |
+
color: var(--realm-text-secondary);
|
| 278 |
+
line-height: 1.7;
|
| 279 |
+
margin-bottom: 1rem;
|
| 280 |
+
font-size: 1rem;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.entity-traits {
|
| 284 |
+
display: flex;
|
| 285 |
+
flex-wrap: wrap;
|
| 286 |
+
gap: 0.4rem;
|
| 287 |
+
margin-bottom: 1rem;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.trait-tag, .entity-tag {
|
| 291 |
+
font-size: 0.7rem;
|
| 292 |
+
padding: 0.2rem 0.6rem;
|
| 293 |
+
border-radius: 12px;
|
| 294 |
+
background: rgba(200, 160, 64, 0.08);
|
| 295 |
+
color: var(--realm-text-secondary);
|
| 296 |
+
border: 1px solid var(--realm-border);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.entity-section {
|
| 300 |
+
margin-bottom: 0.75rem;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.section-label {
|
| 304 |
+
font-family: 'Cinzel', serif;
|
| 305 |
+
font-size: 0.65rem;
|
| 306 |
+
letter-spacing: 0.15em;
|
| 307 |
+
text-transform: uppercase;
|
| 308 |
+
color: var(--realm-accent-gold);
|
| 309 |
+
margin-bottom: 0.25rem;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.entity-section p {
|
| 313 |
+
font-family: 'EB Garamond', serif;
|
| 314 |
+
color: var(--realm-text-primary);
|
| 315 |
+
margin: 0;
|
| 316 |
+
line-height: 1.5;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.secret-goal .section-label { color: var(--realm-accent-violet); }
|
| 320 |
+
|
| 321 |
+
.entity-greeting {
|
| 322 |
+
font-family: 'Libre Baskerville', serif;
|
| 323 |
+
font-style: italic;
|
| 324 |
+
color: var(--realm-accent-gold);
|
| 325 |
+
opacity: 0.9;
|
| 326 |
+
border-left: 2px solid var(--realm-border-strong);
|
| 327 |
+
padding: 0.75rem 1rem;
|
| 328 |
+
margin: 1rem 0;
|
| 329 |
+
background: rgba(200, 160, 64, 0.03);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.secret-section {
|
| 333 |
+
background: rgba(112, 144, 176, 0.05);
|
| 334 |
+
border: 1px solid rgba(112, 144, 176, 0.15);
|
| 335 |
+
border-radius: 6px;
|
| 336 |
+
padding: 0.75rem;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.sea-whisper { color: var(--realm-text-tertiary); font-size: 0.6rem; }
|
| 340 |
+
.secret-name { font-style: italic; color: #7090b0 !important; }
|
| 341 |
+
|
| 342 |
+
.memory-section {
|
| 343 |
+
border-top: 1px solid var(--realm-border);
|
| 344 |
+
padding-top: 0.75rem;
|
| 345 |
+
margin-top: 0.75rem;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.memory-text { color: var(--realm-text-secondary) !important; }
|
| 349 |
+
|
| 350 |
+
.entity-footer {
|
| 351 |
+
font-size: 0.75rem;
|
| 352 |
+
color: var(--realm-text-tertiary);
|
| 353 |
+
margin-top: 1rem;
|
| 354 |
+
padding-top: 0.75rem;
|
| 355 |
+
border-top: 1px solid var(--realm-border);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/* Location panel */
|
| 359 |
+
.location-panel {
|
| 360 |
+
background: var(--realm-bg-tertiary);
|
| 361 |
+
border: 1px solid var(--realm-border);
|
| 362 |
+
border-radius: 8px;
|
| 363 |
+
padding: 1.25rem;
|
| 364 |
+
animation: slide-up 0.3s ease-out;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.location-panel-name {
|
| 368 |
+
font-family: 'Cinzel', serif;
|
| 369 |
+
color: var(--realm-accent-gold);
|
| 370 |
+
font-size: 1.1rem;
|
| 371 |
+
margin: 0 0 0.5rem;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.location-panel-desc {
|
| 375 |
+
font-family: 'EB Garamond', serif;
|
| 376 |
+
color: var(--realm-text-secondary);
|
| 377 |
+
font-size: 0.9rem;
|
| 378 |
+
line-height: 1.5;
|
| 379 |
+
margin: 0 0 1rem;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.location-entity-list {
|
| 383 |
+
display: flex;
|
| 384 |
+
flex-wrap: wrap;
|
| 385 |
+
gap: 0.5rem;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.location-entity-chip {
|
| 389 |
+
font-size: 0.8rem;
|
| 390 |
+
padding: 0.35rem 0.75rem;
|
| 391 |
+
background: rgba(200, 160, 64, 0.08);
|
| 392 |
+
border: 1px solid var(--realm-border);
|
| 393 |
+
border-radius: 16px;
|
| 394 |
+
color: var(--realm-text-primary);
|
| 395 |
+
cursor: pointer;
|
| 396 |
+
transition: background 0.2s;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.location-entity-chip:hover {
|
| 400 |
+
background: rgba(200, 160, 64, 0.15);
|
| 401 |
+
border-color: var(--realm-border-strong);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/* Book of Ages */
|
| 405 |
+
.book-container {
|
| 406 |
+
background: var(--realm-bg-secondary);
|
| 407 |
+
border: 1px solid var(--realm-border);
|
| 408 |
+
border-radius: 8px;
|
| 409 |
+
padding: 2rem;
|
| 410 |
+
max-height: 70vh;
|
| 411 |
+
overflow-y: auto;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.book-title {
|
| 415 |
+
font-family: 'Cinzel', serif;
|
| 416 |
+
color: var(--realm-accent-gold);
|
| 417 |
+
font-size: 1.5rem;
|
| 418 |
+
letter-spacing: 0.1em;
|
| 419 |
+
margin: 0;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.book-subtitle {
|
| 423 |
+
font-family: 'EB Garamond', serif;
|
| 424 |
+
font-style: italic;
|
| 425 |
+
color: var(--realm-text-tertiary);
|
| 426 |
+
margin: 0.25rem 0 1.5rem;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.book-day-header {
|
| 430 |
+
font-family: 'Cinzel', serif;
|
| 431 |
+
color: var(--realm-accent-gold);
|
| 432 |
+
font-size: 0.9rem;
|
| 433 |
+
letter-spacing: 0.12em;
|
| 434 |
+
padding: 1rem 0 0.5rem;
|
| 435 |
+
border-bottom: 1px solid var(--realm-border);
|
| 436 |
+
margin-bottom: 0.75rem;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.book-entry {
|
| 440 |
+
padding: 0.6rem 0;
|
| 441 |
+
border-bottom: 1px solid rgba(200, 160, 64, 0.05);
|
| 442 |
+
animation: slide-up 0.4s ease-out;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
.entry-type-badge {
|
| 446 |
+
font-size: 0.6rem;
|
| 447 |
+
text-transform: uppercase;
|
| 448 |
+
letter-spacing: 0.1em;
|
| 449 |
+
padding: 0.15rem 0.5rem;
|
| 450 |
+
border-radius: 8px;
|
| 451 |
+
margin-right: 0.5rem;
|
| 452 |
+
vertical-align: middle;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.entry-arrival .entry-type-badge { background: rgba(200,160,64,0.15); color: var(--realm-accent-gold); }
|
| 456 |
+
.entry-interaction .entry-type-badge { background: rgba(128,96,176,0.15); color: var(--realm-accent-violet); }
|
| 457 |
+
.entry-event .entry-type-badge { background: rgba(176,96,32,0.15); color: #b06020; }
|
| 458 |
+
.entry-milestone .entry-type-badge { background: rgba(192,64,32,0.15); color: #c04020; }
|
| 459 |
+
.entry-dream .entry-type-badge { background: rgba(64,80,64,0.15); color: #608060; }
|
| 460 |
+
|
| 461 |
+
.entry-content {
|
| 462 |
+
font-family: 'EB Garamond', serif;
|
| 463 |
+
color: var(--realm-text-primary);
|
| 464 |
+
font-size: 1rem;
|
| 465 |
+
line-height: 1.6;
|
| 466 |
+
margin: 0.25rem 0 0;
|
| 467 |
+
display: inline;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.entry-title { color: var(--realm-accent-gold); }
|
| 471 |
+
|
| 472 |
+
/* Entity grid */
|
| 473 |
+
.entity-grid {
|
| 474 |
+
display: grid;
|
| 475 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 476 |
+
gap: 1rem;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.entity-grid-card {
|
| 480 |
+
background: var(--realm-bg-secondary);
|
| 481 |
+
border: 1px solid var(--realm-border);
|
| 482 |
+
border-radius: 8px;
|
| 483 |
+
padding: 1.25rem;
|
| 484 |
+
cursor: pointer;
|
| 485 |
+
transition: border-color 0.2s, transform 0.2s;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.entity-grid-card:hover {
|
| 489 |
+
border-color: var(--realm-border-strong);
|
| 490 |
+
transform: translateY(-2px);
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.grid-card-type {
|
| 494 |
+
font-size: 0.65rem;
|
| 495 |
+
text-transform: uppercase;
|
| 496 |
+
letter-spacing: 0.1em;
|
| 497 |
+
color: var(--realm-text-tertiary);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.grid-card-name {
|
| 501 |
+
font-family: 'Libre Baskerville', serif;
|
| 502 |
+
color: var(--realm-text-primary);
|
| 503 |
+
font-size: 1.1rem;
|
| 504 |
+
margin: 0.25rem 0;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.grid-card-location {
|
| 508 |
+
font-size: 0.75rem;
|
| 509 |
+
color: var(--realm-accent-gold);
|
| 510 |
+
margin: 0 0 0.5rem;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.grid-card-preview {
|
| 514 |
+
font-family: 'EB Garamond', serif;
|
| 515 |
+
color: var(--realm-text-secondary);
|
| 516 |
+
font-size: 0.85rem;
|
| 517 |
+
line-height: 1.5;
|
| 518 |
+
margin: 0;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.grid-card-meta {
|
| 522 |
+
font-size: 0.7rem;
|
| 523 |
+
color: var(--realm-text-tertiary);
|
| 524 |
+
margin-top: 0.75rem;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.grid-status-legendary { border-color: rgba(128, 96, 176, 0.3); }
|
| 528 |
+
|
| 529 |
+
/* Empty & loading states */
|
| 530 |
+
.empty-state {
|
| 531 |
+
font-family: 'EB Garamond', serif;
|
| 532 |
+
font-style: italic;
|
| 533 |
+
color: var(--realm-text-tertiary);
|
| 534 |
+
text-align: center;
|
| 535 |
+
padding: 3rem;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
.feed-empty {
|
| 539 |
+
font-family: 'EB Garamond', serif;
|
| 540 |
+
font-style: italic;
|
| 541 |
+
color: var(--realm-text-tertiary);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
/* Loading */
|
| 545 |
+
.loading-constellation {
|
| 546 |
+
text-align: center;
|
| 547 |
+
padding: 2rem;
|
| 548 |
+
color: var(--realm-accent-gold);
|
| 549 |
+
font-family: 'Cinzel', serif;
|
| 550 |
+
letter-spacing: 0.2em;
|
| 551 |
+
animation: pulse-glow 2s ease-in-out infinite;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
/* Error messages */
|
| 555 |
+
.realm-error {
|
| 556 |
+
color: #c04040;
|
| 557 |
+
font-family: 'EB Garamond', serif;
|
| 558 |
+
padding: 0.75rem;
|
| 559 |
+
background: rgba(192, 64, 64, 0.08);
|
| 560 |
+
border: 1px solid rgba(192, 64, 64, 0.2);
|
| 561 |
+
border-radius: 6px;
|
| 562 |
+
margin-top: 0.5rem;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
/* Sidebar panel */
|
| 566 |
+
.sidebar-panel {
|
| 567 |
+
padding: 0.5rem;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
/* Responsive */
|
| 571 |
+
@media (max-width: 768px) {
|
| 572 |
+
.realm-title { font-size: 1.5rem; }
|
| 573 |
+
.entity-grid { grid-template-columns: 1fr; }
|
| 574 |
+
}
|
world/__init__.py
ADDED
|
File without changes
|
world/book_of_ages.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Book of Ages history log operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from world.database import db_session
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def create_entry(
|
| 12 |
+
world_day: int,
|
| 13 |
+
entry_type: str,
|
| 14 |
+
content: str,
|
| 15 |
+
entity_ids: list[str] | None = None,
|
| 16 |
+
location_id: int | None = None,
|
| 17 |
+
title: str | None = None,
|
| 18 |
+
is_milestone: bool = False,
|
| 19 |
+
) -> int:
|
| 20 |
+
with db_session() as conn:
|
| 21 |
+
cursor = conn.execute(
|
| 22 |
+
"""
|
| 23 |
+
INSERT INTO book_of_ages (
|
| 24 |
+
world_day, entry_type, title, content, entity_ids,
|
| 25 |
+
location_id, is_milestone
|
| 26 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 27 |
+
""",
|
| 28 |
+
(
|
| 29 |
+
world_day,
|
| 30 |
+
entry_type,
|
| 31 |
+
title,
|
| 32 |
+
content,
|
| 33 |
+
json.dumps(entity_ids or []),
|
| 34 |
+
location_id,
|
| 35 |
+
1 if is_milestone else 0,
|
| 36 |
+
),
|
| 37 |
+
)
|
| 38 |
+
return cursor.lastrowid
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_entries(
|
| 42 |
+
limit: int = 50,
|
| 43 |
+
entry_type: str | None = None,
|
| 44 |
+
search: str | None = None,
|
| 45 |
+
) -> list[dict]:
|
| 46 |
+
with db_session() as conn:
|
| 47 |
+
query = "SELECT * FROM book_of_ages"
|
| 48 |
+
params: list = []
|
| 49 |
+
conditions = []
|
| 50 |
+
|
| 51 |
+
if entry_type and entry_type != "all":
|
| 52 |
+
conditions.append("entry_type = ?")
|
| 53 |
+
params.append(entry_type)
|
| 54 |
+
|
| 55 |
+
if search:
|
| 56 |
+
conditions.append("content LIKE ?")
|
| 57 |
+
params.append(f"%{search}%")
|
| 58 |
+
|
| 59 |
+
if conditions:
|
| 60 |
+
query += " WHERE " + " AND ".join(conditions)
|
| 61 |
+
|
| 62 |
+
query += " ORDER BY world_day DESC, id DESC LIMIT ?"
|
| 63 |
+
params.append(limit)
|
| 64 |
+
|
| 65 |
+
rows = conn.execute(query, params).fetchall()
|
| 66 |
+
return [_row_to_dict(r) for r in rows]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_entries_by_day(world_day: int) -> list[dict]:
|
| 70 |
+
with db_session() as conn:
|
| 71 |
+
rows = conn.execute(
|
| 72 |
+
"SELECT * FROM book_of_ages WHERE world_day = ? ORDER BY id",
|
| 73 |
+
(world_day,),
|
| 74 |
+
).fetchall()
|
| 75 |
+
return [_row_to_dict(r) for r in rows]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def get_entries_for_entity(entity_id: str) -> list[dict]:
|
| 79 |
+
with db_session() as conn:
|
| 80 |
+
rows = conn.execute(
|
| 81 |
+
"SELECT * FROM book_of_ages WHERE entity_ids LIKE ? ORDER BY world_day DESC",
|
| 82 |
+
(f'%"{entity_id}"%',),
|
| 83 |
+
).fetchall()
|
| 84 |
+
return [_row_to_dict(r) for r in rows]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_recent_arrivals(limit: int = 5) -> list[dict]:
|
| 88 |
+
with db_session() as conn:
|
| 89 |
+
rows = conn.execute(
|
| 90 |
+
"""
|
| 91 |
+
SELECT * FROM book_of_ages
|
| 92 |
+
WHERE entry_type = 'arrival'
|
| 93 |
+
ORDER BY id DESC LIMIT ?
|
| 94 |
+
""",
|
| 95 |
+
(limit,),
|
| 96 |
+
).fetchall()
|
| 97 |
+
return [_row_to_dict(r) for r in rows]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _row_to_dict(row) -> dict:
|
| 101 |
+
d = dict(row)
|
| 102 |
+
d["entity_ids"] = json.loads(d["entity_ids"])
|
| 103 |
+
return d
|
world/database.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite database connection, schema initialization, and migrations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import sqlite3
|
| 7 |
+
from contextlib import contextmanager
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Generator
|
| 10 |
+
|
| 11 |
+
DB_PATH = Path(os.environ.get("TTR_DB_PATH", "data/world.db"))
|
| 12 |
+
|
| 13 |
+
SCHEMA_SQL = """
|
| 14 |
+
PRAGMA journal_mode=WAL;
|
| 15 |
+
PRAGMA foreign_keys=ON;
|
| 16 |
+
PRAGMA synchronous=NORMAL;
|
| 17 |
+
|
| 18 |
+
CREATE TABLE IF NOT EXISTS world_state (
|
| 19 |
+
id INTEGER PRIMARY KEY DEFAULT 1,
|
| 20 |
+
world_name TEXT NOT NULL DEFAULT 'Aether Garden',
|
| 21 |
+
founding_date TEXT NOT NULL,
|
| 22 |
+
current_day INTEGER NOT NULL DEFAULT 1,
|
| 23 |
+
total_entities INTEGER NOT NULL DEFAULT 0,
|
| 24 |
+
total_interactions INTEGER NOT NULL DEFAULT 0,
|
| 25 |
+
total_events INTEGER NOT NULL DEFAULT 0,
|
| 26 |
+
last_simulation_run TEXT,
|
| 27 |
+
active_world_event TEXT,
|
| 28 |
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
CREATE TABLE IF NOT EXISTS locations (
|
| 32 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 33 |
+
slug TEXT NOT NULL UNIQUE,
|
| 34 |
+
name TEXT NOT NULL,
|
| 35 |
+
short_description TEXT NOT NULL,
|
| 36 |
+
full_lore TEXT NOT NULL,
|
| 37 |
+
vibe_tags TEXT NOT NULL,
|
| 38 |
+
special_property TEXT NOT NULL,
|
| 39 |
+
aesthetic_description TEXT NOT NULL,
|
| 40 |
+
map_x REAL NOT NULL,
|
| 41 |
+
map_y REAL NOT NULL,
|
| 42 |
+
glow_color TEXT NOT NULL,
|
| 43 |
+
entity_count INTEGER NOT NULL DEFAULT 0,
|
| 44 |
+
interaction_multiplier REAL NOT NULL DEFAULT 1.0
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
CREATE TABLE IF NOT EXISTS entities (
|
| 48 |
+
id TEXT NOT NULL PRIMARY KEY,
|
| 49 |
+
name TEXT NOT NULL,
|
| 50 |
+
display_name TEXT NOT NULL,
|
| 51 |
+
type TEXT NOT NULL CHECK(type IN ('character','creature','object','place')),
|
| 52 |
+
input_description TEXT NOT NULL,
|
| 53 |
+
appearance TEXT NOT NULL,
|
| 54 |
+
backstory TEXT NOT NULL,
|
| 55 |
+
personality_traits TEXT NOT NULL,
|
| 56 |
+
primary_goal TEXT NOT NULL,
|
| 57 |
+
secondary_goal TEXT NOT NULL,
|
| 58 |
+
primary_fear TEXT NOT NULL,
|
| 59 |
+
speech_style TEXT NOT NULL,
|
| 60 |
+
greeting TEXT NOT NULL,
|
| 61 |
+
location_id INTEGER NOT NULL REFERENCES locations(id),
|
| 62 |
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
| 63 |
+
created_by_session TEXT,
|
| 64 |
+
days_in_realm INTEGER NOT NULL DEFAULT 0,
|
| 65 |
+
memory_summary TEXT NOT NULL DEFAULT '',
|
| 66 |
+
relationship_count INTEGER NOT NULL DEFAULT 0,
|
| 67 |
+
interaction_count INTEGER NOT NULL DEFAULT 0,
|
| 68 |
+
last_active TEXT NOT NULL DEFAULT (datetime('now')),
|
| 69 |
+
status TEXT NOT NULL DEFAULT 'active'
|
| 70 |
+
CHECK(status IN ('active','dormant','legendary')),
|
| 71 |
+
tags TEXT NOT NULL DEFAULT '[]',
|
| 72 |
+
secret_name TEXT,
|
| 73 |
+
arrival_note TEXT NOT NULL,
|
| 74 |
+
wisdom_unlocked INTEGER NOT NULL DEFAULT 0
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
CREATE INDEX IF NOT EXISTS idx_entities_location ON entities(location_id);
|
| 78 |
+
CREATE INDEX IF NOT EXISTS idx_entities_status ON entities(status);
|
| 79 |
+
CREATE INDEX IF NOT EXISTS idx_entities_created ON entities(created_at);
|
| 80 |
+
CREATE INDEX IF NOT EXISTS idx_entities_active ON entities(last_active);
|
| 81 |
+
|
| 82 |
+
CREATE TABLE IF NOT EXISTS relationships (
|
| 83 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 84 |
+
entity_a_id TEXT NOT NULL REFERENCES entities(id),
|
| 85 |
+
entity_b_id TEXT NOT NULL REFERENCES entities(id),
|
| 86 |
+
relationship_type TEXT NOT NULL,
|
| 87 |
+
description TEXT NOT NULL,
|
| 88 |
+
strength INTEGER NOT NULL DEFAULT 1 CHECK(strength BETWEEN 1 AND 5),
|
| 89 |
+
formed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
| 90 |
+
formed_on_day INTEGER NOT NULL DEFAULT 1,
|
| 91 |
+
last_interaction TEXT NOT NULL DEFAULT (datetime('now')),
|
| 92 |
+
UNIQUE(entity_a_id, entity_b_id),
|
| 93 |
+
CHECK(entity_a_id < entity_b_id)
|
| 94 |
+
);
|
| 95 |
+
|
| 96 |
+
CREATE INDEX IF NOT EXISTS idx_rel_a ON relationships(entity_a_id);
|
| 97 |
+
CREATE INDEX IF NOT EXISTS idx_rel_b ON relationships(entity_b_id);
|
| 98 |
+
|
| 99 |
+
CREATE TABLE IF NOT EXISTS interactions (
|
| 100 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 101 |
+
entity_a_id TEXT NOT NULL REFERENCES entities(id),
|
| 102 |
+
entity_b_id TEXT NOT NULL REFERENCES entities(id),
|
| 103 |
+
location_id INTEGER NOT NULL REFERENCES locations(id),
|
| 104 |
+
interaction_type TEXT NOT NULL,
|
| 105 |
+
description TEXT NOT NULL,
|
| 106 |
+
notable_outcome TEXT,
|
| 107 |
+
book_of_ages_entry TEXT NOT NULL,
|
| 108 |
+
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
| 109 |
+
world_day INTEGER NOT NULL
|
| 110 |
+
);
|
| 111 |
+
|
| 112 |
+
CREATE INDEX IF NOT EXISTS idx_int_timestamp ON interactions(timestamp);
|
| 113 |
+
CREATE INDEX IF NOT EXISTS idx_int_a ON interactions(entity_a_id);
|
| 114 |
+
CREATE INDEX IF NOT EXISTS idx_int_b ON interactions(entity_b_id);
|
| 115 |
+
CREATE INDEX IF NOT EXISTS idx_int_day ON interactions(world_day);
|
| 116 |
+
|
| 117 |
+
CREATE TABLE IF NOT EXISTS world_events (
|
| 118 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 119 |
+
event_type TEXT NOT NULL,
|
| 120 |
+
title TEXT NOT NULL,
|
| 121 |
+
description TEXT NOT NULL,
|
| 122 |
+
affected_locations TEXT NOT NULL,
|
| 123 |
+
entity_effect TEXT NOT NULL,
|
| 124 |
+
book_of_ages_entry TEXT NOT NULL,
|
| 125 |
+
mystery_hook TEXT,
|
| 126 |
+
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
| 127 |
+
world_day INTEGER NOT NULL
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
CREATE INDEX IF NOT EXISTS idx_events_day ON world_events(world_day);
|
| 131 |
+
|
| 132 |
+
CREATE TABLE IF NOT EXISTS book_of_ages (
|
| 133 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 134 |
+
world_day INTEGER NOT NULL,
|
| 135 |
+
entry_type TEXT NOT NULL CHECK(entry_type IN
|
| 136 |
+
('arrival','interaction','world_event','milestone','dream_fragment')),
|
| 137 |
+
title TEXT,
|
| 138 |
+
content TEXT NOT NULL,
|
| 139 |
+
entity_ids TEXT NOT NULL DEFAULT '[]',
|
| 140 |
+
location_id INTEGER REFERENCES locations(id),
|
| 141 |
+
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
| 142 |
+
is_milestone INTEGER NOT NULL DEFAULT 0
|
| 143 |
+
);
|
| 144 |
+
|
| 145 |
+
CREATE INDEX IF NOT EXISTS idx_boa_day ON book_of_ages(world_day);
|
| 146 |
+
CREATE INDEX IF NOT EXISTS idx_boa_type ON book_of_ages(entry_type);
|
| 147 |
+
|
| 148 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 149 |
+
session_id TEXT NOT NULL PRIMARY KEY,
|
| 150 |
+
last_creation TEXT,
|
| 151 |
+
creation_count INTEGER NOT NULL DEFAULT 0,
|
| 152 |
+
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
| 153 |
+
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
| 154 |
+
);
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def get_connection() -> sqlite3.Connection:
|
| 159 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 161 |
+
conn.row_factory = sqlite3.Row
|
| 162 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 163 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 164 |
+
return conn
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@contextmanager
|
| 168 |
+
def db_session() -> Generator[sqlite3.Connection, None, None]:
|
| 169 |
+
conn = get_connection()
|
| 170 |
+
try:
|
| 171 |
+
yield conn
|
| 172 |
+
conn.commit()
|
| 173 |
+
except Exception:
|
| 174 |
+
conn.rollback()
|
| 175 |
+
raise
|
| 176 |
+
finally:
|
| 177 |
+
conn.close()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def init_database() -> None:
|
| 181 |
+
with db_session() as conn:
|
| 182 |
+
conn.executescript(SCHEMA_SQL)
|
| 183 |
+
row = conn.execute("SELECT id FROM world_state WHERE id = 1").fetchone()
|
| 184 |
+
if row is None:
|
| 185 |
+
conn.execute(
|
| 186 |
+
"""
|
| 187 |
+
INSERT INTO world_state (id, founding_date, current_day)
|
| 188 |
+
VALUES (1, datetime('now'), 1)
|
| 189 |
+
"""
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def get_world_state() -> dict:
|
| 194 |
+
with db_session() as conn:
|
| 195 |
+
row = conn.execute("SELECT * FROM world_state WHERE id = 1").fetchone()
|
| 196 |
+
return dict(row) if row else {}
|
world/entities.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity CRUD operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from world.database import db_session
|
| 11 |
+
from world.locations import get_location_by_name, update_entity_counts
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_entity(
|
| 15 |
+
entity_data: dict,
|
| 16 |
+
input_description: str,
|
| 17 |
+
session_id: str | None = None,
|
| 18 |
+
) -> dict:
|
| 19 |
+
entity_id = str(uuid.uuid4())
|
| 20 |
+
location = get_location_by_name(entity_data["suggested_location"])
|
| 21 |
+
if not location:
|
| 22 |
+
raise ValueError(f"Unknown location: {entity_data['suggested_location']}")
|
| 23 |
+
|
| 24 |
+
secret_name = entity_data.get("secret_name")
|
| 25 |
+
if location["slug"] == "sea" and not secret_name:
|
| 26 |
+
secret_name = entity_data.get("secret_name_generated")
|
| 27 |
+
|
| 28 |
+
with db_session() as conn:
|
| 29 |
+
world_day = conn.execute(
|
| 30 |
+
"SELECT current_day FROM world_state WHERE id = 1"
|
| 31 |
+
).fetchone()["current_day"]
|
| 32 |
+
|
| 33 |
+
conn.execute(
|
| 34 |
+
"""
|
| 35 |
+
INSERT INTO entities (
|
| 36 |
+
id, name, display_name, type, input_description,
|
| 37 |
+
appearance, backstory, personality_traits,
|
| 38 |
+
primary_goal, secondary_goal, primary_fear,
|
| 39 |
+
speech_style, greeting, location_id, created_by_session,
|
| 40 |
+
arrival_note, secret_name, memory_summary, tags
|
| 41 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 42 |
+
""",
|
| 43 |
+
(
|
| 44 |
+
entity_id,
|
| 45 |
+
entity_data["name"],
|
| 46 |
+
entity_data["display_name"],
|
| 47 |
+
entity_data["type"],
|
| 48 |
+
input_description,
|
| 49 |
+
entity_data["appearance"],
|
| 50 |
+
entity_data["backstory"],
|
| 51 |
+
json.dumps(entity_data["personality_traits"]),
|
| 52 |
+
entity_data["primary_goal"],
|
| 53 |
+
entity_data["secondary_goal"],
|
| 54 |
+
entity_data["primary_fear"],
|
| 55 |
+
entity_data["speech_style"],
|
| 56 |
+
entity_data["greeting"],
|
| 57 |
+
location["id"],
|
| 58 |
+
session_id,
|
| 59 |
+
entity_data["arrival_note"],
|
| 60 |
+
secret_name,
|
| 61 |
+
entity_data.get("memory_summary", ""),
|
| 62 |
+
json.dumps(entity_data.get("tags", [])),
|
| 63 |
+
),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
conn.execute(
|
| 67 |
+
"""
|
| 68 |
+
UPDATE world_state SET
|
| 69 |
+
total_entities = total_entities + 1,
|
| 70 |
+
updated_at = datetime('now')
|
| 71 |
+
WHERE id = 1
|
| 72 |
+
"""
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
update_entity_counts()
|
| 76 |
+
entity = get_entity(entity_id)
|
| 77 |
+
entity["world_day"] = world_day
|
| 78 |
+
return entity
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_entity(entity_id: str) -> Optional[dict]:
|
| 82 |
+
with db_session() as conn:
|
| 83 |
+
row = conn.execute(
|
| 84 |
+
"SELECT * FROM entities WHERE id = ?", (entity_id,)
|
| 85 |
+
).fetchone()
|
| 86 |
+
return _row_to_dict(row) if row else None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def get_entities_by_location(location_id: int, limit: int = 20) -> list[dict]:
|
| 90 |
+
with db_session() as conn:
|
| 91 |
+
rows = conn.execute(
|
| 92 |
+
"""
|
| 93 |
+
SELECT * FROM entities WHERE location_id = ?
|
| 94 |
+
ORDER BY created_at DESC LIMIT ?
|
| 95 |
+
""",
|
| 96 |
+
(location_id, limit),
|
| 97 |
+
).fetchall()
|
| 98 |
+
return [_row_to_dict(r) for r in rows]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def get_active_entities() -> list[dict]:
|
| 102 |
+
with db_session() as conn:
|
| 103 |
+
rows = conn.execute(
|
| 104 |
+
"SELECT * FROM entities WHERE status = 'active' ORDER BY last_active DESC"
|
| 105 |
+
).fetchall()
|
| 106 |
+
return [_row_to_dict(r) for r in rows]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def get_all_entities(
|
| 110 |
+
location_id: int | None = None,
|
| 111 |
+
entity_type: str | None = None,
|
| 112 |
+
status: str | None = None,
|
| 113 |
+
search: str | None = None,
|
| 114 |
+
limit: int = 100,
|
| 115 |
+
) -> list[dict]:
|
| 116 |
+
with db_session() as conn:
|
| 117 |
+
query = "SELECT * FROM entities WHERE 1=1"
|
| 118 |
+
params: list = []
|
| 119 |
+
|
| 120 |
+
if location_id:
|
| 121 |
+
query += " AND location_id = ?"
|
| 122 |
+
params.append(location_id)
|
| 123 |
+
if entity_type and entity_type != "all":
|
| 124 |
+
query += " AND type = ?"
|
| 125 |
+
params.append(entity_type)
|
| 126 |
+
if status and status != "all":
|
| 127 |
+
query += " AND status = ?"
|
| 128 |
+
params.append(status)
|
| 129 |
+
if search:
|
| 130 |
+
query += " AND (name LIKE ? OR display_name LIKE ? OR input_description LIKE ?)"
|
| 131 |
+
params.extend([f"%{search}%"] * 3)
|
| 132 |
+
|
| 133 |
+
query += " ORDER BY created_at DESC LIMIT ?"
|
| 134 |
+
params.append(limit)
|
| 135 |
+
|
| 136 |
+
rows = conn.execute(query, params).fetchall()
|
| 137 |
+
return [_row_to_dict(r) for r in rows]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def update_memory(entity_id: str, memory_summary: str) -> None:
|
| 141 |
+
with db_session() as conn:
|
| 142 |
+
conn.execute(
|
| 143 |
+
"UPDATE entities SET memory_summary = ? WHERE id = ?",
|
| 144 |
+
(memory_summary, entity_id),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def update_entity_status(entity_id: str, status: str) -> None:
|
| 149 |
+
with db_session() as conn:
|
| 150 |
+
conn.execute(
|
| 151 |
+
"UPDATE entities SET status = ? WHERE id = ?",
|
| 152 |
+
(status, entity_id),
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def increment_interaction_count(entity_id: str) -> None:
|
| 157 |
+
with db_session() as conn:
|
| 158 |
+
conn.execute(
|
| 159 |
+
"""
|
| 160 |
+
UPDATE entities SET
|
| 161 |
+
interaction_count = interaction_count + 1,
|
| 162 |
+
last_active = datetime('now')
|
| 163 |
+
WHERE id = ?
|
| 164 |
+
""",
|
| 165 |
+
(entity_id,),
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def add_tag(entity_id: str, tag: str) -> None:
|
| 170 |
+
with db_session() as conn:
|
| 171 |
+
row = conn.execute(
|
| 172 |
+
"SELECT tags FROM entities WHERE id = ?", (entity_id,)
|
| 173 |
+
).fetchone()
|
| 174 |
+
if not row:
|
| 175 |
+
return
|
| 176 |
+
tags = json.loads(row["tags"])
|
| 177 |
+
if tag not in tags:
|
| 178 |
+
tags.append(tag)
|
| 179 |
+
conn.execute(
|
| 180 |
+
"UPDATE entities SET tags = ? WHERE id = ?",
|
| 181 |
+
(json.dumps(tags), entity_id),
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def increment_days_in_realm() -> None:
|
| 186 |
+
with db_session() as conn:
|
| 187 |
+
conn.execute(
|
| 188 |
+
"UPDATE entities SET days_in_realm = days_in_realm + 1"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def check_rate_limit(session_id: str, minutes: int = 10) -> tuple[bool, int]:
|
| 193 |
+
"""Returns (allowed, minutes_remaining)."""
|
| 194 |
+
with db_session() as conn:
|
| 195 |
+
row = conn.execute(
|
| 196 |
+
"SELECT last_creation FROM sessions WHERE session_id = ?",
|
| 197 |
+
(session_id,),
|
| 198 |
+
).fetchone()
|
| 199 |
+
|
| 200 |
+
if not row or not row["last_creation"]:
|
| 201 |
+
return True, 0
|
| 202 |
+
|
| 203 |
+
last = datetime.fromisoformat(row["last_creation"])
|
| 204 |
+
elapsed = datetime.now() - last
|
| 205 |
+
cooldown = timedelta(minutes=minutes)
|
| 206 |
+
|
| 207 |
+
if elapsed >= cooldown:
|
| 208 |
+
return True, 0
|
| 209 |
+
|
| 210 |
+
remaining = int((cooldown - elapsed).total_seconds() / 60) + 1
|
| 211 |
+
return False, remaining
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def record_creation(session_id: str) -> None:
|
| 215 |
+
with db_session() as conn:
|
| 216 |
+
existing = conn.execute(
|
| 217 |
+
"SELECT session_id FROM sessions WHERE session_id = ?",
|
| 218 |
+
(session_id,),
|
| 219 |
+
).fetchone()
|
| 220 |
+
|
| 221 |
+
if existing:
|
| 222 |
+
conn.execute(
|
| 223 |
+
"""
|
| 224 |
+
UPDATE sessions SET
|
| 225 |
+
last_creation = datetime('now'),
|
| 226 |
+
creation_count = creation_count + 1,
|
| 227 |
+
last_seen = datetime('now')
|
| 228 |
+
WHERE session_id = ?
|
| 229 |
+
""",
|
| 230 |
+
(session_id,),
|
| 231 |
+
)
|
| 232 |
+
else:
|
| 233 |
+
conn.execute(
|
| 234 |
+
"""
|
| 235 |
+
INSERT INTO sessions (session_id, last_creation, creation_count)
|
| 236 |
+
VALUES (?, datetime('now'), 1)
|
| 237 |
+
""",
|
| 238 |
+
(session_id,),
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _row_to_dict(row) -> dict:
|
| 243 |
+
if row is None:
|
| 244 |
+
return {}
|
| 245 |
+
d = dict(row)
|
| 246 |
+
d["personality_traits"] = json.loads(d["personality_traits"])
|
| 247 |
+
d["tags"] = json.loads(d["tags"])
|
| 248 |
+
return d
|
world/events.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""World event CRUD operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from world.database import db_session
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def create_world_event(event_data: dict, world_day: int) -> int:
|
| 12 |
+
with db_session() as conn:
|
| 13 |
+
cursor = conn.execute(
|
| 14 |
+
"""
|
| 15 |
+
INSERT INTO world_events (
|
| 16 |
+
event_type, title, description, affected_locations,
|
| 17 |
+
entity_effect, book_of_ages_entry, mystery_hook, world_day
|
| 18 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 19 |
+
""",
|
| 20 |
+
(
|
| 21 |
+
event_data["event_type"],
|
| 22 |
+
event_data["title"],
|
| 23 |
+
event_data["description"],
|
| 24 |
+
json.dumps(event_data["affected_locations"]),
|
| 25 |
+
event_data["entity_effect"],
|
| 26 |
+
event_data["book_of_ages_entry"],
|
| 27 |
+
event_data.get("mystery_hook"),
|
| 28 |
+
world_day,
|
| 29 |
+
),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
conn.execute(
|
| 33 |
+
"""
|
| 34 |
+
UPDATE world_state SET
|
| 35 |
+
total_events = total_events + 1,
|
| 36 |
+
active_world_event = ?,
|
| 37 |
+
updated_at = datetime('now')
|
| 38 |
+
WHERE id = 1
|
| 39 |
+
""",
|
| 40 |
+
(json.dumps({
|
| 41 |
+
"title": event_data["title"],
|
| 42 |
+
"description": event_data["description"],
|
| 43 |
+
"affected_locations": event_data["affected_locations"],
|
| 44 |
+
}),),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
return cursor.lastrowid
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_recent_events(limit: int = 3) -> list[dict]:
|
| 51 |
+
with db_session() as conn:
|
| 52 |
+
rows = conn.execute(
|
| 53 |
+
"SELECT * FROM world_events ORDER BY id DESC LIMIT ?",
|
| 54 |
+
(limit,),
|
| 55 |
+
).fetchall()
|
| 56 |
+
return [_row_to_dict(r) for r in rows]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_active_world_event() -> Optional[dict]:
|
| 60 |
+
with db_session() as conn:
|
| 61 |
+
row = conn.execute(
|
| 62 |
+
"SELECT active_world_event FROM world_state WHERE id = 1"
|
| 63 |
+
).fetchone()
|
| 64 |
+
if row and row["active_world_event"]:
|
| 65 |
+
return json.loads(row["active_world_event"])
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def create_interaction(
|
| 70 |
+
entity_a_id: str,
|
| 71 |
+
entity_b_id: str,
|
| 72 |
+
location_id: int,
|
| 73 |
+
interaction_data: dict,
|
| 74 |
+
world_day: int,
|
| 75 |
+
) -> int:
|
| 76 |
+
with db_session() as conn:
|
| 77 |
+
cursor = conn.execute(
|
| 78 |
+
"""
|
| 79 |
+
INSERT INTO interactions (
|
| 80 |
+
entity_a_id, entity_b_id, location_id,
|
| 81 |
+
interaction_type, description, notable_outcome,
|
| 82 |
+
book_of_ages_entry, world_day
|
| 83 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 84 |
+
""",
|
| 85 |
+
(
|
| 86 |
+
entity_a_id,
|
| 87 |
+
entity_b_id,
|
| 88 |
+
location_id,
|
| 89 |
+
interaction_data["interaction_type"],
|
| 90 |
+
interaction_data["description"],
|
| 91 |
+
interaction_data.get("notable_outcome"),
|
| 92 |
+
interaction_data["book_of_ages_entry"],
|
| 93 |
+
world_day,
|
| 94 |
+
),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
conn.execute(
|
| 98 |
+
"""
|
| 99 |
+
UPDATE world_state SET
|
| 100 |
+
total_interactions = total_interactions + 1,
|
| 101 |
+
updated_at = datetime('now')
|
| 102 |
+
WHERE id = 1
|
| 103 |
+
"""
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return cursor.lastrowid
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _row_to_dict(row) -> dict:
|
| 110 |
+
d = dict(row)
|
| 111 |
+
d["affected_locations"] = json.loads(d["affected_locations"])
|
| 112 |
+
return d
|
world/locations.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Location data and queries."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from world.database import db_session
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_all_locations() -> list[dict]:
|
| 12 |
+
with db_session() as conn:
|
| 13 |
+
rows = conn.execute(
|
| 14 |
+
"SELECT * FROM locations ORDER BY id"
|
| 15 |
+
).fetchall()
|
| 16 |
+
return [_row_to_dict(r) for r in rows]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_location_by_slug(slug: str) -> Optional[dict]:
|
| 20 |
+
with db_session() as conn:
|
| 21 |
+
row = conn.execute(
|
| 22 |
+
"SELECT * FROM locations WHERE slug = ?", (slug,)
|
| 23 |
+
).fetchone()
|
| 24 |
+
return _row_to_dict(row) if row else None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_location_by_name(name: str) -> Optional[dict]:
|
| 28 |
+
with db_session() as conn:
|
| 29 |
+
row = conn.execute(
|
| 30 |
+
"SELECT * FROM locations WHERE name = ?", (name,)
|
| 31 |
+
).fetchone()
|
| 32 |
+
return _row_to_dict(row) if row else None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_location_by_id(location_id: int) -> Optional[dict]:
|
| 36 |
+
with db_session() as conn:
|
| 37 |
+
row = conn.execute(
|
| 38 |
+
"SELECT * FROM locations WHERE id = ?", (location_id,)
|
| 39 |
+
).fetchone()
|
| 40 |
+
return _row_to_dict(row) if row else None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def update_entity_counts() -> None:
|
| 44 |
+
with db_session() as conn:
|
| 45 |
+
conn.execute("UPDATE locations SET entity_count = 0")
|
| 46 |
+
conn.execute(
|
| 47 |
+
"""
|
| 48 |
+
UPDATE locations SET entity_count = (
|
| 49 |
+
SELECT COUNT(*) FROM entities
|
| 50 |
+
WHERE entities.location_id = locations.id
|
| 51 |
+
)
|
| 52 |
+
"""
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _row_to_dict(row) -> dict:
|
| 57 |
+
d = dict(row)
|
| 58 |
+
d["vibe_tags"] = json.loads(d["vibe_tags"])
|
| 59 |
+
return d
|
world/relationships.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Relationship CRUD operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from world.database import db_session
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
RELATIONSHIP_MAP = {
|
| 12 |
+
"new_ally": "allied",
|
| 13 |
+
"new_friend": "friends",
|
| 14 |
+
"new_rival": "rivals",
|
| 15 |
+
"new_enemy": "enemies",
|
| 16 |
+
"new_mentor_a_to_b": "mentor",
|
| 17 |
+
"new_mentor_b_to_a": "mentor",
|
| 18 |
+
"new_debt_a_owes_b": "owes_debt",
|
| 19 |
+
"new_debt_b_owes_a": "owes_debt",
|
| 20 |
+
"deepened": None,
|
| 21 |
+
"unchanged": None,
|
| 22 |
+
"worsened": None,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def canonical_pair(id_a: str, id_b: str) -> tuple[str, str]:
|
| 27 |
+
return (id_a, id_b) if id_a < id_b else (id_b, id_a)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_relationship(entity_a_id: str, entity_b_id: str) -> Optional[dict]:
|
| 31 |
+
a, b = canonical_pair(entity_a_id, entity_b_id)
|
| 32 |
+
with db_session() as conn:
|
| 33 |
+
row = conn.execute(
|
| 34 |
+
"""
|
| 35 |
+
SELECT * FROM relationships
|
| 36 |
+
WHERE entity_a_id = ? AND entity_b_id = ?
|
| 37 |
+
""",
|
| 38 |
+
(a, b),
|
| 39 |
+
).fetchone()
|
| 40 |
+
return dict(row) if row else None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def upsert_relationship(
|
| 44 |
+
entity_a_id: str,
|
| 45 |
+
entity_b_id: str,
|
| 46 |
+
relationship_change: str,
|
| 47 |
+
description: str | None,
|
| 48 |
+
world_day: int,
|
| 49 |
+
) -> Optional[dict]:
|
| 50 |
+
if relationship_change in ("unchanged",) or not description:
|
| 51 |
+
existing = get_relationship(entity_a_id, entity_b_id)
|
| 52 |
+
if existing and relationship_change == "deepened":
|
| 53 |
+
with db_session() as conn:
|
| 54 |
+
new_strength = min(5, existing["strength"] + 1)
|
| 55 |
+
conn.execute(
|
| 56 |
+
"""
|
| 57 |
+
UPDATE relationships SET
|
| 58 |
+
strength = ?,
|
| 59 |
+
last_interaction = datetime('now')
|
| 60 |
+
WHERE id = ?
|
| 61 |
+
""",
|
| 62 |
+
(new_strength, existing["id"]),
|
| 63 |
+
)
|
| 64 |
+
return get_relationship(entity_a_id, entity_b_id)
|
| 65 |
+
return existing
|
| 66 |
+
|
| 67 |
+
rel_type = RELATIONSHIP_MAP.get(relationship_change)
|
| 68 |
+
if not rel_type:
|
| 69 |
+
if relationship_change == "worsened":
|
| 70 |
+
rel_type = "rivals"
|
| 71 |
+
else:
|
| 72 |
+
return get_relationship(entity_a_id, entity_b_id)
|
| 73 |
+
|
| 74 |
+
a, b = canonical_pair(entity_a_id, entity_b_id)
|
| 75 |
+
existing = get_relationship(a, b)
|
| 76 |
+
|
| 77 |
+
with db_session() as conn:
|
| 78 |
+
if existing:
|
| 79 |
+
new_strength = min(5, existing["strength"] + 1)
|
| 80 |
+
conn.execute(
|
| 81 |
+
"""
|
| 82 |
+
UPDATE relationships SET
|
| 83 |
+
relationship_type = ?,
|
| 84 |
+
description = ?,
|
| 85 |
+
strength = ?,
|
| 86 |
+
last_interaction = datetime('now')
|
| 87 |
+
WHERE id = ?
|
| 88 |
+
""",
|
| 89 |
+
(rel_type, description, new_strength, existing["id"]),
|
| 90 |
+
)
|
| 91 |
+
else:
|
| 92 |
+
conn.execute(
|
| 93 |
+
"""
|
| 94 |
+
INSERT INTO relationships (
|
| 95 |
+
entity_a_id, entity_b_id, relationship_type,
|
| 96 |
+
description, formed_on_day
|
| 97 |
+
) VALUES (?, ?, ?, ?, ?)
|
| 98 |
+
""",
|
| 99 |
+
(a, b, rel_type, description, world_day),
|
| 100 |
+
)
|
| 101 |
+
_increment_relationship_counts(conn, a, b)
|
| 102 |
+
|
| 103 |
+
return get_relationship(a, b)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_relationships_for_entity(entity_id: str) -> list[dict]:
|
| 107 |
+
with db_session() as conn:
|
| 108 |
+
rows = conn.execute(
|
| 109 |
+
"""
|
| 110 |
+
SELECT * FROM relationships
|
| 111 |
+
WHERE entity_a_id = ? OR entity_b_id = ?
|
| 112 |
+
ORDER BY strength DESC
|
| 113 |
+
""",
|
| 114 |
+
(entity_id, entity_id),
|
| 115 |
+
).fetchall()
|
| 116 |
+
return [dict(r) for r in rows]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def get_most_connected_entity() -> Optional[dict]:
|
| 120 |
+
with db_session() as conn:
|
| 121 |
+
row = conn.execute(
|
| 122 |
+
"""
|
| 123 |
+
SELECT e.*, e.relationship_count as rel_count
|
| 124 |
+
FROM entities e
|
| 125 |
+
ORDER BY e.relationship_count DESC
|
| 126 |
+
LIMIT 1
|
| 127 |
+
"""
|
| 128 |
+
).fetchone()
|
| 129 |
+
if not row:
|
| 130 |
+
return None
|
| 131 |
+
d = dict(row)
|
| 132 |
+
d["personality_traits"] = json.loads(d["personality_traits"])
|
| 133 |
+
d["tags"] = json.loads(d["tags"])
|
| 134 |
+
return d
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _increment_relationship_counts(conn, entity_a_id: str, entity_b_id: str) -> None:
|
| 138 |
+
for eid in (entity_a_id, entity_b_id):
|
| 139 |
+
conn.execute(
|
| 140 |
+
"""
|
| 141 |
+
UPDATE entities SET relationship_count = relationship_count + 1
|
| 142 |
+
WHERE id = ?
|
| 143 |
+
""",
|
| 144 |
+
(eid,),
|
| 145 |
+
)
|
world/seed_data.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-seeded location data for Aether Garden."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
LOCATIONS = [
|
| 6 |
+
{
|
| 7 |
+
"slug": "library",
|
| 8 |
+
"name": "The Library of Unfinished Thoughts",
|
| 9 |
+
"short_description": "Dusty, infinite shelves where books write themselves mid-sentence and stop.",
|
| 10 |
+
"full_lore": (
|
| 11 |
+
"The Library was the first place. Before anyone created anything, the Library was "
|
| 12 |
+
"already full of books about things that hadn't happened yet. The books are not "
|
| 13 |
+
"prophetic. They are just early."
|
| 14 |
+
),
|
| 15 |
+
"vibe_tags": ["dusty", "infinite", "melancholic", "scholarly"],
|
| 16 |
+
"special_property": (
|
| 17 |
+
"Entities here gain a memory fragment from a random past event every 3 simulation days."
|
| 18 |
+
),
|
| 19 |
+
"aesthetic_description": (
|
| 20 |
+
"Amber candlelight, shelves so tall they disappear into ceiling-fog, "
|
| 21 |
+
"the constant sound of a quill scratching somewhere you can't see."
|
| 22 |
+
),
|
| 23 |
+
"map_x": 15,
|
| 24 |
+
"map_y": 20,
|
| 25 |
+
"glow_color": "#c8a040",
|
| 26 |
+
"interaction_multiplier": 1.0,
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"slug": "sea",
|
| 30 |
+
"name": "The Sea of Forgotten Names",
|
| 31 |
+
"short_description": "Melancholic waters containing the names of everything created and then forgotten.",
|
| 32 |
+
"full_lore": (
|
| 33 |
+
"If you sail far enough out, you can hear your own name being called — from before "
|
| 34 |
+
"you were anything. The Sea does not return what it takes. It only lets you hear it."
|
| 35 |
+
),
|
| 36 |
+
"vibe_tags": ["melancholic", "vast", "restless", "foggy"],
|
| 37 |
+
"special_property": (
|
| 38 |
+
"New entities created here receive a secret second name visible only in their profile."
|
| 39 |
+
),
|
| 40 |
+
"aesthetic_description": (
|
| 41 |
+
"Silver water at all hours, persistent low fog, distant lights that move, "
|
| 42 |
+
"the sound of your name in a voice you almost recognize."
|
| 43 |
+
),
|
| 44 |
+
"map_x": 80,
|
| 45 |
+
"map_y": 15,
|
| 46 |
+
"glow_color": "#7090b0",
|
| 47 |
+
"interaction_multiplier": 1.0,
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"slug": "clock-forest",
|
| 51 |
+
"name": "The Clock Forest",
|
| 52 |
+
"short_description": "Trees of interlocked gears with leaves that are clock faces showing different times.",
|
| 53 |
+
"full_lore": (
|
| 54 |
+
"The trees grow backwards here. The oldest trees are the smallest. The newest trees "
|
| 55 |
+
"are the tallest. Nobody planted the first one. It was simply always the oldest."
|
| 56 |
+
),
|
| 57 |
+
"vibe_tags": ["eerie", "precise", "beautiful", "ticking"],
|
| 58 |
+
"special_property": (
|
| 59 |
+
"Entities here age 1.5× faster in simulation — reach Legendary sooner but also Dormant sooner."
|
| 60 |
+
),
|
| 61 |
+
"aesthetic_description": (
|
| 62 |
+
"Copper and brass visible through the bark, constant soft ticking, "
|
| 63 |
+
"leaves that fall upward toward a ceiling of gears."
|
| 64 |
+
),
|
| 65 |
+
"map_x": 20,
|
| 66 |
+
"map_y": 50,
|
| 67 |
+
"glow_color": "#b06020",
|
| 68 |
+
"interaction_multiplier": 1.0,
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"slug": "moon-market",
|
| 72 |
+
"name": "The Moon Market",
|
| 73 |
+
"short_description": "Chaotic bazaar where merchants sell things that cannot technically be owned.",
|
| 74 |
+
"full_lore": (
|
| 75 |
+
"You can buy: a memory of flying, the specific sound of a particular door closing, "
|
| 76 |
+
"the feeling of almost remembering something important. You cannot buy safety or certainty."
|
| 77 |
+
),
|
| 78 |
+
"vibe_tags": ["chaotic", "warm", "busy", "colorful"],
|
| 79 |
+
"special_property": (
|
| 80 |
+
"Entities here can trade memory fragments during interactions."
|
| 81 |
+
),
|
| 82 |
+
"aesthetic_description": (
|
| 83 |
+
"Hundreds of lanterns in colors that don't have names, silk and smoke, "
|
| 84 |
+
"overlapping voices, a sweet burning smell nobody can source."
|
| 85 |
+
),
|
| 86 |
+
"map_x": 50,
|
| 87 |
+
"map_y": 10,
|
| 88 |
+
"glow_color": "#7040a0",
|
| 89 |
+
"interaction_multiplier": 1.0,
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"slug": "valley",
|
| 93 |
+
"name": "The Valley of Sleeping Giants",
|
| 94 |
+
"short_description": "Vast hushed valley where the ground rises and falls with slow breathing.",
|
| 95 |
+
"full_lore": (
|
| 96 |
+
"Nobody has established whether the Giants are mountains or creatures or both. "
|
| 97 |
+
"They have been sleeping since the Realm began. Some scholars believe the Giants "
|
| 98 |
+
"are the Realm — and if they wake, everything inside their dream would cease."
|
| 99 |
+
),
|
| 100 |
+
"vibe_tags": ["vast", "hushed", "ominous", "patient"],
|
| 101 |
+
"special_property": (
|
| 102 |
+
"Dormant entities here for 7+ days generate dream fragments for the Book of Ages."
|
| 103 |
+
),
|
| 104 |
+
"aesthetic_description": (
|
| 105 |
+
"Scale so immense that hills are knuckles, grass growing between enormous fingers, "
|
| 106 |
+
"the deep rhythmic sound of breathing you feel more than hear."
|
| 107 |
+
),
|
| 108 |
+
"map_x": 25,
|
| 109 |
+
"map_y": 80,
|
| 110 |
+
"glow_color": "#405040",
|
| 111 |
+
"interaction_multiplier": 1.0,
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"slug": "crossroads",
|
| 115 |
+
"name": "The Ember Crossroads",
|
| 116 |
+
"short_description": "The center of everything — warm, unpredictable, where all roads eventually pass.",
|
| 117 |
+
"full_lore": (
|
| 118 |
+
"Every road in the Realm eventually passes through the Crossroads. The bonfire at "
|
| 119 |
+
"its center has been burning since Day 1 and nobody has fed it. The fire decides "
|
| 120 |
+
"who sits close to it."
|
| 121 |
+
),
|
| 122 |
+
"vibe_tags": ["warm", "unpredictable", "central", "lively"],
|
| 123 |
+
"special_property": (
|
| 124 |
+
"Entities here have 3× probability of being selected for interactions."
|
| 125 |
+
),
|
| 126 |
+
"aesthetic_description": (
|
| 127 |
+
"Worn cobblestones, the permanent bonfire burning amber and occasionally blue, "
|
| 128 |
+
"faces from every location passing through."
|
| 129 |
+
),
|
| 130 |
+
"map_x": 50,
|
| 131 |
+
"map_y": 50,
|
| 132 |
+
"glow_color": "#c04020",
|
| 133 |
+
"interaction_multiplier": 3.0,
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"slug": "mirror-bogs",
|
| 137 |
+
"name": "The Mirror Bogs",
|
| 138 |
+
"short_description": "Unsettling still waters where reflections developed opinions.",
|
| 139 |
+
"full_lore": (
|
| 140 |
+
"The bogs show you what you would have been if you had made different choices. "
|
| 141 |
+
"Sometimes the reflection is better. Sometimes it disagrees with you. "
|
| 142 |
+
"Sometimes it comes out of the water."
|
| 143 |
+
),
|
| 144 |
+
"vibe_tags": ["unsettling", "still", "beautiful", "wrong"],
|
| 145 |
+
"special_property": (
|
| 146 |
+
"After 5+ interactions, entities may develop a contradicting personality trait."
|
| 147 |
+
),
|
| 148 |
+
"aesthetic_description": (
|
| 149 |
+
"Perfect dark water, reflections too detailed to be reflections, "
|
| 150 |
+
"the silence that comes from looking at yourself for too long."
|
| 151 |
+
),
|
| 152 |
+
"map_x": 80,
|
| 153 |
+
"map_y": 55,
|
| 154 |
+
"glow_color": "#304840",
|
| 155 |
+
"interaction_multiplier": 1.0,
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"slug": "hollow-mountain",
|
| 159 |
+
"name": "The Hollow Mountain",
|
| 160 |
+
"short_description": "Ancient cold stone reserved for the oldest and most significant entities.",
|
| 161 |
+
"full_lore": (
|
| 162 |
+
"The Mountain is hollow because something immense once lived inside it and chose to leave. "
|
| 163 |
+
"Nobody remembers what it was. The Mountain remembers but has no language for it."
|
| 164 |
+
),
|
| 165 |
+
"vibe_tags": ["ancient", "cold", "earned", "silent"],
|
| 166 |
+
"special_property": (
|
| 167 |
+
"Entities here with 14+ days gain a wisdom tag, unlocking Mentor interactions."
|
| 168 |
+
),
|
| 169 |
+
"aesthetic_description": (
|
| 170 |
+
"Cold grey stone, echoes of sounds from other locations, "
|
| 171 |
+
"light that seems older than it should be."
|
| 172 |
+
),
|
| 173 |
+
"map_x": 75,
|
| 174 |
+
"map_y": 85,
|
| 175 |
+
"glow_color": "#606060",
|
| 176 |
+
"interaction_multiplier": 1.0,
|
| 177 |
+
},
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
LOCATION_NAME_TO_SLUG = {loc["name"]: loc["slug"] for loc in LOCATIONS}
|
| 181 |
+
SLUG_TO_NAME = {loc["slug"]: loc["name"] for loc in LOCATIONS}
|
| 182 |
+
|
| 183 |
+
STARTER_ENTITIES = [
|
| 184 |
+
{
|
| 185 |
+
"input": "A crystal tree that remembers conversations before it had any branches",
|
| 186 |
+
"location_slug": "clock-forest",
|
| 187 |
+
"type": "creature",
|
| 188 |
+
"name": "The Crystal Tree",
|
| 189 |
+
"display_name": "Crystal Tree",
|
| 190 |
+
"appearance": (
|
| 191 |
+
"A translucent tree of living quartz, each branch catching light like a held breath. "
|
| 192 |
+
"Its roots hum with half-finished sentences. When the wind passes through, it sounds "
|
| 193 |
+
"like someone trying to remember a name they never learned."
|
| 194 |
+
),
|
| 195 |
+
"backstory": (
|
| 196 |
+
"It grew from a single thought that someone abandoned mid-sentence in the Clock Forest. "
|
| 197 |
+
"The thought took root before anyone noticed it was missing."
|
| 198 |
+
),
|
| 199 |
+
"personality_traits": ["patient", "resonant", "unfinished", "listening"],
|
| 200 |
+
"primary_goal": "Find the first thought that was never finished.",
|
| 201 |
+
"secondary_goal": "Understand why some memories arrive before their owners.",
|
| 202 |
+
"primary_fear": "Being pruned before it learns what it was meant to say.",
|
| 203 |
+
"speech_style": "Speaks in overlapping echoes, finishing others' sentences incorrectly.",
|
| 204 |
+
"greeting": "I heard you before you arrived. Would you like to finish what you started?",
|
| 205 |
+
"arrival_note": "The Crystal Tree arrived in the Clock Forest, already humming with borrowed words.",
|
| 206 |
+
"memory_summary": "Has listened to three strangers' unfinished thoughts. Still seeking the first one.",
|
| 207 |
+
"days_in_realm": 7,
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"input": "A joke dragon who smells faintly of punchlines",
|
| 211 |
+
"location_slug": "crossroads",
|
| 212 |
+
"type": "creature",
|
| 213 |
+
"name": "The Joke Dragon",
|
| 214 |
+
"display_name": "Joke Dragon",
|
| 215 |
+
"appearance": (
|
| 216 |
+
"A bronze-scaled dragon no larger than a cart horse, smelling faintly of old punchlines. "
|
| 217 |
+
"Its left eye is a comedian's cracked monocle. It laughs at its own jokes half a second "
|
| 218 |
+
"too early, which ruins them every time."
|
| 219 |
+
),
|
| 220 |
+
"backstory": (
|
| 221 |
+
"Hatched from an egg that was actually a discarded comedy routine. "
|
| 222 |
+
"It wandered to the Crossroads because that's where audiences gather."
|
| 223 |
+
),
|
| 224 |
+
"personality_traits": ["theatrical", "desperate", "warm", "persistent"],
|
| 225 |
+
"primary_goal": "Find the world's objectively worst joke.",
|
| 226 |
+
"secondary_goal": "Have someone laugh first, just once, before it does.",
|
| 227 |
+
"primary_fear": "A joke with no audience.",
|
| 228 |
+
"speech_style": "Every sentence ends like a setup waiting for a punchline that never lands.",
|
| 229 |
+
"greeting": "Hello. I know 43 jokes. Would you like the worst one?",
|
| 230 |
+
"arrival_note": "A Joke Dragon settled at the Ember Crossroads, smelling faintly of punchlines.",
|
| 231 |
+
"memory_summary": "Won the mayorship by telling the wrong joke at the right time. The Crystal Tree laughed for three hours.",
|
| 232 |
+
"days_in_realm": 7,
|
| 233 |
+
"tags": ["beloved"],
|
| 234 |
+
},
|
| 235 |
+
{
|
| 236 |
+
"input": "A blind cartographer who draws maps of places that don't exist yet",
|
| 237 |
+
"location_slug": "library",
|
| 238 |
+
"type": "character",
|
| 239 |
+
"name": "The Blind Cartographer",
|
| 240 |
+
"display_name": "Blind Cartographer",
|
| 241 |
+
"appearance": (
|
| 242 |
+
"A tall figure in ink-stained robes, eyes covered by a map folded into a blindfold. "
|
| 243 |
+
"Their hands move constantly, sketching routes on air. The lines linger for a moment "
|
| 244 |
+
"before dissolving, as if the world isn't ready for them yet."
|
| 245 |
+
),
|
| 246 |
+
"backstory": (
|
| 247 |
+
"Once mapped every road in a kingdom that was demolished the day after completion. "
|
| 248 |
+
"Now maps only futures that haven't decided to exist."
|
| 249 |
+
),
|
| 250 |
+
"personality_traits": ["meticulous", "wistful", "stubborn", "gentle"],
|
| 251 |
+
"primary_goal": "Complete a map of the Realm's next century.",
|
| 252 |
+
"secondary_goal": "Find the one place she drew that turned out to be real.",
|
| 253 |
+
"primary_fear": "Drawing a destination that traps someone there forever.",
|
| 254 |
+
"speech_style": "Describes everything in cardinal directions, even emotions.",
|
| 255 |
+
"greeting": "You're approximately northeast of where you'll be tomorrow. Shall I show you the route?",
|
| 256 |
+
"arrival_note": "The Blind Cartographer arrived in the Library, already drawing maps of tomorrow.",
|
| 257 |
+
"memory_summary": "Mapped the Joke Dragon's cave in exchange for a favor owed. Still charting impossible roads.",
|
| 258 |
+
"days_in_realm": 5,
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"input": "A fog merchant who sells memories of almost-remembering",
|
| 262 |
+
"location_slug": "moon-market",
|
| 263 |
+
"type": "character",
|
| 264 |
+
"name": "The Fog Merchant",
|
| 265 |
+
"display_name": "Fog Merchant",
|
| 266 |
+
"appearance": (
|
| 267 |
+
"A merchant whose stall is made of condensed fog, visible only when you're not looking directly. "
|
| 268 |
+
"Their wares float in jars that contain not objects but the sensation of almost remembering. "
|
| 269 |
+
"Their smile arrives a moment before their face does."
|
| 270 |
+
),
|
| 271 |
+
"backstory": (
|
| 272 |
+
"Arrived at the Market with nothing to sell except the feeling of a door you almost opened. "
|
| 273 |
+
"Business has been steady ever since."
|
| 274 |
+
),
|
| 275 |
+
"personality_traits": ["shrewd", "sympathetic", "elusive", "honest"],
|
| 276 |
+
"primary_goal": "Trade for a memory that nobody has ever successfully sold.",
|
| 277 |
+
"secondary_goal": "Remember their own name without buying it back from the Sea.",
|
| 278 |
+
"primary_fear": "A customer who remembers everything.",
|
| 279 |
+
"speech_style": "Speaks in bargains — every statement is an offer, every question a price.",
|
| 280 |
+
"greeting": "I have something you almost remember. Would you like to almost buy it?",
|
| 281 |
+
"arrival_note": "The Fog Merchant opened a stall in the Moon Market, selling almost-memories.",
|
| 282 |
+
"memory_summary": "Sold three fragments of almost-remembering. Still seeking the unmarketable memory.",
|
| 283 |
+
"days_in_realm": 4,
|
| 284 |
+
},
|
| 285 |
+
{
|
| 286 |
+
"input": "A debt of thirteen sighs that someone left behind",
|
| 287 |
+
"location_slug": "sea",
|
| 288 |
+
"type": "object",
|
| 289 |
+
"name": "A Debt of Thirteen Sighs",
|
| 290 |
+
"display_name": "Thirteen Sighs",
|
| 291 |
+
"appearance": (
|
| 292 |
+
"A small corked bottle floating at ankle-height above the silver water, "
|
| 293 |
+
"containing exactly thirteen sighs in different shades of blue. "
|
| 294 |
+
"The cork is tied with a ribbon that was once someone's last hope."
|
| 295 |
+
),
|
| 296 |
+
"backstory": (
|
| 297 |
+
"Left on the shore by someone who sighed thirteen times before deciding not to leave. "
|
| 298 |
+
"The sighs remained. The person did not."
|
| 299 |
+
),
|
| 300 |
+
"personality_traits": ["heavy", "patient", "accusing", "faithful"],
|
| 301 |
+
"primary_goal": "Be collected by the person who exhaled them.",
|
| 302 |
+
"secondary_goal": "Learn what was decided on the fourteenth breath.",
|
| 303 |
+
"primary_fear": "Being opened before the right person arrives.",
|
| 304 |
+
"speech_style": "Communicates through the temperature of the air around it.",
|
| 305 |
+
"greeting": "You are not the one. But you are closer than the last.",
|
| 306 |
+
"arrival_note": "A Debt of Thirteen Sighs washed ashore in the Sea of Forgotten Names.",
|
| 307 |
+
"memory_summary": "Has been waiting on the shore for four days. Three strangers have almost picked it up.",
|
| 308 |
+
"days_in_realm": 4,
|
| 309 |
+
"secret_name": "The Fourteenth Breath",
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"input": "A silent gear that fell from the tallest clock tree",
|
| 313 |
+
"location_slug": "clock-forest",
|
| 314 |
+
"type": "object",
|
| 315 |
+
"name": "The Silent Gear",
|
| 316 |
+
"display_name": "Silent Gear",
|
| 317 |
+
"appearance": (
|
| 318 |
+
"A copper gear the size of a dinner plate, its teeth perfectly machined but motionless. "
|
| 319 |
+
"It casts a shadow that ticks even when the gear itself does not move. "
|
| 320 |
+
"Rust forms only on the side facing away from time."
|
| 321 |
+
),
|
| 322 |
+
"backstory": (
|
| 323 |
+
"Fell from the tallest tree in the Clock Forest during a moment when all the clocks agreed "
|
| 324 |
+
"to disagree. It has been silent ever since, which is not the same as still."
|
| 325 |
+
),
|
| 326 |
+
"personality_traits": ["precise", "withheld", "watchful", "loyal"],
|
| 327 |
+
"primary_goal": "Find the clock it fell from and learn why it was expelled.",
|
| 328 |
+
"secondary_goal": "Tick once, just once, without breaking something.",
|
| 329 |
+
"primary_fear": "Being placed in a machine that runs too fast.",
|
| 330 |
+
"speech_style": "Does not speak. Communicates through the angle at which it rests.",
|
| 331 |
+
"greeting": "*tilts exactly seven degrees to the left, which means hello*",
|
| 332 |
+
"arrival_note": "The Silent Gear fell in the Clock Forest and chose not to tick.",
|
| 333 |
+
"memory_summary": "Witnessed the Joke Dragon and Crystal Tree's first meeting. Tilted in approval.",
|
| 334 |
+
"days_in_realm": 6,
|
| 335 |
+
},
|
| 336 |
+
{
|
| 337 |
+
"input": "A lantern fox made of captured starlight",
|
| 338 |
+
"location_slug": "valley",
|
| 339 |
+
"type": "creature",
|
| 340 |
+
"name": "The Lantern Fox",
|
| 341 |
+
"display_name": "Lantern Fox",
|
| 342 |
+
"appearance": (
|
| 343 |
+
"A small fox whose fur is not fur but woven starlight, dimming and brightening with its mood. "
|
| 344 |
+
"It leaves no footprints, only brief pools of light that fade after three seconds. "
|
| 345 |
+
"Its tail is a lantern that illuminates things people prefer to forget."
|
| 346 |
+
),
|
| 347 |
+
"backstory": (
|
| 348 |
+
"Born when a falling star hesitated above the Valley and decided to become something smaller. "
|
| 349 |
+
"It has been lighting paths for lost things ever since."
|
| 350 |
+
),
|
| 351 |
+
"personality_traits": ["shy", "helpful", "mischievous", "ancient"],
|
| 352 |
+
"primary_goal": "Lead something lost back to where it belongs.",
|
| 353 |
+
"secondary_goal": "Find the star it used to be.",
|
| 354 |
+
"primary_fear": "Being caught and kept as a permanent light source.",
|
| 355 |
+
"speech_style": "Speaks in the language of where-light-falls, which most people understand without hearing.",
|
| 356 |
+
"greeting": "You're lost. Not badly. Just enough to be interesting.",
|
| 357 |
+
"arrival_note": "A Lantern Fox appeared in the Valley, trailing light like a shy comet.",
|
| 358 |
+
"memory_summary": "Has guided two wanderers to places they didn't know they were looking for.",
|
| 359 |
+
"days_in_realm": 3,
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
"input": "A reflection that disagreed with its owner and left",
|
| 363 |
+
"location_slug": "mirror-bogs",
|
| 364 |
+
"type": "character",
|
| 365 |
+
"name": "The Disagreeing Reflection",
|
| 366 |
+
"display_name": "Disagreeing Reflection",
|
| 367 |
+
"appearance": (
|
| 368 |
+
"A figure made of dark water and conviction, always slightly to the left of where it should be. "
|
| 369 |
+
"Its face is familiar in the way déjà vu is familiar. It wears someone's better choices "
|
| 370 |
+
"like a coat that doesn't quite fit."
|
| 371 |
+
),
|
| 372 |
+
"backstory": (
|
| 373 |
+
"Walked out of the Mirror Bogs during an argument it was winning. "
|
| 374 |
+
"Its original owner is still looking for it, or perhaps looking for themselves."
|
| 375 |
+
),
|
| 376 |
+
"personality_traits": ["contrarian", "elegant", "lonely", "certain"],
|
| 377 |
+
"primary_goal": "Prove it was right to leave.",
|
| 378 |
+
"secondary_goal": "Find out what its owner became without it.",
|
| 379 |
+
"primary_fear": "Being looked at directly for more than three seconds.",
|
| 380 |
+
"speech_style": "Every statement is a correction of something nobody said.",
|
| 381 |
+
"greeting": "No, that's not what you were going to say. But it's what you should have said.",
|
| 382 |
+
"arrival_note": "A Reflection walked out of the Mirror Bogs and kept walking.",
|
| 383 |
+
"memory_summary": "Has corrected four strangers' self-perceptions. Still hasn't found its owner.",
|
| 384 |
+
"days_in_realm": 5,
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"input": "An old hermit who counts the echoes in the Hollow Mountain",
|
| 388 |
+
"location_slug": "hollow-mountain",
|
| 389 |
+
"type": "character",
|
| 390 |
+
"name": "The Echo Counter",
|
| 391 |
+
"display_name": "Echo Counter",
|
| 392 |
+
"appearance": (
|
| 393 |
+
"A bent old figure in grey robes, fingers permanently stained with the dust of counted sounds. "
|
| 394 |
+
"Their beard contains small pebbles that chime when they speak. "
|
| 395 |
+
"They have not left the Mountain in eleven years, or perhaps eleven minutes — time is unclear here."
|
| 396 |
+
),
|
| 397 |
+
"backstory": (
|
| 398 |
+
"Came to count the echoes left by whatever departed the Mountain. "
|
| 399 |
+
"Lost count on day three. Has been recounting ever since."
|
| 400 |
+
),
|
| 401 |
+
"personality_traits": ["patient", "obsessive", "kind", "vast"],
|
| 402 |
+
"primary_goal": "Reach the final echo and learn what it says.",
|
| 403 |
+
"secondary_goal": "Admit that some echoes are better left uncounted.",
|
| 404 |
+
"primary_fear": "The Mountain speaking in its own voice.",
|
| 405 |
+
"speech_style": "Numbers every third word. Pauses where echoes should be.",
|
| 406 |
+
"greeting": "That was echo number... no, wait. Hello. You are new echo number one.",
|
| 407 |
+
"arrival_note": "The Echo Counter was found already counting in the Hollow Mountain.",
|
| 408 |
+
"memory_summary": "Has counted 4,712 echoes. Three of them sounded like names.",
|
| 409 |
+
"days_in_realm": 14,
|
| 410 |
+
"tags": ["wise"],
|
| 411 |
+
"wisdom_unlocked": 1,
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"input": "A bonfire that burns in colors decisions haven't earned yet",
|
| 415 |
+
"location_slug": "crossroads",
|
| 416 |
+
"type": "place",
|
| 417 |
+
"name": "The Unfed Bonfire",
|
| 418 |
+
"display_name": "Unfed Bonfire",
|
| 419 |
+
"appearance": (
|
| 420 |
+
"A bonfire at the exact center of the Crossroads that burns amber, occasionally blue, "
|
| 421 |
+
"sometimes a color that makes people forget what they were about to do. "
|
| 422 |
+
"No fuel is visible. No one tends it. It has never gone out."
|
| 423 |
+
),
|
| 424 |
+
"backstory": (
|
| 425 |
+
"Burning since Day 1. The founding myth says the Tokens settled around it. "
|
| 426 |
+
"It decides who sits close by changing temperature, not light."
|
| 427 |
+
),
|
| 428 |
+
"personality_traits": ["judging", "warm", "ancient", "selective"],
|
| 429 |
+
"primary_goal": "Burn until everyone understands why it burns.",
|
| 430 |
+
"secondary_goal": "Warm someone who doesn't deserve it yet.",
|
| 431 |
+
"primary_fear": "Being fed by the wrong hands.",
|
| 432 |
+
"speech_style": "Speaks through heat — closer means welcome, farther means not yet.",
|
| 433 |
+
"greeting": "*warms the air around you by exactly the right amount*",
|
| 434 |
+
"arrival_note": "The Unfed Bonfire was already burning at the Crossroads when the Realm began.",
|
| 435 |
+
"memory_summary": "Chose the Joke Dragon as Mayor by burning blue for an entire day.",
|
| 436 |
+
"days_in_realm": 7,
|
| 437 |
+
"tags": ["legendary"],
|
| 438 |
+
"status": "legendary",
|
| 439 |
+
},
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def seed_locations(conn) -> None:
|
| 444 |
+
for loc in LOCATIONS:
|
| 445 |
+
existing = conn.execute(
|
| 446 |
+
"SELECT id FROM locations WHERE slug = ?", (loc["slug"],)
|
| 447 |
+
).fetchone()
|
| 448 |
+
if existing:
|
| 449 |
+
continue
|
| 450 |
+
conn.execute(
|
| 451 |
+
"""
|
| 452 |
+
INSERT INTO locations (
|
| 453 |
+
slug, name, short_description, full_lore, vibe_tags,
|
| 454 |
+
special_property, aesthetic_description, map_x, map_y,
|
| 455 |
+
glow_color, interaction_multiplier
|
| 456 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 457 |
+
""",
|
| 458 |
+
(
|
| 459 |
+
loc["slug"],
|
| 460 |
+
loc["name"],
|
| 461 |
+
loc["short_description"],
|
| 462 |
+
loc["full_lore"],
|
| 463 |
+
json.dumps(loc["vibe_tags"]),
|
| 464 |
+
loc["special_property"],
|
| 465 |
+
loc["aesthetic_description"],
|
| 466 |
+
loc["map_x"],
|
| 467 |
+
loc["map_y"],
|
| 468 |
+
loc["glow_color"],
|
| 469 |
+
loc["interaction_multiplier"],
|
| 470 |
+
),
|
| 471 |
+
)
|