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

workbenchthis machine
Files heron/1-the-first-minute-a-new-delver-should-und
295 lines · 9.1 KB · ~2.7k 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
/** One line the delver was told, and the delver's own turn it belongs to. */
241
export interface Message {
242
  text: string;
243
  turn: number;
244
}
245
246
export function say(db: Database, delverId: number, turn: number, lines: readonly string[]): void {
247
  // A chaser crossing the edge of the lantern every other step used to write
248
  // "Something moves at the edge of your lantern." thirteen times in a row,
249
  // which reads as a broken game rather than a tense one. The log never
250
  // repeats its own last line — but a line said again is re-dated to the turn
251
  // it was said on, so pressing "descend" twice off a stair is answered twice.
252
  const last = db
253
    .query<{ id: number; text: string }, [number]>(
254
      "SELECT id, text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 1",
255
    )
256
    .get(delverId);
257
  let previous = last?.text ?? "";
258
  let previousId = last?.id ?? 0;
259
  for (const line of lines) {
260
    if (line === previous) {
261
      db.query("UPDATE message SET turn = ?2 WHERE id = ?1").run(previousId, turn);
262
      continue;
263
    }
264
    const made = db
265
      .query<{ id: number }, [number, number, string]>(
266
        "INSERT INTO message (delver_id, turn, text) VALUES (?1, ?2, ?3) RETURNING id",
267
      )
268
      .get(delverId, turn, line);
269
    previous = line;
270
    previousId = made?.id ?? 0;
271
  }
272
  // Keep the tail only: nobody scrolls a dungeon log.
273
  db.query(
274
    `DELETE FROM message WHERE delver_id = ?1 AND id NOT IN
275
     (SELECT id FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 40)`,
276
  ).run(delverId);
277
}
278
279
export const heard = (db: Database, delverId: number, limit = 7): Message[] =>
280
  db
281
    .query<Message, [number, number]>(
282
      "SELECT text, turn FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT ?2",
283
    )
284
    .all(delverId, limit)
285
    .reverse();
286
287
export const cacheState = (db: Database, depth: number, now: number): Set<number> =>
288
  new Set(
289
    db
290
      .query<{ cell: number }, [number, number]>(
291
        "SELECT cell FROM cache_taken WHERE depth = ?1 AND until_turn > ?2",
292
      )
293
      .all(depth, now)
294
      .map((r) => r.cell),
295
  );
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts