heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files 1088a14b6a321ebc5ef08f91e2687da00670cf11
| 1 | /** |
| 2 | * What the dungeon remembers. The map is a pure function of the seed, so |
| 3 | * nothing here stores tiles: these tables hold only what has *happened* — |
| 4 | * who is down there, what has been seen, what is dead, and where. |
| 5 | * |
| 6 | * `seen` is the one that makes this a shared game rather than five private |
| 7 | * ones: a tile lit by anybody's lantern stays on everybody's map. |
| 8 | */ |
| 9 | |
| 10 | import { Database } from "bun:sqlite"; |
| 11 | import { mkdirSync } from "node:fs"; |
| 12 | import { join } from "node:path"; |
| 13 | |
| 14 | export interface Delver { |
| 15 | id: number; |
| 16 | hand: string; |
| 17 | name: string; |
| 18 | hp: number; |
| 19 | max_hp: number; |
| 20 | depth: number; |
| 21 | cell: number; |
| 22 | gold: number; |
| 23 | turns: number; |
| 24 | state: "delving" | "dead" | "returned"; |
| 25 | started_at: string; |
| 26 | ended_at: string | null; |
| 27 | epitaph: string | null; |
| 28 | } |
| 29 | |
| 30 | export interface Monster { |
| 31 | id: number; |
| 32 | depth: number; |
| 33 | spawn: number; |
| 34 | cell: number; |
| 35 | kind: string; |
| 36 | hp: number; |
| 37 | dead_until: number; |
| 38 | } |
| 39 | |
| 40 | export interface Grave { |
| 41 | id: number; |
| 42 | depth: number; |
| 43 | cell: number; |
| 44 | name: string; |
| 45 | epitaph: string; |
| 46 | gold: number; |
| 47 | looted: number; |
| 48 | at: string; |
| 49 | } |
| 50 | |
| 51 | export interface Score { |
| 52 | id: number; |
| 53 | name: string; |
| 54 | gold: number; |
| 55 | depth: number; |
| 56 | turns: number; |
| 57 | at: string; |
| 58 | } |
| 59 | |
| 60 | export function open(dataDir = process.env.FORGE_APP_DATA ?? "./data"): Database { |
| 61 | mkdirSync(dataDir, { recursive: true }); |
| 62 | const db = new Database(join(dataDir, "lanternfall.db")); |
| 63 | db.exec("PRAGMA journal_mode = WAL"); |
| 64 | db.exec(`CREATE TABLE IF NOT EXISTS delver ( |
| 65 | id INTEGER PRIMARY KEY, |
| 66 | hand TEXT NOT NULL, |
| 67 | name TEXT NOT NULL, |
| 68 | hp INTEGER NOT NULL, |
| 69 | max_hp INTEGER NOT NULL, |
| 70 | depth INTEGER NOT NULL, |
| 71 | cell INTEGER NOT NULL, |
| 72 | gold INTEGER NOT NULL DEFAULT 0, |
| 73 | turns INTEGER NOT NULL DEFAULT 0, |
| 74 | state TEXT NOT NULL DEFAULT 'delving' CHECK (state IN ('delving','dead','returned')), |
| 75 | started_at TEXT NOT NULL DEFAULT (datetime('now')), |
| 76 | ended_at TEXT, |
| 77 | epitaph TEXT |
| 78 | )`); |
| 79 | db.exec("CREATE INDEX IF NOT EXISTS delver_by_hand ON delver (hand, state)"); |
| 80 | db.exec("CREATE INDEX IF NOT EXISTS delver_by_depth ON delver (depth, state)"); |
| 81 | // The community map. One row per tile anyone has ever lit. |
| 82 | db.exec(`CREATE TABLE IF NOT EXISTS seen ( |
| 83 | depth INTEGER NOT NULL, |
| 84 | cell INTEGER NOT NULL, |
| 85 | PRIMARY KEY (depth, cell) |
| 86 | ) WITHOUT ROWID`); |
| 87 | db.exec(`CREATE TABLE IF NOT EXISTS monster ( |
| 88 | id INTEGER PRIMARY KEY, |
| 89 | depth INTEGER NOT NULL, |
| 90 | spawn INTEGER NOT NULL, |
| 91 | cell INTEGER NOT NULL, |
| 92 | kind TEXT NOT NULL, |
| 93 | hp INTEGER NOT NULL, |
| 94 | dead_until INTEGER NOT NULL DEFAULT 0, |
| 95 | UNIQUE (depth, spawn) |
| 96 | )`); |
| 97 | db.exec(`CREATE TABLE IF NOT EXISTS grave ( |
| 98 | id INTEGER PRIMARY KEY, |
| 99 | depth INTEGER NOT NULL, |
| 100 | cell INTEGER NOT NULL, |
| 101 | name TEXT NOT NULL, |
| 102 | epitaph TEXT NOT NULL, |
| 103 | gold INTEGER NOT NULL DEFAULT 0, |
| 104 | looted INTEGER NOT NULL DEFAULT 0, |
| 105 | at TEXT NOT NULL DEFAULT (datetime('now')) |
| 106 | )`); |
| 107 | db.exec("CREATE INDEX IF NOT EXISTS grave_by_place ON grave (depth, cell)"); |
| 108 | db.exec(`CREATE TABLE IF NOT EXISTS cache_taken ( |
| 109 | depth INTEGER NOT NULL, |
| 110 | cell INTEGER NOT NULL, |
| 111 | until_turn INTEGER NOT NULL, |
| 112 | PRIMARY KEY (depth, cell) |
| 113 | ) WITHOUT ROWID`); |
| 114 | db.exec(`CREATE TABLE IF NOT EXISTS score ( |
| 115 | id INTEGER PRIMARY KEY, |
| 116 | name TEXT NOT NULL, |
| 117 | gold INTEGER NOT NULL, |
| 118 | depth INTEGER NOT NULL, |
| 119 | turns INTEGER NOT NULL, |
| 120 | at TEXT NOT NULL DEFAULT (datetime('now')) |
| 121 | )`); |
| 122 | // What the delver has been told, so the page and the terminal say the same |
| 123 | // thing and a reload does not lose the last blow. |
| 124 | db.exec(`CREATE TABLE IF NOT EXISTS message ( |
| 125 | id INTEGER PRIMARY KEY, |
| 126 | delver_id INTEGER NOT NULL, |
| 127 | turn INTEGER NOT NULL, |
| 128 | text TEXT NOT NULL |
| 129 | )`); |
| 130 | db.exec("CREATE INDEX IF NOT EXISTS message_by_delver ON message (delver_id, id)"); |
| 131 | db.exec(`CREATE TABLE IF NOT EXISTS world ( |
| 132 | id INTEGER PRIMARY KEY CHECK (id = 1), |
| 133 | turn INTEGER NOT NULL DEFAULT 0 |
| 134 | )`); |
| 135 | db.query("INSERT OR IGNORE INTO world (id, turn) VALUES (1, 0)").run(); |
| 136 | return db; |
| 137 | } |
| 138 | |
| 139 | export const turn = (db: Database): number => |
| 140 | db.query<{ turn: number }, []>("SELECT turn FROM world WHERE id = 1").get()?.turn ?? 0; |
| 141 | |
| 142 | export const tick = (db: Database): number => { |
| 143 | db.query("UPDATE world SET turn = turn + 1 WHERE id = 1").run(); |
| 144 | return turn(db); |
| 145 | }; |
| 146 | |
| 147 | export const activeDelver = (db: Database, hand: string): Delver | null => |
| 148 | db |
| 149 | .query<Delver, [string]>( |
| 150 | "SELECT * FROM delver WHERE hand = ?1 AND state = 'delving' ORDER BY id DESC LIMIT 1", |
| 151 | ) |
| 152 | .get(hand); |
| 153 | |
| 154 | export const lastDelver = (db: Database, hand: string): Delver | null => |
| 155 | db |
| 156 | .query<Delver, [string]>("SELECT * FROM delver WHERE hand = ?1 ORDER BY id DESC LIMIT 1") |
| 157 | .get(hand); |
| 158 | |
| 159 | export function createDelver( |
| 160 | db: Database, |
| 161 | hand: string, |
| 162 | name: string, |
| 163 | cell: number, |
| 164 | ): Delver { |
| 165 | const made = db |
| 166 | .query<Delver, [string, string, number]>( |
| 167 | `INSERT INTO delver (hand, name, hp, max_hp, depth, cell) |
| 168 | VALUES (?1, ?2, 12, 12, 1, ?3) RETURNING *`, |
| 169 | ) |
| 170 | .get(hand, name, cell); |
| 171 | if (!made) throw new Error("lanternfall: the dungeon refused a delver"); |
| 172 | return made; |
| 173 | } |
| 174 | |
| 175 | export const saveDelver = (db: Database, delver: Delver): void => { |
| 176 | db.query( |
| 177 | `UPDATE delver SET hp = ?2, depth = ?3, cell = ?4, gold = ?5, turns = ?6, |
| 178 | state = ?7, ended_at = ?8, epitaph = ?9 WHERE id = ?1`, |
| 179 | ).run( |
| 180 | delver.id, |
| 181 | delver.hp, |
| 182 | delver.depth, |
| 183 | delver.cell, |
| 184 | delver.gold, |
| 185 | delver.turns, |
| 186 | delver.state, |
| 187 | delver.ended_at, |
| 188 | delver.epitaph, |
| 189 | ); |
| 190 | }; |
| 191 | |
| 192 | export const delversOn = (db: Database, depth: number): Delver[] => |
| 193 | db |
| 194 | .query<Delver, [number]>( |
| 195 | "SELECT * FROM delver WHERE depth = ?1 AND state = 'delving'", |
| 196 | ) |
| 197 | .all(depth); |
| 198 | |
| 199 | export const monstersOn = (db: Database, depth: number, now: number): Monster[] => |
| 200 | db |
| 201 | .query<Monster, [number, number]>( |
| 202 | "SELECT * FROM monster WHERE depth = ?1 AND hp > 0 AND dead_until <= ?2", |
| 203 | ) |
| 204 | .all(depth, now); |
| 205 | |
| 206 | export const seenOn = (db: Database, depth: number): Set<number> => |
| 207 | new Set( |
| 208 | db |
| 209 | .query<{ cell: number }, [number]>("SELECT cell FROM seen WHERE depth = ?1") |
| 210 | .all(depth) |
| 211 | .map((r) => r.cell), |
| 212 | ); |
| 213 | |
| 214 | export function markSeen(db: Database, depth: number, cells: readonly number[]): void { |
| 215 | const insert = db.query("INSERT OR IGNORE INTO seen (depth, cell) VALUES (?1, ?2)"); |
| 216 | for (const cell of cells) insert.run(depth, cell); |
| 217 | } |
| 218 | |
| 219 | export const gravesOn = (db: Database, depth: number): Grave[] => |
| 220 | db.query<Grave, [number]>("SELECT * FROM grave WHERE depth = ?1").all(depth); |
| 221 | |
| 222 | export const graveAt = (db: Database, depth: number, cell: number): Grave | null => |
| 223 | db |
| 224 | .query<Grave, [number, number]>( |
| 225 | "SELECT * FROM grave WHERE depth = ?1 AND cell = ?2 ORDER BY id DESC LIMIT 1", |
| 226 | ) |
| 227 | .get(depth, cell); |
| 228 | |
| 229 | export const recentGraves = (db: Database, limit = 8): Grave[] => |
| 230 | db.query<Grave, [number]>("SELECT * FROM grave ORDER BY id DESC LIMIT ?1").all(limit); |
| 231 | |
| 232 | export const topScores = (db: Database, limit = 8): Score[] => |
| 233 | db |
| 234 | .query<Score, [number]>("SELECT * FROM score ORDER BY gold DESC, depth DESC LIMIT ?1") |
| 235 | .all(limit); |
| 236 | |
| 237 | export const countSeen = (db: Database): number => |
| 238 | db.query<{ n: number }, []>("SELECT count(*) AS n FROM seen").get()?.n ?? 0; |
| 239 | |
| 240 | export function say(db: Database, delverId: number, turn: number, lines: readonly string[]): void { |
| 241 | const insert = db.query("INSERT INTO message (delver_id, turn, text) VALUES (?1, ?2, ?3)"); |
| 242 | for (const line of lines) insert.run(delverId, turn, line); |
| 243 | // Keep the tail only: nobody scrolls a dungeon log. |
| 244 | db.query( |
| 245 | `DELETE FROM message WHERE delver_id = ?1 AND id NOT IN |
| 246 | (SELECT id FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 40)`, |
| 247 | ).run(delverId); |
| 248 | } |
| 249 | |
| 250 | export const heard = (db: Database, delverId: number, limit = 7): string[] => |
| 251 | db |
| 252 | .query<{ text: string }, [number, number]>( |
| 253 | "SELECT text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT ?2", |
| 254 | ) |
| 255 | .all(delverId, limit) |
| 256 | .map((r) => r.text) |
| 257 | .reverse(); |
| 258 | |
| 259 | export const cacheState = (db: Database, depth: number, now: number): Set<number> => |
| 260 | new Set( |
| 261 | db |
| 262 | .query<{ cell: number }, [number, number]>( |
| 263 | "SELECT cell FROM cache_taken WHERE depth = ?1 AND until_turn > ?2", |
| 264 | ) |
| 265 | .all(depth, now) |
| 266 | .map((r) => r.cell), |
| 267 | ); |