heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
lanternfall: one dungeon, five floors, and everyone who went down it
A shared roguelike. Five floors, each a pure function of one seed, so the dungeon is identical for every player and the database never stores a tile - only what has happened in it. What makes it shared rather than five private games: - the map is common memory. A tile lit by anyone's lantern stays lit on everyone's map; a new delver arrives in a dungeon already drawn by the people who died in it. - the dead stay where they fell, as a grave holding the gold they carried, with an epitaph composed from a grammar (nobody types free text into this world, which is also why a public game needs no moderation). Whoever finds it takes half. - other delvers appear on your floor, and a monster hunts whoever it can see and is closest to - which may not be you. Gold counts for nothing until you climb out through the first floor's stair. Two playtest findings fixed before this landed: monsters could spawn on the arrival stair (a fresh delver was at 3 of 12 hp in seven turns having done nothing wrong - nothing now spawns within 8 tiles of an entrance), and there was no way to heal at all, so resting was added and deliberately made conditional on nothing being able to see you. 26 tests. Two are the load-bearing ones: a flood fill from each entrance proving the stair is reachable, and a two-hand test proving the shared map is really shared. Keyboard play is one file under static/; with JavaScript off the game plays identically, one click per turn. No dependencies.
13 files
changed +1774
−185
README.md +67 −14
| @@ -1,23 +1,76 @@ | ||
| 1 | 1 | # lanternfall |
| 2 | 2 | |
| 3 | One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it. | |
| 3 | One dungeon, five floors, and everybody who has ever gone down it. | |
| 4 | 4 | |
| 5 | This is an app: the forge runs it. Production follows the default branch; | |
| 6 | every open pull gets a preview at its own address with its own copy of the | |
| 7 | data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what | |
| 8 | makes "production data" and "preview data" two different things. | |
| 5 | You get a delver, twelve hit points and a lantern that throws about four | |
| 6 | paces. Below you are rats, then goblins, then things with names you would | |
| 7 | rather not learn, and caches of coin tucked into the walls. Gold in your | |
| 8 | pocket is worth nothing: it only counts when you climb back out through the | |
| 9 | first floor's stair, and if you die on the way it stays where you fell. | |
| 10 | ||
| 11 | ## What makes it a shared game | |
| 12 | ||
| 13 | - **The map is everyone's.** A tile lit by anybody's lantern stays on | |
| 14 | everybody's map, forever. A new delver walks into a dungeon already drawn | |
| 15 | by the people who died in it — rooms in outline, corridors half sketched — | |
| 16 | and the dark ahead is the part nobody has reached yet. | |
| 17 | - **The dead stay where they fell.** Dying leaves a grave at that exact tile | |
| 18 | with a composed epitaph, and it holds the gold you were carrying. Whoever | |
| 19 | finds it takes half. The good advice on gravestones is real advice: it was | |
| 20 | written where somebody actually died. | |
| 21 | - **Everyone is in the same rooms at the same time.** Other delvers show up | |
| 22 | on your floor as a blue `@`, and the monster hunting you might be hunting | |
| 23 | them instead — it goes for whoever it can see and is closest to. | |
| 24 | - **The floors are permanent.** Every floor is a pure function of one seed, | |
| 25 | so the dungeon is the same for everyone and always will be. The database | |
| 26 | stores only what has *happened*: who is down there, what has been lit, what | |
| 27 | is dead and where. | |
| 28 | ||
| 29 | ## Playing | |
| 30 | ||
| 31 | Arrows or `wasd` (or `hjkl`) move. Walking into something attacks it. `.` | |
| 32 | waits — and waiting is the only way to heal, which works only when nothing | |
| 33 | can see you, so a cleared room is worth remembering. `>` goes down a stair, | |
| 34 | `<` goes up; `<` on the first floor is the way out, and the only way to keep | |
| 35 | what you are carrying. | |
| 36 | ||
| 37 | Every control is a real form button. The keyboard is one small file under | |
| 38 | `static/`; with JavaScript off the game plays exactly the same, one click per | |
| 39 | turn. | |
| 40 | ||
| 41 | ## In a terminal | |
| 42 | ||
| 43 | sh | |
| 44 | ssh -p 2222 lanternfall@<the forge> # the same dungeon, the same database | |
| 45 | ||
| 46 | ||
| 47 | The terminal view is the same map drawn in 256 colours, refreshed every few | |
| 48 | seconds so you can watch somebody else's lantern move. The account at the | |
| 49 | keyboard is your name, so the gravestone will have it on. | |
| 50 | ||
| 51 | ## Running it | |
| 9 | 52 | |
| 10 | 53 | sh |
| 11 | 54 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| 12 | bun test # the check every pull runs | |
| 13 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | |
| 55 | bun test # 26 tests | |
| 56 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal client | |
| 14 | 57 | |
| 15 | 58 | |
| 16 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app | |
| 17 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is | |
| 18 | the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on | |
| 19 | a real pty over the same data. | |
| 59 | ## Shape | |
| 20 | 60 | |
| 21 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` | |
| 22 | (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT` | |
| 23 | when it starts the app. `forge.json` says how. | |
| 61 | | file | what it holds | | |
| 62 | | ------------------ | ------------------------------------------------------------------------ | | |
| 63 | | `src/dungeon.ts` | the floors: generation, line of sight, the step a monster takes — pure | | |
| 64 | | `src/game.ts` | the rules: a turn, a blow, a stair, a death, a climb out | | |
| 65 | | `src/db.ts` | what happened: delvers, the shared map, monsters, graves, the log | | |
| 66 | | `src/names.ts` | names and epitaphs, composed from a grammar — nobody types into the world | | |
| 67 | | `src/pages.ts` | the screen | | |
| 68 | | `src/server.ts` | the door and the cookie | | |
| 69 | | `src/tty.ts` | the same dungeon in a terminal | | |
| 70 | | `static/keys.v1.js`| keyboard play, layered over controls that already work without it | | |
| 71 | ||
| 72 | Two tests are the ones worth knowing about: one floods every floor from its | |
| 73 | entrance to prove the stair is actually reachable, and one plays two hands to | |
| 74 | prove the map they share is really shared. | |
| 75 | ||
| 76 | No dependencies. |
forge.json +2
| @@ -2,6 +2,8 @@ | ||
| 2 | 2 | "app": { |
| 3 | 3 | "run": "bun run src/server.ts", |
| 4 | 4 | "test": "bun test", |
| 5 | "health": "/", | |
| 6 | "ready": 30, | |
| 5 | 7 | "tty": "bun run src/tty.ts" |
| 6 | 8 | } |
| 7 | 9 | } |
src/db.ts added +267
| @@ -0,0 +1,267 @@ | ||
| 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 | export function say(db: Database, delverId: number, turn: number, lines: readonly string[]): void { | |
| 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); | |
| 243 | // Keep the tail only: nobody scrolls a dungeon log. | |
| 244 | db.query( | |
| 245 | `DELETE FROM message WHERE delver_id = ?1 AND id NOT IN | |
| 246 | (SELECT id FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 40)`, | |
| 247 | ).run(delverId); | |
| 248 | } | |
| 249 | ||
| 250 | export const heard = (db: Database, delverId: number, limit = 7): string[] => | |
| 251 | db | |
| 252 | .query<{ text: string }, [number, number]>( | |
| 253 | "SELECT text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT ?2", | |
| 254 | ) | |
| 255 | .all(delverId, limit) | |
| 256 | .map((r) => r.text) | |
| 257 | .reverse(); | |
| 258 | ||
| 259 | export const cacheState = (db: Database, depth: number, now: number): Set<number> => | |
| 260 | new Set( | |
| 261 | db | |
| 262 | .query<{ cell: number }, [number, number]>( | |
| 263 | "SELECT cell FROM cache_taken WHERE depth = ?1 AND until_turn > ?2", | |
| 264 | ) | |
| 265 | .all(depth, now) | |
| 266 | .map((r) => r.cell), | |
| 267 | ); |
src/dungeon.test.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | DEPTHS, HEIGHT, WIDTH, at, distance, floorAt, openCells, seesFrom, | |
| 4 | stepToward, visibleFrom, walkable, xy, | |
| 5 | } from "./dungeon.ts"; | |
| 6 | ||
| 7 | test("a floor is the same floor every time it is asked for", () => { | |
| 8 | for (let depth = 1; depth <= DEPTHS; depth += 1) | |
| 9 | expect(floorAt(depth).tiles.join("")).toBe(floorAt(depth).tiles.join("")); | |
| 10 | expect(floorAt(1).tiles.join("")).not.toBe(floorAt(2).tiles.join("")); | |
| 11 | }); | |
| 12 | ||
| 13 | test("every floor has rooms, an entrance, and a way on", () => { | |
| 14 | for (let depth = 1; depth <= DEPTHS; depth += 1) { | |
| 15 | const floor = floorAt(depth); | |
| 16 | expect(floor.rooms.length).toBeGreaterThanOrEqual(5); | |
| 17 | expect(openCells(floor).length).toBeGreaterThan(150); | |
| 18 | expect(floor.tiles[floor.entrance]).toBe("<"); | |
| 19 | if (depth < DEPTHS) expect(floor.tiles[floor.descent as number]).toBe(">"); | |
| 20 | else expect(floor.descent).toBeUndefined(); | |
| 21 | } | |
| 22 | }); | |
| 23 | ||
| 24 | test("the stair is reachable from the entrance", () => { | |
| 25 | // A floor you cannot cross is a floor nobody can finish. Flood fill. | |
| 26 | for (let depth = 1; depth < DEPTHS; depth += 1) { | |
| 27 | const floor = floorAt(depth); | |
| 28 | const seen = new Set<number>([floor.entrance]); | |
| 29 | const queue = [floor.entrance]; | |
| 30 | while (queue.length > 0) { | |
| 31 | const cell = queue.shift() as number; | |
| 32 | const { x, y } = xy(cell); | |
| 33 | for (const [dx, dy] of [[0,-1],[0,1],[1,0],[-1,0]] as const) { | |
| 34 | const nx = x + dx; | |
| 35 | const ny = y + dy; | |
| 36 | if (nx < 0 || ny < 0 || nx >= WIDTH || ny >= HEIGHT) continue; | |
| 37 | const next = at(nx, ny); | |
| 38 | if (seen.has(next) || !walkable(floor.tiles[next])) continue; | |
| 39 | seen.add(next); | |
| 40 | queue.push(next); | |
| 41 | } | |
| 42 | } | |
| 43 | expect(seen.has(floor.descent as number)).toBe(true); | |
| 44 | } | |
| 45 | }); | |
| 46 | ||
| 47 | test("a wall stops the light at the wall", () => { | |
| 48 | const floor = floorAt(1); | |
| 49 | const wall = floor.tiles.findIndex((t) => t === "#"); | |
| 50 | const lit = visibleFrom(floor, floor.entrance); | |
| 51 | expect(lit).toContain(floor.entrance); | |
| 52 | // Nothing lit is further than the lantern reaches. | |
| 53 | for (const cell of lit) expect(distance(floor.entrance, cell)).toBeLessThanOrEqual(4); | |
| 54 | // And sight into solid rock stops: a cell behind a wall is not visible. | |
| 55 | const blocked = floor.tiles.findIndex( | |
| 56 | (t, i) => t === "." && !seesFrom(floor, floor.entrance, i) && distance(floor.entrance, i) <= 4, | |
| 57 | ); | |
| 58 | if (blocked !== -1) expect(lit).not.toContain(blocked); | |
| 59 | expect(wall).toBeGreaterThan(-1); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a step toward something goes around the corner it cannot go through", () => { | |
| 63 | const floor = floorAt(1); | |
| 64 | const from = floor.entrance; | |
| 65 | const step = stepToward(floor, from, floor.descent as number); | |
| 66 | expect(walkable(floor.tiles[step])).toBe(true); | |
| 67 | expect(distance(step, from)).toBeLessThanOrEqual(1); | |
| 68 | }); |
src/dungeon.ts added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | /** | |
| 2 | * The dungeon itself: generated, never stored. Five floors, each a pure | |
| 3 | * function of the seed and the depth — so every delver walks the same rooms, | |
| 4 | * every corridor is where the last one left it, and the database only has to | |
| 5 | * remember what has *happened* in the dungeon, not what it looks like. | |
| 6 | * | |
| 7 | * Nothing in this file touches the database or the clock. | |
| 8 | */ | |
| 9 | ||
| 10 | export const WIDTH = 48; | |
| 11 | export const HEIGHT = 24; | |
| 12 | export const DEPTHS = 5; | |
| 13 | /** How far a lantern throws, in tiles. */ | |
| 14 | export const LANTERN = 4; | |
| 15 | ||
| 16 | export type Tile = "#" | "." | ">" | "<"; | |
| 17 | ||
| 18 | export interface Room { | |
| 19 | x: number; | |
| 20 | y: number; | |
| 21 | w: number; | |
| 22 | h: number; | |
| 23 | } | |
| 24 | ||
| 25 | export interface Floor { | |
| 26 | depth: number; | |
| 27 | tiles: Tile[]; | |
| 28 | rooms: Room[]; | |
| 29 | /** Where a delver arrives when coming down (or starting out). */ | |
| 30 | entrance: number; | |
| 31 | /** The stair down; on the deepest floor there is none. */ | |
| 32 | descent: number | undefined; | |
| 33 | } | |
| 34 | ||
| 35 | export const at = (x: number, y: number): number => y * WIDTH + x; | |
| 36 | export const xy = (cell: number): { x: number; y: number } => ({ | |
| 37 | x: cell % WIDTH, | |
| 38 | y: Math.floor(cell / WIDTH), | |
| 39 | }); | |
| 40 | ||
| 41 | /** Deterministic noise: the same seed, depth and salt always give the same roll. */ | |
| 42 | function roll(seed: number, depth: number, salt: number): number { | |
| 43 | let h = (seed * 2654435761) ^ (depth * 40503) ^ (salt * 2246822519); | |
| 44 | h = Math.imul(h ^ (h >>> 15), 2246822519); | |
| 45 | h = Math.imul(h ^ (h >>> 13), 3266489917); | |
| 46 | return ((h ^ (h >>> 16)) >>> 0) / 4294967296; | |
| 47 | } | |
| 48 | ||
| 49 | const between = (r: number, lo: number, hi: number): number => | |
| 50 | lo + Math.floor(r * (hi - lo + 1)); | |
| 51 | ||
| 52 | function carveRoom(tiles: Tile[], room: Room): void { | |
| 53 | for (let y = room.y; y < room.y + room.h; y += 1) | |
| 54 | for (let x = room.x; x < room.x + room.w; x += 1) tiles[at(x, y)] = "."; | |
| 55 | } | |
| 56 | ||
| 57 | function carveCorridor(tiles: Tile[], a: Room, b: Room, bendFirst: boolean): void { | |
| 58 | const ax = Math.floor(a.x + a.w / 2); | |
| 59 | const ay = Math.floor(a.y + a.h / 2); | |
| 60 | const bx = Math.floor(b.x + b.w / 2); | |
| 61 | const by = Math.floor(b.y + b.h / 2); | |
| 62 | const hor = (y: number, from: number, to: number): void => { | |
| 63 | for (let x = Math.min(from, to); x <= Math.max(from, to); x += 1) | |
| 64 | tiles[at(x, y)] = "."; | |
| 65 | }; | |
| 66 | const ver = (x: number, from: number, to: number): void => { | |
| 67 | for (let y = Math.min(from, to); y <= Math.max(from, to); y += 1) | |
| 68 | tiles[at(x, y)] = "."; | |
| 69 | }; | |
| 70 | if (bendFirst) { | |
| 71 | hor(ay, ax, bx); | |
| 72 | ver(bx, ay, by); | |
| 73 | } else { | |
| 74 | ver(ax, ay, by); | |
| 75 | hor(by, ax, bx); | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | const overlaps = (a: Room, b: Room): boolean => | |
| 80 | a.x < b.x + b.w + 1 && | |
| 81 | a.x + a.w + 1 > b.x && | |
| 82 | a.y < b.y + b.h + 1 && | |
| 83 | a.y + a.h + 1 > b.y; | |
| 84 | ||
| 85 | /** One floor, built the same way every time it is asked for. */ | |
| 86 | export function floorAt(depth: number, seed = 1848): Floor { | |
| 87 | const tiles: Tile[] = new Array(WIDTH * HEIGHT).fill("#"); | |
| 88 | const rooms: Room[] = []; | |
| 89 | // Deeper floors are more broken up: fewer, smaller, further apart. | |
| 90 | const attempts = 60; | |
| 91 | const maxRooms = Math.max(5, 11 - depth); | |
| 92 | for (let i = 0; i < attempts && rooms.length < maxRooms; i += 1) { | |
| 93 | const w = between(roll(seed, depth, i * 7 + 1), 5, Math.max(6, 11 - depth)); | |
| 94 | const h = between(roll(seed, depth, i * 7 + 2), 3, Math.max(4, 7 - Math.floor(depth / 2))); | |
| 95 | const room: Room = { | |
| 96 | x: between(roll(seed, depth, i * 7 + 3), 1, WIDTH - w - 2), | |
| 97 | y: between(roll(seed, depth, i * 7 + 4), 1, HEIGHT - h - 2), | |
| 98 | w, | |
| 99 | h, | |
| 100 | }; | |
| 101 | if (rooms.some((other) => overlaps(room, other))) continue; | |
| 102 | rooms.push(room); | |
| 103 | } | |
| 104 | for (const room of rooms) carveRoom(tiles, room); | |
| 105 | for (let i = 1; i < rooms.length; i += 1) | |
| 106 | carveCorridor(tiles, rooms[i - 1] as Room, rooms[i] as Room, roll(seed, depth, i * 13) < 0.5); | |
| 107 | ||
| 108 | const first = rooms[0] as Room; | |
| 109 | const last = rooms[rooms.length - 1] as Room; | |
| 110 | const entrance = at(Math.floor(first.x + first.w / 2), Math.floor(first.y + first.h / 2)); | |
| 111 | tiles[entrance] = "<"; | |
| 112 | let descent: number | undefined; | |
| 113 | if (depth < DEPTHS) { | |
| 114 | descent = at(Math.floor(last.x + last.w / 2), Math.floor(last.y + last.h / 2)); | |
| 115 | tiles[descent] = ">"; | |
| 116 | } | |
| 117 | return { depth, tiles, rooms, entrance, descent }; | |
| 118 | } | |
| 119 | ||
| 120 | export const walkable = (tile: Tile | undefined): boolean => | |
| 121 | tile === "." || tile === ">" || tile === "<"; | |
| 122 | ||
| 123 | /** Floor tiles in reading order — what anything placed in the dungeon picks from. */ | |
| 124 | export function openCells(floor: Floor): number[] { | |
| 125 | const open: number[] = []; | |
| 126 | for (const [cell, tile] of floor.tiles.entries()) | |
| 127 | if (tile === ".") open.push(cell); | |
| 128 | return open; | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * Line of sight, Bresenham. A wall stops the light at the wall, so you see | |
| 133 | * the wall itself and nothing behind it — which is what makes a doorway read | |
| 134 | * as a doorway. | |
| 135 | */ | |
| 136 | export function seesFrom(floor: Floor, from: number, to: number): boolean { | |
| 137 | const a = xy(from); | |
| 138 | const b = xy(to); | |
| 139 | let { x, y } = a; | |
| 140 | const dx = Math.abs(b.x - x); | |
| 141 | const dy = Math.abs(b.y - y); | |
| 142 | const sx = x < b.x ? 1 : -1; | |
| 143 | const sy = y < b.y ? 1 : -1; | |
| 144 | let err = dx - dy; | |
| 145 | for (;;) { | |
| 146 | if (x === b.x && y === b.y) return true; | |
| 147 | const e2 = 2 * err; | |
| 148 | if (e2 > -dy) { | |
| 149 | err -= dy; | |
| 150 | x += sx; | |
| 151 | } | |
| 152 | if (e2 < dx) { | |
| 153 | err += dx; | |
| 154 | y += sy; | |
| 155 | } | |
| 156 | if (x === b.x && y === b.y) return true; | |
| 157 | if (!walkable(floor.tiles[at(x, y)])) return false; | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | export const distance = (a: number, b: number): number => { | |
| 162 | const p = xy(a); | |
| 163 | const q = xy(b); | |
| 164 | return Math.max(Math.abs(p.x - q.x), Math.abs(p.y - q.y)); | |
| 165 | }; | |
| 166 | ||
| 167 | /** Every cell a delver standing here can currently see. */ | |
| 168 | export function visibleFrom(floor: Floor, from: number, radius = LANTERN): number[] { | |
| 169 | const seen: number[] = []; | |
| 170 | const { x, y } = xy(from); | |
| 171 | for (let dy = -radius; dy <= radius; dy += 1) | |
| 172 | for (let dx = -radius; dx <= radius; dx += 1) { | |
| 173 | const nx = x + dx; | |
| 174 | const ny = y + dy; | |
| 175 | if (nx < 0 || ny < 0 || nx >= WIDTH || ny >= HEIGHT) continue; | |
| 176 | const cell = at(nx, ny); | |
| 177 | if (distance(from, cell) > radius) continue; | |
| 178 | if (seesFrom(floor, from, cell)) seen.push(cell); | |
| 179 | } | |
| 180 | return seen; | |
| 181 | } | |
| 182 | ||
| 183 | /** One step from `from` toward `to`, around walls where it can manage it. */ | |
| 184 | export function stepToward(floor: Floor, from: number, to: number): number { | |
| 185 | const a = xy(from); | |
| 186 | const b = xy(to); | |
| 187 | const sx = Math.sign(b.x - a.x); | |
| 188 | const sy = Math.sign(b.y - a.y); | |
| 189 | const tries = [ | |
| 190 | at(a.x + sx, a.y + sy), | |
| 191 | sx !== 0 ? at(a.x + sx, a.y) : at(a.x, a.y + sy), | |
| 192 | sy !== 0 ? at(a.x, a.y + sy) : at(a.x + sx, a.y), | |
| 193 | ]; | |
| 194 | for (const cell of tries) | |
| 195 | if (cell !== from && walkable(floor.tiles[cell])) return cell; | |
| 196 | return from; | |
| 197 | } |
src/game.test.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { Database } from "bun:sqlite"; | |
| 6 | import * as store from "./db.ts"; | |
| 7 | import { act, breedOf, cacheCells, delverFor, populate, viewFor } from "./game.ts"; | |
| 8 | import { distance, floorAt, xy, at } from "./dungeon.ts"; | |
| 9 | ||
| 10 | const fresh = (): Database => store.open(mkdtempSync(join(tmpdir(), "lanternfall-"))); | |
| 11 | ||
| 12 | test("a hand gets one delver, and the same one back", () => { | |
| 13 | const db = fresh(); | |
| 14 | const first = delverFor(db, "hand-a"); | |
| 15 | expect(first.hp).toBe(12); | |
| 16 | expect(first.depth).toBe(1); | |
| 17 | expect(delverFor(db, "hand-a").id).toBe(first.id); | |
| 18 | expect(delverFor(db, "hand-b").id).not.toBe(first.id); | |
| 19 | db.close(); | |
| 20 | }); | |
| 21 | ||
| 22 | test("nothing is waiting on the stair you arrive on", () => { | |
| 23 | const db = fresh(); | |
| 24 | for (let depth = 1; depth <= 5; depth += 1) { | |
| 25 | populate(db, depth); | |
| 26 | const floor = floorAt(depth); | |
| 27 | for (const monster of store.monstersOn(db, depth, 0)) | |
| 28 | expect(distance(monster.cell, floor.entrance)).toBeGreaterThan(8); | |
| 29 | } | |
| 30 | db.close(); | |
| 31 | }); | |
| 32 | ||
| 33 | test("walking into a monster hits it, and killing it pays", () => { | |
| 34 | const db = fresh(); | |
| 35 | const delver = delverFor(db, "hand-a"); | |
| 36 | const east = delver.cell + 1; | |
| 37 | db.query( | |
| 38 | "INSERT INTO monster (depth, spawn, cell, kind, hp) VALUES (1, ?1, ?1, 'rat', 2)", | |
| 39 | ).run(east); | |
| 40 | const hit = act(db, "hand-a", "east"); | |
| 41 | expect(hit.log.join(" ")).toMatch(/put the rat down|hit the rat/); | |
| 42 | if (hit.log.join(" ").includes("put the rat down")) { | |
| 43 | expect(hit.delver.gold).toBe(breedOf("rat").gold); | |
| 44 | // It stays dead for a while, then the dungeon has rats again. | |
| 45 | expect(store.monstersOn(db, 1, store.turn(db)).some((m) => m.cell === east)).toBe(false); | |
| 46 | expect(store.monstersOn(db, 1, store.turn(db) + 200).length).toBeGreaterThan(0); | |
| 47 | } | |
| 48 | db.close(); | |
| 49 | }); | |
| 50 | ||
| 51 | test("dying leaves a grave where it happened, with what you were carrying", () => { | |
| 52 | const db = fresh(); | |
| 53 | const delver = delverFor(db, "hand-a"); | |
| 54 | db.query("UPDATE delver SET hp = 1, gold = 30 WHERE id = ?1").run(delver.id); | |
| 55 | // A wight next door finishes anyone in one bite. | |
| 56 | db.query( | |
| 57 | "INSERT INTO monster (depth, spawn, cell, kind, hp) VALUES (1, ?1, ?1, 'wight', 12)", | |
| 58 | ).run(delver.cell + 1); | |
| 59 | const turn = act(db, "hand-a", "wait"); | |
| 60 | expect(turn.died).toBe(true); | |
| 61 | const graves = store.recentGraves(db); | |
| 62 | expect(graves.length).toBe(1); | |
| 63 | expect(graves[0]?.gold).toBe(30); | |
| 64 | expect(graves[0]?.epitaph).toContain(delver.name); | |
| 65 | expect(graves[0]?.epitaph).toMatch(/Traveller:/); | |
| 66 | // And the hand gets a new delver, with a new name, at full health. | |
| 67 | const next = delverFor(db, "hand-a"); | |
| 68 | expect(next.id).not.toBe(delver.id); | |
| 69 | expect(next.hp).toBe(12); | |
| 70 | expect(next.gold).toBe(0); | |
| 71 | db.close(); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a grave pays the next delver half of what it holds, once", () => { | |
| 75 | const db = fresh(); | |
| 76 | const delver = delverFor(db, "hand-a"); | |
| 77 | const east = delver.cell + 1; | |
| 78 | db.query( | |
| 79 | "INSERT INTO grave (depth, cell, name, epitaph, gold) VALUES (1, ?1, 'Wren Tallow', 'Wren Tallow died. Traveller: beware the corridor.', 20)", | |
| 80 | ).run(east); | |
| 81 | const first = act(db, "hand-a", "east"); | |
| 82 | expect(first.delver.gold).toBe(10); | |
| 83 | expect(first.log.join(" ")).toContain("Traveller:"); | |
| 84 | const again = act(db, "hand-a", "west"); | |
| 85 | const back = act(db, "hand-a", "east"); | |
| 86 | expect(back.delver.gold).toBe(10); | |
| 87 | expect(again.delver.gold).toBe(10); | |
| 88 | db.close(); | |
| 89 | }); | |
| 90 | ||
| 91 | test("the stairs are the only way down, and climbing out banks the gold", () => { | |
| 92 | const db = fresh(); | |
| 93 | const delver = delverFor(db, "hand-a"); | |
| 94 | expect(act(db, "hand-a", "descend").log.join(" ")).toContain("no stair"); | |
| 95 | const floor = floorAt(1); | |
| 96 | db.query("UPDATE delver SET cell = ?2, gold = 41 WHERE id = ?1").run( | |
| 97 | delver.id, | |
| 98 | floor.descent as number, | |
| 99 | ); | |
| 100 | const down = act(db, "hand-a", "descend"); | |
| 101 | expect(down.delver.depth).toBe(2); | |
| 102 | expect(down.delver.cell).toBe(floorAt(2).entrance); | |
| 103 | // Back up, then out through the first floor's entrance. | |
| 104 | const up = act(db, "hand-a", "ascend"); | |
| 105 | expect(up.delver.depth).toBe(1); | |
| 106 | db.query("UPDATE delver SET cell = ?2 WHERE id = ?1").run(delver.id, floor.entrance); | |
| 107 | const out = act(db, "hand-a", "ascend"); | |
| 108 | expect(out.climbedOut).toBe(true); | |
| 109 | const scores = store.topScores(db); | |
| 110 | expect(scores[0]?.gold).toBe(41); | |
| 111 | expect(scores[0]?.name).toBe(delver.name); | |
| 112 | db.close(); | |
| 113 | }); | |
| 114 | ||
| 115 | test("resting heals, but only when nothing can see you", () => { | |
| 116 | const db = fresh(); | |
| 117 | const delver = delverFor(db, "hand-a"); | |
| 118 | db.query("UPDATE delver SET hp = 5 WHERE id = ?1").run(delver.id); | |
| 119 | const rest = act(db, "hand-a", "wait"); | |
| 120 | expect(rest.delver.hp).toBe(6); | |
| 121 | expect(rest.log.join(" ")).toContain("catch your breath"); | |
| 122 | db.query( | |
| 123 | "INSERT INTO monster (depth, spawn, cell, kind, hp) VALUES (1, ?1, ?1, 'rat', 2)", | |
| 124 | ).run(rest.delver.cell + 2); | |
| 125 | const watched = act(db, "hand-a", "wait"); | |
| 126 | expect(watched.log.join(" ")).toContain("still moving"); | |
| 127 | db.close(); | |
| 128 | }); | |
| 129 | ||
| 130 | test("the map is shared: one lantern lights it for everyone", () => { | |
| 131 | const db = fresh(); | |
| 132 | delverFor(db, "hand-a"); | |
| 133 | const explored = store.seenOn(db, 1).size; | |
| 134 | expect(explored).toBeGreaterThan(10); | |
| 135 | for (let i = 0; i < 6; i += 1) act(db, "hand-a", "west"); | |
| 136 | const after = store.seenOn(db, 1); | |
| 137 | expect(after.size).toBeGreaterThan(explored); | |
| 138 | // A hand that has never moved sees everything the other one lit. | |
| 139 | delverFor(db, "hand-b"); | |
| 140 | const view = viewFor(db, delverFor(db, "hand-b")); | |
| 141 | for (const cell of after) expect(view.seen.has(cell)).toBe(true); | |
| 142 | // But only its own lantern is *visible* — memory is not sight. | |
| 143 | expect(view.visible.size).toBeLessThan(after.size); | |
| 144 | db.close(); | |
| 145 | }); | |
| 146 | ||
| 147 | test("caches pay once and refill later, so a stripped floor is worth returning to", () => { | |
| 148 | const db = fresh(); | |
| 149 | const floor = floorAt(1); | |
| 150 | const cache = cacheCells(floor)[0] as number; | |
| 151 | const delver = delverFor(db, "hand-a"); | |
| 152 | db.query("UPDATE delver SET cell = ?2 WHERE id = ?1").run(delver.id, cache - 1); | |
| 153 | const took = act(db, "hand-a", "east"); | |
| 154 | expect(took.delver.gold).toBeGreaterThan(0); | |
| 155 | const carried = took.delver.gold; | |
| 156 | act(db, "hand-a", "west"); | |
| 157 | const again = act(db, "hand-a", "east"); | |
| 158 | expect(again.delver.gold).toBe(carried); | |
| 159 | db.close(); | |
| 160 | }); |
src/game.ts added +317
| @@ -0,0 +1,317 @@ | ||
| 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 | const next = stepToward(floor, monster.cell, target.cell); | |
| 152 | const occupied = monsters.some((m) => m.id !== monster.id && m.cell === next); | |
| 153 | if (!occupied && next !== monster.cell) { | |
| 154 | db.query("UPDATE monster SET cell = ?2 WHERE id = ?1").run(monster.id, next); | |
| 155 | monster.cell = next; | |
| 156 | } | |
| 157 | } | |
| 158 | return { ...delver, hp }; | |
| 159 | } | |
| 160 | ||
| 161 | function die(db: Database, delver: Delver, log: string[]): Delver { | |
| 162 | const epitaph = epitaphFor(delver.name, delver.depth, delver.cell, delver.turns); | |
| 163 | db.query( | |
| 164 | "INSERT INTO grave (depth, cell, name, epitaph, gold) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 165 | ).run(delver.depth, delver.cell, delver.name, epitaph, delver.gold); | |
| 166 | log.push(`${delver.name} dies on floor ${delver.depth}, carrying ${delver.gold} gold.`); | |
| 167 | return { | |
| 168 | ...delver, | |
| 169 | hp: 0, | |
| 170 | state: "dead", | |
| 171 | ended_at: new Date().toISOString(), | |
| 172 | epitaph, | |
| 173 | }; | |
| 174 | } | |
| 175 | ||
| 176 | /** One action. The only way the world moves. */ | |
| 177 | export function act(db: Database, hand: string, action: Action): TurnResult { | |
| 178 | let delver = delverFor(db, hand); | |
| 179 | const log: string[] = []; | |
| 180 | const floor = floorAt(delver.depth); | |
| 181 | const now = store.tick(db); | |
| 182 | delver = { ...delver, turns: delver.turns + 1 }; | |
| 183 | let climbedOut = false; | |
| 184 | ||
| 185 | if (action === "descend" || action === "ascend") { | |
| 186 | const tile = floor.tiles[delver.cell]; | |
| 187 | if (action === "descend" && tile === ">") { | |
| 188 | const next = floorAt(delver.depth + 1); | |
| 189 | populate(db, next.depth); | |
| 190 | delver = { ...delver, depth: next.depth, cell: next.entrance }; | |
| 191 | log.push(`You climb down to floor ${next.depth}. The air is colder.`); | |
| 192 | } else if (action === "ascend" && tile === "<" && delver.depth === 1) { | |
| 193 | db.query( | |
| 194 | "INSERT INTO score (name, gold, depth, turns) VALUES (?1, ?2, ?3, ?4)", | |
| 195 | ).run(delver.name, delver.gold, delver.depth, delver.turns); | |
| 196 | delver = { ...delver, state: "returned", ended_at: new Date().toISOString() }; | |
| 197 | log.push(`${delver.name} climbs out with ${delver.gold} gold. It is banked.`); | |
| 198 | climbedOut = true; | |
| 199 | } else if (action === "ascend" && tile === "<") { | |
| 200 | const up = floorAt(delver.depth - 1); | |
| 201 | delver = { ...delver, depth: up.depth, cell: up.descent ?? up.entrance }; | |
| 202 | log.push(`You climb back up to floor ${up.depth}.`); | |
| 203 | } else { | |
| 204 | log.push("There is no stair here."); | |
| 205 | } | |
| 206 | } else if (action !== "wait") { | |
| 207 | const here = xy(delver.cell); | |
| 208 | const step = DIRECTIONS[action]; | |
| 209 | const nx = here.x + step.dx; | |
| 210 | const ny = here.y + step.dy; | |
| 211 | const target = at(nx, ny); | |
| 212 | const monsters = store.monstersOn(db, delver.depth, now); | |
| 213 | const monster = monsters.find((m) => m.cell === target); | |
| 214 | if (monster) { | |
| 215 | // Bump to attack. Damage is 2–4, decided by the turn, not by luck the | |
| 216 | // client could re-roll. | |
| 217 | const breed = breedOf(monster.kind); | |
| 218 | const hit = 2 + Math.floor(noise(now, delver.id, monster.id) * 3); | |
| 219 | const left = monster.hp - hit; | |
| 220 | if (left <= 0) { | |
| 221 | db.query("UPDATE monster SET hp = 0, dead_until = ?2, cell = spawn WHERE id = ?1").run( | |
| 222 | monster.id, | |
| 223 | now + 150, | |
| 224 | ); | |
| 225 | delver = { ...delver, gold: delver.gold + breed.gold }; | |
| 226 | log.push(`You put the ${monster.kind} down. ${breed.gold} gold.`); | |
| 227 | } else { | |
| 228 | db.query("UPDATE monster SET hp = ?2 WHERE id = ?1").run(monster.id, left); | |
| 229 | log.push(`You hit the ${monster.kind} for ${hit}.`); | |
| 230 | } | |
| 231 | } else if (!walkable(floor.tiles[target])) { | |
| 232 | log.push("Stone."); | |
| 233 | } else { | |
| 234 | delver = { ...delver, cell: target }; | |
| 235 | // A cache pays out once, then refills slowly — so a floor someone | |
| 236 | // stripped an hour ago is worth walking again tomorrow. | |
| 237 | if (cacheCells(floor).includes(target)) { | |
| 238 | const taken = db | |
| 239 | .query<{ until_turn: number }, [number, number]>( | |
| 240 | "SELECT until_turn FROM cache_taken WHERE depth = ?1 AND cell = ?2", | |
| 241 | ) | |
| 242 | .get(delver.depth, target); | |
| 243 | if (!taken || taken.until_turn <= now) { | |
| 244 | const coin = 4 + Math.floor(noise(delver.depth, target, 17) * 9); | |
| 245 | db.query( | |
| 246 | `INSERT INTO cache_taken (depth, cell, until_turn) VALUES (?1, ?2, ?3) | |
| 247 | ON CONFLICT (depth, cell) DO UPDATE SET until_turn = excluded.until_turn`, | |
| 248 | ).run(delver.depth, target, now + 400); | |
| 249 | delver = { ...delver, gold: delver.gold + coin }; | |
| 250 | log.push(`A cache of ${coin} gold, tucked into the wall.`); | |
| 251 | } | |
| 252 | } | |
| 253 | const grave = store.graveAt(db, delver.depth, target); | |
| 254 | if (grave && grave.looted === 0) { | |
| 255 | const share = Math.floor(grave.gold / 2); | |
| 256 | db.query("UPDATE grave SET looted = 1 WHERE id = ?1").run(grave.id); | |
| 257 | if (share > 0) delver = { ...delver, gold: delver.gold + share }; | |
| 258 | log.push(`${grave.epitaph}${share > 0 ? ` You take ${share} of their gold.` : ""}`); | |
| 259 | } else if (grave) { | |
| 260 | log.push(grave.epitaph); | |
| 261 | } | |
| 262 | } | |
| 263 | } else { | |
| 264 | // Waiting is the only medicine. It works when nothing can see you, which | |
| 265 | // makes retreating into a cleared room a real decision rather than a | |
| 266 | // pause — every turn spent resting is a turn the floor keeps moving. | |
| 267 | const monsters = store.monstersOn(db, delver.depth, now); | |
| 268 | const watched = monsters.some( | |
| 269 | (m) => distance(m.cell, delver.cell) <= 6 && seesFrom(floor, m.cell, delver.cell), | |
| 270 | ); | |
| 271 | if (watched) log.push("You wait. Something is still moving out there."); | |
| 272 | else if (delver.hp < delver.max_hp) { | |
| 273 | delver = { ...delver, hp: Math.min(delver.max_hp, delver.hp + 1) }; | |
| 274 | log.push(`You catch your breath. ${delver.hp} of ${delver.max_hp}.`); | |
| 275 | } else log.push("You wait, and listen. Nothing comes."); | |
| 276 | } | |
| 277 | ||
| 278 | if (!climbedOut) { | |
| 279 | delver = monsterTurn(db, delver, now, log); | |
| 280 | if (delver.hp <= 0) delver = die(db, delver, log); | |
| 281 | store.markSeen(db, delver.depth, visibleFrom(floorAt(delver.depth), delver.cell)); | |
| 282 | } | |
| 283 | store.saveDelver(db, delver); | |
| 284 | store.say(db, delver.id, now, log); | |
| 285 | return { delver, log, died: delver.state === "dead", climbedOut }; | |
| 286 | } | |
| 287 | ||
| 288 | /** What the delver can see right now, and what the dungeon remembers. */ | |
| 289 | export interface View { | |
| 290 | floor: Floor; | |
| 291 | visible: Set<number>; | |
| 292 | seen: Set<number>; | |
| 293 | monsters: Monster[]; | |
| 294 | graves: Map<number, string>; | |
| 295 | others: Map<number, string>; | |
| 296 | } | |
| 297 | ||
| 298 | export function viewFor(db: Database, delver: Delver): View { | |
| 299 | const floor = floorAt(delver.depth); | |
| 300 | const now = store.turn(db); | |
| 301 | const visible = new Set(visibleFrom(floor, delver.cell, LANTERN)); | |
| 302 | const graves = new Map<number, string>(); | |
| 303 | for (const grave of store.gravesOn(db, delver.depth)) graves.set(grave.cell, grave.name); | |
| 304 | const others = new Map<number, string>(); | |
| 305 | for (const other of store.delversOn(db, delver.depth)) | |
| 306 | if (other.id !== delver.id) others.set(other.cell, other.name); | |
| 307 | return { | |
| 308 | floor, | |
| 309 | visible, | |
| 310 | seen: store.seenOn(db, delver.depth), | |
| 311 | monsters: store.monstersOn(db, delver.depth, now).filter((m) => visible.has(m.cell)), | |
| 312 | graves, | |
| 313 | others, | |
| 314 | }; | |
| 315 | } | |
| 316 | ||
| 317 | export { DEPTHS, LANTERN }; |
src/names.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * Names and epitaphs, composed rather than typed. Nobody writes free text | |
| 3 | * into this dungeon: a delver's name comes from their hand, and the dead | |
| 4 | * speak in a fixed grammar. It keeps the world's voice consistent, and it | |
| 5 | * means a public game needs no moderation. | |
| 6 | */ | |
| 7 | ||
| 8 | const FIRST = [ | |
| 9 | "Alder", "Bryn", "Coll", "Dunnock", "Eira", "Fen", "Gorse", "Hale", | |
| 10 | "Isolde", "Jarrow", "Kestrel", "Lirien", "Morrow", "Nix", "Orrin", "Pell", | |
| 11 | "Quill", "Rook", "Sable", "Thorn", "Ursel", "Vane", "Wren", "Yarrow", | |
| 12 | ]; | |
| 13 | const SECOND = [ | |
| 14 | "Ashdown", "Barrowe", "Coldwater", "Dimmick", "Emberly", "Fallow", | |
| 15 | "Greaves", "Hollowell", "Ironsides", "Jessop", "Kindle", "Lampwright", | |
| 16 | "Mordaunt", "Nettles", "Oakes", "Pyre", "Quarles", "Rushbrook", | |
| 17 | "Stannard", "Tallow", "Underhill", "Vesper", "Wickham", "Yewtree", | |
| 18 | ]; | |
| 19 | ||
| 20 | function hash(text: string, salt = 0): number { | |
| 21 | let h = 2166136261 ^ salt; | |
| 22 | for (const ch of text) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0; | |
| 23 | return h >>> 0; | |
| 24 | } | |
| 25 | ||
| 26 | export function nameFor(hand: string, attempt: number): string { | |
| 27 | const h = hash(hand, attempt * 7919); | |
| 28 | return `${FIRST[h % FIRST.length]} ${SECOND[(h >>> 9) % SECOND.length]}`; | |
| 29 | } | |
| 30 | ||
| 31 | const ENDINGS = [ | |
| 32 | "died with their lantern still lit", | |
| 33 | "was cornered in a doorway", | |
| 34 | "bled out two steps from the stair", | |
| 35 | "went one room further", | |
| 36 | "did not hear it coming", | |
| 37 | "stopped to count their gold", | |
| 38 | "was found facing the wrong way", | |
| 39 | "ran out of floor", | |
| 40 | ]; | |
| 41 | ||
| 42 | const COUNSEL = [ | |
| 43 | "beware the corridor", | |
| 44 | "try the far door", | |
| 45 | "seek the stair, not the gold", | |
| 46 | "do not follow the walls", | |
| 47 | "turn back at the rats", | |
| 48 | "the dark room is a trap", | |
| 49 | "rest before the descent", | |
| 50 | "carry less, climb sooner", | |
| 51 | ]; | |
| 52 | ||
| 53 | /** What the stone says. Deterministic in where and how the delver died. */ | |
| 54 | export function epitaphFor(name: string, depth: number, cell: number, turns: number): string { | |
| 55 | const h = hash(`${name}:${depth}:${cell}:${turns}`); | |
| 56 | const ending = ENDINGS[h % ENDINGS.length]; | |
| 57 | const counsel = COUNSEL[(h >>> 11) % COUNSEL.length]; | |
| 58 | return `${name} ${ending} on floor ${depth}. Traveller: ${counsel}.`; | |
| 59 | } | |
| 60 | ||
| 61 | export const ordinal = (n: number): string => | |
| 62 | ["surface", "first", "second", "third", "fourth", "fifth"][n] ?? `floor ${n}`; |
src/pages.ts added +286
| @@ -0,0 +1,286 @@ | ||
| 1 | /** | |
| 2 | * The screen. One committed look — a lit room at the bottom of a hole — so | |
| 3 | * the page paints its own dark ground rather than borrowing the browser's. | |
| 4 | * | |
| 5 | * The map is the whole design: lit tiles are warm, remembered tiles are cold | |
| 6 | * and thin, and everything else is the dark. That difference is the game's | |
| 7 | * only real information, so nothing else on the page is allowed to be as | |
| 8 | * bright as the lantern. | |
| 9 | */ | |
| 10 | ||
| 11 | import { DEPTHS, HEIGHT, WIDTH, xy } from "./dungeon.ts"; | |
| 12 | import { breedOf, type View } from "./game.ts"; | |
| 13 | import { ordinal } from "./names.ts"; | |
| 14 | import type { Delver, Grave, Score } from "./db.ts"; | |
| 15 | ||
| 16 | export const escape = (s: string): string => | |
| 17 | s.replace( | |
| 18 | /[&<>"']/g, | |
| 19 | (c) => | |
| 20 | ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? | |
| 21 | c, | |
| 22 | ); | |
| 23 | ||
| 24 | const STYLE = ` | |
| 25 | :root { | |
| 26 | --dark: #0a0b0e; | |
| 27 | --stone: #14161c; | |
| 28 | --rule: #242832; | |
| 29 | --ink: #d9d4c7; | |
| 30 | --dim: #6d6a63; | |
| 31 | --memory: #3b414d; | |
| 32 | --lantern: #e8c07a; | |
| 33 | --floor-lit: #8a7a5c; | |
| 34 | --wall-lit: #5d5344; | |
| 35 | --gold: #e0b341; | |
| 36 | --blood: #c4513f; | |
| 37 | --bone: #b9a8d4; | |
| 38 | --stair: #6fae86; | |
| 39 | --you: #f3ece0; | |
| 40 | color-scheme: dark; | |
| 41 | } | |
| 42 | * { box-sizing: border-box; } | |
| 43 | body { | |
| 44 | margin: 0; background: var(--dark); color: var(--ink); | |
| 45 | font: 15px/1.6 ui-monospace, "SF Mono", Menlo, monospace; | |
| 46 | padding: 1.25rem 1rem 3rem; | |
| 47 | } | |
| 48 | .wrap { max-width: 63rem; margin: 0 auto; display: flex; flex-direction: column; gap: 1.1rem; } | |
| 49 | header { display: flex; flex-wrap: wrap; gap: .6rem 1.4rem; align-items: baseline; } | |
| 50 | h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: .22em; text-transform: uppercase; margin: 0; color: var(--lantern); } | |
| 51 | h1 a { color: inherit; text-decoration: none; } | |
| 52 | .tag { color: var(--dim); font-size: .82rem; } | |
| 53 | a { color: var(--stair); } | |
| 54 | ||
| 55 | .board { display: flex; flex-wrap: wrap; gap: 1.1rem; align-items: flex-start; } | |
| 56 | .glass { flex: 1 1 32rem; min-width: 0; background: #06070a; border: 1px solid var(--rule); border-radius: 3px; padding: .7rem; overflow-x: auto; } | |
| 57 | .map { display: grid; grid-template-columns: repeat(${WIDTH}, 1ch); line-height: 1.05; font-size: clamp(9px, 2.1vw, 15px); white-space: pre; } | |
| 58 | .map span { display: block; text-align: center; } | |
| 59 | .u { color: #0d0e12; } /* never seen */ | |
| 60 | .m { color: var(--memory); } /* remembered */ | |
| 61 | .f { color: var(--floor-lit); } /* lit floor */ | |
| 62 | .w { color: var(--wall-lit); } /* lit wall */ | |
| 63 | .s { color: var(--stair); font-weight: 700; } | |
| 64 | .c { color: var(--gold); } | |
| 65 | .g { color: var(--bone); } | |
| 66 | .e { color: var(--blood); font-weight: 700; } | |
| 67 | .o { color: #7fa7c4; font-weight: 700; } | |
| 68 | .y { color: var(--you); font-weight: 700; text-shadow: 0 0 6px var(--lantern); } | |
| 69 | ||
| 70 | aside { flex: 1 1 15rem; display: flex; flex-direction: column; gap: 1rem; min-width: 13rem; } | |
| 71 | .panel { background: var(--stone); border: 1px solid var(--rule); border-radius: 3px; padding: .8rem .9rem; } | |
| 72 | .panel h2 { font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; color: var(--dim); margin: 0 0 .5rem; font-weight: 600; } | |
| 73 | .stat { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; } | |
| 74 | .stat b { font-weight: 600; color: var(--ink); } | |
| 75 | .hp { height: 7px; background: #241a18; border-radius: 2px; overflow: hidden; margin: .45rem 0 .2rem; } | |
| 76 | .hp div { height: 100%; background: linear-gradient(90deg, var(--blood), #d98b5f); } | |
| 77 | .entry { font-size: .82rem; color: var(--dim); padding: .3rem 0; border-top: 1px solid var(--rule); } | |
| 78 | .entry:first-of-type { border-top: 0; } | |
| 79 | .entry b { color: var(--bone); font-weight: 600; } | |
| 80 | .entry i { color: var(--gold); font-style: normal; } | |
| 81 | ||
| 82 | .log { background: #06070a; border: 1px solid var(--rule); border-radius: 3px; padding: .7rem .9rem; min-height: 6.5rem; display: flex; flex-direction: column; justify-content: flex-end; gap: .1rem; } | |
| 83 | .log p { margin: 0; font-size: .86rem; color: var(--dim); } | |
| 84 | .log p:last-child { color: var(--ink); } | |
| 85 | ||
| 86 | .controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } | |
| 87 | .pad { display: grid; grid-template-columns: repeat(3, 3rem); grid-template-rows: repeat(2, 3rem); gap: .35rem; } | |
| 88 | form.inline { display: contents; } | |
| 89 | button { | |
| 90 | font: inherit; min-height: 44px; min-width: 3rem; padding: .4rem .7rem; cursor: pointer; | |
| 91 | color: var(--ink); background: #171a21; border: 1px solid var(--rule); border-radius: 3px; | |
| 92 | } | |
| 93 | button:hover { border-color: var(--lantern); color: var(--lantern); } | |
| 94 | button:focus-visible { outline: 2px solid var(--lantern); outline-offset: 2px; } | |
| 95 | .pad .n { grid-column: 2; grid-row: 1; } | |
| 96 | .pad .w { grid-column: 1; grid-row: 2; } | |
| 97 | .pad .s { grid-column: 2; grid-row: 2; } | |
| 98 | .pad .e { grid-column: 3; grid-row: 2; } | |
| 99 | .others { display: flex; flex-wrap: wrap; gap: .4rem; } | |
| 100 | .keys { color: var(--dim); font-size: .78rem; } | |
| 101 | .legend { display: flex; flex-wrap: wrap; gap: .4rem 1rem; color: var(--dim); font-size: .78rem; } | |
| 102 | .dead { text-align: center; padding: 2rem 1rem; } | |
| 103 | .dead h2 { color: var(--blood); letter-spacing: .1em; font-size: 1rem; } | |
| 104 | .dead p { color: var(--dim); max-width: 34rem; margin: .5rem auto; } | |
| 105 | footer { color: var(--dim); font-size: .76rem; border-top: 1px solid var(--rule); padding-top: .8rem; } | |
| 106 | `; | |
| 107 | ||
| 108 | export interface Chrome { | |
| 109 | env: string; | |
| 110 | pull: string | undefined; | |
| 111 | commit: string; | |
| 112 | ssh: string | undefined; | |
| 113 | } | |
| 114 | ||
| 115 | const where = (c: Chrome): string => | |
| 116 | c.env === "preview" ? `preview of pull #${c.pull ?? "?"}` : c.env; | |
| 117 | ||
| 118 | function layout(chrome: Chrome, title: string, body: string): string { | |
| 119 | return `<!doctype html> | |
| 120 | <meta charset="utf-8"> | |
| 121 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 122 | <title>${escape(title)}</title> | |
| 123 | <style>${STYLE}</style> | |
| 124 | <div class="wrap"> | |
| 125 | <header> | |
| 126 | <h1><a href="/">lanternfall</a></h1> | |
| 127 | <span class="tag">one dungeon · five floors · everyone's map</span> | |
| 128 | </header> | |
| 129 | ${body} | |
| 130 | <footer> | |
| 131 | <p>${escape(where(chrome))}${chrome.commit ? ` · ${escape(chrome.commit)}` : ""}${chrome.ssh ? ` · terminal: ${escape(chrome.ssh)}` : ""}</p> | |
| 132 | <p>The floors are generated from a seed, so they are the same for everyone and always will be. Only what has happened is written down.</p> | |
| 133 | </footer> | |
| 134 | </div> | |
| 135 | <script src="/static/keys.v1.js" defer></script> | |
| 136 | `; | |
| 137 | } | |
| 138 | ||
| 139 | /** The dungeon as the delver currently understands it. */ | |
| 140 | function mapOf(view: View, delver: Delver, caches: Set<number>): string { | |
| 141 | const cells: string[] = []; | |
| 142 | for (let cell = 0; cell < WIDTH * HEIGHT; cell += 1) { | |
| 143 | const tile = view.floor.tiles[cell] ?? "#"; | |
| 144 | const lit = view.visible.has(cell); | |
| 145 | const known = view.seen.has(cell); | |
| 146 | let glyph = tile === "#" ? "#" : tile === "." ? "·" : tile; | |
| 147 | let cls = lit ? (tile === "#" ? "w" : "f") : known ? "m" : "u"; | |
| 148 | if (!lit && !known) { | |
| 149 | cells.push(`<span class="u"> </span>`); | |
| 150 | continue; | |
| 151 | } | |
| 152 | if (tile === ">" || tile === "<") cls = lit ? "s" : "m"; | |
| 153 | // Things are only there when you can see them; the map remembers rooms, | |
| 154 | // not what is standing in them. | |
| 155 | if (lit) { | |
| 156 | if (caches.has(cell)) { | |
| 157 | glyph = "$"; | |
| 158 | cls = "c"; | |
| 159 | } | |
| 160 | const monster = view.monsters.find((m) => m.cell === cell); | |
| 161 | if (monster) { | |
| 162 | glyph = breedOf(monster.kind).glyph; | |
| 163 | cls = "e"; | |
| 164 | } else if (view.others.has(cell)) { | |
| 165 | glyph = "@"; | |
| 166 | cls = "o"; | |
| 167 | } | |
| 168 | } | |
| 169 | if (view.graves.has(cell) && (lit || known)) { | |
| 170 | glyph = "†"; | |
| 171 | cls = lit ? "g" : "m"; | |
| 172 | } | |
| 173 | if (cell === delver.cell) { | |
| 174 | glyph = "@"; | |
| 175 | cls = "y"; | |
| 176 | } | |
| 177 | cells.push(`<span class="${cls}">${glyph}</span>`); | |
| 178 | } | |
| 179 | return `<div class="glass"><div class="map" role="img" aria-label="the dungeon floor, drawn in ascii">${cells.join("")}</div></div>`; | |
| 180 | } | |
| 181 | ||
| 182 | const button = (action: string, label: string, extra = ""): string => | |
| 183 | `<form class="inline" method="post" action="/act"><button class="${extra}" name="action" value="${action}">${label}</button></form>`; | |
| 184 | ||
| 185 | export function gamePage( | |
| 186 | chrome: Chrome, | |
| 187 | delver: Delver, | |
| 188 | view: View, | |
| 189 | caches: Set<number>, | |
| 190 | log: readonly string[], | |
| 191 | graves: readonly Grave[], | |
| 192 | scores: readonly Score[], | |
| 193 | known: number, | |
| 194 | ): string { | |
| 195 | const tile = view.floor.tiles[delver.cell]; | |
| 196 | const stairs = | |
| 197 | (tile === ">" ? button("descend", "descend ›") : "") + | |
| 198 | (tile === "<" ? button("ascend", delver.depth === 1 ? "climb out ↑" : "climb up ↑") : ""); | |
| 199 | return layout( | |
| 200 | chrome, | |
| 201 | `floor ${delver.depth} — lanternfall`, | |
| 202 | `<div class="board"> | |
| 203 | ${mapOf(view, delver, caches)} | |
| 204 | <aside> | |
| 205 | <div class="panel"> | |
| 206 | <h2>${escape(delver.name)}</h2> | |
| 207 | <div class="stat"><span>health</span><b>${delver.hp} / ${delver.max_hp}</b></div> | |
| 208 | <div class="hp"><div style="width:${Math.max(0, Math.round((delver.hp / delver.max_hp) * 100))}%"></div></div> | |
| 209 | <div class="stat"><span>carrying</span><b>${delver.gold} gold</b></div> | |
| 210 | <div class="stat"><span>depth</span><b>${ordinal(delver.depth)} floor</b></div> | |
| 211 | <div class="stat"><span>turns</span><b>${delver.turns}</b></div> | |
| 212 | </div> | |
| 213 | <div class="panel"> | |
| 214 | <h2>the dead</h2> | |
| 215 | ${graves.length === 0 ? `<p class="entry">Nobody yet.</p>` : graves | |
| 216 | .slice(0, 5) | |
| 217 | .map( | |
| 218 | (g) => | |
| 219 | `<p class="entry"><b>${escape(g.name)}</b> · floor ${g.depth} · <i>${g.gold}g</i><br>${escape(g.epitaph)}</p>`, | |
| 220 | ) | |
| 221 | .join("")} | |
| 222 | </div> | |
| 223 | <div class="panel"> | |
| 224 | <h2>climbed out</h2> | |
| 225 | ${scores.length === 0 ? `<p class="entry">Nobody has come back up yet.</p>` : scores | |
| 226 | .slice(0, 5) | |
| 227 | .map( | |
| 228 | (s) => `<p class="entry"><b>${escape(s.name)}</b> · <i>${s.gold}g</i> · floor ${s.depth}</p>`, | |
| 229 | ) | |
| 230 | .join("")} | |
| 231 | </div> | |
| 232 | <div class="panel"> | |
| 233 | <h2>the dungeon remembers</h2> | |
| 234 | <div class="stat"><span>tiles lit, ever</span><b>${known}</b></div> | |
| 235 | <div class="stat"><span>floors</span><b>${DEPTHS}</b></div> | |
| 236 | </div> | |
| 237 | </aside> | |
| 238 | </div> | |
| 239 | ||
| 240 | <div class="log">${log.length === 0 ? `<p>You are at the mouth of the dungeon. Your lantern throws about four paces.</p>` : log.map((line) => `<p>${escape(line)}</p>`).join("")}</div> | |
| 241 | ||
| 242 | <div class="controls"> | |
| 243 | <div class="pad"> | |
| 244 | ${button("north", "↑", "n")}${button("west", "←", "w")}${button("south", "↓", "s")}${button("east", "→", "e")} | |
| 245 | </div> | |
| 246 | <div class="others"> | |
| 247 | ${button("wait", "wait / rest")} | |
| 248 | ${stairs} | |
| 249 | </div> | |
| 250 | <p class="keys">arrows or wasd move · <b>.</b> waits · <b>></b> descends · <b><</b> climbs</p> | |
| 251 | </div> | |
| 252 | <p class="legend"> | |
| 253 | <span><b class="y">@</b> you</span><span><b style="color:#7fa7c4">@</b> another delver</span> | |
| 254 | <span><b style="color:var(--blood)">r g G S W</b> what lives here</span><span><b style="color:var(--gold)">$</b> a cache</span> | |
| 255 | <span><b style="color:var(--bone)">†</b> a grave</span><span><b style="color:var(--stair)">> <</b> stairs</span> | |
| 256 | </p>`, | |
| 257 | ); | |
| 258 | } | |
| 259 | ||
| 260 | export function endPage( | |
| 261 | chrome: Chrome, | |
| 262 | delver: Delver, | |
| 263 | climbedOut: boolean, | |
| 264 | log: readonly string[], | |
| 265 | ): string { | |
| 266 | return layout( | |
| 267 | chrome, | |
| 268 | climbedOut ? "climbed out — lanternfall" : "died — lanternfall", | |
| 269 | `<div class="dead"> | |
| 270 | <h2>${climbedOut ? "You climbed out." : "You died."}</h2> | |
| 271 | <p>${escape(delver.name)} ${climbedOut ? `banked ${delver.gold} gold` : `fell on the ${ordinal(delver.depth)} floor with ${delver.gold} gold`} after ${delver.turns} turns.</p> | |
| 272 | ${delver.epitaph ? `<p>${escape(delver.epitaph)}</p>` : ""} | |
| 273 | ${climbedOut ? "" : `<p>Your grave is where you fell, and whoever finds it takes half of what you were carrying.</p>`} | |
| 274 | <div class="log" style="max-width:34rem;margin:1.2rem auto;">${log.map((l) => `<p>${escape(l)}</p>`).join("")}</div> | |
| 275 | <form method="post" action="/act"><button name="action" value="wait">send someone else down</button></form> | |
| 276 | </div>`, | |
| 277 | ); | |
| 278 | } | |
| 279 | ||
| 280 | export function notFoundPage(chrome: Chrome): string { | |
| 281 | return layout( | |
| 282 | chrome, | |
| 283 | "not found — lanternfall", | |
| 284 | `<div class="dead"><h2>There is nothing here.</h2><p><a href="/">Back to the dungeon.</a></p></div>`, | |
| 285 | ); | |
| 286 | } |
src/server.test.ts +107 −33
| @@ -3,68 +3,142 @@ import { mkdtempSync } from "node:fs"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "lanternfall-web-")); | |
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | 9 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | 10 | |
| 11 | test("serves the page", async () => { | |
| 12 | const response = await fetch(base + "/"); | |
| 13 | expect(response.status).toBe(200); | |
| 14 | expect(await response.text()).toContain("<h1>"); | |
| 11 | class Player { | |
| 12 | cookie = ""; | |
| 13 | private keep(response: Response): void { | |
| 14 | const set = response.headers.get("set-cookie"); | |
| 15 | if (set) this.cookie = set.split(";")[0] ?? ""; | |
| 16 | } | |
| 17 | async look(path = "/"): Promise<string> { | |
| 18 | const response = await fetch(base + path, { | |
| 19 | headers: this.cookie === "" ? {} : { cookie: this.cookie }, | |
| 20 | }); | |
| 21 | this.keep(response); | |
| 22 | return response.text(); | |
| 23 | } | |
| 24 | async act(action: string): Promise<Response> { | |
| 25 | const response = await fetch(`${base}/act`, { | |
| 26 | method: "POST", | |
| 27 | body: new URLSearchParams({ action }), | |
| 28 | redirect: "manual", | |
| 29 | headers: { | |
| 30 | "content-type": "application/x-www-form-urlencoded", | |
| 31 | ...(this.cookie === "" ? {} : { cookie: this.cookie }), | |
| 32 | }, | |
| 33 | }); | |
| 34 | this.keep(response); | |
| 35 | return response; | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | test("the dungeon opens with a delver, a map and a lantern", async () => { | |
| 40 | const page = await new Player().look(); | |
| 41 | expect(page).toContain("lanternfall"); | |
| 42 | expect(page).toContain("health"); | |
| 43 | expect(page).toContain("12 / 12"); | |
| 44 | expect(page).toContain('aria-label="the dungeon floor, drawn in ascii"'); | |
| 45 | // 48 x 24 cells, all rendered. | |
| 46 | expect((page.match(/<span class="/g) ?? []).length).toBeGreaterThanOrEqual(48 * 24); | |
| 15 | 47 | }); |
| 16 | 48 | |
| 17 | test("keeps a note", async () => { | |
| 18 | const form = new FormData(); | |
| 19 | form.set("body", "hello from a test"); | |
| 20 | const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 21 | expect(posted.status).toBe(303); | |
| 22 | expect(await (await fetch(base + "/")).text()).toContain("hello from a test"); | |
| 49 | test("a visitor gets one delver, kept in an HttpOnly cookie", async () => { | |
| 50 | const response = await fetch(`${base}/`); | |
| 51 | const set = response.headers.get("set-cookie") ?? ""; | |
| 52 | expect(set).toMatch(/delver=[a-f0-9]{32}/); | |
| 53 | expect(set).toContain("HttpOnly"); | |
| 54 | expect(set).toContain("SameSite=Lax"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("moving takes a turn and is remembered", async () => { | |
| 58 | const player = new Player(); | |
| 59 | await player.look(); | |
| 60 | const moved = await player.act("west"); | |
| 61 | expect(moved.status).toBe(303); | |
| 62 | expect(moved.headers.get("location")).toBe("/"); | |
| 63 | const page = await player.look(); | |
| 64 | expect(page).toMatch(/turns<\/span><b>[1-9]/); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an action the game does not have is refused, not guessed at", async () => { | |
| 68 | const player = new Player(); | |
| 69 | await player.look(); | |
| 70 | const response = await player.act("teleport"); | |
| 71 | expect(response.status).toBe(303); | |
| 72 | expect(response.headers.get("location")).toBe("/"); | |
| 73 | expect(await player.look()).toContain("12 / 12"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("two players share one dungeon and one map", async () => { | |
| 77 | const first = new Player(); | |
| 78 | await first.look(); | |
| 79 | for (const move of ["west", "west", "west", "north"]) await first.act(move); | |
| 80 | const second = new Player(); | |
| 81 | const page = await second.look(); | |
| 82 | // The tiles the first one lit are remembered on the second one's screen. | |
| 83 | expect((page.match(/class="m"/g) ?? []).length).toBeGreaterThan(0); | |
| 84 | expect(page).toContain("tiles lit, ever"); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the map hides what has never been lit", async () => { | |
| 88 | const page = await new Player().look(); | |
| 89 | expect((page.match(/class="u"/g) ?? []).length).toBeGreaterThan(400); | |
| 90 | }); | |
| 91 | ||
| 92 | test("the keyboard layer is a file, not an inline script", async () => { | |
| 93 | const page = await new Player().look(); | |
| 94 | expect(page).toContain('<script src="/static/keys.v1.js" defer></script>'); | |
| 95 | expect(page).not.toMatch(/<script>[^<]/); | |
| 96 | const script = await fetch(`${base}/static/keys.v1.js`); | |
| 97 | expect(script.status).toBe(200); | |
| 98 | expect(script.headers.get("cache-control")).toContain("immutable"); | |
| 23 | 99 | }); |
| 24 | 100 | |
| 25 | 101 | test("stands on the security floor", async () => { |
| 26 | const response = await fetch(base + "/"); | |
| 102 | const response = await fetch(`${base}/`); | |
| 27 | 103 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 28 | 104 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 29 | 105 | }); |
| 30 | 106 | |
| 31 | test("a bug answers with the floor, not the runtime's page", async () => { | |
| 32 | // A percent-escape that is not one makes the search params throw. | |
| 33 | const response = await fetch(base + "/notes", { | |
| 107 | test("refuses a cross-site move", async () => { | |
| 108 | const response = await fetch(`${base}/act`, { | |
| 34 | 109 | method: "POST", |
| 35 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 36 | body: "body=%E0%A4%A", | |
| 110 | body: new URLSearchParams({ action: "west" }), | |
| 111 | redirect: "manual", | |
| 112 | headers: { | |
| 113 | "content-type": "application/x-www-form-urlencoded", | |
| 114 | origin: "https://evil.example", | |
| 115 | "sec-fetch-site": "cross-site", | |
| 116 | }, | |
| 37 | 117 | }); |
| 38 | expect(response.status).toBe(200); | |
| 39 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 118 | expect(response.status).toBe(403); | |
| 40 | 119 | }); |
| 41 | 120 | |
| 42 | 121 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 43 | // The cap counts bytes as they arrive and never reads Content-Length, so | |
| 44 | // a chunked body with no length is refused the same way this one is. | |
| 45 | const response = await fetch(base + "/notes", { | |
| 122 | const response = await fetch(`${base}/act`, { | |
| 46 | 123 | method: "POST", |
| 47 | 124 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 48 | body: "body=" + "x".repeat(1024 * 1024), | |
| 125 | body: "action=" + "x".repeat(1024 * 1024), | |
| 49 | 126 | }); |
| 50 | 127 | expect(response.status).toBe(413); |
| 51 | 128 | }); |
| 52 | 129 | |
| 53 | 130 | test("HEAD sees the app up", async () => { |
| 54 | const response = await server.fetch(new Request(base + "/", { method: "HEAD" })); | |
| 131 | const response = await server.fetch(new Request(`${base}/`, { method: "HEAD" })); | |
| 55 | 132 | expect(response.status).toBe(200); |
| 56 | 133 | expect(await response.text()).toBe(""); |
| 57 | 134 | }); |
| 58 | 135 | |
| 59 | test("refuses a cross-site post", async () => { | |
| 60 | const form = new FormData(); | |
| 61 | form.set("body", "from another site"); | |
| 62 | const posted = await fetch(base + "/notes", { | |
| 63 | method: "POST", | |
| 64 | body: form, | |
| 136 | test("the end pages belong to a run that ended", async () => { | |
| 137 | const player = new Player(); | |
| 138 | await player.look(); | |
| 139 | const response = await fetch(`${base}/died`, { | |
| 140 | headers: { cookie: player.cookie }, | |
| 65 | 141 | redirect: "manual", |
| 66 | headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" }, | |
| 67 | 142 | }); |
| 68 | expect(posted.status).toBe(403); | |
| 69 | expect(await (await fetch(base + "/")).text()).not.toContain("from another site"); | |
| 143 | expect(response.status).toBe(303); | |
| 70 | 144 | }); |
src/server.ts +96 −76
| @@ -1,47 +1,65 @@ | ||
| 1 | 1 | /** |
| 2 | * lanternfall — a small app with real data. | |
| 2 | * lanternfall — the door. The rules are in `game.ts`, the dungeon in | |
| 3 | * `dungeon.ts` (pure), what happened in `db.ts`; here there is a table of | |
| 4 | * paths and a cookie. | |
| 3 | 5 | * |
| 4 | * Notes live in a SQLite file under FORGE_APP_DATA. Production and each | |
| 5 | * preview get their own directory, so a preview starts with production's | |
| 6 | * notes and never writes back to them. | |
| 6 | * A delver belongs to one opaque HttpOnly cookie. It names nobody: the name | |
| 7 | * on the gravestone is derived from the cookie, which is also why a dead | |
| 8 | * delver's successor is somebody new rather than the same person again. | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { mkdirSync } from "node:fs"; | |
| 11 | import { randomBytes } from "node:crypto"; | |
| 11 | 12 | import { join } from "node:path"; |
| 13 | import * as store from "./db.ts"; | |
| 14 | import { act, delverFor, isAction, viewFor } from "./game.ts"; | |
| 15 | import { endPage, gamePage, notFoundPage } from "./pages.ts"; | |
| 12 | 16 | |
| 13 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 14 | mkdirSync(dataDir, { recursive: true }); | |
| 15 | const db = new Database(join(dataDir, "app.db")); | |
| 16 | db.exec("PRAGMA journal_mode = WAL"); | |
| 17 | db.exec( | |
| 18 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 19 | ); | |
| 17 | const db = store.open(); | |
| 20 | 18 | |
| 21 | const env = process.env.FORGE_APP_ENV ?? "local"; | |
| 22 | const pull = process.env.FORGE_APP_PULL; | |
| 23 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); | |
| 19 | const chrome = { | |
| 20 | env: process.env.FORGE_APP_ENV ?? "local", | |
| 21 | pull: process.env.FORGE_APP_PULL, | |
| 22 | commit: (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7), | |
| 23 | ssh: process.env.FORGE_APP_SSH, | |
| 24 | }; | |
| 24 | 25 | |
| 25 | const escape = (s: string): string => | |
| 26 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 26 | const HAND = "delver"; | |
| 27 | ||
| 28 | function handOf(request: Request): string | undefined { | |
| 29 | for (const part of (request.headers.get("cookie") ?? "").split(";")) { | |
| 30 | const [name, ...rest] = part.trim().split("="); | |
| 31 | if (name === HAND) { | |
| 32 | const value = rest.join("="); | |
| 33 | if (/^[a-f0-9]{32}$/.test(value)) return value; | |
| 34 | } | |
| 35 | } | |
| 36 | return undefined; | |
| 37 | } | |
| 38 | ||
| 39 | const newHand = (): string => randomBytes(16).toString("hex"); | |
| 40 | ||
| 41 | const handCookie = (hand: string): string => | |
| 42 | `${HAND}=${hand}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${ | |
| 43 | publicOrigin.startsWith("https:") ? "; Secure" : "" | |
| 44 | }`; | |
| 27 | 45 | |
| 28 | // The security floor every response stands on. Scripts and styles come from | |
| 29 | // this origin (inline styles allowed, inline scripts not), nothing frames us, | |
| 30 | // browsers never guess a content type, and referrers stay on this site. | |
| 31 | 46 | // The app's public address is what the forge told us, never the socket's. |
| 32 | 47 | const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin; |
| 33 | 48 | |
| 34 | const SECURITY = { | |
| 49 | // The security floor every response stands on. The one addition to the | |
| 50 | // scaffold's: script-src is still 'self', and the page's only script is a | |
| 51 | // file under /static — there is no inline script anywhere. | |
| 52 | const SECURITY: Record<string, string> = { | |
| 35 | 53 | "content-security-policy": |
| 36 | 54 | "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", |
| 37 | 55 | "x-content-type-options": "nosniff", |
| 38 | 56 | "referrer-policy": "strict-origin-when-cross-origin", |
| 39 | 57 | "permissions-policy": "camera=(), microphone=(), geolocation=()", |
| 40 | // Once served over https, always: browsers remember for a year. | |
| 41 | 58 | ...(publicOrigin.startsWith("https:") |
| 42 | 59 | ? { "strict-transport-security": "max-age=31536000" } |
| 43 | 60 | : {}), |
| 44 | 61 | }; |
| 62 | ||
| 45 | 63 | const html = (body: string, status = 200): Response => |
| 46 | 64 | new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } }); |
| 47 | 65 | |
| @@ -104,41 +122,20 @@ async function readForm(request: Request): Promise<FormData | undefined> { | ||
| 104 | 122 | }).formData(); |
| 105 | 123 | } |
| 106 | 124 | |
| 107 | function page(): string { | |
| 108 | const notes = db | |
| 109 | .query<{ id: number; body: string; created_at: string }, []>( | |
| 110 | "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100", | |
| 111 | ) | |
| 112 | .all(); | |
| 113 | const where = | |
| 114 | env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 115 | return `<!doctype html> | |
| 116 | <meta charset="utf-8"> | |
| 117 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 118 | <title>lanternfall</title> | |
| 119 | <style> | |
| 120 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 121 | body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; } | |
| 122 | .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem; | |
| 123 | background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; } | |
| 124 | form { display: flex; gap: .5rem; margin: 1.5rem 0; } | |
| 125 | input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; } | |
| 126 | button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; } | |
| 127 | li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; } | |
| 128 | small { opacity: .6; } | |
| 129 | </style> | |
| 130 | <h1>lanternfall</h1> | |
| 131 | <p>One dungeon, many delvers. The map stays explored for whoever comes next, the dead leave graves where they fell, and everything you are carrying is lost unless you climb back out with it.</p> | |
| 132 | <p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p> | |
| 133 | <form method="post" action="/notes"> | |
| 134 | <input name="body" placeholder="Write a note" required maxlength="500" autofocus> | |
| 135 | <button>Add</button> | |
| 136 | </form> | |
| 137 | <ul>${notes | |
| 138 | .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`) | |
| 139 | .join("")}</ul> | |
| 140 | <p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p> | |
| 141 | `; | |
| 125 | /** Everything the screen needs for one delver, in one place. */ | |
| 126 | function screen(hand: string): string { | |
| 127 | const delver = delverFor(db, hand); | |
| 128 | const now = store.turn(db); | |
| 129 | return gamePage( | |
| 130 | chrome, | |
| 131 | delver, | |
| 132 | viewFor(db, delver), | |
| 133 | store.cacheState(db, delver.depth, now), | |
| 134 | store.heard(db, delver.id), | |
| 135 | store.recentGraves(db), | |
| 136 | store.topScores(db), | |
| 137 | store.countSeen(db), | |
| 138 | ); | |
| 142 | 139 | } |
| 143 | 140 | |
| 144 | 141 | export const server = Bun.serve({ |
| @@ -148,40 +145,63 @@ export const server = Bun.serve({ | ||
| 148 | 145 | const url = new URL(request.url); |
| 149 | 146 | if (request.method === "POST" && !sameOrigin(request)) |
| 150 | 147 | return new Response("forbidden", { status: 403 }); |
| 151 | if (request.method === "POST" && url.pathname === "/notes") { | |
| 148 | ||
| 149 | if (request.method === "POST" && url.pathname === "/act") { | |
| 152 | 150 | const form = await readForm(request); |
| 153 | 151 | if (form === undefined) |
| 154 | 152 | return new Response("too large", { status: 413, headers: SECURITY }); |
| 155 | const body = String(form.get("body") ?? "").trim().slice(0, 500); | |
| 156 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body); | |
| 157 | return Response.redirect("/", 303); | |
| 153 | const hand = handOf(request) ?? newHand(); | |
| 154 | const headers = new Headers({ "set-cookie": handCookie(hand), ...SECURITY }); | |
| 155 | const wanted = String(form.get("action") ?? ""); | |
| 156 | if (!isAction(wanted)) { | |
| 157 | headers.set("location", "/"); | |
| 158 | return new Response(null, { status: 303, headers }); | |
| 159 | } | |
| 160 | const result = act(db, hand, wanted); | |
| 161 | // A run that ended gets its own page once; the next action sends | |
| 162 | // somebody new down. | |
| 163 | headers.set("location", result.died ? "/died" : result.climbedOut ? "/out" : "/"); | |
| 164 | return new Response(null, { status: 303, headers }); | |
| 158 | 165 | } |
| 159 | // HEAD is GET without the body: a health check sees the app up. | |
| 160 | if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { | |
| 161 | const full = html(page()); | |
| 166 | ||
| 167 | const reading = request.method === "GET" || request.method === "HEAD"; | |
| 168 | ||
| 169 | if (reading && url.pathname === "/") { | |
| 170 | const seen = handOf(request); | |
| 171 | const hand = seen ?? newHand(); | |
| 172 | const full = html(screen(hand)); | |
| 173 | const headers = new Headers(full.headers); | |
| 174 | if (seen === undefined) headers.set("set-cookie", handCookie(hand)); | |
| 162 | 175 | return request.method === "HEAD" |
| 163 | ? new Response(null, { status: full.status, headers: full.headers }) | |
| 164 | : full; | |
| 176 | ? new Response(null, { status: full.status, headers }) | |
| 177 | : new Response(await full.text(), { status: full.status, headers }); | |
| 165 | 178 | } |
| 166 | // Files under ./static are served as they are, cached for a year: name | |
| 167 | // them by content (app.3f2a9c.js) and a change is a new name, never a | |
| 168 | // stale cache. The path is confined to the directory. | |
| 169 | if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) { | |
| 179 | ||
| 180 | if (reading && (url.pathname === "/died" || url.pathname === "/out")) { | |
| 181 | const hand = handOf(request); | |
| 182 | const last = hand === undefined ? null : store.lastDelver(db, hand); | |
| 183 | if (last === null || last.state === "delving") return Response.redirect("/", 303); | |
| 184 | return html( | |
| 185 | endPage(chrome, last, last.state === "returned", store.heard(db, last.id)), | |
| 186 | ); | |
| 187 | } | |
| 188 | ||
| 189 | if (reading && url.pathname.startsWith("/static/") && !url.pathname.includes("..")) { | |
| 170 | 190 | const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1))); |
| 171 | 191 | if (await file.exists()) |
| 172 | 192 | return new Response(file, { |
| 173 | 193 | headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY }, |
| 174 | 194 | }); |
| 175 | 195 | } |
| 176 | return new Response("not found", { status: 404, headers: SECURITY }); | |
| 196 | ||
| 197 | return html(notFoundPage(chrome), 404); | |
| 177 | 198 | }, |
| 178 | // A bug answers with the floor and nothing else: never the runtime's own | |
| 179 | // error page, which carries source and paths, and never without the | |
| 180 | // headers. The detail goes to the log, where it belongs. | |
| 181 | 199 | error(error) { |
| 182 | 200 | console.error(error); |
| 183 | 201 | return html("<h1>Something went wrong</h1>", 500); |
| 184 | 202 | }, |
| 185 | 203 | }); |
| 186 | 204 | |
| 187 | console.log(`lanternfall listening on http://${server.hostname}:${server.port} (${env})`); | |
| 205 | console.log( | |
| 206 | `lanternfall is open on http://${server.hostname}:${server.port} (${chrome.env}), turn ${store.turn(db)}`, | |
| 207 | ); |
src/tty.ts +121 −62
| @@ -1,74 +1,133 @@ | ||
| 1 | 1 | /** |
| 2 | * lanternfall, in a terminal. The forge runs this on a pty when someone | |
| 3 | * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the | |
| 4 | * web page uses. j/k move, a adds a note, d deletes the one under the | |
| 5 | * cursor, q leaves. Plain ANSI, no dependencies. | |
| 2 | * lanternfall in a terminal — the same dungeon, the same database, the same | |
| 3 | * shared map. `ssh` in and you are another delver on the floor; the web | |
| 4 | * players can see you standing there, and you can see them. | |
| 5 | * | |
| 6 | * hjkl or the arrows move, `.` waits and rests, `>` and `<` take stairs, | |
| 7 | * `q` leaves. Your name comes from the account at the keyboard, so the | |
| 8 | * gravestone will have it on. | |
| 6 | 9 | */ |
| 7 | 10 | |
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { mkdirSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import * as store from "./db.ts"; | |
| 12 | import { HEIGHT, WIDTH, floorAt } from "./dungeon.ts"; | |
| 13 | import { act, breedOf, cacheCells, delverFor, isAction, viewFor } from "./game.ts"; | |
| 14 | import { ordinal } from "./names.ts"; | |
| 11 | 15 | |
| 12 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 13 | mkdirSync(dataDir, { recursive: true }); | |
| 14 | const db = new Database(join(dataDir, "app.db")); | |
| 15 | db.exec("PRAGMA journal_mode = WAL"); | |
| 16 | db.exec( | |
| 17 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 18 | ); | |
| 19 | const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local"); | |
| 20 | ||
| 21 | type Note = { id: number; body: string; created_at: string }; | |
| 22 | const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all(); | |
| 23 | ||
| 24 | let cursor = 0; | |
| 25 | let typing: string | undefined; | |
| 26 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 16 | const db = store.open(); | |
| 17 | const hand = `tty:${process.env.FORGE_APP_USER || "guest"}`; | |
| 27 | 18 | const esc = "\x1b["; |
| 19 | const out = (s: string): void => { | |
| 20 | process.stdout.write(s); | |
| 21 | }; | |
| 22 | const paint = (code: number, text: string, bold = false): string => | |
| 23 | `${esc}${bold ? "1;" : ""}38;5;${code}m${text}${esc}0m`; | |
| 24 | ||
| 25 | const DARK = 234; | |
| 26 | const MEMORY = 239; | |
| 27 | const LIT_FLOOR = 137; | |
| 28 | const LIT_WALL = 95; | |
| 29 | const STAIR = 71; | |
| 30 | const GOLD = 178; | |
| 31 | const BONE = 183; | |
| 32 | const BEAST = 167; | |
| 33 | const OTHER = 74; | |
| 34 | const YOU = 230; | |
| 28 | 35 | |
| 29 | 36 | function draw(): void { |
| 30 | const rows = notes(); | |
| 31 | const lines: string[] = []; | |
| 32 | lines.push(`${esc}1mlanternfall${esc}0m ${esc}2m${where}${esc}0m`); | |
| 33 | lines.push(""); | |
| 34 | if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`); | |
| 35 | rows.forEach((n, i) => { | |
| 36 | const mark = i === cursor ? "▸" : " "; | |
| 37 | const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body; | |
| 38 | lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`); | |
| 39 | }); | |
| 40 | lines.push(""); | |
| 41 | lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`); | |
| 42 | out(`${esc}2J${esc}H` + lines.join("\r\n")); | |
| 37 | const delver = delverFor(db, hand); | |
| 38 | const view = viewFor(db, delver); | |
| 39 | const caches = store.cacheState(db, delver.depth, store.turn(db)); | |
| 40 | out(`${esc}2J${esc}H`); | |
| 41 | out( | |
| 42 | `${paint(GOLD, "lanternfall", true)} ${paint(MEMORY, `· ${ordinal(delver.depth)} floor · turn ${delver.turns}`)}\r\n\r\n`, | |
| 43 | ); | |
| 44 | for (let y = 0; y < HEIGHT; y += 1) { | |
| 45 | let line = " "; | |
| 46 | for (let x = 0; x < WIDTH; x += 1) { | |
| 47 | const cell = y * WIDTH + x; | |
| 48 | const tile = view.floor.tiles[cell] ?? "#"; | |
| 49 | const lit = view.visible.has(cell); | |
| 50 | const known = view.seen.has(cell); | |
| 51 | if (cell === delver.cell) { | |
| 52 | line += paint(YOU, "@", true); | |
| 53 | continue; | |
| 54 | } | |
| 55 | if (!lit && !known) { | |
| 56 | line += " "; | |
| 57 | continue; | |
| 58 | } | |
| 59 | if (lit) { | |
| 60 | const monster = view.monsters.find((m) => m.cell === cell); | |
| 61 | if (monster) { | |
| 62 | line += paint(BEAST, breedOf(monster.kind).glyph, true); | |
| 63 | continue; | |
| 64 | } | |
| 65 | if (view.others.has(cell)) { | |
| 66 | line += paint(OTHER, "@", true); | |
| 67 | continue; | |
| 68 | } | |
| 69 | if (caches.has(cell) === false && view.floor.tiles[cell] === "." && isCache(view, cell)) { | |
| 70 | line += paint(GOLD, "$"); | |
| 71 | continue; | |
| 72 | } | |
| 73 | } | |
| 74 | if (view.graves.has(cell)) { | |
| 75 | line += paint(lit ? BONE : MEMORY, "†"); | |
| 76 | continue; | |
| 77 | } | |
| 78 | if (tile === ">" || tile === "<") { | |
| 79 | line += paint(lit ? STAIR : MEMORY, tile, lit); | |
| 80 | continue; | |
| 81 | } | |
| 82 | if (!lit) { | |
| 83 | line += paint(MEMORY, tile === "#" ? "#" : "·"); | |
| 84 | continue; | |
| 85 | } | |
| 86 | line += paint(tile === "#" ? LIT_WALL : LIT_FLOOR, tile === "#" ? "#" : "·"); | |
| 87 | } | |
| 88 | out(`${line}\r\n`); | |
| 89 | } | |
| 90 | const bar = `${"♥".repeat(Math.max(0, delver.hp))}${paint(DARK, "·".repeat(Math.max(0, delver.max_hp - delver.hp)))}`; | |
| 91 | out(`\r\n ${paint(BEAST, bar)} ${paint(GOLD, `${delver.gold}g`)} ${paint(MEMORY, delver.name)}\r\n`); | |
| 92 | for (const line of store.heard(db, delver.id, 4)) out(` ${paint(MEMORY, line)}\r\n`); | |
| 93 | out(`\r\n ${paint(DARK, "hjkl/arrows move · . rest · > descend · < climb · q quit")}\r\n`); | |
| 43 | 94 | } |
| 44 | 95 | |
| 45 | process.stdin.setRawMode?.(true); | |
| 46 | process.stdin.resume(); | |
| 47 | process.stdin.setEncoding("utf8"); | |
| 48 | draw(); | |
| 49 | process.stdin.on("data", (key: string) => { | |
| 50 | if (typing !== undefined) { | |
| 51 | if (key === "\r") { | |
| 52 | const body = typing.trim(); | |
| 53 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500)); | |
| 54 | typing = undefined; | |
| 55 | cursor = 0; | |
| 56 | } else if (key === "\x1b") typing = undefined; | |
| 57 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 58 | else if (key >= " ") typing += key; | |
| 59 | draw(); | |
| 60 | return; | |
| 61 | } | |
| 62 | if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } | |
| 63 | const count = notes().length; | |
| 64 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); | |
| 65 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 66 | if (key === "a") typing = ""; | |
| 67 | if (key === "d") { | |
| 68 | const n = notes()[cursor]; | |
| 69 | if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id); | |
| 70 | cursor = Math.min(cursor, Math.max(0, notes().length - 1)); | |
| 96 | // A cache is only a rumour until you are standing next to it; the glyph shows | |
| 97 | // where one is *known* to sit, which is what the web page does too. | |
| 98 | function isCache(view: ReturnType<typeof viewFor>, cell: number): boolean { | |
| 99 | return cacheCellsFor(view.floor.depth).includes(cell); | |
| 100 | } | |
| 101 | const cacheMemo = new Map<number, number[]>(); | |
| 102 | function cacheCellsFor(depth: number): number[] { | |
| 103 | const had = cacheMemo.get(depth); | |
| 104 | if (had) return had; | |
| 105 | const cells = cacheCells(floorAt(depth)); | |
| 106 | cacheMemo.set(depth, cells); | |
| 107 | return cells; | |
| 108 | } | |
| 109 | ||
| 110 | const KEYS: Record<string, string> = { | |
| 111 | h: "west", j: "south", k: "north", l: "east", | |
| 112 | [`${esc}A`]: "north", [`${esc}B`]: "south", [`${esc}C`]: "east", [`${esc}D`]: "west", | |
| 113 | ".": "wait", " ": "wait", ">": "descend", "<": "ascend", | |
| 114 | }; | |
| 115 | ||
| 116 | function key(data: Buffer): void { | |
| 117 | const pressed = data.toString("utf8"); | |
| 118 | if (pressed === "q" || pressed === "\x03" || pressed === "\x04") { | |
| 119 | out(`${esc}2J${esc}H`); | |
| 120 | db.close(); | |
| 121 | process.exit(0); | |
| 71 | 122 | } |
| 123 | const action = KEYS[pressed]; | |
| 124 | if (action !== undefined && isAction(action)) act(db, hand, action); | |
| 72 | 125 | draw(); |
| 73 | }); | |
| 126 | } | |
| 127 | ||
| 128 | if (process.stdin.isTTY) process.stdin.setRawMode(true); | |
| 129 | process.stdin.on("data", key); | |
| 74 | 130 | process.stdout.on("resize", draw); |
| 131 | // Somebody else's turn is your turn too: the floor moves while you stand still. | |
| 132 | setInterval(draw, 3000); | |
| 133 | draw(); |
static/keys.v1.js added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** | |
| 2 | * Keyboard play, layered on top of forms that already work without it. | |
| 3 | * Every control on the page is a real submit button; this just finds the one | |
| 4 | * a keypress means and presses it. No fetch, no state, no rendering — the | |
| 5 | * server owns the turn either way. | |
| 6 | */ | |
| 7 | (function () { | |
| 8 | var KEYS = { | |
| 9 | ArrowUp: "north", ArrowDown: "south", ArrowLeft: "west", ArrowRight: "east", | |
| 10 | w: "north", s: "south", a: "west", d: "east", | |
| 11 | k: "north", j: "south", h: "west", l: "east", | |
| 12 | ".": "wait", " ": "wait", ">": "descend", "<": "ascend", | |
| 13 | }; | |
| 14 | document.addEventListener("keydown", function (event) { | |
| 15 | if (event.metaKey || event.ctrlKey || event.altKey) return; | |
| 16 | var action = KEYS[event.key]; | |
| 17 | if (!action) return; | |
| 18 | var button = document.querySelector('button[value="' + action + '"]'); | |
| 19 | if (!button) return; | |
| 20 | event.preventDefault(); | |
| 21 | button.focus(); | |
| 22 | button.click(); | |
| 23 | }); | |
| 24 | })(); |