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

workbenchthis machine
Files e53b98b72d84ce5feeddbe32d2586ca5df139720
336 lines · 13.7 KB · ~4.0k tok Raw History
1
/**
2
 * The rules. Everything a turn can be: a step, a blow, a stair, a climb out.
3
 * The dungeon is pure (`dungeon.ts`) and the memory is a database (`db.ts`);
4
 * this is the layer that puts one against the other, and the only place that
5
 * decides what a delver is allowed to do.
6
 *
7
 * One turn of the world happens per delver action, so a busy dungeon moves
8
 * faster than a quiet one — which is the right way round for a game several
9
 * people wander into at different hours.
10
 */
11
12
import type { Database } from "bun:sqlite";
13
import * as store from "./db.ts";
14
import type { Delver, Monster } from "./db.ts";
15
import {
16
  DEPTHS, LANTERN, at, distance, floorAt, openCells, seesFrom, stepToward,
17
  visibleFrom, walkable, xy, type Floor,
18
} from "./dungeon.ts";
19
import { epitaphFor, nameFor } from "./names.ts";
20
21
export type Direction = "north" | "south" | "east" | "west";
22
export type Action = Direction | "wait" | "descend" | "ascend";
23
24
export const DIRECTIONS: Record<Direction, { dx: number; dy: number }> = {
25
  north: { dx: 0, dy: -1 },
26
  south: { dx: 0, dy: 1 },
27
  east: { dx: 1, dy: 0 },
28
  west: { dx: -1, dy: 0 },
29
};
30
31
export const isAction = (value: string): value is Action =>
32
  value === "wait" || value === "descend" || value === "ascend" || value in DIRECTIONS;
33
34
interface Breed {
35
  kind: string;
36
  glyph: string;
37
  hp: number;
38
  bite: number;
39
  gold: number;
40
  /** How far it notices a lantern. */
41
  notice: number;
42
}
43
44
/** Deeper is worse. The rat is a tutorial; the wight is not. */
45
export const BREEDS: Breed[] = [
46
  { kind: "rat", glyph: "r", hp: 2, bite: 1, gold: 2, notice: 4 },
47
  { kind: "goblin", glyph: "g", hp: 4, bite: 2, gold: 5, notice: 6 },
48
  { kind: "ghoul", glyph: "G", hp: 6, bite: 3, gold: 9, notice: 7 },
49
  { kind: "shade", glyph: "S", hp: 8, bite: 4, gold: 14, notice: 8 },
50
  { kind: "wight", glyph: "W", hp: 12, bite: 5, gold: 22, notice: 9 },
51
];
52
53
export const breedOf = (kind: string): Breed =>
54
  BREEDS.find((b) => b.kind === kind) ?? (BREEDS[0] as Breed);
55
56
function noise(a: number, b: number, c: number): number {
57
  let h = (a * 2654435761) ^ (b * 40503) ^ (c * 2246822519);
58
  h = Math.imul(h ^ (h >>> 15), 2246822519);
59
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
60
}
61
62
/** Where the caches of coin sit on a floor. Deterministic, like everything. */
63
export function cacheCells(floor: Floor): number[] {
64
  const open = openCells(floor);
65
  const count = 3 + floor.depth;
66
  const cells: number[] = [];
67
  for (let i = 0; i < count; i += 1) {
68
    const cell = open[Math.floor(noise(floor.depth, i, 97) * open.length)];
69
    if (cell !== undefined && !cells.includes(cell)) cells.push(cell);
70
  }
71
  return cells;
72
}
73
74
/** Fill a floor's monsters the first time anyone sets foot on it. */
75
export function populate(db: Database, depth: number): void {
76
  const already = db
77
    .query<{ n: number }, [number]>("SELECT count(*) AS n FROM monster WHERE depth = ?1")
78
    .get(depth)?.n ?? 0;
79
  if (already > 0) return;
80
  const floor = floorAt(depth);
81
  const open = openCells(floor);
82
  const count = 5 + depth * 2;
83
  const insert = db.query(
84
    "INSERT OR IGNORE INTO monster (depth, spawn, cell, kind, hp) VALUES (?1, ?2, ?2, ?3, ?4)",
85
  );
86
  for (let i = 0; i < count; i += 1) {
87
    const cell = open[Math.floor(noise(depth, i, 31) * open.length)];
88
    if (cell === undefined) continue;
89
    // A landing you arrive on has to be survivable: nothing spawns within
90
    // sight of the stair. A first playtest put two rats on the entrance and
91
    // a fresh delver was at 3 of 12 hp inside seven turns, having done
92
    // nothing wrong.
93
    if (distance(cell, floor.entrance) <= 8) continue;
94
    // A floor's roster is mostly its own tier, with the occasional visitor
95
    // from one below — the first ghoul on floor 2 is the moment it lands.
96
    const tier = Math.min(BREEDS.length - 1, depth - 1 + (noise(depth, i, 53) < 0.2 ? 1 : 0));
97
    const breed = BREEDS[Math.max(0, tier)] as Breed;
98
    insert.run(depth, cell, breed.kind, breed.hp);
99
  }
100
}
101
102
export interface TurnResult {
103
  delver: Delver;
104
  /** What to tell the player, newest last. */
105
  log: string[];
106
  died: boolean;
107
  climbedOut: boolean;
108
}
109
110
/** The delver a hand is currently playing, made if there is none. */
111
export function delverFor(db: Database, hand: string): Delver {
112
  const active = store.activeDelver(db, hand);
113
  if (active) return active;
114
  const past = db
115
    .query<{ n: number }, [string]>("SELECT count(*) AS n FROM delver WHERE hand = ?1")
116
    .get(hand)?.n ?? 0;
117
  const floor = floorAt(1);
118
  populate(db, 1);
119
  const made = store.createDelver(db, hand, nameFor(hand, past), floor.entrance);
120
  store.markSeen(db, 1, visibleFrom(floor, made.cell));
121
  return made;
122
}
123
124
/** Everything the monsters do, once, after the delver has acted. */
125
function monsterTurn(db: Database, delver: Delver, now: number, log: string[]): Delver {
126
  const floor = floorAt(delver.depth);
127
  const monsters = store.monstersOn(db, delver.depth, now);
128
  const others = store.delversOn(db, delver.depth).filter((d) => d.id !== delver.id);
129
  let hp = delver.hp;
130
  for (const monster of monsters) {
131
    const breed = breedOf(monster.kind);
132
    // It goes for whoever is closest and in sight — which may not be you.
133
    const targets = [delver, ...others];
134
    let target: Delver | undefined;
135
    let best = Infinity;
136
    for (const candidate of targets) {
137
      const d = distance(monster.cell, candidate.cell);
138
      if (d <= breed.notice && d < best && seesFrom(floor, monster.cell, candidate.cell)) {
139
        best = d;
140
        target = candidate;
141
      }
142
    }
143
    if (target === undefined) continue;
144
    if (best <= 1) {
145
      if (target.id === delver.id) {
146
        hp -= breed.bite;
147
        log.push(`The ${monster.kind} bites you for ${breed.bite}.`);
148
      }
149
      continue;
150
    }
151
    // Chasers move at two thirds of a delver's pace. At equal speed nothing
152
    // pursued can ever reach a stair or rest, so every fight on a floor with
153
    // more than one monster was a death sentence — two goblins took a fresh
154
    // delver from 12 to 2 with no legal way to disengage. Deterministic in
155
    // the turn and the monster, so it is not luck either.
156
    if ((now + monster.id) % 3 === 0) continue;
157
    const next = stepToward(floor, monster.cell, target.cell);
158
    const occupied = monsters.some((m) => m.id !== monster.id && m.cell === next);
159
    if (!occupied && next !== monster.cell) {
160
      db.query("UPDATE monster SET cell = ?2 WHERE id = ?1").run(monster.id, next);
161
      monster.cell = next;
162
    }
163
  }
164
  return { ...delver, hp };
165
}
166
167
function die(db: Database, delver: Delver, log: string[]): Delver {
168
  const epitaph = epitaphFor(delver.name, delver.depth, delver.cell, delver.turns);
169
  db.query(
170
    "INSERT INTO grave (depth, cell, name, epitaph, gold) VALUES (?1, ?2, ?3, ?4, ?5)",
171
  ).run(delver.depth, delver.cell, delver.name, epitaph, delver.gold);
172
  log.push(`${delver.name} dies on floor ${delver.depth}, carrying ${delver.gold} gold.`);
173
  return {
174
    ...delver,
175
    hp: 0,
176
    state: "dead",
177
    ended_at: new Date().toISOString(),
178
    epitaph,
179
  };
180
}
181
182
/** One action. The only way the world moves. */
183
export function act(db: Database, hand: string, action: Action): TurnResult {
184
  let delver = delverFor(db, hand);
185
  const log: string[] = [];
186
  const floor = floorAt(delver.depth);
187
  const now = store.tick(db);
188
  delver = { ...delver, turns: delver.turns + 1 };
189
  let climbedOut = false;
190
191
  if (action === "descend" || action === "ascend") {
192
    const tile = floor.tiles[delver.cell];
193
    if (action === "descend" && tile === ">") {
194
      const next = floorAt(delver.depth + 1);
195
      populate(db, next.depth);
196
      delver = { ...delver, depth: next.depth, cell: next.entrance };
197
      log.push(`You climb down to floor ${next.depth}. The air is colder.`);
198
    } else if (action === "ascend" && tile === "<" && delver.depth === 1) {
199
      db.query(
200
        "INSERT INTO score (name, gold, depth, turns) VALUES (?1, ?2, ?3, ?4)",
201
      ).run(delver.name, delver.gold, delver.depth, delver.turns);
202
      delver = { ...delver, state: "returned", ended_at: new Date().toISOString() };
203
      log.push(`${delver.name} climbs out with ${delver.gold} gold. It is banked.`);
204
      climbedOut = true;
205
    } else if (action === "ascend" && tile === "<") {
206
      const up = floorAt(delver.depth - 1);
207
      delver = { ...delver, depth: up.depth, cell: up.descent ?? up.entrance };
208
      log.push(`You climb back up to floor ${up.depth}.`);
209
    } else {
210
      log.push("There is no stair here.");
211
    }
212
  } else if (action !== "wait") {
213
    const watchedBefore = store
214
      .monstersOn(db, delver.depth, now)
215
      .some((m) => distance(m.cell, delver.cell) <= LANTERN && seesFrom(floor, delver.cell, m.cell));
216
    const here = xy(delver.cell);
217
    const step = DIRECTIONS[action];
218
    const nx = here.x + step.dx;
219
    const ny = here.y + step.dy;
220
    const target = at(nx, ny);
221
    const monsters = store.monstersOn(db, delver.depth, now);
222
    const monster = monsters.find((m) => m.cell === target);
223
    if (monster) {
224
      // Bump to attack. Damage is 2–4, decided by the turn, not by luck the
225
      // client could re-roll.
226
      const breed = breedOf(monster.kind);
227
      const hit = 2 + Math.floor(noise(now, delver.id, monster.id) * 3);
228
      const left = monster.hp - hit;
229
      if (left <= 0) {
230
        db.query("UPDATE monster SET hp = 0, dead_until = ?2, cell = spawn WHERE id = ?1").run(
231
          monster.id,
232
          now + 150,
233
        );
234
        delver = { ...delver, gold: delver.gold + breed.gold };
235
        log.push(`You put the ${monster.kind} down. ${breed.gold} gold.`);
236
      } else {
237
        db.query("UPDATE monster SET hp = ?2 WHERE id = ?1").run(monster.id, left);
238
        log.push(`You hit the ${monster.kind} for ${hit}.`);
239
      }
240
    } else if (!walkable(floor.tiles[target])) {
241
      log.push("Stone.");
242
    } else {
243
      delver = { ...delver, cell: target };
244
      // A cache pays out once, then refills slowly — so a floor someone
245
      // stripped an hour ago is worth walking again tomorrow.
246
      if (cacheCells(floor).includes(target)) {
247
        const taken = db
248
          .query<{ until_turn: number }, [number, number]>(
249
            "SELECT until_turn FROM cache_taken WHERE depth = ?1 AND cell = ?2",
250
          )
251
          .get(delver.depth, target);
252
        if (!taken || taken.until_turn <= now) {
253
          const coin = 4 + Math.floor(noise(delver.depth, target, 17) * 9);
254
          db.query(
255
            `INSERT INTO cache_taken (depth, cell, until_turn) VALUES (?1, ?2, ?3)
256
             ON CONFLICT (depth, cell) DO UPDATE SET until_turn = excluded.until_turn`,
257
          ).run(delver.depth, target, now + 400);
258
          delver = { ...delver, gold: delver.gold + coin };
259
          log.push(`A cache of ${coin} gold, tucked into the wall.`);
260
        }
261
      }
262
      const under = floor.tiles[target];
263
      if (under === ">") log.push("A stair down, here.");
264
      else if (under === "<")
265
        log.push(delver.depth === 1 ? "The way out, here. Climb and the gold is yours." : "A stair up, here.");
266
      const watchedNow = store
267
        .monstersOn(db, delver.depth, now)
268
        .some((m) => distance(m.cell, target) <= LANTERN && seesFrom(floor, target, m.cell));
269
      if (watchedNow && !watchedBefore) log.push("Something moves at the edge of your lantern.");
270
      const grave = store.graveAt(db, delver.depth, target);
271
      if (grave && grave.looted === 0) {
272
        const share = Math.floor(grave.gold / 2);
273
        db.query("UPDATE grave SET looted = 1 WHERE id = ?1").run(grave.id);
274
        if (share > 0) delver = { ...delver, gold: delver.gold + share };
275
        log.push(`${grave.epitaph}${share > 0 ? ` You take ${share} of their gold.` : ""}`);
276
      } else if (grave) {
277
        log.push(grave.epitaph);
278
      }
279
    }
280
  } else {
281
    // Waiting is the only medicine. It works when nothing can see you, which
282
    // makes retreating into a cleared room a real decision rather than a
283
    // pause — every turn spent resting is a turn the floor keeps moving.
284
    const monsters = store.monstersOn(db, delver.depth, now);
285
    const watched = monsters.some(
286
      (m) => distance(m.cell, delver.cell) <= 6 && seesFrom(floor, m.cell, delver.cell),
287
    );
288
    if (watched) log.push("You wait. Something is still moving out there.");
289
    else if (delver.hp < delver.max_hp) {
290
      delver = { ...delver, hp: Math.min(delver.max_hp, delver.hp + 1) };
291
      log.push(`You catch your breath. ${delver.hp} of ${delver.max_hp}.`);
292
    } else log.push("You wait, and listen. Nothing comes.");
293
  }
294
295
  if (!climbedOut) {
296
    delver = monsterTurn(db, delver, now, log);
297
    if (delver.hp <= 0) delver = die(db, delver, log);
298
    store.markSeen(db, delver.depth, visibleFrom(floorAt(delver.depth), delver.cell));
299
  }
300
  store.saveDelver(db, delver);
301
  // Dated by the delver's own turn, so the screen can tell what happened
302
  // *this* turn from what it is still remembering.
303
  store.say(db, delver.id, delver.turns, log);
304
  return { delver, log, died: delver.state === "dead", climbedOut };
305
}
306
307
/** What the delver can see right now, and what the dungeon remembers. */
308
export interface View {
309
  floor: Floor;
310
  visible: Set<number>;
311
  seen: Set<number>;
312
  monsters: Monster[];
313
  graves: Map<number, string>;
314
  others: Map<number, string>;
315
}
316
317
export function viewFor(db: Database, delver: Delver): View {
318
  const floor = floorAt(delver.depth);
319
  const now = store.turn(db);
320
  const visible = new Set(visibleFrom(floor, delver.cell, LANTERN));
321
  const graves = new Map<number, string>();
322
  for (const grave of store.gravesOn(db, delver.depth)) graves.set(grave.cell, grave.name);
323
  const others = new Map<number, string>();
324
  for (const other of store.delversOn(db, delver.depth))
325
    if (other.id !== delver.id) others.set(other.cell, other.name);
326
  return {
327
    floor,
328
    visible,
329
    seen: store.seenOn(db, delver.depth),
330
    monsters: store.monstersOn(db, delver.depth, now).filter((m) => visible.has(m.cell)),
331
    graves,
332
    others,
333
  };
334
}
335
336
export { DEPTHS, LANTERN };
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts