""" database.py — Tiny Civilization persistent storage layer. SQLite-backed, safe for concurrent Gradio calls. """ import sqlite3 import json import os from datetime import datetime from typing import Optional # ── DB path: prefer HF /data (persistent volume), fallback to cwd ── _DATA_DIRS = ["/data", "."] DB_PATH = os.getenv("TINY_DB_PATH", "") if not DB_PATH: for _d in _DATA_DIRS: try: os.makedirs(_d, exist_ok=True) _t = os.path.join(_d, ".wtest"); open(_t,"w").write("ok"); os.remove(_t) DB_PATH = os.path.join(_d, "tiny_civilization.db"); break except Exception: continue if not DB_PATH: DB_PATH = "tiny_civilization.db" def _conn() -> sqlite3.Connection: c = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10) c.row_factory = sqlite3.Row return c # ───────────────────────────────────────────────────────────────── # Schema + seed # ───────────────────────────────────────────────────────────────── def init_db() -> None: with _conn() as con: cur = con.cursor() cur.execute("""CREATE TABLE IF NOT EXISTS days ( day_number INTEGER PRIMARY KEY, headline TEXT NOT NULL, full_newspaper_text TEXT NOT NULL, timestamp TEXT NOT NULL )""") cur.execute("""CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, day_number INTEGER NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, description TEXT NOT NULL )""") cur.execute("""CREATE TABLE IF NOT EXISTS creatures ( name TEXT PRIMARY KEY, relationship_scores TEXT NOT NULL DEFAULT '{}', inventory TEXT NOT NULL DEFAULT '[]' )""") cur.execute("""CREATE TABLE IF NOT EXISTS nudges ( id INTEGER PRIMARY KEY AUTOINCREMENT, day_number INTEGER NOT NULL, nudge_type TEXT NOT NULL, nudge_value TEXT NOT NULL )""") con.commit() # ── Seed creatures ───────────────────────────────────────── _SEED = { "fox": { "relationships": {"badger": 42, "squirrel": 61, "mole": 55}, "inventory": ["forged certificate of merit", "silk scarf (dubious origin)"], }, "badger": { "relationships": {"fox": 28, "squirrel": 67, "mole": 72}, "inventory": ["ancient grudge (well-preserved)", "favourite grey stone"], }, "squirrel": { "relationships": {"fox": 63, "badger": 70, "mole": 48}, "inventory": ["seven-and-a-half acorns", "borrowed umbrella (decade old)"], }, "mole": { "relationships": {"fox": 51, "badger": 76, "squirrel": 53}, "inventory": ["map of secret tunnels", "crystal monocle", "lost button"], }, } for name, data in _SEED.items(): if not cur.execute("SELECT 1 FROM creatures WHERE name=?", (name,)).fetchone(): cur.execute( "INSERT INTO creatures (name,relationship_scores,inventory) VALUES (?,?,?)", (name, json.dumps(data["relationships"]), json.dumps(data["inventory"])), ) con.commit() # ── Seed Day 0: the Founding ─────────────────────────────── if not cur.execute("SELECT 1 FROM days WHERE day_number=0").fetchone(): founding_text = ( "TINYWICK HOLLOW DECLARES ITSELF A CIVILISATION TODAY\n\n" "In a development that surprised absolutely no one who knows these four, Tinywick " "Hollow has formally declared itself a civilisation. The founding document was " "signed by Beatrice Badger (who insists it must be legally binding), Reginald " "Fox (who has already forged three certified copies), Cornelius Squirrel (who " "invented a device to sign it faster, then signed it twice by mistake), and " "Millicent Mole (who observed that the document was, in a sense, already signed " "underground, and then descended). The future of Tinywick Hollow remains, as " "always, magnificently uncertain.\n\n" "WEATHER: Portentous, with scattered significance.\n" "FOX: Forged three certificates before breakfast.\n" "BADGER: Insisted on thirteen constitutional amendments before lunch.\n" "SQUIRREL: Invented a signing machine! It signed the wrong document!\n" "MOLE: Something is already happening underground." ) cur.execute( "INSERT INTO days (day_number,headline,full_newspaper_text,timestamp) VALUES (?,?,?,?)", (0, "TINYWICK HOLLOW DECLARES ITSELF A CIVILISATION TODAY", founding_text, datetime.now().isoformat()) ) con.commit() # ───────────────────────────────────────────────────────────────── # Days # ───────────────────────────────────────────────────────────────── def save_day(day_number: int, headline: str, full_newspaper_text: str) -> None: with _conn() as con: con.execute( "INSERT OR REPLACE INTO days (day_number,headline,full_newspaper_text,timestamp) VALUES (?,?,?,?)", (day_number, headline, full_newspaper_text, datetime.now().isoformat()), ); con.commit() def get_latest_day() -> Optional[dict]: with _conn() as con: row = con.execute("SELECT * FROM days ORDER BY day_number DESC LIMIT 1").fetchone() return dict(row) if row else None def get_day(day_number: int) -> Optional[dict]: with _conn() as con: row = con.execute("SELECT * FROM days WHERE day_number=?", (day_number,)).fetchone() return dict(row) if row else None def get_all_headlines() -> list[tuple[int, str]]: with _conn() as con: rows = con.execute("SELECT day_number,headline FROM days ORDER BY day_number DESC").fetchall() return [(r["day_number"], r["headline"]) for r in rows] def get_next_day_number() -> int: with _conn() as con: row = con.execute("SELECT MAX(day_number) AS m FROM days").fetchone() return (row["m"] or 0) + 1 # ───────────────────────────────────────────────────────────────── # Events # ───────────────────────────────────────────────────────────────── def save_event(day_number: int, actor: str, action: str, target: str, description: str) -> None: with _conn() as con: con.execute( "INSERT INTO events (day_number,actor,action,target,description) VALUES (?,?,?,?,?)", (day_number, actor, action, target, description), ); con.commit() def get_events_for_day(day_number: int) -> list[dict]: with _conn() as con: rows = con.execute( "SELECT actor,action,target,description FROM events WHERE day_number=?", (day_number,) ).fetchall() return [dict(r) for r in rows] # ───────────────────────────────────────────────────────────────── # Nudges # ───────────────────────────────────────────────────────────────── def save_nudge(day_number: int, nudge_type: str, nudge_value: str) -> None: with _conn() as con: con.execute("INSERT INTO nudges (day_number,nudge_type,nudge_value) VALUES (?,?,?)", (day_number, nudge_type, nudge_value)); con.commit() def get_recent_nudges(limit: int = 4) -> list[dict]: with _conn() as con: rows = con.execute( "SELECT day_number,nudge_type,nudge_value FROM nudges ORDER BY id DESC LIMIT ?", (limit,) ).fetchall() return [dict(r) for r in rows] # ───────────────────────────────────────────────────────────────── # Creatures # ───────────────────────────────────────────────────────────────── def _parse_creature(row: sqlite3.Row) -> dict: d = dict(row) d["relationship_scores"] = json.loads(d["relationship_scores"]) d["inventory"] = json.loads(d["inventory"]) return d def get_creature(name: str) -> Optional[dict]: with _conn() as con: row = con.execute("SELECT * FROM creatures WHERE name=?", (name,)).fetchone() return _parse_creature(row) if row else None def get_all_creatures() -> list[dict]: with _conn() as con: rows = con.execute("SELECT * FROM creatures").fetchall() return [_parse_creature(r) for r in rows] def update_creature(name: str, relationship_scores: Optional[dict]=None, inventory: Optional[list]=None) -> None: with _conn() as con: if relationship_scores is not None and inventory is not None: con.execute("UPDATE creatures SET relationship_scores=?,inventory=? WHERE name=?", (json.dumps(relationship_scores), json.dumps(inventory), name)) elif relationship_scores is not None: con.execute("UPDATE creatures SET relationship_scores=? WHERE name=?", (json.dumps(relationship_scores), name)) elif inventory is not None: con.execute("UPDATE creatures SET inventory=? WHERE name=?", (json.dumps(inventory), name)) con.commit() # ───────────────────────────────────────────────────────────────── # Civilisation stats # ───────────────────────────────────────────────────────────────── def get_civ_stats() -> dict: with _conn() as con: total_days = con.execute("SELECT COUNT(*) FROM days").fetchone()[0] total_events = con.execute("SELECT COUNT(*) FROM events").fetchone()[0] total_nudges = con.execute("SELECT COUNT(*) FROM nudges").fetchone()[0] creatures = get_all_creatures() best_pair = ("?", "?", 0); worst_pair = ("?", "?", 100) for c in creatures: for other, score in c["relationship_scores"].items(): pair = tuple(sorted([c["name"], other])) if score > best_pair[2]: best_pair = (*pair, score) if score < worst_pair[2]: worst_pair = (*pair, score) dominant = max(creatures, key=lambda c: sum(c["relationship_scores"].values()), default={"name":"?"}) return { "total_days": total_days, "total_events": total_events, "total_nudges": total_nudges, "best_pair": best_pair, "worst_pair": worst_pair, "dominant": dominant["name"], }