heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
lanternfall: make being chased survivable, and stop the log stuttering
Two things a live delve showed, neither visible from the tests that existed: - A chaser crossing the edge of the lantern wrote 'Something moves at the edge of your lantern.' thirteen times in a row. It was not a bug in the first-sight check: a monster at distance 5 closing to 4 and back is genuinely entering and leaving the light. The log now never repeats its own last line, which is the right rule regardless of who is chasing whom. - Monsters moved at exactly a delver's pace, so nothing pursued could ever reach a stair or rest: two goblins took a fresh delver from 12 hp to 2 with no legal way to disengage, and the delver before it died the same way. Chasers now skip one turn in three - deterministic in the turn and the monster id, so disengaging is a decision rather than a dice roll. They still close; you can now get to the stair first if you turn early enough. 30 tests. The new one puts a rat three tiles off in plain sight and asserts it needs more than the minimum two turns to reach biting distance.
3 files
changed +60
−2
src/db.ts +15 −1
| @@ -239,7 +239,21 @@ export const countSeen = (db: Database): number => | ||
| 239 | 239 | |
| 240 | 240 | export function say(db: Database, delverId: number, turn: number, lines: readonly string[]): void { |
| 241 | 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); | |
| 242 | // A chaser crossing the edge of the lantern every other step used to write | |
| 243 | // "Something moves at the edge of your lantern." thirteen times in a row, | |
| 244 | // which reads as a broken game rather than a tense one. The log never | |
| 245 | // repeats its own last line. | |
| 246 | let previous = | |
| 247 | db | |
| 248 | .query<{ text: string }, [number]>( | |
| 249 | "SELECT text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 1", | |
| 250 | ) | |
| 251 | .get(delverId)?.text ?? ""; | |
| 252 | for (const line of lines) { | |
| 253 | if (line === previous) continue; | |
| 254 | insert.run(delverId, turn, line); | |
| 255 | previous = line; | |
| 256 | } | |
| 243 | 257 | // Keep the tail only: nobody scrolls a dungeon log. |
| 244 | 258 | db.query( |
| 245 | 259 | `DELETE FROM message WHERE delver_id = ?1 AND id NOT IN |
src/game.test.ts +39 −1
| @@ -5,7 +5,7 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { Database } from "bun:sqlite"; |
| 6 | 6 | import * as store from "./db.ts"; |
| 7 | 7 | import { act, breedOf, cacheCells, delverFor, populate, viewFor } from "./game.ts"; |
| 8 | import { distance, floorAt, xy, at } from "./dungeon.ts"; | |
| 8 | import { distance, floorAt, visibleFrom, xy, at } from "./dungeon.ts"; | |
| 9 | 9 | |
| 10 | 10 | const fresh = (): Database => store.open(mkdtempSync(join(tmpdir(), "lanternfall-"))); |
| 11 | 11 | |
| @@ -181,3 +181,41 @@ test("caches pay once and refill later, so a stripped floor is worth returning t | ||
| 181 | 181 | expect(again.delver.gold).toBe(carried); |
| 182 | 182 | db.close(); |
| 183 | 183 | }); |
| 184 | ||
| 185 | test("a delver can break away: chasers move at two thirds pace", () => { | |
| 186 | const db = fresh(); | |
| 187 | const delver = delverFor(db, "hand-a"); | |
| 188 | const floor = floorAt(1); | |
| 189 | // A rat three tiles off, on real floor, in plain sight of the delver. | |
| 190 | const spot = visibleFrom(floor, delver.cell) | |
| 191 | .find((cell) => floor.tiles[cell] === "." && distance(cell, delver.cell) === 3); | |
| 192 | expect(spot).toBeDefined(); | |
| 193 | db.query( | |
| 194 | "INSERT INTO monster (depth, spawn, cell, kind, hp) VALUES (1, ?1, ?1, 'rat', 2)", | |
| 195 | ).run(spot as number); | |
| 196 | ||
| 197 | // Three tiles away is two steps from biting distance. At equal pace it is | |
| 198 | // adjacent on the delver's second turn; skipping one turn in three, it | |
| 199 | // needs at least a third. | |
| 200 | let turnsToReach = 0; | |
| 201 | for (let i = 1; i <= 12; i += 1) { | |
| 202 | act(db, "hand-a", "wait"); | |
| 203 | const rat = store.monstersOn(db, 1, store.turn(db))[0]; | |
| 204 | if (rat !== undefined && distance(rat.cell, delver.cell) <= 1) { | |
| 205 | turnsToReach = i; | |
| 206 | break; | |
| 207 | } | |
| 208 | } | |
| 209 | expect(turnsToReach).toBeGreaterThan(2); | |
| 210 | db.close(); | |
| 211 | }); | |
| 212 | ||
| 213 | test("the log does not repeat its own last line", () => { | |
| 214 | const db = fresh(); | |
| 215 | const delver = delverFor(db, "hand-a"); | |
| 216 | store.say(db, delver.id, 1, ["Stone.", "Stone.", "Stone."]); | |
| 217 | store.say(db, delver.id, 2, ["Stone."]); | |
| 218 | const heard = store.heard(db, delver.id, 10); | |
| 219 | expect(heard.filter((l) => l === "Stone.").length).toBe(1); | |
| 220 | db.close(); | |
| 221 | }); |
src/game.ts +6
| @@ -148,6 +148,12 @@ function monsterTurn(db: Database, delver: Delver, now: number, log: string[]): | ||
| 148 | 148 | } |
| 149 | 149 | continue; |
| 150 | 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; | |
| 151 | 157 | const next = stepToward(floor, monster.cell, target.cell); |
| 152 | 158 | const occupied = monsters.some((m) => m.id !== monster.id && m.cell === next); |
| 153 | 159 | if (!occupied && next !== monster.cell) { |