/** * What the dungeon remembers. The map is a pure function of the seed, so * nothing here stores tiles: these tables hold only what has *happened* — * who is down there, what has been seen, what is dead, and where. * * `seen` is the one that makes this a shared game rather than five private * ones: a tile lit by anybody's lantern stays on everybody's map. */ import { Database } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; export interface Delver { id: number; hand: string; name: string; hp: number; max_hp: number; depth: number; cell: number; gold: number; turns: number; state: "delving" | "dead" | "returned"; started_at: string; ended_at: string | null; epitaph: string | null; } export interface Monster { id: number; depth: number; spawn: number; cell: number; kind: string; hp: number; dead_until: number; } export interface Grave { id: number; depth: number; cell: number; name: string; epitaph: string; gold: number; looted: number; at: string; } export interface Score { id: number; name: string; gold: number; depth: number; turns: number; at: string; } export function open(dataDir = process.env.FORGE_APP_DATA ?? "./data"): Database { mkdirSync(dataDir, { recursive: true }); const db = new Database(join(dataDir, "lanternfall.db")); db.exec("PRAGMA journal_mode = WAL"); db.exec(`CREATE TABLE IF NOT EXISTS delver ( id INTEGER PRIMARY KEY, hand TEXT NOT NULL, name TEXT NOT NULL, hp INTEGER NOT NULL, max_hp INTEGER NOT NULL, depth INTEGER NOT NULL, cell INTEGER NOT NULL, gold INTEGER NOT NULL DEFAULT 0, turns INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'delving' CHECK (state IN ('delving','dead','returned')), started_at TEXT NOT NULL DEFAULT (datetime('now')), ended_at TEXT, epitaph TEXT )`); db.exec("CREATE INDEX IF NOT EXISTS delver_by_hand ON delver (hand, state)"); db.exec("CREATE INDEX IF NOT EXISTS delver_by_depth ON delver (depth, state)"); // The community map. One row per tile anyone has ever lit. db.exec(`CREATE TABLE IF NOT EXISTS seen ( depth INTEGER NOT NULL, cell INTEGER NOT NULL, PRIMARY KEY (depth, cell) ) WITHOUT ROWID`); db.exec(`CREATE TABLE IF NOT EXISTS monster ( id INTEGER PRIMARY KEY, depth INTEGER NOT NULL, spawn INTEGER NOT NULL, cell INTEGER NOT NULL, kind TEXT NOT NULL, hp INTEGER NOT NULL, dead_until INTEGER NOT NULL DEFAULT 0, UNIQUE (depth, spawn) )`); db.exec(`CREATE TABLE IF NOT EXISTS grave ( id INTEGER PRIMARY KEY, depth INTEGER NOT NULL, cell INTEGER NOT NULL, name TEXT NOT NULL, epitaph TEXT NOT NULL, gold INTEGER NOT NULL DEFAULT 0, looted INTEGER NOT NULL DEFAULT 0, at TEXT NOT NULL DEFAULT (datetime('now')) )`); db.exec("CREATE INDEX IF NOT EXISTS grave_by_place ON grave (depth, cell)"); db.exec(`CREATE TABLE IF NOT EXISTS cache_taken ( depth INTEGER NOT NULL, cell INTEGER NOT NULL, until_turn INTEGER NOT NULL, PRIMARY KEY (depth, cell) ) WITHOUT ROWID`); db.exec(`CREATE TABLE IF NOT EXISTS score ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, gold INTEGER NOT NULL, depth INTEGER NOT NULL, turns INTEGER NOT NULL, at TEXT NOT NULL DEFAULT (datetime('now')) )`); // What the delver has been told, so the page and the terminal say the same // thing and a reload does not lose the last blow. db.exec(`CREATE TABLE IF NOT EXISTS message ( id INTEGER PRIMARY KEY, delver_id INTEGER NOT NULL, turn INTEGER NOT NULL, text TEXT NOT NULL )`); db.exec("CREATE INDEX IF NOT EXISTS message_by_delver ON message (delver_id, id)"); db.exec(`CREATE TABLE IF NOT EXISTS world ( id INTEGER PRIMARY KEY CHECK (id = 1), turn INTEGER NOT NULL DEFAULT 0 )`); db.query("INSERT OR IGNORE INTO world (id, turn) VALUES (1, 0)").run(); return db; } export const turn = (db: Database): number => db.query<{ turn: number }, []>("SELECT turn FROM world WHERE id = 1").get()?.turn ?? 0; export const tick = (db: Database): number => { db.query("UPDATE world SET turn = turn + 1 WHERE id = 1").run(); return turn(db); }; export const activeDelver = (db: Database, hand: string): Delver | null => db .query( "SELECT * FROM delver WHERE hand = ?1 AND state = 'delving' ORDER BY id DESC LIMIT 1", ) .get(hand); export const lastDelver = (db: Database, hand: string): Delver | null => db .query("SELECT * FROM delver WHERE hand = ?1 ORDER BY id DESC LIMIT 1") .get(hand); export function createDelver( db: Database, hand: string, name: string, cell: number, ): Delver { const made = db .query( `INSERT INTO delver (hand, name, hp, max_hp, depth, cell) VALUES (?1, ?2, 12, 12, 1, ?3) RETURNING *`, ) .get(hand, name, cell); if (!made) throw new Error("lanternfall: the dungeon refused a delver"); return made; } export const saveDelver = (db: Database, delver: Delver): void => { db.query( `UPDATE delver SET hp = ?2, depth = ?3, cell = ?4, gold = ?5, turns = ?6, state = ?7, ended_at = ?8, epitaph = ?9 WHERE id = ?1`, ).run( delver.id, delver.hp, delver.depth, delver.cell, delver.gold, delver.turns, delver.state, delver.ended_at, delver.epitaph, ); }; export const delversOn = (db: Database, depth: number): Delver[] => db .query( "SELECT * FROM delver WHERE depth = ?1 AND state = 'delving'", ) .all(depth); export const monstersOn = (db: Database, depth: number, now: number): Monster[] => db .query( "SELECT * FROM monster WHERE depth = ?1 AND hp > 0 AND dead_until <= ?2", ) .all(depth, now); export const seenOn = (db: Database, depth: number): Set => new Set( db .query<{ cell: number }, [number]>("SELECT cell FROM seen WHERE depth = ?1") .all(depth) .map((r) => r.cell), ); export function markSeen(db: Database, depth: number, cells: readonly number[]): void { const insert = db.query("INSERT OR IGNORE INTO seen (depth, cell) VALUES (?1, ?2)"); for (const cell of cells) insert.run(depth, cell); } export const gravesOn = (db: Database, depth: number): Grave[] => db.query("SELECT * FROM grave WHERE depth = ?1").all(depth); export const graveAt = (db: Database, depth: number, cell: number): Grave | null => db .query( "SELECT * FROM grave WHERE depth = ?1 AND cell = ?2 ORDER BY id DESC LIMIT 1", ) .get(depth, cell); export const recentGraves = (db: Database, limit = 8): Grave[] => db.query("SELECT * FROM grave ORDER BY id DESC LIMIT ?1").all(limit); export const topScores = (db: Database, limit = 8): Score[] => db .query("SELECT * FROM score ORDER BY gold DESC, depth DESC LIMIT ?1") .all(limit); export const countSeen = (db: Database): number => db.query<{ n: number }, []>("SELECT count(*) AS n FROM seen").get()?.n ?? 0; export function say(db: Database, delverId: number, turn: number, lines: readonly string[]): void { const insert = db.query("INSERT INTO message (delver_id, turn, text) VALUES (?1, ?2, ?3)"); for (const line of lines) insert.run(delverId, turn, line); // Keep the tail only: nobody scrolls a dungeon log. db.query( `DELETE FROM message WHERE delver_id = ?1 AND id NOT IN (SELECT id FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 40)`, ).run(delverId); } export const heard = (db: Database, delverId: number, limit = 7): string[] => db .query<{ text: string }, [number, number]>( "SELECT text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT ?2", ) .all(delverId, limit) .map((r) => r.text) .reverse(); export const cacheState = (db: Database, depth: number, now: number): Set => new Set( db .query<{ cell: number }, [number, number]>( "SELECT cell FROM cache_taken WHERE depth = ?1 AND until_turn > ?2", ) .all(depth, now) .map((r) => r.cell), );