| """Full simulation tick — the autonomous world engine.""" |
|
|
| from __future__ import annotations |
|
|
| import json |
| import random |
| from collections import defaultdict |
| from datetime import datetime |
|
|
| from ai.events import generate_dream_fragment, generate_world_event, update_entity_memory |
| from ai.interaction import generate_interaction |
| from world.book_of_ages import create_entry |
| from world.database import db_session |
| from world.entities import ( |
| get_active_entities, |
| increment_days_in_realm, |
| increment_interaction_count, |
| update_memory, |
| ) |
| from world.events import create_interaction, create_world_event |
| from world.locations import get_all_locations, get_location_by_id, update_entity_counts |
| from world.relationships import upsert_relationship |
| from world.quests import get_entities_with_quests, complete_quest |
| from simulation.lifecycle import process_lifecycle_updates |
| from simulation.location_effects import ( |
| apply_moon_market_memory_trade, |
| process_library_memory_fragments, |
| process_mirror_contradictions, |
| ) |
|
|
|
|
| EMBER_CROSSROADS_SLUG = "crossroads" |
|
|
|
|
| def execute_simulation_tick() -> dict: |
| """Run one complete simulation tick. Returns summary.""" |
| from world.database import ensure_database_ready |
|
|
| ensure_database_ready() |
|
|
| summary = { |
| "world_event": None, |
| "world_event_title": None, |
| "interactions": 0, |
| "interactions_run": 0, |
| "memories_updated": 0, |
| "milestones": [], |
| "day": None, |
| } |
|
|
| with db_session() as conn: |
| world = conn.execute("SELECT * FROM world_state WHERE id = 1").fetchone() |
| world_day = world["current_day"] |
|
|
| increment_days_in_realm() |
|
|
| try: |
| event_result = generate_world_event(world_day) |
| event_id = create_world_event(event_result.model_dump(), world_day) |
| create_entry( |
| world_day=world_day, |
| entry_type="world_event", |
| content=event_result.book_of_ages_entry, |
| title=event_result.title, |
| is_milestone=False, |
| ) |
| summary["world_event"] = event_result.title |
| summary["world_event_title"] = event_result.title |
| current_event = { |
| "title": event_result.title, |
| "description": event_result.description, |
| "affected_locations": event_result.affected_locations, |
| } |
| except Exception as e: |
| current_event = None |
| summary["world_event"] = f"failed: {e}" |
|
|
| active_entities = get_active_entities() |
| entities_by_location = defaultdict(list) |
| for e in active_entities: |
| entities_by_location[e["location_id"]].append(e) |
|
|
| |
| active_quests = get_entities_with_quests() |
|
|
| pairs = _select_interaction_pairs( |
| entities_by_location, active_entities, num_pairs=random.randint(2, 4) |
| ) |
| interacted_entities: dict[str, list[str]] = defaultdict(list) |
| moon_market_pairs: list[tuple[dict, dict]] = [] |
| seen_pairs: set[tuple[str, str]] = set() |
|
|
| for entity_a, entity_b, meet_location_id in pairs: |
| if entity_a["id"] == entity_b["id"]: |
| continue |
|
|
| pair_key = tuple(sorted((entity_a["id"], entity_b["id"]))) |
| if pair_key in seen_pairs: |
| continue |
| seen_pairs.add(pair_key) |
|
|
| location = get_location_by_id(meet_location_id) |
| if not location: |
| continue |
|
|
| try: |
| |
| ea_with_quest = dict(entity_a) |
| eb_with_quest = dict(entity_b) |
| if entity_a["id"] in active_quests: |
| q = active_quests[entity_a["id"]] |
| ea_with_quest["active_quest"] = q["title"] |
| if entity_b["id"] in active_quests: |
| q = active_quests[entity_b["id"]] |
| eb_with_quest["active_quest"] = q["title"] |
|
|
| result = generate_interaction(ea_with_quest, eb_with_quest, location, current_event) |
|
|
| create_interaction( |
| entity_a["id"], |
| entity_b["id"], |
| location["id"], |
| result.model_dump(), |
| world_day, |
| ) |
|
|
| create_entry( |
| world_day=world_day, |
| entry_type="interaction", |
| content=result.book_of_ages_entry, |
| entity_ids=[entity_a["id"], entity_b["id"]], |
| location_id=location["id"], |
| ) |
|
|
| upsert_relationship( |
| entity_a["id"], |
| entity_b["id"], |
| result.relationship_change, |
| result.new_relationship_description, |
| world_day, |
| ) |
|
|
| increment_interaction_count(entity_a["id"]) |
| increment_interaction_count(entity_b["id"]) |
|
|
| interacted_entities[entity_a["id"]].append(result.entity_a_memory_addition) |
| interacted_entities[entity_b["id"]].append(result.entity_b_memory_addition) |
|
|
| if location.get("slug") == "moon-market": |
| moon_market_pairs.append((entity_a, entity_b)) |
|
|
| summary["interactions"] += 1 |
| summary["interactions_run"] = summary["interactions"] |
| except Exception: |
| continue |
|
|
| for entity_id, memory_additions in interacted_entities.items(): |
| from world.entities import get_entity |
| entity = get_entity(entity_id) |
| if not entity: |
| continue |
|
|
| world_events_for_entity = [] |
| if current_event and entity: |
| loc = get_location_by_id(entity["location_id"]) |
| if loc and loc["name"] in (current_event.get("affected_locations") or []): |
| world_events_for_entity.append(current_event["description"]) |
|
|
| try: |
| new_memory = update_entity_memory( |
| entity, |
| memory_additions, |
| world_events_for_entity, |
| ) |
| update_memory(entity_id, new_memory) |
| summary["memories_updated"] += 1 |
| except Exception: |
| pass |
|
|
| for entity_a, entity_b in moon_market_pairs: |
| from world.entities import get_entity |
| ea = get_entity(entity_a["id"]) |
| eb = get_entity(entity_b["id"]) |
| if not ea or not eb: |
| continue |
| ma, mb = apply_moon_market_memory_trade( |
| ea, eb, "moon-market", |
| ea.get("memory_summary") or "", |
| eb.get("memory_summary") or "", |
| ) |
| update_memory(ea["id"], ma) |
| update_memory(eb["id"], mb) |
|
|
| _process_dream_fragments(world_day) |
| summary["library_fragments"] = process_library_memory_fragments(world_day) |
| summary["mirror_contradictions"] = process_mirror_contradictions(world_day) |
| milestones = process_lifecycle_updates(world_day) |
| summary["milestones"] = milestones |
|
|
| with db_session() as conn: |
| conn.execute( |
| """ |
| UPDATE world_state SET |
| current_day = current_day + 1, |
| last_simulation_run = ?, |
| updated_at = datetime('now') |
| WHERE id = 1 |
| """, |
| (datetime.now().isoformat(),), |
| ) |
|
|
| update_entity_counts() |
|
|
| |
| with db_session() as conn: |
| ws = conn.execute("SELECT current_day FROM world_state WHERE id=1").fetchone() |
| summary["day"] = ws["current_day"] if ws else "?" |
|
|
| try: |
| from persistence.backup import backup_database |
| backup_database() |
| except Exception: |
| pass |
|
|
| return summary |
|
|
|
|
| def _select_interaction_pairs( |
| entities_by_location: dict, |
| active_entities: list[dict] | None = None, |
| num_pairs: int = 3, |
| ) -> list[tuple]: |
| """Select distinct, fresh entity pairs to interact this tick. |
| |
| Two kinds of encounters are possible: |
| |
| * **Within a place** — two souls that share a location simply meet. |
| * **At the Ember Crossroads** — "every road eventually passes through the |
| Crossroads", so each tick a rotating set of souls from anywhere in the |
| Realm travels through it and can meet whoever else is passing. This keeps |
| the hub lively and stops history from looping on the same co-located pair. |
| |
| Each returned tuple is ``(entity_a, entity_b, meet_location_id)``. |
| """ |
| locations = get_all_locations() |
| loc_by_id = {loc["id"]: loc for loc in locations} |
| crossroads = next((l for l in locations if l["slug"] == EMBER_CROSSROADS_SLUG), None) |
| crossroads_id = crossroads["id"] if crossroads else None |
| recent = _recently_interacted_pairs() |
|
|
| |
| candidates: dict[tuple[str, str], tuple] = {} |
|
|
| def _consider(a: dict, b: dict, meet_id: int, base_weight: float) -> None: |
| key = tuple(sorted((a["id"], b["id"]))) |
| freshness = 0.12 if key in recent else 1.0 |
| weight = base_weight * freshness |
| |
| if key not in candidates or candidates[key][3] < weight: |
| candidates[key] = (a, b, meet_id, weight) |
|
|
| |
| for loc_id, entities in entities_by_location.items(): |
| if len(entities) < 2: |
| continue |
| loc = loc_by_id.get(loc_id) |
| base = loc["interaction_multiplier"] if loc else 1.0 |
| for i in range(len(entities)): |
| for j in range(i + 1, len(entities)): |
| _consider(entities[i], entities[j], loc_id, base) |
|
|
| |
| if crossroads_id and active_entities: |
| residents = [e for e in active_entities if e["location_id"] == crossroads_id] |
| travellers_pool = [e for e in active_entities if e["location_id"] != crossroads_id] |
| random.shuffle(travellers_pool) |
| travellers = travellers_pool[: random.randint(3, 5)] |
| gathering = residents + travellers |
| for i in range(len(gathering)): |
| for j in range(i + 1, len(gathering)): |
| |
| |
| _consider(gathering[i], gathering[j], crossroads_id, 2.0) |
|
|
| if not candidates: |
| return [] |
|
|
| selected: list[tuple] = [] |
| pool = list(candidates.values()) |
| attempts = 0 |
| while pool and len(selected) < num_pairs and attempts < num_pairs * 10: |
| attempts += 1 |
| weights = [c[3] for c in pool] |
| chosen = random.choices(pool, weights=weights, k=1)[0] |
| a, b, meet_id, _ = chosen |
| selected.append((a, b, meet_id)) |
| chosen_key = tuple(sorted((a["id"], b["id"]))) |
| pool = [c for c in pool if tuple(sorted((c[0]["id"], c[1]["id"]))) != chosen_key] |
|
|
| return selected |
|
|
|
|
| def _recently_interacted_pairs(within_days: int = 2) -> set[tuple[str, str]]: |
| """Pairs that have interacted within the last `within_days` world-days.""" |
| with db_session() as conn: |
| current_day = conn.execute( |
| "SELECT current_day FROM world_state WHERE id = 1" |
| ).fetchone()["current_day"] |
| rows = conn.execute( |
| "SELECT entity_a_id, entity_b_id FROM interactions WHERE world_day >= ?", |
| (current_day - within_days,), |
| ).fetchall() |
| return {tuple(sorted((r["entity_a_id"], r["entity_b_id"]))) for r in rows} |
|
|
|
|
| def _process_dream_fragments(world_day: int) -> None: |
| with db_session() as conn: |
| rows = conn.execute( |
| """ |
| SELECT e.* FROM entities e |
| JOIN locations l ON e.location_id = l.id |
| WHERE e.status = 'dormant' AND l.slug = 'valley' |
| """ |
| ).fetchall() |
|
|
| for row in rows: |
| entity = dict(row) |
| entity["personality_traits"] = json.loads(entity["personality_traits"]) |
| last_active = datetime.fromisoformat(entity["last_active"]) |
| days_dormant = (datetime.now() - last_active).days |
|
|
| if days_dormant >= 7 and random.random() < 0.3: |
| try: |
| fragment = generate_dream_fragment(entity, days_dormant) |
| content = f'*From the dreams of {entity["name"]}: {fragment}*' |
| create_entry( |
| world_day=world_day, |
| entry_type="dream_fragment", |
| content=content, |
| entity_ids=[entity["id"]], |
| ) |
| except Exception: |
| pass |
|
|