Public: anyone can read this; 1 person and the crew can change it.

test #4

Closed opened this
Closed without merging

classes → main 1 commit

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
17 files changed +682 −50
README.md +8 −5
@@ -27,10 +27,13 @@ it: the preview comes up the moment the app answers, and says `live`, or
2727 positions, emotes and chat cross the wire — a walk is sent once, as a
2828 target, and every client slides the avatar there itself.
2929 - **A delve** (`/delve/<id>`). Open a table or join one: up to four chefs a
30 party. Click to walk, click a monster to swing at it (or `Space`/`J` for
31 the nearest), right-click / `Shift`+click / `K` / `Shift` to dodge through
32 a hit. WASD or arrows steer. A downed chef gets back up if a friend stands
33 over them. Clear five rooms of the day's dungeon to win.
30 party. First pick a class in Cheffry's dressing room on the hub — Knight
31 (sword, sturdy), Archer (bow, quick), Mage (spellbook, glass cannon) —
32 each with its own dodge and a signature move on `E`. Click to walk, click
33 a monster to fight it (or `Space`/`J` for the nearest), right-click /
34 `Shift`+click / `K` / `Shift` to dodge through a hit. WASD or arrows
35 steer. A downed chef gets back up if a friend stands over them. Clear
36 five rooms of the day's dungeon to win.
3437 - **Three enemies, three brains** — the Meatball Slime (chases), the
3538 Baguette Knight (winds up and charges), the Pepper Imp (keeps its distance
3639 and shoots) — plus Skewer Spikes and chests of coins, truffles, soup and
@@ -70,7 +73,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
7073 | file | what it is |
7174 | ---------------------------------- | -------------------------------------------------------------- |
7275 | `src/actions.ts` | notes, avatars, delves, heroes, runs; the audit trail, read |
73 | `src/sim.ts` | the delve: a pure seeded tick — melee, dodge, brains, traps |
76 | `src/sim.ts` | the delve: a pure seeded tick — classes, shots, abilities, brains |
7477 | `src/content.ts`, `content/` | the game as JSON, checked whole at boot |
7578 | `src/hub.ts` | the square in memory: who stands where, emotes, chat |
7679 | `src/delves.ts` | the live instances, their parties, ticking; runs and ghosts |
content/classes.json added +1
@@ -0,0 +1,1 @@
1 {"$description":"The classes a chef can delve as, picked in Cheffry's dressing room on the hub. A new class is a new entry here — no code changes — as long as its ability reuses one of the three kinds the sim knows: bulwark (a shield plus a shove), volley (several shots in a spread) or blink (a short teleport). knight is first and is the fallback when a saved class is unknown.","classes":[{"ability":{"cooldownTicks":300,"description":"Raise a pot-lid shield: brief invulnerability, and a shove that sends the room's monsters tumbling.","duration":20,"id":"bulwark","kind":"bulwark","knockback":1.4,"name":"Bulwark","radius":2.2},"cooldown":9,"damage":1,"dodgeCooldown":24,"dodgeSpeed":0.42,"dodgeTicks":6,"hp":8,"id":"knight","invuln":16,"name":"Knight","ranged":false,"reach":1.1,"revive":40,"speed":0.16,"swing":6,"tagline":"Sturdy, straightforward, and legally a knight. The dungeon's original recipe.","weapon":"sword"},{"ability":{"cooldownTicks":240,"count":3,"description":"Loose three arrows in a spread. Table manners not included.","id":"volley","kind":"volley","name":"Volley","spread":0.28},"cooldown":14,"damage":1,"dodgeCooldown":16,"dodgeSpeed":0.55,"dodgeTicks":5,"hp":5,"id":"archer","invuln":12,"name":"Archer","range":6,"ranged":true,"revive":40,"shotSpeed":0.3,"speed":0.16,"swing":4,"tagline":"Fires lunch from across the room. Never does the dishes.","weapon":"bow"},{"ability":{"cooldownTicks":200,"description":"Teleport three tiles forward in a puff of flour, briefly untouchable.","distance":3,"id":"blink","invuln":12,"kind":"blink","name":"Blink"},"cooldown":22,"damage":2,"dodgeCooldown":24,"dodgeSpeed":0.42,"dodgeTicks":6,"hp":4,"id":"mage","invuln":12,"name":"Mage","range":5,"ranged":true,"revive":40,"shotSpeed":0.25,"speed":0.15,"swing":8,"tagline":"A glass cannon. Emphasis on glass. Do not tap.","weapon":"spellbook"}]}↵
contract/openapi.json +13 −2
@@ -648,6 +648,9 @@
648648 "properties": {
649649 "delve": {
650650 "type": "integer"
651 },
652 "class": {
653 "type": "string"
651654 }
652655 },
653656 "required": [
@@ -747,13 +750,14 @@
747750 "application/json": {
748751 "schema": {
749752 "type": "object",
750 "description": "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). stop.",
753 "description": "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). ability: your class's signature move. stop.",
751754 "properties": {
752755 "type": {
753756 "enum": [
754757 "move",
755758 "attack",
756759 "dodge",
760 "ability",
757761 "stop"
758762 ]
759763 },
@@ -1328,6 +1332,9 @@
13281332 },
13291333 "name": {
13301334 "type": "string"
1335 },
1336 "classId": {
1337 "type": "string"
13311338 }
13321339 }
13331340 }
@@ -1385,13 +1392,17 @@
13851392 },
13861393 "author_name": {
13871394 "type": "string"
1395 },
1396 "classId": {
1397 "type": "string"
13881398 }
13891399 },
13901400 "required": [
13911401 "id",
13921402 "delve_id",
13931403 "author_id",
1394 "author_name"
1404 "author_name",
1405 "classId"
13951406 ]
13961407 },
13971408 "Run": {
src/actions.ts +32 −8
@@ -503,17 +503,38 @@ export function watchDelve(
503503 * Open today's dungeon with yourself at the table. Whoever is in another
504504 * delve leaves it first: a chef cooks in one kitchen at a time.
505505 */
506 export const openDelve = (db: Database, who: Who): Result<DelveView> =>
507 audited(db, who, "delve", "create", undefined, openOne(db, who));
506 export const openDelve = (
507 db: Database,
508 who: Who,
509 input: { class?: string } = {},
510 ): Result<DelveView> =>
511 audited(db, who, "delve", "create", undefined, openOne(db, who, input));
508512
509 function openOne(db: Database, who: Who): Result<DelveView> {
513 /** The class a chef delves as: the dressing room's pick, or the knight. */
514 function checkClass(input: { class?: string }): Result<string> {
515 const classId = input.class ?? "knight";
516 if (!game.classes.some((c) => c.id === classId))
517 return fail(
518 "invalid",
519 `No such class: ${classId}. The dressing room offers ${game.classes.map((c) => c.id).join(", ")}.`,
520 );
521 return ok(classId);
522 }
523
524 function openOne(
525 db: Database,
526 who: Who,
527 input: { class?: string },
528 ): Result<DelveView> {
510529 if (who.role === "anon" || permit(who, "delve", "create") !== "yes")
511530 return refuse(who, "Sign in to start a delve.", "You may not start a delve.");
531 const picked = checkClass(input);
532 if (!picked.ok) return picked;
512533 const now = Date.now();
513534 const before = kitchen.heroOfAuthor(who.id);
514535 if (before !== undefined) kitchen.dismiss(before, now);
515536 const delve = kitchen.open(who, now, (d) => recordRun(db, d));
516 kitchen.enlist(delve, who, now);
537 kitchen.enlist(delve, who, now, picked.value);
517538 emit("delve.opened", { id: delve.id });
518539 return ok(kitchen.view(delve));
519540 }
@@ -559,11 +580,11 @@ export function getHero(
559580 export const joinDelve = (
560581 db: Database,
561582 who: Who,
562 input: { delve: number },
583 input: { delve: number; class?: string },
563584 ): Result<HeroView> =>
564585 audited(db, who, "hero", "create", undefined, join(who, input));
565586
566 function join(who: Who, input: { delve: number }): Result<HeroView> {
587 function join(who: Who, input: { delve: number; class?: string }): Result<HeroView> {
567588 if (who.role === "anon" || permit(who, "hero", "create") !== "yes")
568589 return refuse(who, "Sign in to join a party.", "You may not join a party.");
569590 if (!Number.isInteger(input.delve))
@@ -571,13 +592,15 @@ function join(who: Who, input: { delve: number }): Result<HeroView> {
571592 const delve = kitchen.delveOf(input.delve);
572593 if (delve === undefined || delve.ended !== null)
573594 return fail("not_found", "That delve is over, or never was.");
595 const picked = checkClass(input);
596 if (!picked.ok) return picked;
574597 const now = Date.now();
575598 const before = kitchen.heroOfAuthor(who.id);
576599 if (before?.delve_id === delve.id) return ok(kitchen.heroView(before));
577600 if (!kitchen.joinable(delve))
578601 return fail("invalid", `That party is full: ${kitchen.PARTY_MAX} chefs a table.`);
579602 if (before !== undefined) kitchen.dismiss(before, now);
580 const hero = kitchen.enlist(delve, who, now);
603 const hero = kitchen.enlist(delve, who, now, picked.value);
581604 emit("delve.changed", { id: delve.id });
582605 return ok(kitchen.heroView(hero));
583606 }
@@ -608,8 +631,9 @@ export function readOrder(
608631 ? { type: "attack", target: input.target as number }
609632 : "A target is a monster's id, a whole number.";
610633 }
634 if (type === "ability") return { type: "ability" };
611635 if (type === "stop") return { type: "stop" };
612 return "An order is move, attack, dodge or stop.";
636 return "An order is move, attack, dodge, ability or stop.";
613637 }
614638
615639 /** Give your hero an order: move, attack, dodge or stop. */
src/content.test.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * The classes file, checked like every other content file: bad data names
3 * its field, and the app will not start on it.
4 */
5 import { expect, test } from "bun:test";
6 import { readFileSync } from "node:fs";
7 import { check } from "./content.ts";
8
9 const raw = (name: string): unknown =>
10 JSON.parse(readFileSync(new URL(`../content/${name}.json`, import.meta.url), "utf8"));
11 const files = () => ({
12 rules: raw("rules"),
13 monsters: raw("monsters"),
14 traps: raw("traps"),
15 loot: raw("loot"),
16 rooms: raw("rooms"),
17 hub: raw("hub"),
18 classes: raw("classes"),
19 });
20 const withClasses = (mutate: (c: any) => void) => {
21 const f = files();
22 mutate((f.classes as any).classes);
23 return check(f);
24 };
25
26 test("the shipped classes are clean", () => {
27 expect(check(files())).toEqual([]);
28 });
29
30 test("class ids are unique and one of them is the knight fallback", () => {
31 const dup = withClasses((list) => list.push({ ...list[0] }));
32 expect(dup.some((p) => p.includes("appears twice"))).toBe(true);
33 const noKnight = withClasses((list) => {
34 list.splice(0, list.length, ...list.filter((c: any) => c.id !== "knight"));
35 });
36 expect(noKnight.some((p) => p.includes("knight"))).toBe(true);
37 });
38
39 test("a class names its tagline, weapon and ranged-ness", () => {
40 const bad = withClasses((list) => {
41 delete list[1].tagline;
42 list[1].weapon = "";
43 list[1].ranged = "sometimes";
44 });
45 expect(bad.some((p) => p.includes("tagline is missing"))).toBe(true);
46 expect(bad.some((p) => p.includes("weapon is missing"))).toBe(true);
47 expect(bad.some((p) => p.includes("ranged must be true or false"))).toBe(true);
48 });
49
50 test("class numbers are positive, and reach or range fits the weapon", () => {
51 const bad = withClasses((list) => {
52 list[0].hp = 0;
53 delete list[0].reach;
54 list[1].range = -2;
55 delete list[2].shotSpeed;
56 });
57 expect(bad.some((p) => p.includes("hp must be a positive number"))).toBe(true);
58 expect(bad.some((p) => p.includes("reach must be a positive number"))).toBe(true);
59 expect(bad.some((p) => p.includes("range must be a positive number"))).toBe(true);
60 expect(bad.some((p) => p.includes("shotSpeed must be a positive number"))).toBe(true);
61 });
62
63 test("an ability is named, timed, and of a kind the sim knows", () => {
64 const bad = withClasses((list) => {
65 list[0].ability.kind = "time-stop";
66 list[0].ability.cooldownTicks = 0;
67 delete list[1].ability.name;
68 delete list[2].ability.distance;
69 });
70 expect(bad.some((p) => p.includes("kind must be one of"))).toBe(true);
71 expect(bad.some((p) => p.includes("cooldownTicks must be a positive number"))).toBe(true);
72 expect(bad.some((p) => p.includes("name is missing"))).toBe(true);
73 expect(bad.some((p) => p.includes("distance must be a positive number"))).toBe(true);
74 });
src/content.ts +38
@@ -38,6 +38,7 @@ export interface Game extends Content {
3838 }
3939
4040 const BEHAVIORS = ["chase", "charge", "shoot"];
41 const ABILITY_KINDS = ["bulwark", "volley", "blink"];
4142 const RESERVED = "#.SEC";
4243
4344 const isObject = (v: unknown): v is Record<string, unknown> =>
@@ -54,6 +55,7 @@ export function check(files: {
5455 loot: unknown;
5556 rooms: unknown;
5657 hub: unknown;
58 classes: unknown;
5759 }): string[] {
5860 const problems: string[] = [];
5961 const say = (where: string, what: string) => problems.push(`${where}: ${what}`);
@@ -177,6 +179,40 @@ export function check(files: {
177179 });
178180 if (finals === 0) say("rooms", "at least one room must be final");
179181
182 const classes = listOf(files.classes, "classes");
183 ids("classes", classes);
184 if (!classes.some((c) => isObject(c) && c.id === "knight"))
185 say("classes", 'one class must be the "knight" fallback');
186 classes.forEach((c, i) => {
187 const where = `classes[${i}]`;
188 if (!isObject(c)) return;
189 for (const key of ["tagline", "weapon"])
190 if (typeof c[key] !== "string" || c[key] === "")
191 say(where, `${key} is missing`);
192 if (typeof c.ranged !== "boolean") say(where, "ranged must be true or false");
193 need(where, c, [
194 "hp", "speed", "damage", "swing", "cooldown",
195 "dodgeSpeed", "dodgeTicks", "dodgeCooldown", "invuln", "revive",
196 ]);
197 if (c.ranged === true) need(where, c, ["range", "shotSpeed"]);
198 else need(where, c, ["reach"]);
199 const a = c.ability;
200 const awhere = `${where}.ability`;
201 if (!isObject(a)) {
202 say(where, "ability is missing");
203 return;
204 }
205 for (const key of ["id", "name", "description"])
206 if (typeof a[key] !== "string" || a[key] === "")
207 say(awhere, `${key} is missing`);
208 if (!positive(a.cooldownTicks)) say(awhere, "cooldownTicks must be a positive number");
209 if (!ABILITY_KINDS.includes(String(a.kind)))
210 say(awhere, `kind must be one of ${ABILITY_KINDS.join(", ")}`);
211 if (a.kind === "bulwark") need(awhere, a, ["duration", "knockback", "radius"]);
212 if (a.kind === "volley") need(awhere, a, ["count", "spread"]);
213 if (a.kind === "blink") need(awhere, a, ["distance", "invuln"]);
214 });
215
180216 const paints = new Set(
181217 tokens.filter((t) => t.name.startsWith("--color-")).map((t) => t.name.slice(8)),
182218 );
@@ -203,6 +239,7 @@ function load(): Game {
203239 loot: read("loot"),
204240 rooms: read("rooms"),
205241 hub: read("hub"),
242 classes: read("classes"),
206243 };
207244 const problems = check(files);
208245 if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`);
@@ -214,6 +251,7 @@ function load(): Game {
214251 traps: f.traps!.traps as Game["traps"],
215252 loot: f.loot!.loot as Game["loot"],
216253 rooms: f.rooms!.rooms as Game["rooms"],
254 classes: f.classes!.classes as Game["classes"],
217255 hub: { ...hub, props: f.hub!.props as Prop[] },
218256 };
219257 }
src/delves.ts +10 −2
@@ -46,6 +46,8 @@ export interface HeroRow {
4646 readonly delve_id: number;
4747 readonly author_id: string;
4848 readonly author_name: string;
49 /** The class from content/classes.json this chef delves as. */
50 readonly classId: string;
4951 seen: number;
5052 }
5153
@@ -72,7 +74,7 @@ export interface DelveView {
7274 readonly day: string;
7375 readonly author_id: string;
7476 readonly host_name: string;
75 readonly party: readonly { id: number; author_id: string; name: string }[];
77 readonly party: readonly { id: number; author_id: string; name: string; classId: string }[];
7678 readonly depth: number;
7779 readonly rooms: number;
7880 readonly room: string;
@@ -86,6 +88,7 @@ export interface HeroView {
8688 readonly delve_id: number;
8789 readonly author_id: string;
8890 readonly author_name: string;
91 readonly classId: string;
8992 }
9093
9194 const delves = new Map<number, Delve>();
@@ -117,6 +120,7 @@ export const view = (d: Delve): DelveView => ({
117120 id: h.id,
118121 author_id: h.author_id,
119122 name: h.author_name,
123 classId: h.classId,
120124 })),
121125 depth: d.world.depth,
122126 rooms: d.world.plan.length,
@@ -131,6 +135,7 @@ export const heroView = (h: HeroRow): HeroView => ({
131135 delve_id: h.delve_id,
132136 author_id: h.author_id,
133137 author_name: h.author_name,
138 classId: h.classId,
134139 });
135140
136141 /** Open today's dungeon for a party. `onEnd` is told once, when it is over. */
@@ -167,16 +172,19 @@ export function enlist(
167172 delve: Delve,
168173 who: { id: string; name: string },
169174 now: number,
175 classId: string = "knight",
170176 ): HeroRow {
177 const known = game.classes.some((c) => c.id === classId) ? classId : "knight";
171178 const row: HeroRow = {
172179 id: nextHero++,
173180 delve_id: delve.id,
174181 author_id: who.id,
175182 author_name: who.name,
183 classId: known,
176184 seen: now,
177185 };
178186 heroes.set(row.id, row);
179 addHero(game, delve.world, row.id, who.name);
187 addHero(game, delve.world, row.id, who.name, known);
180188 if (!delve.crew.includes(who.name)) delve.crew.push(who.name);
181189 send(delve);
182190 return row;
src/game.test.ts +36
@@ -189,3 +189,39 @@ test("a finished delve goes on the board with its crew and ghost; an empty one d
189189 expect(one.ok && one.value.ghost).toBe("0,15,55;0,20,55");
190190 expect(failed(listRuns(db, anon, { day: "yesterday" }))).toBe("invalid");
191191 });
192
193 test("joining names a class; the dressing room's word is law", () => {
194 const host = chef();
195 const opened = openDelve(db, host, { class: "mage" });
196 if (!opened.ok) throw new Error(opened.message);
197 const id = opened.value.id;
198 expect(opened.value.party[0]!.classId).toBe("mage");
199 const guest = chef();
200 const joined = joinDelve(db, guest, { delve: id, class: "archer" });
201 if (!joined.ok) throw new Error(joined.message);
202 expect(joined.value.classId).toBe("archer");
203 const table = kitchen.delveOf(id)!;
204 expect(table.world.heroes.find((h) => h.id === joined.value.id)!.classId).toBe("archer");
205 const party = kitchen.view(table).party;
206 expect(party.map((h) => h.classId).sort()).toEqual(["archer", "mage"]);
207 });
208
209 test("an unknown class is refused; no class means knight", () => {
210 const host = chef();
211 const opened = openDelve(db, host);
212 if (!opened.ok) throw new Error(opened.message);
213 const id = opened.value.id;
214 expect(opened.value.party[0]!.classId).toBe("knight");
215 const bogus = joinDelve(db, chef(), { delve: id, class: "paladin" });
216 expect(failed(bogus)).toBe("invalid");
217 const plain = joinDelve(db, chef(), { delve: id });
218 if (!plain.ok) throw new Error(plain.message);
219 expect(plain.value.classId).toBe("knight");
220 });
221
222 test("an ability is an order the sim can take", () => {
223 expect(readOrder({ type: "ability" }, { w: 10, h: 10 })).toEqual({ type: "ability" });
224 expect(readOrder({ type: "dance" }, { w: 10, h: 10 })).toBe(
225 "An order is move, attack, dodge, ability or stop.",
226 );
227 });
src/pages/delve.ts +13 −1
@@ -30,6 +30,11 @@ const OUTCOME: Record<string, string> = {
3030 abandoned: "Everyone left the table.",
3131 };
3232
33 const className = (classId: string): string =>
34 game.classes.find((c) => c.id === classId)?.name ?? classId;
35 const abilityName = (classId: string): string =>
36 game.classes.find((c) => c.id === classId)?.ability.name ?? "Ability";
37
3338 export function delvePage(p: DelvePage): string {
3439 const { who, delve, hero } = p;
3540 const path = `/delve/${delve.id}`;
@@ -41,6 +46,7 @@ export function delvePage(p: DelvePage): string {
4146 : `<div class="pad">
4247 <button class="button button-big" type="button" data-act="attack">Attack</button>
4348 <button class="button button-big button-alt" type="button" data-act="dodge">Dodge</button>
49 <button class="button button-big" type="button" data-act="ability">${escape(abilityName(hero.classId))}</button>
4450 </div>`;
4551 return layout(
4652 `<h2>${escape(delve.host_name)}'s table <small class="muted mono">${escape(delve.day)}</small></h2>
@@ -75,6 +81,7 @@ export function delvePage(p: DelvePage): string {
7581 <li><b>Walk</b>: click or tap the floor, or WASD / arrow keys.</li>
7682 <li><b>Fight</b>: click a monster (your chef keeps swinging), or <kbd>Space</kbd> for the nearest.</li>
7783 <li><b>Dodge</b>: shift-click or right-click where to roll, or <kbd>K</kbd>. Rolling dodges bites, charges, seeds and spikes.</li>
84 <li><b>Ability</b>: <kbd>E</kbd> for your class's signature move — a knight's Bulwark, an archer's Volley, a mage's Blink.</li>
7885 <li><b>Chests</b> open to the touch. <b>Downed?</b> A teammate standing beside you brings you back.</li>
7986 <li>Clear a room and the door opens. Five floors, then the Pass.</li>
8087 </ul>
@@ -91,8 +98,12 @@ export function delvePage(p: DelvePage): string {
9198 function party(p: DelvePage, path: string): string {
9299 const { who, delve, hero } = p;
93100 const names = delve.party
94 .map((h) => `<li>${escape(h.name)}${h.author_id === delve.author_id ? " <small class=\"muted\">host</small>" : ""}</li>`)
101 .map((h) => `<li>${escape(h.name)} <small class="muted">${escape(className(h.classId))}</small>${h.author_id === delve.author_id ? " <small class=\"muted\">host</small>" : ""}</li>`)
95102 .join("");
103 const yours =
104 hero === undefined
105 ? ""
106 : `<p class="callout">You delve as the <b>${escape(className(hero.classId))}</b> — press <kbd>E</kbd> for ${escape(abilityName(hero.classId))}.</p>`;
96107 let action = "";
97108 if (delve.over === null) {
98109 if (who.role === "anon")
@@ -105,6 +116,7 @@ function party(p: DelvePage, path: string): string {
105116 }
106117 return `<section class="card">
107118 <h2>At the table <small class="muted">${delve.party.length} of 4</small></h2>
119 ${yours}
108120 ${names === "" ? `<p class="muted">Nobody left.</p>` : `<ul class="list-plain">${names}</ul>`}
109121 ${action}
110122 ${delve.over === null ? `<p class="muted">Bring friends: <code class="share">${escape(publicOrigin + path)}</code></p>` : ""}
src/pages/hub.ts +23 −1
@@ -45,6 +45,7 @@ export function hubPage(p: HubPage): string {
4545 ${controls(p)}
4646 <div class="grid">
4747 ${tables(p)}
48 ${dressingRoom(p)}
4849 ${board(p)}
4950 ${square(p)}
5051 ${wall(p)}
@@ -73,6 +74,7 @@ function controls(p: HubPage): string {
7374 return `<section class="card controls">
7475 ${back}
7576 <form method="post" action="/delves">
77 <input type="hidden" name="class" value="knight" data-class>
7678 <button class="button button-big">Start today's delve</button>
7779 </form>
7880 <form class="row wrap" method="post" action="/hub/emote" data-hub aria-label="Emotes">${emotes}</form>
@@ -93,7 +95,7 @@ function tables(p: HubPage): string {
9395 const join =
9496 p.who.role === "anon" || seated || !d.joinable
9597 ? ""
96 : `<form method="post" action="/delves/${d.id}/join"><button class="button">Join</button></form>`;
98 : `<form method="post" action="/delves/${d.id}/join"><input type="hidden" name="class" value="knight" data-class><button class="button">Join</button></form>`;
9799 return `<li class="item">
98100 <div>
99101 <p><b>${escape(d.host_name)}'s table</b> · ${escape(roomName(d.room))}</p>
@@ -109,6 +111,26 @@ function tables(p: HubPage): string {
109111 </section>`;
110112 }
111113
114 function dressingRoom(p: HubPage): string {
115 if (p.who.role === "anon") return "";
116 const cards = game.classes
117 .map(
118 (c, i) => `<label class="class-card">
119 <input type="radio" name="pick-class" value="${escape(c.id)}"${i === 0 ? " checked" : ""}>
120 <span class="class-name">${escape(c.name)}</span>
121 <span class="muted">${escape(c.weapon)}</span>
122 <span class="class-tag">${escape(c.tagline)}</span>
123 <span class="muted"><b>${escape(c.ability.name)}</b> — ${escape(c.ability.description)}</span>
124 </label>`,
125 )
126 .join("");
127 return `<section class="card" id="dressing-room">
128 <h2>Cheffry's dressing room</h2>
129 <p class="muted">Pick tonight's class. The dungeon won't know what hit it.</p>
130 <div class="class-pick" role="radiogroup" aria-label="Choose your class">${cards}</div>
131 </section>`;
132 }
133
112134 function board(p: HubPage): string {
113135 const rows = p.runs
114136 .map(
src/routes.ts +35 −9
@@ -187,6 +187,7 @@ export const DELVE: Schema = {
187187 id: { type: "integer" },
188188 author_id: { type: "string" },
189189 name: { type: "string" },
190 classId: { type: "string" },
190191 },
191192 },
192193 },
@@ -206,15 +207,16 @@ export const HERO: Schema = {
206207 delve_id: { type: "integer" },
207208 author_id: { type: "string" },
208209 author_name: { type: "string" },
210 classId: { type: "string" },
209211 },
210 required: ["id", "delve_id", "author_id", "author_name"],
212 required: ["id", "delve_id", "author_id", "author_name", "classId"],
211213 };
212214 const ORDER: Schema = {
213215 type: "object",
214216 description:
215 "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). stop.",
217 "move: walk to (x, z). attack: fight `target` (a monster's id), or swing where you face. dodge: roll toward (x, z). ability: your class's signature move. stop.",
216218 properties: {
217 type: { enum: ["move", "attack", "dodge", "stop"] },
219 type: { enum: ["move", "attack", "dodge", "ability", "stop"] },
218220 x: { type: "number" },
219221 z: { type: "number" },
220222 target: { type: "integer" },
@@ -286,6 +288,16 @@ const done = <T>(result: Result<T>): Response =>
286288 const whole = (value: string | null): number | undefined =>
287289 value !== null && /^\d+$/.test(value) ? Number(value) : undefined;
288290
291 /** A form's `class` field, when the request bothered with a form at all. */
292 const formClass = async (request: Request): Promise<string | undefined> => {
293 const type = request.headers.get("content-type") ?? "";
294 if (!type.includes("form")) return undefined;
295 const form = await readForm(request);
296 if (form === undefined) return undefined;
297 const v = form.get("class");
298 return v === null ? undefined : String(v);
299 };
300
289301 // The API's description, read from git once: what the tests hold equal to this table.
290302 const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
291303 const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
@@ -402,8 +414,8 @@ export const routes: readonly Route[] = [
402414 {
403415 method: "POST",
404416 path: "/delves",
405 handle: (c) => {
406 const opened = openDelve(c.db, c.who);
417 handle: async (c) => {
418 const opened = openDelve(c.db, c.who, { class: await formClass(c.request) });
407419 return opened.ok
408420 ? back(undefined, `/delve/${opened.value.id}`)
409421 : back(opened.message);
@@ -412,9 +424,12 @@ export const routes: readonly Route[] = [
412424 {
413425 method: "POST",
414426 path: "/delves/:id/join",
415 handle: (c) => {
427 handle: async (c) => {
416428 const id = Number(c.params.id);
417 const joined = joinDelve(c.db, c.who, { delve: id });
429 const joined = joinDelve(c.db, c.who, {
430 delve: id,
431 class: await formClass(c.request),
432 });
418433 return joined.ok
419434 ? back(undefined, `/delve/${joined.value.delve_id}`)
420435 : back(joined.message, Number.isInteger(id) ? `/delve/${id}` : "/");
@@ -604,8 +619,16 @@ export const routes: readonly Route[] = [
604619 handle: async (c) => {
605620 const body = await readJson(c.request);
606621 if (body === undefined) return json({ error: "too_large" }, 413);
622 const f = fields(body);
607623 return answer(
608 joinDelve(c.db, c.who, { delve: Number(fields(body).delve) }),
624 joinDelve(
625 c.db,
626 c.who,
627 {
628 delve: Number(f.delve),
629 ...(f.class === undefined ? {} : { class: String(f.class) }),
630 },
631 ),
609632 201,
610633 );
611634 },
@@ -613,7 +636,10 @@ export const routes: readonly Route[] = [
613636 summary: "Take a seat at a table (four a table).",
614637 body: {
615638 type: "object",
616 properties: { delve: { type: "integer" } },
639 properties: {
640 delve: { type: "integer" },
641 class: { type: "string" },
642 },
617643 required: ["delve"],
618644 },
619645 answer: HERO,
src/server.test.ts +22
@@ -439,3 +439,25 @@ test("the sim and the content are served by content, cached for a year", async (
439439 const data = (await (await fetch(base + content!)).json()) as { monsters: { id: string }[] };
440440 expect(data.monsters.map((m) => m.id)).toEqual(["meatball", "baguette", "pepper"]);
441441 });
442
443 test("joining names a class by form and by API; a bogus class is refused", async () => {
444 const host = as("u61", "host");
445 const opened = await api("/api/delves", { method: "POST", ...jsonBody({}) }, host);
446 expect(opened.status).toBe(201);
447 const delve = (await opened.json()) as { id: number };
448 const guest = as("u62", "guest");
449 const form = new FormData();
450 form.set("class", "mage");
451 const joined = await api(`/delves/${delve.id}/join`, { method: "POST", body: form, redirect: "manual" }, guest);
452 expect(joined.status).toBe(303);
453 const other = as("u63", "other");
454 const apiJoined = await api("/api/heroes", { method: "POST", ...jsonBody({ delve: delve.id, class: "archer" }) }, other);
455 expect(apiJoined.status).toBe(201);
456 expect(((await apiJoined.json()) as { classId: string }).classId).toBe("archer");
457 const bogus = await api("/api/heroes", { method: "POST", ...jsonBody({ delve: delve.id, class: "paladin" }) }, as("u64", "bogus"));
458 expect(bogus.status).toBe(422);
459 const party = (await (await fetch(`${base}/api/delves/${delve.id}`)).json()) as {
460 party: { classId: string }[];
461 };
462 expect(party.party.map((h) => h.classId).sort()).toEqual(["archer", "knight", "mage"]);
463 });
src/sim.test.ts +122 −1
@@ -137,6 +137,7 @@ test("the Pepper Imp keeps its distance and throws seeds", () => {
137137 const w = createWorld(c, 1);
138138 const hero = addHero(c, w, 1, "a");
139139 hero.invuln = 0;
140 const startHp = hero.hp;
140141 w.monsters[0]!.cd = 0;
141142 let shots = 0;
142143 for (let t = 0; t < 80; t++) {
@@ -144,7 +145,7 @@ test("the Pepper Imp keeps its distance and throws seeds", () => {
144145 shots = Math.max(shots, w.shots.length);
145146 }
146147 expect(shots).toBeGreaterThan(0);
147 expect(w.heroes[0]!.hp).toBeLessThan(c.rules.hero.hp);
148 expect(w.heroes[0]!.hp).toBeLessThan(startHp);
148149 });
149150
150151 test("a chest opens to the touch and the loot is the seed's", () => {
@@ -181,3 +182,123 @@ test("a downed chef is revived by a teammate; a party all down is wiped", () =>
181182 run(c, w, 2);
182183 expect(w.over).toBe("wiped");
183184 });
185
186 test("a hero delves as their class; an unknown class falls back to knight", () => {
187 const c = kitchen(["#######", "#S...E#", "#######"]);
188 const w = createWorld(c, 1);
189 const archer = addHero(c, w, 1, "a", "archer");
190 expect(archer.classId).toBe("archer");
191 expect(archer.hp).toBe(5);
192 const mage = addHero(c, w, 2, "b", "mage");
193 expect(mage.classId).toBe("mage");
194 expect(mage.hp).toBe(4);
195 const bogus = addHero(c, w, 3, "c", "paladin");
196 expect(bogus.classId).toBe("knight");
197 expect(bogus.hp).toBe(8);
198 expect(snapshot(w).heroes.map((h) => h.classId)).toEqual([
199 "archer",
200 "mage",
201 "knight",
202 ]);
203 });
204
205 test("the archer fires arrows from afar; the knight still swings steel", () => {
206 const c = kitchen(["#########", "#S..m..E#", "#########"]);
207 const w = createWorld(c, 1);
208 addHero(c, w, 1, "a", "archer");
209 const foe = w.monsters[0]!;
210 const startHp = foe.hp;
211 order(w, 1, { type: "attack", target: foe.id });
212 step(c, w);
213 const arrows = w.shots.filter((s) => s.from === "hero");
214 expect(arrows.length).toBeGreaterThan(0);
215 expect(arrows[0]!.by).toBe(1);
216 run(c, w, 60);
217 const foeAfter = w.monsters.find((m) => m.id === foe.id);
218 expect(foeAfter === undefined || foeAfter.hp < startHp).toBe(true);
219
220 const c2 = kitchen(["#########", "#S..m..E#", "#########"]);
221 const w2 = createWorld(c2, 1);
222 addHero(c2, w2, 1, "b", "knight");
223 order(w2, 1, { type: "attack", target: w2.monsters[0]!.id });
224 run(c2, w2, 30);
225 expect(w2.shots.filter((s) => s.from === "hero")).toHaveLength(0);
226 });
227
228 test("the mage's bolt hits harder and slower", () => {
229 const c = kitchen(["#########", "#S..m..E#", "#########"]);
230 const w = createWorld(c, 1);
231 const mage = addHero(c, w, 1, "a", "mage");
232 order(w, 1, { type: "attack", target: w.monsters[0]!.id });
233 step(c, w);
234 expect(w.shots.filter((s) => s.from === "hero").length).toBeGreaterThan(0);
235 expect(mage.cooldown).toBe(22);
236 });
237
238 test("the archer rolls faster and sooner than the knight", () => {
239 const c = kitchen(["#######", "#S...E#", "#######"]);
240 const w = createWorld(c, 1);
241 const archer = addHero(c, w, 1, "a", "archer");
242 order(w, 1, { type: "dodge", x: 5, z: 1.5 });
243 step(c, w);
244 expect(archer.dodgeCd).toBe(16);
245 const w2 = createWorld(c, 2);
246 const knight = addHero(c, w2, 1, "b", "knight");
247 order(w2, 1, { type: "dodge", x: 5, z: 1.5 });
248 step(c, w2);
249 expect(knight.dodgeCd).toBe(24);
250 });
251
252 test("bulwark: a pot-lid shield and a shove", () => {
253 const c = kitchen(["#########", "#S.m...E#", "#########"]);
254 const w = createWorld(c, 1);
255 const hero = addHero(c, w, 1, "a", "knight");
256 hero.invuln = 0;
257 const foe = w.monsters[0]!;
258 const beforeX = foe.x;
259 order(w, 1, { type: "ability" });
260 const seen = step(c, w);
261 expect(seen.some((h) => h.type === "ability")).toBe(true);
262 expect(hero.invuln).toBeGreaterThanOrEqual(20);
263 expect(hero.abilityCd).toBe(300);
264 expect(foe.x).toBeGreaterThan(beforeX);
265 });
266
267 test("volley: three arrows in a spread", () => {
268 const c = kitchen(["#########", "#S....E##", "#########"]);
269 const w = createWorld(c, 1);
270 addHero(c, w, 1, "a", "archer");
271 order(w, 1, { type: "ability" });
272 step(c, w);
273 const arrows = w.shots.filter((s) => s.from === "hero");
274 expect(arrows).toHaveLength(3);
275 const dzs = arrows.map((s) => Math.round(s.dz * 1000));
276 expect(new Set(dzs).size).toBe(3);
277 });
278
279 test("blink: three tiles forward in a puff of flour", () => {
280 const c = kitchen(["############", "#S.......E#", "############"]);
281 const w = createWorld(c, 1);
282 const hero = addHero(c, w, 1, "a", "mage");
283 hero.invuln = 0;
284 const beforeX = hero.x;
285 order(w, 1, { type: "ability" });
286 step(c, w);
287 expect(hero.x).toBeGreaterThan(beforeX + 2);
288 expect(hero.x).toBeLessThan(beforeX + 3.5);
289 expect(hero.invuln).toBeGreaterThan(0);
290 expect(hero.abilityCd).toBe(200);
291 });
292
293 test("an ability on cooldown fizzles quietly", () => {
294 const c = kitchen(["#########", "#S....E##", "#########"]);
295 const w = createWorld(c, 1);
296 addHero(c, w, 1, "a", "archer");
297 order(w, 1, { type: "ability" });
298 step(c, w);
299 const first = w.shots.length;
300 order(w, 1, { type: "ability" });
301 const seen = step(c, w);
302 expect(w.shots.length).toBe(first);
303 expect(seen.some((h) => h.type === "ability")).toBe(false);
304 });
src/sim.ts +197 −21
@@ -71,6 +71,46 @@ export interface RoomDef {
7171 readonly final?: boolean;
7272 readonly map: readonly string[];
7373 }
74 /**
75 * One playable class, as data. A fourth class is a new entry in
76 * `content/classes.json`: the sim only knows the three ability kinds,
77 * so an ability reusing one of them needs no code.
78 */
79 export interface ClassAbility {
80 readonly id: string;
81 readonly name: string;
82 readonly description: string;
83 readonly cooldownTicks: number;
84 readonly kind: "bulwark" | "volley" | "blink";
85 readonly duration?: number;
86 readonly knockback?: number;
87 readonly radius?: number;
88 readonly count?: number;
89 readonly spread?: number;
90 readonly distance?: number;
91 readonly invuln?: number;
92 }
93 export interface ClassDef {
94 readonly id: string;
95 readonly name: string;
96 readonly tagline: string;
97 readonly weapon: string;
98 readonly ranged: boolean;
99 readonly hp: number;
100 readonly speed: number;
101 readonly damage: number;
102 readonly reach?: number;
103 readonly range?: number;
104 readonly shotSpeed?: number;
105 readonly swing: number;
106 readonly cooldown: number;
107 readonly dodgeSpeed: number;
108 readonly dodgeTicks: number;
109 readonly dodgeCooldown: number;
110 readonly invuln: number;
111 readonly revive: number;
112 readonly ability: ClassAbility;
113 }
74114 export interface Content {
75115 readonly rules: {
76116 readonly tickHz: number;
@@ -83,6 +123,7 @@ export interface Content {
83123 readonly traps: readonly TrapDef[];
84124 readonly loot: readonly LootDef[];
85125 readonly rooms: readonly RoomDef[];
126 readonly classes: readonly ClassDef[];
86127 }
87128
88129 // ── The world ──
@@ -90,6 +131,8 @@ export interface Content {
90131 export interface Hero {
91132 readonly id: number;
92133 readonly name: string;
134 /** Which class from content/classes.json this chef delves as. */
135 classId: string;
93136 x: number;
94137 z: number;
95138 fx: number;
@@ -100,10 +143,12 @@ export interface Hero {
100143 target: number | null;
101144 wantSwing: boolean;
102145 wantDodge: { x: number; z: number } | null;
146 wantAbility: boolean;
103147 swing: number;
104148 cooldown: number;
105149 dodge: number;
106150 dodgeCd: number;
151 abilityCd: number;
107152 dx: number;
108153 dz: number;
109154 invuln: number;
@@ -131,6 +176,9 @@ export interface Shot {
131176 readonly dz: number;
132177 ttl: number;
133178 readonly damage: number;
179 /** Who fired it: a hero's shots bite monsters, a monster's bite heroes. */
180 readonly from: "hero" | "monster";
181 readonly by: number;
134182 }
135183 export interface Chest {
136184 readonly id: number;
@@ -163,6 +211,7 @@ export type Happening =
163211 | { readonly type: "revived"; readonly hero: number }
164212 | { readonly type: "slain"; readonly monster: number; readonly kind: string }
165213 | { readonly type: "loot"; readonly hero: number; readonly loot: string }
214 | { readonly type: "ability"; readonly hero: number; readonly ability: string }
166215 | { readonly type: "open" }
167216 | { readonly type: "room"; readonly depth: number; readonly room: string }
168217 | { readonly type: "over"; readonly outcome: Outcome };
@@ -192,6 +241,7 @@ export type Order =
192241 | { readonly type: "move"; readonly x: number; readonly z: number }
193242 | { readonly type: "attack"; readonly target?: number | undefined }
194243 | { readonly type: "dodge"; readonly x: number; readonly z: number }
244 | { readonly type: "ability" }
195245 | { readonly type: "stop" };
196246
197247 // ── Randomness: mulberry32, its state kept in the world ──
@@ -277,6 +327,33 @@ const roomById = (content: Content, id: string): RoomDef => {
277327 const monsterDef = (content: Content, kind: string): MonsterDef =>
278328 content.monsters.find((m) => m.id === kind)!;
279329
330 /** The class a hero delves as: unknown ids fall back to the knight. */
331 export const classOf = (content: Content, classId: string): ClassDef => {
332 const found = content.classes.find((c) => c.id === classId);
333 if (found !== undefined) return found;
334 return content.classes.find((c) => c.id === "knight") ?? content.classes[0]!;
335 };
336
337 /** A hero's numbers: their class's stats over the shared baseline. */
338 function heroRules(content: Content, hero: Hero): HeroRules {
339 const c = classOf(content, hero.classId);
340 const base = content.rules.hero;
341 return {
342 hp: c.hp,
343 radius: base.radius,
344 speed: c.speed,
345 damage: c.damage,
346 reach: c.reach ?? base.reach,
347 swing: c.swing,
348 cooldown: c.cooldown,
349 dodgeSpeed: c.dodgeSpeed,
350 dodgeTicks: c.dodgeTicks,
351 dodgeCooldown: c.dodgeCooldown,
352 invuln: c.invuln,
353 revive: c.revive,
354 };
355 }
356
280357 // ── The world, made and changed ──
281358
282359 export function createWorld(content: Content, seed: number): World {
@@ -356,28 +433,32 @@ export function addHero(
356433 world: World,
357434 id: number,
358435 name: string,
436 classId: string = "knight",
359437 ): Hero {
360 const rules = content.rules.hero;
438 const cls = classOf(content, classId);
361439 const hero: Hero = {
362440 id,
363441 name,
442 classId: cls.id,
364443 x: 0,
365444 z: 0,
366445 fx: 1,
367446 fz: 0,
368 hp: rules.hp,
447 hp: cls.hp,
369448 power: 0,
370449 goal: null,
371450 target: null,
372451 wantSwing: false,
373452 wantDodge: null,
453 wantAbility: false,
374454 swing: 0,
375455 cooldown: 0,
376456 dodge: 0,
377457 dodgeCd: 0,
458 abilityCd: 0,
378459 dx: 0,
379460 dz: 0,
380 invuln: rules.invuln,
461 invuln: cls.invuln,
381462 downed: false,
382463 revive: 0,
383464 };
@@ -403,6 +484,7 @@ export function order(world: World, heroId: number, o: Order): void {
403484 hero.goal = null;
404485 } else hero.wantSwing = true;
405486 } else if (o.type === "dodge") hero.wantDodge = { x: o.x, z: o.z };
487 else if (o.type === "ability") hero.wantAbility = true;
406488 else {
407489 hero.goal = null;
408490 hero.target = null;
@@ -464,10 +546,9 @@ export function step(content: Content, world: World): Happening[] {
464546 world.happened = [];
465547 if (world.over !== null) return world.happened;
466548 world.tick++;
467 const rules = content.rules.hero;
468 for (const hero of world.heroes) stepHero(content, world, hero, rules);
549 for (const hero of world.heroes) stepHero(content, world, hero);
469550 for (const monster of world.monsters) stepMonster(content, world, monster);
470 stepShots(world);
551 stepShots(content, world);
471552 stepTraps(content, world);
472553
473554 const slain = world.monsters.filter((m) => m.hp <= 0);
@@ -524,14 +605,12 @@ export function finish(world: World, outcome: Outcome): void {
524605 world.happened.push({ type: "over", outcome });
525606 }
526607
527 function stepHero(
528 content: Content,
529 world: World,
530 hero: Hero,
531 rules: HeroRules,
532 ): void {
608 function stepHero(content: Content, world: World, hero: Hero): void {
609 const rules = heroRules(content, hero);
610 const cls = classOf(content, hero.classId);
533611 if (hero.cooldown > 0) hero.cooldown--;
534612 if (hero.dodgeCd > 0) hero.dodgeCd--;
613 if (hero.abilityCd > 0) hero.abilityCd--;
535614 if (hero.invuln > 0) hero.invuln--;
536615 if (hero.swing > 0) hero.swing--;
537616
@@ -573,7 +652,9 @@ function stepHero(
573652 if (foe === undefined) hero.target = null;
574653 else {
575654 const d = dist(hero, foe);
576 const reach = rules.reach + monsterDef(content, foe.kind).radius * 0.5;
655 const reach = cls.ranged
656 ? (cls.range ?? rules.reach)
657 : rules.reach + monsterDef(content, foe.kind).radius * 0.5;
577658 const dir = toward(hero, foe);
578659 if (dir !== undefined) {
579660 hero.fx = dir.x;
@@ -603,7 +684,15 @@ function stepHero(
603684
604685 if (hero.wantSwing) {
605686 hero.wantSwing = false;
606 if (hero.cooldown === 0 && hero.dodge === 0) swing(content, world, hero, rules);
687 if (hero.cooldown === 0 && hero.dodge === 0) {
688 if (cls.ranged) fireShot(content, world, hero, cls, hero.fx, hero.fz, 0);
689 else swing(content, world, hero, rules);
690 }
691 }
692
693 if (hero.wantAbility) {
694 hero.wantAbility = false;
695 if (hero.abilityCd === 0 && hero.dodge === 0) useAbility(content, world, hero, cls);
607696 }
608697
609698 // Treasure opens to the touch.
@@ -638,6 +727,71 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo
638727 }
639728 }
640729
730 /**
731 * A hero's shot: an arrow, a bolt, whatever the class throws. The spread is
732 * a sideways nudge, not an angle — no trigonometry, the same on any engine.
733 */
734 function fireShot(
735 content: Content,
736 world: World,
737 hero: Hero,
738 cls: ClassDef,
739 dx: number,
740 dz: number,
741 spread: number,
742 ): void {
743 const rules = heroRules(content, hero);
744 hero.swing = rules.swing;
745 hero.cooldown = rules.cooldown;
746 const speed = cls.shotSpeed ?? 0.25;
747 const vx = (dx + -dz * spread) * speed;
748 const vz = (dz + dx * spread) * speed;
749 world.shots.push({
750 id: world.nextId++,
751 x: hero.x,
752 z: hero.z,
753 dx: vx,
754 dz: vz,
755 ttl: Math.ceil(((cls.range ?? 6) + 3) / speed),
756 damage: rules.damage + hero.power,
757 from: "hero",
758 by: hero.id,
759 });
760 }
761
762 /** A class's signature move, by kind — the JSON names it, this cooks it. */
763 function useAbility(content: Content, world: World, hero: Hero, cls: ClassDef): void {
764 const a = cls.ability;
765 hero.abilityCd = a.cooldownTicks;
766 world.happened.push({ type: "ability", hero: hero.id, ability: a.id });
767 if (a.kind === "bulwark") {
768 hero.invuln = Math.max(hero.invuln, a.duration ?? 20);
769 const radius = a.radius ?? 2.2;
770 const push = a.knockback ?? 1.2;
771 for (const m of world.monsters) {
772 if (m.hp <= 0) continue;
773 const dir = toward(hero, m);
774 if (dir === undefined || dist(hero, m) > radius) continue;
775 slide(world, m, dir.x * push, dir.z * push, monsterDef(content, m.kind).radius);
776 }
777 } else if (a.kind === "volley") {
778 const n = a.count ?? 3;
779 const spread = a.spread ?? 0.28;
780 for (let i = 0; i < n; i++)
781 fireShot(content, world, hero, cls, hero.fx, hero.fz, (i - (n - 1) / 2) * spread);
782 } else if (a.kind === "blink") {
783 const rules = heroRules(content, hero);
784 let left = a.distance ?? 3;
785 while (left > 0) {
786 const hop = Math.min(left, 0.5);
787 if (slide(world, hero, hero.fx * hop, hero.fz * hop, rules.radius)) break;
788 left -= hop;
789 }
790 hero.invuln = Math.max(hero.invuln, a.invuln ?? 10);
791 hero.goal = null;
792 }
793 }
794
641795 function roll(content: Content, world: World) {
642796 const total = content.loot.reduce((sum, l) => sum + l.weight, 0);
643797 let pick = random(world) * total;
@@ -651,7 +805,7 @@ function roll(content: Content, world: World) {
651805 function hurt(content: Content, world: World, hero: Hero, damage: number): boolean {
652806 if (hero.downed || hero.invuln > 0 || hero.dodge > 0) return false;
653807 hero.hp -= damage;
654 hero.invuln = content.rules.hero.invuln;
808 hero.invuln = heroRules(content, hero).invuln;
655809 world.happened.push({ type: "hurt", hero: hero.id, damage });
656810 if (hero.hp <= 0) {
657811 hero.hp = 0;
@@ -748,26 +902,47 @@ function stepMonster(content: Content, world: World, m: Monster): void {
748902 dz: dir.z * speed,
749903 ttl: Math.ceil((range + 3) / speed),
750904 damage: def.damage,
905 from: "monster",
906 by: m.id,
751907 });
752908 m.cd = def.cooldown ?? 45;
753909 }
754910 }
755911
756 function stepShots(world: World): void {
757 world.shots = world.shots.filter((s) => {
912 function stepShots(content: Content, world: World): void {
913 for (const s of world.shots) {
758914 s.x += s.dx;
759915 s.z += s.dz;
760916 s.ttl--;
761 return s.ttl > 0 && !solid(world, Math.floor(s.x), Math.floor(s.z));
762 });
917 if (s.ttl <= 0) continue;
918 // A hero's shot bites the first monster it reaches.
919 if (s.from === "hero") {
920 for (const m of world.monsters) {
921 if (m.hp <= 0) continue;
922 const def = monsterDef(content, m.kind);
923 if (dist(s, m) > 0.35 + def.radius) continue;
924 const dir = toward(s, m);
925 m.hp -= s.damage;
926 m.hurt = 4;
927 if (dir !== undefined) slide(world, m, dir.x * 0.3, dir.z * 0.3, def.radius);
928 world.happened.push({ type: "hit", by: s.by, on: m.id, damage: s.damage });
929 s.ttl = 0;
930 break;
931 }
932 }
933 }
934 world.shots = world.shots.filter(
935 (s) => s.ttl > 0 && !solid(world, Math.floor(s.x), Math.floor(s.z)),
936 );
763937 }
764938
765939 function stepTraps(content: Content, world: World): void {
766940 // Shots land here too: one pass over the heroes for everything that bites.
767941 for (const hero of world.heroes) {
768 // A seed splats on whoever it reaches, unless they are rolling through it.
942 // A monster's seed splats on whoever it reaches, unless they are rolling through it.
769943 world.shots = world.shots.filter((s) => {
770 if (hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) return true;
944 if (s.from === "hero" || hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4)
945 return true;
771946 hurt(content, world, hero, s.damage);
772947 return false;
773948 });
@@ -804,6 +979,7 @@ export function snapshot(world: World) {
804979 heroes: world.heroes.map((h) => ({
805980 id: h.id,
806981 name: h.name,
982 classId: h.classId,
807983 x: round(h.x),
808984 z: round(h.z),
809985 fx: round(h.fx),
static/app.css +31
@@ -423,3 +423,34 @@ kbd {
423423 animation: none;
424424 }
425425 }
426
427 /* Cheffry's dressing room: the class picker. */
428 .class-pick {
429 display: grid;
430 gap: var(--space-3);
431 grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
432 }
433 .class-card {
434 display: grid;
435 gap: var(--space-1);
436 align-content: start;
437 padding: var(--space-3);
438 border: 1px solid var(--color-line);
439 border-radius: var(--radius-md);
440 cursor: pointer;
441 min-height: var(--size-tap);
442 }
443 .class-card:has(input:checked) {
444 border-color: var(--color-accent);
445 outline: 2px solid var(--color-accent);
446 }
447 .class-card input {
448 accent-color: var(--color-accent);
449 }
450 .class-name {
451 font-weight: bold;
452 }
453 .class-tag {
454 font-size: var(--text-sm);
455 color: var(--color-muted);
456 }
static/delve.js +4
@@ -343,6 +343,9 @@ function listen() {
343343 } else if (k === "k" || k === "Shift") {
344344 e.preventDefault();
345345 dodgeAhead();
346 } else if (k === "e") {
347 e.preventDefault();
348 orderUp({ type: "ability" });
346349 }
347350 });
348351 document.addEventListener("keyup", (e) => {
@@ -351,6 +354,7 @@ function listen() {
351354 });
352355 document.querySelector("[data-act=attack]")?.addEventListener("click", attackNearest);
353356 document.querySelector("[data-act=dodge]")?.addEventListener("click", dodgeAhead);
357 document.querySelector("[data-act=ability]")?.addEventListener("click", () => orderUp({ type: "ability" }));
354358 }
355359
356360 // Held keys walk a couple of tiles ahead, again while they stay held.
static/hub.js +23
@@ -201,7 +201,30 @@ function listen() {
201201 });
202202 }
203203
204 // Cheffry's dressing room: the picked class is remembered, and every
205 // join or start form carries it when it goes — even tables that open later.
206 const CLASS_KEY = "cheffry-class";
207 function dressingRoom() {
208 const radios = [...document.querySelectorAll('input[name="pick-class"]')];
209 if (radios.length === 0) return;
210 const known = (id) => radios.some((r) => r.value === id);
211 const saved = localStorage.getItem(CLASS_KEY);
212 if (saved && known(saved))
213 radios.find((r) => r.value === saved).checked = true;
214 for (const r of radios)
215 r.addEventListener("change", () => localStorage.setItem(CLASS_KEY, r.value));
216 document.addEventListener("submit", (e) => {
217 const form = e.target;
218 if (!(form instanceof HTMLFormElement)) return;
219 const field = form.querySelector("input[data-class]");
220 if (!field) return;
221 const picked = document.querySelector('input[name="pick-class"]:checked');
222 field.value = picked && known(picked.value) ? picked.value : "knight";
223 });
224 }
225
204226 async function start() {
227 dressingRoom();
205228 if (!renderer) return;
206229 root.classList.add("ready");
207230 game = await (await fetch(root.dataset.content)).json();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs