/** * The rules. Everything a turn can be: a step, a blow, a stair, a climb out. * The dungeon is pure (`dungeon.ts`) and the memory is a database (`db.ts`); * this is the layer that puts one against the other, and the only place that * decides what a delver is allowed to do. * * One turn of the world happens per delver action, so a busy dungeon moves * faster than a quiet one — which is the right way round for a game several * people wander into at different hours. */ import type { Database } from "bun:sqlite"; import * as store from "./db.ts"; import type { Delver, Monster } from "./db.ts"; import { DEPTHS, LANTERN, at, distance, floorAt, openCells, seesFrom, stepToward, visibleFrom, walkable, xy, type Floor, } from "./dungeon.ts"; import { epitaphFor, nameFor } from "./names.ts"; export type Direction = "north" | "south" | "east" | "west"; export type Action = Direction | "wait" | "descend" | "ascend"; export const DIRECTIONS: Record = { north: { dx: 0, dy: -1 }, south: { dx: 0, dy: 1 }, east: { dx: 1, dy: 0 }, west: { dx: -1, dy: 0 }, }; export const isAction = (value: string): value is Action => value === "wait" || value === "descend" || value === "ascend" || value in DIRECTIONS; interface Breed { kind: string; glyph: string; hp: number; bite: number; gold: number; /** How far it notices a lantern. */ notice: number; } /** Deeper is worse. The rat is a tutorial; the wight is not. */ export const BREEDS: Breed[] = [ { kind: "rat", glyph: "r", hp: 2, bite: 1, gold: 2, notice: 4 }, { kind: "goblin", glyph: "g", hp: 4, bite: 2, gold: 5, notice: 6 }, { kind: "ghoul", glyph: "G", hp: 6, bite: 3, gold: 9, notice: 7 }, { kind: "shade", glyph: "S", hp: 8, bite: 4, gold: 14, notice: 8 }, { kind: "wight", glyph: "W", hp: 12, bite: 5, gold: 22, notice: 9 }, ]; export const breedOf = (kind: string): Breed => BREEDS.find((b) => b.kind === kind) ?? (BREEDS[0] as Breed); function noise(a: number, b: number, c: number): number { let h = (a * 2654435761) ^ (b * 40503) ^ (c * 2246822519); h = Math.imul(h ^ (h >>> 15), 2246822519); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } /** Where the caches of coin sit on a floor. Deterministic, like everything. */ export function cacheCells(floor: Floor): number[] { const open = openCells(floor); const count = 3 + floor.depth; const cells: number[] = []; for (let i = 0; i < count; i += 1) { const cell = open[Math.floor(noise(floor.depth, i, 97) * open.length)]; if (cell !== undefined && !cells.includes(cell)) cells.push(cell); } return cells; } /** Fill a floor's monsters the first time anyone sets foot on it. */ export function populate(db: Database, depth: number): void { const already = db .query<{ n: number }, [number]>("SELECT count(*) AS n FROM monster WHERE depth = ?1") .get(depth)?.n ?? 0; if (already > 0) return; const floor = floorAt(depth); const open = openCells(floor); const count = 5 + depth * 2; const insert = db.query( "INSERT OR IGNORE INTO monster (depth, spawn, cell, kind, hp) VALUES (?1, ?2, ?2, ?3, ?4)", ); for (let i = 0; i < count; i += 1) { const cell = open[Math.floor(noise(depth, i, 31) * open.length)]; if (cell === undefined) continue; // A landing you arrive on has to be survivable: nothing spawns within // sight of the stair. A first playtest put two rats on the entrance and // a fresh delver was at 3 of 12 hp inside seven turns, having done // nothing wrong. if (distance(cell, floor.entrance) <= 8) continue; // A floor's roster is mostly its own tier, with the occasional visitor // from one below — the first ghoul on floor 2 is the moment it lands. const tier = Math.min(BREEDS.length - 1, depth - 1 + (noise(depth, i, 53) < 0.2 ? 1 : 0)); const breed = BREEDS[Math.max(0, tier)] as Breed; insert.run(depth, cell, breed.kind, breed.hp); } } export interface TurnResult { delver: Delver; /** What to tell the player, newest last. */ log: string[]; died: boolean; climbedOut: boolean; } /** The delver a hand is currently playing, made if there is none. */ export function delverFor(db: Database, hand: string): Delver { const active = store.activeDelver(db, hand); if (active) return active; const past = db .query<{ n: number }, [string]>("SELECT count(*) AS n FROM delver WHERE hand = ?1") .get(hand)?.n ?? 0; const floor = floorAt(1); populate(db, 1); const made = store.createDelver(db, hand, nameFor(hand, past), floor.entrance); store.markSeen(db, 1, visibleFrom(floor, made.cell)); return made; } /** Everything the monsters do, once, after the delver has acted. */ function monsterTurn(db: Database, delver: Delver, now: number, log: string[]): Delver { const floor = floorAt(delver.depth); const monsters = store.monstersOn(db, delver.depth, now); const others = store.delversOn(db, delver.depth).filter((d) => d.id !== delver.id); let hp = delver.hp; for (const monster of monsters) { const breed = breedOf(monster.kind); // It goes for whoever is closest and in sight — which may not be you. const targets = [delver, ...others]; let target: Delver | undefined; let best = Infinity; for (const candidate of targets) { const d = distance(monster.cell, candidate.cell); if (d <= breed.notice && d < best && seesFrom(floor, monster.cell, candidate.cell)) { best = d; target = candidate; } } if (target === undefined) continue; if (best <= 1) { if (target.id === delver.id) { hp -= breed.bite; log.push(`The ${monster.kind} bites you for ${breed.bite}.`); } continue; } // Chasers move at two thirds of a delver's pace. At equal speed nothing // pursued can ever reach a stair or rest, so every fight on a floor with // more than one monster was a death sentence — two goblins took a fresh // delver from 12 to 2 with no legal way to disengage. Deterministic in // the turn and the monster, so it is not luck either. if ((now + monster.id) % 3 === 0) continue; const next = stepToward(floor, monster.cell, target.cell); const occupied = monsters.some((m) => m.id !== monster.id && m.cell === next); if (!occupied && next !== monster.cell) { db.query("UPDATE monster SET cell = ?2 WHERE id = ?1").run(monster.id, next); monster.cell = next; } } return { ...delver, hp }; } function die(db: Database, delver: Delver, log: string[]): Delver { const epitaph = epitaphFor(delver.name, delver.depth, delver.cell, delver.turns); db.query( "INSERT INTO grave (depth, cell, name, epitaph, gold) VALUES (?1, ?2, ?3, ?4, ?5)", ).run(delver.depth, delver.cell, delver.name, epitaph, delver.gold); log.push(`${delver.name} dies on floor ${delver.depth}, carrying ${delver.gold} gold.`); return { ...delver, hp: 0, state: "dead", ended_at: new Date().toISOString(), epitaph, }; } /** One action. The only way the world moves. */ export function act(db: Database, hand: string, action: Action): TurnResult { let delver = delverFor(db, hand); const log: string[] = []; const floor = floorAt(delver.depth); const now = store.tick(db); delver = { ...delver, turns: delver.turns + 1 }; let climbedOut = false; if (action === "descend" || action === "ascend") { const tile = floor.tiles[delver.cell]; if (action === "descend" && tile === ">") { const next = floorAt(delver.depth + 1); populate(db, next.depth); delver = { ...delver, depth: next.depth, cell: next.entrance }; log.push(`You climb down to floor ${next.depth}. The air is colder.`); } else if (action === "ascend" && tile === "<" && delver.depth === 1) { db.query( "INSERT INTO score (name, gold, depth, turns) VALUES (?1, ?2, ?3, ?4)", ).run(delver.name, delver.gold, delver.depth, delver.turns); delver = { ...delver, state: "returned", ended_at: new Date().toISOString() }; log.push(`${delver.name} climbs out with ${delver.gold} gold. It is banked.`); climbedOut = true; } else if (action === "ascend" && tile === "<") { const up = floorAt(delver.depth - 1); delver = { ...delver, depth: up.depth, cell: up.descent ?? up.entrance }; log.push(`You climb back up to floor ${up.depth}.`); } else { log.push("There is no stair here."); } } else if (action !== "wait") { const watchedBefore = store .monstersOn(db, delver.depth, now) .some((m) => distance(m.cell, delver.cell) <= LANTERN && seesFrom(floor, delver.cell, m.cell)); const here = xy(delver.cell); const step = DIRECTIONS[action]; const nx = here.x + step.dx; const ny = here.y + step.dy; const target = at(nx, ny); const monsters = store.monstersOn(db, delver.depth, now); const monster = monsters.find((m) => m.cell === target); if (monster) { // Bump to attack. Damage is 2–4, decided by the turn, not by luck the // client could re-roll. const breed = breedOf(monster.kind); const hit = 2 + Math.floor(noise(now, delver.id, monster.id) * 3); const left = monster.hp - hit; if (left <= 0) { db.query("UPDATE monster SET hp = 0, dead_until = ?2, cell = spawn WHERE id = ?1").run( monster.id, now + 150, ); delver = { ...delver, gold: delver.gold + breed.gold }; log.push(`You put the ${monster.kind} down. ${breed.gold} gold.`); } else { db.query("UPDATE monster SET hp = ?2 WHERE id = ?1").run(monster.id, left); log.push(`You hit the ${monster.kind} for ${hit}.`); } } else if (!walkable(floor.tiles[target])) { log.push("Stone."); } else { delver = { ...delver, cell: target }; // A cache pays out once, then refills slowly — so a floor someone // stripped an hour ago is worth walking again tomorrow. if (cacheCells(floor).includes(target)) { const taken = db .query<{ until_turn: number }, [number, number]>( "SELECT until_turn FROM cache_taken WHERE depth = ?1 AND cell = ?2", ) .get(delver.depth, target); if (!taken || taken.until_turn <= now) { const coin = 4 + Math.floor(noise(delver.depth, target, 17) * 9); db.query( `INSERT INTO cache_taken (depth, cell, until_turn) VALUES (?1, ?2, ?3) ON CONFLICT (depth, cell) DO UPDATE SET until_turn = excluded.until_turn`, ).run(delver.depth, target, now + 400); delver = { ...delver, gold: delver.gold + coin }; log.push(`A cache of ${coin} gold, tucked into the wall.`); } } const under = floor.tiles[target]; if (under === ">") log.push("A stair down, here."); else if (under === "<") log.push(delver.depth === 1 ? "The way out, here. Climb and the gold is yours." : "A stair up, here."); const watchedNow = store .monstersOn(db, delver.depth, now) .some((m) => distance(m.cell, target) <= LANTERN && seesFrom(floor, target, m.cell)); if (watchedNow && !watchedBefore) log.push("Something moves at the edge of your lantern."); const grave = store.graveAt(db, delver.depth, target); if (grave && grave.looted === 0) { const share = Math.floor(grave.gold / 2); db.query("UPDATE grave SET looted = 1 WHERE id = ?1").run(grave.id); if (share > 0) delver = { ...delver, gold: delver.gold + share }; log.push(`${grave.epitaph}${share > 0 ? ` You take ${share} of their gold.` : ""}`); } else if (grave) { log.push(grave.epitaph); } } } else { // Waiting is the only medicine. It works when nothing can see you, which // makes retreating into a cleared room a real decision rather than a // pause — every turn spent resting is a turn the floor keeps moving. const monsters = store.monstersOn(db, delver.depth, now); const watched = monsters.some( (m) => distance(m.cell, delver.cell) <= 6 && seesFrom(floor, m.cell, delver.cell), ); if (watched) log.push("You wait. Something is still moving out there."); else if (delver.hp < delver.max_hp) { delver = { ...delver, hp: Math.min(delver.max_hp, delver.hp + 1) }; log.push(`You catch your breath. ${delver.hp} of ${delver.max_hp}.`); } else log.push("You wait, and listen. Nothing comes."); } if (!climbedOut) { delver = monsterTurn(db, delver, now, log); if (delver.hp <= 0) delver = die(db, delver, log); store.markSeen(db, delver.depth, visibleFrom(floorAt(delver.depth), delver.cell)); } store.saveDelver(db, delver); // Dated by the delver's own turn, so the screen can tell what happened // *this* turn from what it is still remembering. store.say(db, delver.id, delver.turns, log); return { delver, log, died: delver.state === "dead", climbedOut }; } /** What the delver can see right now, and what the dungeon remembers. */ export interface View { floor: Floor; visible: Set; seen: Set; monsters: Monster[]; graves: Map; others: Map; } export function viewFor(db: Database, delver: Delver): View { const floor = floorAt(delver.depth); const now = store.turn(db); const visible = new Set(visibleFrom(floor, delver.cell, LANTERN)); const graves = new Map(); for (const grave of store.gravesOn(db, delver.depth)) graves.set(grave.cell, grave.name); const others = new Map(); for (const other of store.delversOn(db, delver.depth)) if (other.id !== delver.id) others.set(other.cell, other.name); return { floor, visible, seen: store.seenOn(db, delver.depth), monsters: store.monstersOn(db, delver.depth, now).filter((m) => visible.has(m.cell)), graves, others, }; } export { DEPTHS, LANTERN };