heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files a80ad1c2e997ef7cb60c1375df4ad23cf52f6553
| 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 }; |