heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
lanternfall: say what you are here for, and what happened this turn
Played as a stranger: the first screen said where I was but not what the game wanted; the log kept the last line of an old turn lit while I walked, so "A stair down, here" and "There is no stair here" read as answers to moves they had nothing to do with; and pressing "descend" off a stair a second time said nothing back at all, because the log never repeats its last line. - The welcome now names the goal and the two rules the buttons do not explain (walk into a thing to fight it; wait to heal when unseen). The terminal shows the same three lines to a new delver. - Log lines are dated by the delver's own turn. Lines from this turn are in ink with a marker; everything older is dim. A silent step lights nothing, instead of leaving a stale line lit. - A line said again is re-dated to the turn it was said on, so a repeated refusal is answered again without stuttering. Tests: the dedupe test reads the new message shape; three new tests cover re-dating, turn-dating, and the page marking this turn's lines. Co-Authored-By: Claude Fable 5.1 <[email protected]>
7 files
changed +114
−24
README.md +1 −1
| @@ -52,7 +52,7 @@ keyboard is your name, so the gravestone will have it on. | ||
| 52 | 52 | |
| 53 | 53 | sh |
| 54 | 54 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| 55 | bun test # 26 tests | |
| 55 | bun test # 34 tests | |
| 56 | 56 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal client |
| 57 | 57 | |
| 58 | 58 |
src/db.ts +28 −14
| @@ -237,22 +237,37 @@ export const topScores = (db: Database, limit = 8): Score[] => | ||
| 237 | 237 | export const countSeen = (db: Database): number => |
| 238 | 238 | db.query<{ n: number }, []>("SELECT count(*) AS n FROM seen").get()?.n ?? 0; |
| 239 | 239 | |
| 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 | ||
| 240 | 246 | 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 | 247 | // A chaser crossing the edge of the lantern every other step used to write |
| 243 | 248 | // "Something moves at the edge of your lantern." thirteen times in a row, |
| 244 | 249 | // 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; | |
| 252 | 259 | 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); | |
| 255 | 269 | previous = line; |
| 270 | previousId = made?.id ?? 0; | |
| 256 | 271 | } |
| 257 | 272 | // Keep the tail only: nobody scrolls a dungeon log. |
| 258 | 273 | db.query( |
| @@ -261,13 +276,12 @@ export function say(db: Database, delverId: number, turn: number, lines: readonl | ||
| 261 | 276 | ).run(delverId); |
| 262 | 277 | } |
| 263 | 278 | |
| 264 | export const heard = (db: Database, delverId: number, limit = 7): string[] => | |
| 279 | export const heard = (db: Database, delverId: number, limit = 7): Message[] => | |
| 265 | 280 | 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", | |
| 268 | 283 | ) |
| 269 | 284 | .all(delverId, limit) |
| 270 | .map((r) => r.text) | |
| 271 | 285 | .reverse(); |
| 272 | 286 | |
| 273 | 287 | 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", () => { | ||
| 216 | 216 | store.say(db, delver.id, 1, ["Stone.", "Stone.", "Stone."]); |
| 217 | 217 | store.say(db, delver.id, 2, ["Stone."]); |
| 218 | 218 | 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"); | |
| 220 | 244 | db.close(); |
| 221 | 245 | }); |
src/game.ts +3 −1
| @@ -298,7 +298,9 @@ export function act(db: Database, hand: string, action: Action): TurnResult { | ||
| 298 | 298 | store.markSeen(db, delver.depth, visibleFrom(floorAt(delver.depth), delver.cell)); |
| 299 | 299 | } |
| 300 | 300 | 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); | |
| 302 | 304 | return { delver, log, died: delver.state === "dead", climbedOut }; |
| 303 | 305 | } |
| 304 | 306 |
src/pages.ts +25 −6
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | import { DEPTHS, HEIGHT, WIDTH, xy } from "./dungeon.ts"; |
| 12 | 12 | import { breedOf, type View } from "./game.ts"; |
| 13 | 13 | import { ordinal } from "./names.ts"; |
| 14 | import type { Delver, Grave, Score } from "./db.ts"; | |
| 14 | import type { Delver, Grave, Message, Score } from "./db.ts"; | |
| 15 | 15 | |
| 16 | 16 | export const escape = (s: string): string => |
| 17 | 17 | s.replace( |
| @@ -81,7 +81,8 @@ const STYLE = ` | ||
| 81 | 81 | |
| 82 | 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 | 83 | .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); } | |
| 85 | 86 | |
| 86 | 87 | .controls { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } |
| 87 | 88 | .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 { | ||
| 179 | 180 | return `<div class="glass"><div class="map" role="img" aria-label="the dungeon floor, drawn in ascii">${cells.join("")}</div></div>`; |
| 180 | 181 | } |
| 181 | 182 | |
| 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 | ||
| 182 | 201 | const button = (action: string, label: string, extra = ""): string => |
| 183 | 202 | `<form class="inline" method="post" action="/act"><button class="${extra}" name="action" value="${action}">${label}</button></form>`; |
| 184 | 203 | |
| @@ -187,7 +206,7 @@ export function gamePage( | ||
| 187 | 206 | delver: Delver, |
| 188 | 207 | view: View, |
| 189 | 208 | caches: Set<number>, |
| 190 | log: readonly string[], | |
| 209 | log: readonly Message[], | |
| 191 | 210 | graves: readonly Grave[], |
| 192 | 211 | scores: readonly Score[], |
| 193 | 212 | known: number, |
| @@ -237,7 +256,7 @@ ${mapOf(view, delver, caches)} | ||
| 237 | 256 | </aside> |
| 238 | 257 | </div> |
| 239 | 258 | |
| 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> | |
| 241 | 260 | |
| 242 | 261 | <div class="controls"> |
| 243 | 262 | <div class="pad"> |
| @@ -261,7 +280,7 @@ export function endPage( | ||
| 261 | 280 | chrome: Chrome, |
| 262 | 281 | delver: Delver, |
| 263 | 282 | climbedOut: boolean, |
| 264 | log: readonly string[], | |
| 283 | log: readonly Message[], | |
| 265 | 284 | ): string { |
| 266 | 285 | return layout( |
| 267 | 286 | chrome, |
| @@ -271,7 +290,7 @@ export function endPage( | ||
| 271 | 290 | <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 | 291 | ${delver.epitaph ? `<p>${escape(delver.epitaph)}</p>` : ""} |
| 273 | 292 | ${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> | |
| 275 | 294 | <form method="post" action="/act"><button name="action" value="wait">send someone else down</button></form> |
| 276 | 295 | </div>`, |
| 277 | 296 | ); |
src/server.test.ts +25
| @@ -46,6 +46,31 @@ test("the dungeon opens with a delver, a map and a lantern", async () => { | ||
| 46 | 46 | expect((page.match(/<span class="/g) ?? []).length).toBeGreaterThanOrEqual(48 * 24); |
| 47 | 47 | }); |
| 48 | 48 | |
| 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 | ||
| 49 | 74 | test("a visitor gets one delver, kept in an HttpOnly cookie", async () => { |
| 50 | 75 | const response = await fetch(`${base}/`); |
| 51 | 76 | const set = response.headers.get("set-cookie") ?? ""; |
src/tty.ts +7 −1
| @@ -12,6 +12,7 @@ import * as store from "./db.ts"; | ||
| 12 | 12 | import { HEIGHT, WIDTH, floorAt } from "./dungeon.ts"; |
| 13 | 13 | import { act, breedOf, cacheCells, delverFor, isAction, viewFor } from "./game.ts"; |
| 14 | 14 | import { ordinal } from "./names.ts"; |
| 15 | import { WELCOME_LINES } from "./pages.ts"; | |
| 15 | 16 | |
| 16 | 17 | const db = store.open(); |
| 17 | 18 | const hand = `tty:${process.env.FORGE_APP_USER || "guest"}`; |
| @@ -89,7 +90,12 @@ function draw(): void { | ||
| 89 | 90 | } |
| 90 | 91 | const bar = `${"♥".repeat(Math.max(0, delver.hp))}${paint(DARK, "·".repeat(Math.max(0, delver.max_hp - delver.hp)))}`; |
| 91 | 92 | 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`); | |
| 93 | 99 | out(`\r\n ${paint(DARK, "hjkl/arrows move · . rest · > descend · < climb · q quit")}\r\n`); |
| 94 | 100 | } |
| 95 | 101 |