kavyabhand commited on
Commit
d4f5636
·
verified ·
1 Parent(s): 7e28e2e

Deploy Aether Garden application

Browse files
.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

  • SHA256: 807ba3319d84fb68f0a50ab57d9f35b4c885469aa20ca92b56064906e5b72c19
  • Pointer size: 132 Bytes
  • Size of remote file: 2.69 MB
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
+ )