| """SQLite database connection, schema initialization, and migrations.""" |
|
|
| from __future__ import annotations |
|
|
| import os |
| import sqlite3 |
| from contextlib import contextmanager |
| from pathlib import Path |
| from typing import Generator |
|
|
| DB_PATH = Path(os.environ.get("TTR_DB_PATH", "data/world.db")) |
|
|
| SCHEMA_SQL = """ |
| PRAGMA journal_mode=WAL; |
| PRAGMA foreign_keys=ON; |
| PRAGMA synchronous=NORMAL; |
| |
| CREATE TABLE IF NOT EXISTS world_state ( |
| id INTEGER PRIMARY KEY DEFAULT 1, |
| world_name TEXT NOT NULL DEFAULT 'Aether Garden', |
| founding_date TEXT NOT NULL, |
| current_day INTEGER NOT NULL DEFAULT 1, |
| total_entities INTEGER NOT NULL DEFAULT 0, |
| total_interactions INTEGER NOT NULL DEFAULT 0, |
| total_events INTEGER NOT NULL DEFAULT 0, |
| last_simulation_run TEXT, |
| active_world_event TEXT, |
| updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
| ); |
| |
| CREATE TABLE IF NOT EXISTS locations ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| slug TEXT NOT NULL UNIQUE, |
| name TEXT NOT NULL, |
| short_description TEXT NOT NULL, |
| full_lore TEXT NOT NULL, |
| vibe_tags TEXT NOT NULL, |
| special_property TEXT NOT NULL, |
| aesthetic_description TEXT NOT NULL, |
| map_x REAL NOT NULL, |
| map_y REAL NOT NULL, |
| glow_color TEXT NOT NULL, |
| entity_count INTEGER NOT NULL DEFAULT 0, |
| interaction_multiplier REAL NOT NULL DEFAULT 1.0 |
| ); |
| |
| CREATE TABLE IF NOT EXISTS entities ( |
| id TEXT NOT NULL PRIMARY KEY, |
| name TEXT NOT NULL, |
| display_name TEXT NOT NULL, |
| type TEXT NOT NULL CHECK(type IN ('character','creature','object','place')), |
| input_description TEXT NOT NULL, |
| appearance TEXT NOT NULL, |
| backstory TEXT NOT NULL, |
| personality_traits TEXT NOT NULL, |
| primary_goal TEXT NOT NULL, |
| secondary_goal TEXT NOT NULL, |
| primary_fear TEXT NOT NULL, |
| speech_style TEXT NOT NULL, |
| greeting TEXT NOT NULL, |
| location_id INTEGER NOT NULL REFERENCES locations(id), |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| created_by_session TEXT, |
| days_in_realm INTEGER NOT NULL DEFAULT 0, |
| memory_summary TEXT NOT NULL DEFAULT '', |
| relationship_count INTEGER NOT NULL DEFAULT 0, |
| interaction_count INTEGER NOT NULL DEFAULT 0, |
| last_active TEXT NOT NULL DEFAULT (datetime('now')), |
| status TEXT NOT NULL DEFAULT 'active' |
| CHECK(status IN ('active','dormant','legendary')), |
| tags TEXT NOT NULL DEFAULT '[]', |
| secret_name TEXT, |
| arrival_note TEXT NOT NULL, |
| wisdom_unlocked INTEGER NOT NULL DEFAULT 0 |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_entities_location ON entities(location_id); |
| CREATE INDEX IF NOT EXISTS idx_entities_status ON entities(status); |
| CREATE INDEX IF NOT EXISTS idx_entities_created ON entities(created_at); |
| CREATE INDEX IF NOT EXISTS idx_entities_active ON entities(last_active); |
| |
| CREATE TABLE IF NOT EXISTS relationships ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| entity_a_id TEXT NOT NULL REFERENCES entities(id), |
| entity_b_id TEXT NOT NULL REFERENCES entities(id), |
| relationship_type TEXT NOT NULL, |
| description TEXT NOT NULL, |
| strength INTEGER NOT NULL DEFAULT 1 CHECK(strength BETWEEN 1 AND 5), |
| formed_at TEXT NOT NULL DEFAULT (datetime('now')), |
| formed_on_day INTEGER NOT NULL DEFAULT 1, |
| last_interaction TEXT NOT NULL DEFAULT (datetime('now')), |
| UNIQUE(entity_a_id, entity_b_id), |
| CHECK(entity_a_id < entity_b_id) |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_rel_a ON relationships(entity_a_id); |
| CREATE INDEX IF NOT EXISTS idx_rel_b ON relationships(entity_b_id); |
| |
| CREATE TABLE IF NOT EXISTS interactions ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| entity_a_id TEXT NOT NULL REFERENCES entities(id), |
| entity_b_id TEXT NOT NULL REFERENCES entities(id), |
| location_id INTEGER NOT NULL REFERENCES locations(id), |
| interaction_type TEXT NOT NULL, |
| description TEXT NOT NULL, |
| notable_outcome TEXT, |
| book_of_ages_entry TEXT NOT NULL, |
| timestamp TEXT NOT NULL DEFAULT (datetime('now')), |
| world_day INTEGER NOT NULL |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_int_timestamp ON interactions(timestamp); |
| CREATE INDEX IF NOT EXISTS idx_int_a ON interactions(entity_a_id); |
| CREATE INDEX IF NOT EXISTS idx_int_b ON interactions(entity_b_id); |
| CREATE INDEX IF NOT EXISTS idx_int_day ON interactions(world_day); |
| |
| CREATE TABLE IF NOT EXISTS world_events ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| event_type TEXT NOT NULL, |
| title TEXT NOT NULL, |
| description TEXT NOT NULL, |
| affected_locations TEXT NOT NULL, |
| entity_effect TEXT NOT NULL, |
| book_of_ages_entry TEXT NOT NULL, |
| mystery_hook TEXT, |
| timestamp TEXT NOT NULL DEFAULT (datetime('now')), |
| world_day INTEGER NOT NULL |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_events_day ON world_events(world_day); |
| |
| CREATE TABLE IF NOT EXISTS book_of_ages ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| world_day INTEGER NOT NULL, |
| entry_type TEXT NOT NULL CHECK(entry_type IN |
| ('arrival','interaction','world_event','milestone','dream_fragment')), |
| title TEXT, |
| content TEXT NOT NULL, |
| entity_ids TEXT NOT NULL DEFAULT '[]', |
| location_id INTEGER REFERENCES locations(id), |
| timestamp TEXT NOT NULL DEFAULT (datetime('now')), |
| is_milestone INTEGER NOT NULL DEFAULT 0 |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_boa_day ON book_of_ages(world_day); |
| CREATE INDEX IF NOT EXISTS idx_boa_type ON book_of_ages(entry_type); |
| |
| CREATE TABLE IF NOT EXISTS sessions ( |
| session_id TEXT NOT NULL PRIMARY KEY, |
| last_creation TEXT, |
| creation_count INTEGER NOT NULL DEFAULT 0, |
| first_seen TEXT NOT NULL DEFAULT (datetime('now')), |
| last_seen TEXT NOT NULL DEFAULT (datetime('now')) |
| ); |
| """ |
|
|
|
|
| def get_connection() -> sqlite3.Connection: |
| DB_PATH.parent.mkdir(parents=True, exist_ok=True) |
| conn = sqlite3.connect(DB_PATH, check_same_thread=False) |
| conn.row_factory = sqlite3.Row |
| conn.execute("PRAGMA journal_mode=WAL") |
| conn.execute("PRAGMA foreign_keys=ON") |
| return conn |
|
|
|
|
| @contextmanager |
| def db_session() -> Generator[sqlite3.Connection, None, None]: |
| conn = get_connection() |
| try: |
| yield conn |
| conn.commit() |
| except Exception: |
| conn.rollback() |
| raise |
| finally: |
| conn.close() |
|
|
|
|
| def init_database() -> None: |
| with db_session() as conn: |
| conn.executescript(SCHEMA_SQL) |
| row = conn.execute("SELECT id FROM world_state WHERE id = 1").fetchone() |
| if row is None: |
| conn.execute( |
| """ |
| INSERT INTO world_state (id, founding_date, current_day) |
| VALUES (1, datetime('now'), 1) |
| """ |
| ) |
| |
| _safe_migrate(conn) |
|
|
| |
| from world.quests import init_quests_table |
| from world.presence import init_presence_table |
| init_quests_table() |
| init_presence_table() |
|
|
|
|
| def ensure_database_ready() -> None: |
| """Restore (if configured), create schema, and seed locations. Idempotent.""" |
| from persistence.backup import database_is_valid, restore_database |
| from world.seed_data import seed_locations |
| from world.locations import update_entity_counts |
|
|
| DB_PATH.parent.mkdir(parents=True, exist_ok=True) |
|
|
| if not database_is_valid(): |
| try: |
| restore_database() |
| except Exception as exc: |
| print(f"[db] restore failed: {exc}") |
| if DB_PATH.exists() and not database_is_valid(): |
| DB_PATH.unlink(missing_ok=True) |
|
|
| init_database() |
| with db_session() as conn: |
| seed_locations(conn) |
| update_entity_counts() |
|
|
| if not database_is_valid(): |
| raise RuntimeError(f"Database failed to initialize at {DB_PATH.resolve()}") |
|
|
|
|
| def _safe_migrate(conn) -> None: |
| """Add new columns/indexes that may not exist in older DB files.""" |
| migrations = [ |
| "ALTER TABLE entities ADD COLUMN portrait_url TEXT", |
| ] |
| for sql in migrations: |
| try: |
| conn.execute(sql) |
| except Exception: |
| pass |
|
|
|
|
| def get_world_state() -> dict: |
| with db_session() as conn: |
| row = conn.execute("SELECT * FROM world_state WHERE id = 1").fetchone() |
| return dict(row) if row else {} |
|
|