heron/lanternfall Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files 1088a14b6a321ebc5ef08f91e2687da00670cf11
267 lines · 8.1 KB · ~2.4k tok Raw History
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
  );
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts