aether-garden / world /database.py
kavyabhand's picture
Deploy Aether Garden application
74688c8 verified
Raw
History Blame Contribute Delete
8.95 kB
"""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 migrations (idempotent)
_safe_migrate(conn)
# Init extension tables
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 # Column already exists
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 {}