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.
benjivers committed 1088a14 Browse files
13 files changed +1774 −185
README.md +67 −14
@@ -1,23 +1,76 @@
11 # lanternfall
22
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.
44
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
952
1053 ```sh
1154 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
1457 ```
1558
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
2060
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 @@
22 "app": {
33 "run": "bun run src/server.ts",
44 "test": "bun test",
5 "health": "/",
6 "ready": 30,
57 "tty": "bun run src/tty.ts"
68 }
79 }
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 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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>&gt;</b> descends · <b>&lt;</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)">&gt; &lt;</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";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55
6 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
6 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "lanternfall-web-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
99 const base = `http://${server.hostname}:${server.port}`;
1010
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);
1547 });
1648
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");
2399 });
24100
25101 test("stands on the security floor", async () => {
26 const response = await fetch(base + "/");
102 const response = await fetch(`${base}/`);
27103 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
28104 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
29105 });
30106
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`, {
34109 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 },
37117 });
38 expect(response.status).toBe(200);
39 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
118 expect(response.status).toBe(403);
40119 });
41120
42121 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`, {
46123 method: "POST",
47124 headers: { "content-type": "application/x-www-form-urlencoded" },
48 body: "body=" + "x".repeat(1024 * 1024),
125 body: "action=" + "x".repeat(1024 * 1024),
49126 });
50127 expect(response.status).toBe(413);
51128 });
52129
53130 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" }));
55132 expect(response.status).toBe(200);
56133 expect(await response.text()).toBe("");
57134 });
58135
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 },
65141 redirect: "manual",
66 headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
67142 });
68 expect(posted.status).toBe(403);
69 expect(await (await fetch(base + "/")).text()).not.toContain("from another site");
143 expect(response.status).toBe(303);
70144 });
src/server.ts +96 −76
@@ -1,47 +1,65 @@
11 /**
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.
35 *
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.
79 */
810
9 import { Database } from "bun:sqlite";
10 import { mkdirSync } from "node:fs";
11 import { randomBytes } from "node:crypto";
1112 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";
1216
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();
2018
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 };
2425
25 const escape = (s: string): string =>
26 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 }`;
2745
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.
3146 // The app's public address is what the forge told us, never the socket's.
3247 const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
3348
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> = {
3553 "content-security-policy":
3654 "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
3755 "x-content-type-options": "nosniff",
3856 "referrer-policy": "strict-origin-when-cross-origin",
3957 "permissions-policy": "camera=(), microphone=(), geolocation=()",
40 // Once served over https, always: browsers remember for a year.
4158 ...(publicOrigin.startsWith("https:")
4259 ? { "strict-transport-security": "max-age=31536000" }
4360 : {}),
4461 };
62
4563 const html = (body: string, status = 200): Response =>
4664 new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
4765
@@ -104,41 +122,20 @@ async function readForm(request: Request): Promise<FormData | undefined> {
104122 }).formData();
105123 }
106124
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 );
142139 }
143140
144141 export const server = Bun.serve({
@@ -148,40 +145,63 @@ export const server = Bun.serve({
148145 const url = new URL(request.url);
149146 if (request.method === "POST" && !sameOrigin(request))
150147 return new Response("forbidden", { status: 403 });
151 if (request.method === "POST" && url.pathname === "/notes") {
148
149 if (request.method === "POST" && url.pathname === "/act") {
152150 const form = await readForm(request);
153151 if (form === undefined)
154152 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 });
158165 }
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));
162175 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 });
165178 }
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("..")) {
170190 const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
171191 if (await file.exists())
172192 return new Response(file, {
173193 headers: { "cache-control": "public, max-age=31536000, immutable", ...SECURITY },
174194 });
175195 }
176 return new Response("not found", { status: 404, headers: SECURITY });
196
197 return html(notFoundPage(chrome), 404);
177198 },
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.
181199 error(error) {
182200 console.error(error);
183201 return html("<h1>Something went wrong</h1>", 500);
184202 },
185203 });
186204
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 @@
11 /**
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.
69 */
710
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";
1115
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"}`;
2718 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;
2835
2936 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`);
4394 }
4495
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);
71122 }
123 const action = KEYS[pressed];
124 if (action !== undefined && isAction(action)) act(db, hand, action);
72125 draw();
73 });
126 }
127
128 if (process.stdin.isTTY) process.stdin.setRawMode(true);
129 process.stdin.on("data", key);
74130 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 })();
↑↓ navigate ↵ open esc close

Keyboard shortcuts