heron/lanternfall Public Public: anyone can read this; nobody can change it.

workbenchthis machine

Merge pull request #2 from heron/1-the-first-minute-a-new-delver-should-und

The first minute: a new delver should understand lanternfall before it kills them
benjivers committed 0a44464 merge Browse files
7 files changed +114 −24
README.md +1 −1
@@ -52,7 +52,7 @@ keyboard is your name, so the gravestone will have it on.
5252
5353 ```sh
5454 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
55 bun test # 26 tests
55 bun test # 34 tests
5656 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal client
5757 ```
5858
src/db.ts +28 −14
@@ -237,22 +237,37 @@ export const topScores = (db: Database, limit = 8): Score[] =>
237237 export const countSeen = (db: Database): number =>
238238 db.query<{ n: number }, []>("SELECT count(*) AS n FROM seen").get()?.n ?? 0;
239239
240 /** One line the delver was told, and the delver's own turn it belongs to. */
241 export interface Message {
242 text: string;
243 turn: number;
244 }
245
240246 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)");
242247 // A chaser crossing the edge of the lantern every other step used to write
243248 // "Something moves at the edge of your lantern." thirteen times in a row,
244249 // which reads as a broken game rather than a tense one. The log never
245 // repeats its own last line.
246 let previous =
247 db
248 .query<{ text: string }, [number]>(
249 "SELECT text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 1",
250 )
251 .get(delverId)?.text ?? "";
250 // repeats its own last line — but a line said again is re-dated to the turn
251 // it was said on, so pressing "descend" twice off a stair is answered twice.
252 const last = db
253 .query<{ id: number; text: string }, [number]>(
254 "SELECT id, text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT 1",
255 )
256 .get(delverId);
257 let previous = last?.text ?? "";
258 let previousId = last?.id ?? 0;
252259 for (const line of lines) {
253 if (line === previous) continue;
254 insert.run(delverId, turn, line);
260 if (line === previous) {
261 db.query("UPDATE message SET turn = ?2 WHERE id = ?1").run(previousId, turn);
262 continue;
263 }
264 const made = db
265 .query<{ id: number }, [number, number, string]>(
266 "INSERT INTO message (delver_id, turn, text) VALUES (?1, ?2, ?3) RETURNING id",
267 )
268 .get(delverId, turn, line);
255269 previous = line;
270 previousId = made?.id ?? 0;
256271 }
257272 // Keep the tail only: nobody scrolls a dungeon log.
258273 db.query(
@@ -261,13 +276,12 @@ export function say(db: Database, delverId: number, turn: number, lines: readonl
261276 ).run(delverId);
262277 }
263278
264 export const heard = (db: Database, delverId: number, limit = 7): string[] =>
279 export const heard = (db: Database, delverId: number, limit = 7): Message[] =>
265280 db
266 .query<{ text: string }, [number, number]>(
267 "SELECT text FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT ?2",
281 .query<Message, [number, number]>(
282 "SELECT text, turn FROM message WHERE delver_id = ?1 ORDER BY id DESC LIMIT ?2",
268283 )
269284 .all(delverId, limit)
270 .map((r) => r.text)
271285 .reverse();
272286
273287 export const cacheState = (db: Database, depth: number, now: number): Set<number> =>
src/game.test.ts +25 −1
@@ -216,6 +216,30 @@ test("the log does not repeat its own last line", () => {
216216 store.say(db, delver.id, 1, ["Stone.", "Stone.", "Stone."]);
217217 store.say(db, delver.id, 2, ["Stone."]);
218218 const heard = store.heard(db, delver.id, 10);
219 expect(heard.filter((l) => l === "Stone.").length).toBe(1);
219 expect(heard.filter((l) => l.text === "Stone.").length).toBe(1);
220 db.close();
221 });
222
223 test("a line said again is re-dated to the turn it was said on", () => {
224 // Pressing "descend" twice off a stair used to answer once and then say
225 // nothing at all; the one line now belongs to the latest press.
226 const db = fresh();
227 const delver = delverFor(db, "hand-a");
228 store.say(db, delver.id, 1, ["There is no stair here."]);
229 store.say(db, delver.id, 2, ["There is no stair here."]);
230 const heard = store.heard(db, delver.id, 10);
231 expect(heard).toEqual([{ text: "There is no stair here.", turn: 2 }]);
232 db.close();
233 });
234
235 test("the log is dated by the delver's own turns", () => {
236 const db = fresh();
237 act(db, "hand-a", "descend");
238 act(db, "hand-a", "wait");
239 const delver = delverFor(db, "hand-a");
240 expect(delver.turns).toBe(2);
241 const heard = store.heard(db, delver.id, 10);
242 expect(heard.map((l) => l.turn)).toEqual([1, 2]);
243 expect(heard[1]?.text).toContain("You wait");
220244 db.close();
221245 });
src/game.ts +3 −1
@@ -298,7 +298,9 @@ export function act(db: Database, hand: string, action: Action): TurnResult {
298298 store.markSeen(db, delver.depth, visibleFrom(floorAt(delver.depth), delver.cell));
299299 }
300300 store.saveDelver(db, delver);
301 store.say(db, delver.id, now, log);
301 // Dated by the delver's own turn, so the screen can tell what happened
302 // *this* turn from what it is still remembering.
303 store.say(db, delver.id, delver.turns, log);
302304 return { delver, log, died: delver.state === "dead", climbedOut };
303305 }
304306
src/pages.ts +25 −6
@@ -11,7 +11,7 @@
1111 import { DEPTHS, HEIGHT, WIDTH, xy } from "./dungeon.ts";
1212 import { breedOf, type View } from "./game.ts";
1313 import { ordinal } from "./names.ts";
14 import type { Delver, Grave, Score } from "./db.ts";
14 import type { Delver, Grave, Message, Score } from "./db.ts";
1515
1616 export const escape = (s: string): string =>
1717 s.replace(
@@ -81,7 +81,8 @@ const STYLE = `
8181
8282 .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; }
8383 .log p { margin: 0; font-size: .86rem; color: var(--dim); }
84 .log p:last-child { color: var(--ink); }
84 .log p.now { color: var(--ink); }
85 .log p.now::before { content: "▸ "; color: var(--lantern); }
8586
8687 .controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
8788 .pad { display: grid; grid-template-columns: repeat(3, 3rem); grid-template-rows: repeat(2, 3rem); gap: .35rem; }
@@ -179,6 +180,24 @@ function mapOf(view: View, delver: Delver, caches: Set<number>): string {
179180 return `<div class="glass"><div class="map" role="img" aria-label="the dungeon floor, drawn in ascii">${cells.join("")}</div></div>`;
180181 }
181182
183 /**
184 * What a stranger reads before anything has happened to them: where they
185 * are, what they are here for, and the two rules the buttons do not explain.
186 * It stays until the dungeon has something of its own to say.
187 */
188 export const WELCOME_LINES: readonly string[] = [
189 "You are at the mouth of the dungeon, standing on the way out. Your lantern throws about four paces.",
190 "The gold is below. It is yours only once you climb back out through this stair; die and it stays where you fell.",
191 "Walk into a thing to fight it. Wait to heal, which works only when nothing can see you.",
192 ];
193 const WELCOME = WELCOME_LINES.map((line) => `<p class="now">${escape(line)}</p>`).join("");
194
195 /** The log, with what happened this turn told apart from what is remembered. */
196 const logOf = (log: readonly Message[], turn: number): string =>
197 log
198 .map((line) => `<p${line.turn === turn ? ' class="now"' : ""}>${escape(line.text)}</p>`)
199 .join("");
200
182201 const button = (action: string, label: string, extra = ""): string =>
183202 `<form class="inline" method="post" action="/act"><button class="${extra}" name="action" value="${action}">${label}</button></form>`;
184203
@@ -187,7 +206,7 @@ export function gamePage(
187206 delver: Delver,
188207 view: View,
189208 caches: Set<number>,
190 log: readonly string[],
209 log: readonly Message[],
191210 graves: readonly Grave[],
192211 scores: readonly Score[],
193212 known: number,
@@ -237,7 +256,7 @@ ${mapOf(view, delver, caches)}
237256 </aside>
238257 </div>
239258
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>
259 <div class="log">${log.length === 0 ? WELCOME : logOf(log, delver.turns)}</div>
241260
242261 <div class="controls">
243262 <div class="pad">
@@ -261,7 +280,7 @@ export function endPage(
261280 chrome: Chrome,
262281 delver: Delver,
263282 climbedOut: boolean,
264 log: readonly string[],
283 log: readonly Message[],
265284 ): string {
266285 return layout(
267286 chrome,
@@ -271,7 +290,7 @@ export function endPage(
271290 <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>
272291 ${delver.epitaph ? `<p>${escape(delver.epitaph)}</p>` : ""}
273292 ${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>
293 <div class="log" style="max-width:34rem;margin:1.2rem auto;">${logOf(log, delver.turns)}</div>
275294 <form method="post" action="/act"><button name="action" value="wait">send someone else down</button></form>
276295 </div>`,
277296 );
src/server.test.ts +25
@@ -46,6 +46,31 @@ test("the dungeon opens with a delver, a map and a lantern", async () => {
4646 expect((page.match(/<span class="/g) ?? []).length).toBeGreaterThanOrEqual(48 * 24);
4747 });
4848
49 test("the first screen says what you are here for", async () => {
50 const page = await new Player().look();
51 expect(page).toContain("standing on the way out");
52 expect(page).toContain("yours only once you climb back out");
53 expect(page).toContain("Walk into a thing to fight it");
54 });
55
56 test("the log tells this turn apart from what it remembers", async () => {
57 const player = new Player();
58 await player.look();
59 await player.act("wait");
60 let page = await player.look();
61 // The only line so far belongs to this turn.
62 expect(page).toMatch(/<p class="now">You wait/);
63 await player.act("descend"); // off a stair: refused
64 page = await player.look();
65 expect(page).toMatch(/<p>You wait/);
66 expect(page).toMatch(/<p class="now">There is no stair here\./);
67 // The same refusal again is still answered, not swallowed.
68 await player.act("descend");
69 page = await player.look();
70 expect(page).toMatch(/<p class="now">There is no stair here\./);
71 expect((page.match(/There is no stair here/g) ?? []).length).toBe(1);
72 });
73
4974 test("a visitor gets one delver, kept in an HttpOnly cookie", async () => {
5075 const response = await fetch(`${base}/`);
5176 const set = response.headers.get("set-cookie") ?? "";
src/tty.ts +7 −1
@@ -12,6 +12,7 @@ import * as store from "./db.ts";
1212 import { HEIGHT, WIDTH, floorAt } from "./dungeon.ts";
1313 import { act, breedOf, cacheCells, delverFor, isAction, viewFor } from "./game.ts";
1414 import { ordinal } from "./names.ts";
15 import { WELCOME_LINES } from "./pages.ts";
1516
1617 const db = store.open();
1718 const hand = `tty:${process.env.FORGE_APP_USER || "guest"}`;
@@ -89,7 +90,12 @@ function draw(): void {
8990 }
9091 const bar = `${"♥".repeat(Math.max(0, delver.hp))}${paint(DARK, "·".repeat(Math.max(0, delver.max_hp - delver.hp)))}`;
9192 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 // What happened this turn in ink; what the log remembers, dim. Before
94 // anything has happened, the same welcome the page gives.
95 const heard = store.heard(db, delver.id, 4);
96 if (heard.length === 0) for (const line of WELCOME_LINES) out(` ${paint(YOU, line)}\r\n`);
97 for (const line of heard)
98 out(` ${paint(line.turn === delver.turns ? YOU : MEMORY, line.text)}\r\n`);
9399 out(`\r\n ${paint(DARK, "hjkl/arrows move · . rest · > descend · < climb · q quit")}\r\n`);
94100 }
95101
↑↓ navigate ↵ open esc close

Keyboard shortcuts