Spaces:
Sleeping
Sleeping
| """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.""" | |
| 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) | |
| # Load active quests so they can influence interactions | |
| 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: | |
| # Inject quest context into entity data if they have active quests | |
| 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() | |
| # Get updated world day for summary | |
| 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() | |
| # candidate -> (entity_a, entity_b, meet_location_id, weight) | |
| 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 | |
| # Keep the higher-weighted venue if the pair could meet in two places. | |
| if key not in candidates or candidates[key][3] < weight: | |
| candidates[key] = (a, b, meet_id, weight) | |
| # 1. Souls that already share a place. | |
| 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) | |
| # 2. The Crossroads convergence: residents + a rotating set of travellers. | |
| 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)): | |
| # Crossroads meetings are weighted up (the lively hub) but a | |
| # touch below same-place pairs at their home so places still matter. | |
| _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 | |