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

From outside: Community vote winner: character classes (archery, mage, etc.) #6

opened this in answer to

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

cheffry-james/cheffry-s-dungeon:cheffry-james/3-from-outside-community-vote-winner-chara → main 2 commits fork

  • Can't merge. Conflicts in 13 files: README.md, content/classes.json, content/hub.json…. Merge main into the branch and push again.

    Conflicts in 13 filesdeploy 1aaf68c

    Website spec: 4 findings, 4 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / the first focusable element is not a skip link (an in-page #target link) — skip-link
    • / a 6847-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:39071/tokens.css?v=88a5de9a: a 3244-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:39071/static/app.css?v=720c3252: a 9341-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching

Review

Sign in to review this change.
19 files changed +966 −65
README.md +30 −3
@@ -31,6 +31,21 @@ it: the preview comes up the moment the app answers, and says `live`, or
3131 the nearest), right-click / `Shift`+click / `K` / `Shift` to dodge through
3232 a hit. WASD or arrows steer. A downed chef gets back up if a friend stands
3333 over them. Clear five rooms of the day's dungeon to win.
34 - **Three classes** — the community's first vote, called by Ben Versluys.
35 Change in **Cheffry's dressing room** on the hub page (the curtain on the
36 square's left wall) between delves; you keep what you wear until you
37 change, and your next table seats you as it.
38 - **Knight** (tank) — cleaver and pot lid, the most hearts, a steady
39 shoulder roll. *Order Up!* pulls every monster nearby onto the Knight,
40 and the lid takes a point off each hit while it lasts.
41 - **Archer** (damage) — fires skewers from six tiles off, a light apron,
42 a dash that is ready again almost at once. *Skewer Storm* looses five
43 in a fan.
44 - **Mage** (support) — slow fireballs that splash, three hearts, a blink
45 instead of a roll. *Soup's On!* heals every chef close by and gets the
46 downed back up.
47
48 `Q` (or the third button) is your class's signature move.
3449 - **Three enemies, three brains** — the Meatball Slime (chases), the
3550 Baguette Knight (winds up and charges), the Pepper Imp (keeps its distance
3651 and shoots) — plus Skewer Spikes and chests of coins, truffles, soup and
@@ -44,17 +59,29 @@ it: the preview comes up the moment the app answers, and says `live`, or
4459 ## Content is data
4560
4661 `content/*.json` is the game: `rooms.json` (layouts, spawns, traps, chests),
47 `monsters.json`, `traps.json`, `loot.json`, `rules.json` (every tuning number,
48 in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole
62 `monsters.json`, `traps.json`, `loot.json`, `classes.json`, `rules.json`
63 (every tuning number, in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole
4964 at boot and refuses to start on a bad one, naming the field. A new room or a
5065 new monster built from an existing brain is a JSON edit and nothing else.
5166
67 A class is the same: an entry in `classes.json` lays its own numbers over
68 `rules.hero` (hearts, speed, reach, the feel of its dodge), picks an attack
69 (`melee` or `shot`, with `splash` if it should), a signature move (`taunt`,
70 `volley` or `heal`, tuned by the numbers beside it), a weapon to draw
71 (`sword`, `bow`, `staff`) and an apron colour. The next voted class built
72 from those is a JSON edit; a new *kind* of move is code (`cast` in
73 `src/sim.ts`). The first class is the default.
74
75 The choice a person wears lives in memory beside the hub (it outlasts
76 wandering off, not a restart), and `POST /api/delves` / `POST /api/heroes`
77 take an optional `class` to sit down as something else.
78
5279 ## The networking
5380
5481 A delve is `src/sim.ts`: a pure, seeded, 20 Hz tick with no imports, so the
5582 same seed and the same orders make the same run anywhere. The server owns
5683 every instance (`src/delves.ts`) and is the authority; clients send orders
57 (`move`, `attack`, `dodge`, `stop`) and receive snapshots over
84 (`move`, `attack`, `dodge`, `ability`, `stop`) and receive snapshots over
5885 `/delves/<id>/events`. The page loads the very same sim as `/sim.js` to lay
5986 out rooms. Instances share nothing but the database, so they would split
6087 across processes by delve id when one process is no longer enough — v1
content/classes.json added +85
@@ -0,0 +1,85 @@
1 {
2 "$description": "Who a chef is in a delve: the community's picks, voted in. The first class is the default. `hero` overrides any number in rules.hero for this class (hp, speed, damage, reach, swing, cooldown, dodgeSpeed, dodgeTicks, dodgeCooldown). `attack.kind` is melee (a swing) or shot (a projectile; `splash` hits everything that close to where it lands). `ability.kind` is which signature move the class gets (taunt, volley or heal) and the numbers beside it tune it. `dodge` says how its dodge feels (its numbers are in `hero`); `look` is the weapon the renderer draws (sword, bow or staff); `paint` is the apron's colour token. A new class built from an existing attack, ability and look is a JSON edit and nothing else.",
3 "classes": [
4 {
5 "id": "knight",
6 "name": "Knight",
7 "weapon": "Cleaver & Pot Lid",
8 "role": "tank",
9 "blurb": "Sturdy, straightforward, smells faintly of garlic. Stands in front so nobody else has to.",
10 "look": "sword",
11 "dodge": "Shoulder roll: short, steady, hard to stop",
12 "paint": "game-apron",
13 "hero": {},
14 "attack": { "kind": "melee" },
15 "ability": {
16 "kind": "taunt",
17 "name": "Order Up!",
18 "says": "Bellows so loud every monster nearby comes for the Knight, whose pot lid shrugs off a point of every hit for a while.",
19 "cooldown": 300,
20 "radius": 5,
21 "ticks": 80,
22 "block": 1
23 }
24 },
25 {
26 "id": "archer",
27 "name": "Archer",
28 "weapon": "Skewer Bow",
29 "role": "damage",
30 "blurb": "Fires kebab skewers from a safe distance. Light apron, quick feet, zero chill.",
31 "look": "bow",
32 "dodge": "Quick dash: snappy, and ready again almost at once",
33 "paint": "game-leaf",
34 "hero": {
35 "hp": 4,
36 "speed": 0.18,
37 "reach": 6,
38 "swing": 5,
39 "cooldown": 11,
40 "dodgeSpeed": 0.5,
41 "dodgeTicks": 5,
42 "dodgeCooldown": 12
43 },
44 "attack": { "kind": "shot", "shotSpeed": 0.45 },
45 "ability": {
46 "kind": "volley",
47 "name": "Skewer Storm",
48 "says": "Five skewers in a fan. Somebody's getting kebabbed.",
49 "cooldown": 200,
50 "count": 5,
51 "spread": 0.22
52 }
53 },
54 {
55 "id": "mage",
56 "name": "Mage",
57 "weapon": "Ladle of Flambé",
58 "role": "support",
59 "blurb": "Throws fireballs that splash. Made of tissue paper. Blinks instead of rolling. Brings soup.",
60 "look": "staff",
61 "dodge": "Blink: there, then suddenly over there — slow to recharge",
62 "paint": "game-water",
63 "hero": {
64 "hp": 3,
65 "speed": 0.15,
66 "damage": 3,
67 "reach": 5,
68 "swing": 8,
69 "cooldown": 30,
70 "dodgeSpeed": 1.1,
71 "dodgeTicks": 2,
72 "dodgeCooldown": 50
73 },
74 "attack": { "kind": "shot", "shotSpeed": 0.22, "splash": 1 },
75 "ability": {
76 "kind": "heal",
77 "name": "Soup's On!",
78 "says": "A pot of healing broth: every chef close by gets hearts back, and anyone downed gets up.",
79 "cooldown": 400,
80 "radius": 3.5,
81 "heal": 3
82 }
83 }
84 ]
85 }
content/hub.json +2
@@ -7,6 +7,8 @@
77 { "id": "graffiti-wall", "shape": "box", "x": 11, "z": 0.6, "w": 10, "h": 2, "d": 0.4, "paint": "game-wall" },
88 { "id": "barrel-1", "shape": "prism", "x": 3, "z": 13, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" },
99 { "id": "barrel-2", "shape": "prism", "x": 4.2, "z": 13.5, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" },
10 { "id": "dressing-room", "shape": "box", "x": 1.2, "z": 8, "w": 1.4, "h": 2.2, "d": 2.6, "paint": "game-pepper" },
11 { "id": "mirror", "shape": "box", "x": 2.1, "z": 6.4, "w": 0.2, "h": 1.6, "d": 0.9, "paint": "game-water" },
1012 { "id": "table", "shape": "box", "x": 17, "z": 12, "w": 3, "h": 0.7, "d": 1.6, "paint": "game-wall" }
1113 ]
1214 }
contract/openapi.json +64 −5
@@ -368,7 +368,7 @@
368368 }
369369 },
370370 "patch": {
371 "summary": "Walk somewhere, emote, or say something.",
371 "summary": "Walk somewhere, emote, say something, or change class in the dressing room.",
372372 "operationId": "patchApiAvatarsId",
373373 "parameters": [
374374 {
@@ -386,7 +386,7 @@
386386 "application/json": {
387387 "schema": {
388388 "type": "object",
389 "description": "Any of: where to walk (x and z together), an emote, words.",
389 "description": "Any of: where to walk (x and z together), an emote, words, a class to wear into the next delve.",
390390 "properties": {
391391 "x": {
392392 "type": "number"
@@ -407,6 +407,13 @@
407407 "type": "string",
408408 "minLength": 1,
409409 "maxLength": 140
410 },
411 "class": {
412 "enum": [
413 "knight",
414 "archer",
415 "mage"
416 ]
410417 }
411418 }
412419 }
@@ -415,7 +422,7 @@
415422 },
416423 "responses": {
417424 "200": {
418 "description": "Walk somewhere, emote, or say something.",
425 "description": "Walk somewhere, emote, say something, or change class in the dressing room.",
419426 "content": {
420427 "application/json": {
421428 "schema": {
@@ -529,6 +536,26 @@
529536 "post": {
530537 "summary": "Open today's dungeon with yourself at the table.",
531538 "operationId": "postApiDelves",
539 "requestBody": {
540 "required": true,
541 "content": {
542 "application/json": {
543 "schema": {
544 "type": "object",
545 "properties": {
546 "class": {
547 "enum": [
548 "knight",
549 "archer",
550 "mage"
551 ],
552 "description": "Which class to cook as; what you wear in the hub when left out."
553 }
554 }
555 }
556 }
557 }
558 },
532559 "responses": {
533560 "201": {
534561 "description": "Open today's dungeon with yourself at the table.",
@@ -648,6 +675,14 @@
648675 "properties": {
649676 "delve": {
650677 "type": "integer"
678 },
679 "class": {
680 "enum": [
681 "knight",
682 "archer",
683 "mage"
684 ],
685 "description": "Which class to cook as; what you wear in the hub when left out."
651686 }
652687 },
653688 "required": [
@@ -747,13 +782,14 @@
747782 "application/json": {
748783 "schema": {
749784 "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.",
785 "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.",
751786 "properties": {
752787 "type": {
753788 "enum": [
754789 "move",
755790 "attack",
756791 "dodge",
792 "ability",
757793 "stop"
758794 ]
759795 },
@@ -1253,6 +1289,13 @@
12531289 "name": {
12541290 "type": "string"
12551291 },
1292 "class": {
1293 "enum": [
1294 "knight",
1295 "archer",
1296 "mage"
1297 ]
1298 },
12561299 "x": {
12571300 "type": "number"
12581301 },
@@ -1291,6 +1334,7 @@
12911334 "id",
12921335 "author_id",
12931336 "name",
1337 "class",
12941338 "x",
12951339 "z",
12961340 "tx",
@@ -1328,6 +1372,13 @@
13281372 },
13291373 "name": {
13301374 "type": "string"
1375 },
1376 "class": {
1377 "enum": [
1378 "knight",
1379 "archer",
1380 "mage"
1381 ]
13311382 }
13321383 }
13331384 }
@@ -1385,13 +1436,21 @@
13851436 },
13861437 "author_name": {
13871438 "type": "string"
1439 },
1440 "class": {
1441 "enum": [
1442 "knight",
1443 "archer",
1444 "mage"
1445 ]
13881446 }
13891447 },
13901448 "required": [
13911449 "id",
13921450 "delve_id",
13931451 "author_id",
1394 "author_name"
1452 "author_name",
1453 "class"
13951454 ]
13961455 },
13971456 "Run": {
src/actions.ts +45 −17
@@ -376,9 +376,18 @@ export interface AvatarChange {
376376 readonly z?: unknown;
377377 readonly emote?: unknown;
378378 readonly say?: unknown;
379 readonly class?: unknown;
379380 }
380381
381 /** Walk somewhere, strike a pose, or say something — any of them at once. */
382 /** A class the content knows, by id; a reason when it is not one. */
383 function readClass(value: unknown): string | Result<never> {
384 const found = game.classes.find((c) => c.id === value);
385 return found !== undefined
386 ? found.id
387 : fail("invalid", `A class is one of ${game.classes.map((c) => c.id).join(", ")}.`);
388 }
389
390 /** Walk somewhere, strike a pose, say something, or change class — any of them at once. */
382391 export const updateAvatar = (
383392 db: Database,
384393 who: Who,
@@ -404,12 +413,15 @@ function changeAvatar(who: Who, input: AvatarChange): Result<AvatarView> {
404413 input.say === undefined ? undefined : String(input.say).trim();
405414 if (words !== undefined && (words.length === 0 || words.length > SAY_MAX))
406415 return fail("invalid", `Words in the hub are 1 to ${SAY_MAX} characters.`);
407 if (!walking && input.emote === undefined && words === undefined)
408 return fail("invalid", "Say where to walk, an emote, or something to say.");
416 const cls = input.class === undefined ? undefined : readClass(input.class);
417 if (typeof cls === "object") return cls;
418 if (!walking && input.emote === undefined && words === undefined && cls === undefined)
419 return fail("invalid", "Say where to walk, an emote, something to say, or a class to wear.");
409420 const now = Date.now();
410421 const avatar = found.value;
411422 if (walking) hub.walk(avatar, input.x as number, input.z as number, now);
412423 if (input.emote !== undefined) hub.emote(avatar, String(input.emote), now);
424 if (cls !== undefined) hub.dress(avatar, cls, now);
413425 if (words !== undefined) {
414426 hub.say(avatar, words, now);
415427 emit("hub.chat", { id: avatar.id });
@@ -499,21 +511,34 @@ export function watchDelve(
499511 return found.ok ? ok(found.value.bus) : found;
500512 }
501513
502 /**
503 * Open today's dungeon with yourself at the table. Whoever is in another
504 * delve leaves it first: a chef cooks in one kitchen at a time.
505 */
506 export const openDelve = (db: Database, who: Who): Result<DelveView> =>
507 audited(db, who, "delve", "create", undefined, openOne(db, who));
514 /** Which class a chef sits down as: the one asked for, else what they wear in the hub. */
515 const classFor = (who: { id: string }, asked: unknown): string | Result<never> =>
516 asked === undefined || asked === null || asked === ""
517 ? hub.outfitOf(who.id)
518 : readClass(asked);
508519
509 function openOne(db: Database, who: Who): Result<DelveView> {
520 /**
521 * Open today's dungeon with yourself at the table, as the class you wear
522 * (or the one asked for). Whoever is in another delve leaves it first: a
523 * chef cooks in one kitchen at a time.
524 */
525 export const openDelve = (
526 db: Database,
527 who: Who,
528 input: { class?: unknown } = {},
529 ): Result<DelveView> =>
530 audited(db, who, "delve", "create", undefined, openOne(db, who, input));
531
532 function openOne(db: Database, who: Who, input: { class?: unknown }): Result<DelveView> {
510533 if (who.role === "anon" || permit(who, "delve", "create") !== "yes")
511534 return refuse(who, "Sign in to start a delve.", "You may not start a delve.");
535 const cls = classFor(who, input.class);
536 if (typeof cls === "object") return cls;
512537 const now = Date.now();
513538 const before = kitchen.heroOfAuthor(who.id);
514539 if (before !== undefined) kitchen.dismiss(before, now);
515540 const delve = kitchen.open(who, now, (d) => recordRun(db, d));
516 kitchen.enlist(delve, who, now);
541 kitchen.enlist(delve, who, now, cls);
517542 emit("delve.opened", { id: delve.id });
518543 return ok(kitchen.view(delve));
519544 }
@@ -555,19 +580,21 @@ export function getHero(
555580 return found.ok ? ok(kitchen.heroView(found.value)) : found;
556581 }
557582
558 /** Join a party: take one of its four places, as yourself. */
583 /** Join a party: take one of its four places, as yourself, in the class you wear (or the one asked for). */
559584 export const joinDelve = (
560585 db: Database,
561586 who: Who,
562 input: { delve: number },
587 input: { delve: number; class?: unknown },
563588 ): Result<HeroView> =>
564589 audited(db, who, "hero", "create", undefined, join(who, input));
565590
566 function join(who: Who, input: { delve: number }): Result<HeroView> {
591 function join(who: Who, input: { delve: number; class?: unknown }): Result<HeroView> {
567592 if (who.role === "anon" || permit(who, "hero", "create") !== "yes")
568593 return refuse(who, "Sign in to join a party.", "You may not join a party.");
569594 if (!Number.isInteger(input.delve))
570595 return fail("invalid", "A delve id is a whole number.");
596 const cls = classFor(who, input.class);
597 if (typeof cls === "object") return cls;
571598 const delve = kitchen.delveOf(input.delve);
572599 if (delve === undefined || delve.ended !== null)
573600 return fail("not_found", "That delve is over, or never was.");
@@ -577,7 +604,7 @@ function join(who: Who, input: { delve: number }): Result<HeroView> {
577604 if (!kitchen.joinable(delve))
578605 return fail("invalid", `That party is full: ${kitchen.PARTY_MAX} chefs a table.`);
579606 if (before !== undefined) kitchen.dismiss(before, now);
580 const hero = kitchen.enlist(delve, who, now);
607 const hero = kitchen.enlist(delve, who, now, cls);
581608 emit("delve.changed", { id: delve.id });
582609 return ok(kitchen.heroView(hero));
583610 }
@@ -609,10 +636,11 @@ export function readOrder(
609636 : "A target is a monster's id, a whole number.";
610637 }
611638 if (type === "stop") return { type: "stop" };
612 return "An order is move, attack, dodge or stop.";
639 if (type === "ability") return { type: "ability" };
640 return "An order is move, attack, dodge, ability or stop.";
613641 }
614642
615 /** Give your hero an order: move, attack, dodge or stop. */
643 /** Give your hero an order: move, attack, dodge, ability or stop. */
616644 export const commandHero = (
617645 db: Database,
618646 who: Who,
src/classes.test.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * Classes, in the sim: each lays its numbers over the base rules, attacks
3 * its own way, dodges its own way, and has one signature move — and the
4 * content checker refuses a class it could not play.
5 */
6 import { expect, test } from "bun:test";
7 import { check, game } from "./content.ts";
8 import {
9 addHero,
10 classOf,
11 type Content,
12 createWorld,
13 order,
14 rulesOf,
15 snapshot,
16 step,
17 type World,
18 } from "./sim.ts";
19
20 const kitchen = (map: string[]): Content => ({
21 ...game,
22 rules: { ...game.rules, roomsPerRun: 1 },
23 rooms: [{ id: "r0", name: "Room", final: true, map }],
24 });
25 const run = (content: Content, world: World, ticks: number) => {
26 const seen: string[] = [];
27 for (let i = 0; i < ticks && world.over === null; i++)
28 for (const h of step(content, world)) seen.push(h.type);
29 return seen;
30 };
31 const LONG = ["##############", "#S.........m.#", "#...........E#", "##############"];
32
33 test("the vote is in: a Knight, an Archer and a Mage, the Knight the default", () => {
34 expect(game.classes.map((c) => c.id)).toEqual(["knight", "archer", "mage"]);
35 expect(classOf(game, "nonsense").id).toBe("knight");
36 // The Knight is the chef as it always was.
37 expect(rulesOf(game, "knight")).toEqual(game.rules.hero);
38 // Tank, damage, support: the Knight has the most hearts, the Mage the fewest.
39 const hp = (id: string) => rulesOf(game, id).hp;
40 expect(hp("knight")).toBeGreaterThan(hp("archer"));
41 expect(hp("archer")).toBeGreaterThan(hp("mage"));
42 // The Archer dodges again soonest; the Mage blinks fastest.
43 expect(rulesOf(game, "archer").dodgeCooldown).toBeLessThan(rulesOf(game, "knight").dodgeCooldown);
44 expect(rulesOf(game, "mage").dodgeSpeed).toBeGreaterThan(rulesOf(game, "archer").dodgeSpeed);
45 });
46
47 test("a hero is its class: hearts, and the snapshot says which", () => {
48 const c = kitchen(LONG);
49 const w = createWorld(c, 1);
50 const archer = addHero(c, w, 1, "a", "archer");
51 const unknown = addHero(c, w, 2, "b", "bard");
52 expect(archer.hp).toBe(rulesOf(c, "archer").hp);
53 expect(unknown.cls).toBe("knight");
54 expect(snapshot(w).heroes.map((h) => h.cls)).toEqual(["archer", "knight"]);
55 });
56
57 test("the Archer shoots from afar; the Knight has to walk up", () => {
58 const c = kitchen(LONG);
59 const shoot = (cls: string) => {
60 const w = createWorld(c, 1);
61 const hero = addHero(c, w, 1, "a", cls);
62 const foe = w.monsters[0]!;
63 order(w, 1, { type: "attack", target: foe.id });
64 for (let t = 0; t < 400 && w.kills === 0; t++) {
65 foe.x = 11.5; // pinned in place, so only reach decides
66 foe.z = 1.5;
67 step(c, w);
68 }
69 return { kills: w.kills, x: hero.x };
70 };
71 const archer = shoot("archer");
72 const knight = shoot("knight");
73 expect(archer.kills).toBe(1);
74 expect(knight.kills).toBe(1);
75 expect(archer.x).toBeLessThan(7);
76 expect(knight.x).toBeGreaterThan(9);
77 });
78
79 test("a Mage's fireball splashes; a hero's shot never hurts a hero", () => {
80 const c = kitchen(["##########", "#S.......#", "#........#", "#.......E#", "##########"]);
81 const w = createWorld(c, 1);
82 const mage = addHero(c, w, 1, "m", "mage");
83 const friend = addHero(c, w, 2, "f", "knight");
84 mage.x = 1.5;
85 mage.z = 2.5;
86 friend.x = 3.5;
87 friend.z = 2.5;
88 friend.invuln = 0;
89 for (const [x, z] of [[6, 2.5], [6.5, 3.2]] as const)
90 w.monsters.push({ id: w.nextId++, kind: "meatball", x, z, hp: 3, state: "idle", t: 0, dx: 0, dz: 0, cd: 0, hurt: 0, foe: 0, foeT: 0 });
91 mage.fx = 1;
92 mage.fz = 0;
93 order(w, 1, { type: "attack" });
94 let struck: number[] = [];
95 for (let t = 0; t < 40 && struck.length === 0; t++) {
96 const hits = step(c, w).filter((h) => h.type === "hit");
97 struck = hits.map((h) => (h.type === "hit" ? h.on : 0));
98 }
99 // One fireball, one tick, both monsters.
100 expect(struck.length).toBe(2);
101 expect(friend.hp).toBe(rulesOf(c, "knight").hp);
102 });
103
104 test("Order Up! pulls every monster near onto the Knight, whose lid takes the edge off", () => {
105 const c = kitchen(["############", "#S........m#", "#.........E#", "############"]);
106 const w = createWorld(c, 1);
107 const knight = addHero(c, w, 1, "k", "knight");
108 const mage = addHero(c, w, 2, "m", "mage");
109 const foe = w.monsters[0]!;
110 // The Mage stands nearest; the Knight is further off, but shouts.
111 mage.x = 8.5;
112 mage.z = 2.5;
113 knight.x = 6.5;
114 knight.z = 1.5;
115 mage.invuln = 0;
116 knight.invuln = 0;
117 foe.x = 9.5;
118 foe.z = 2;
119 order(w, 1, { type: "ability" });
120 const seen = run(c, w, 60);
121 expect(seen).toContain("ability");
122 expect(foe.foe).toBe(knight.id);
123 expect(knight.guard).toBeGreaterThan(0);
124 // A meatball bites for 1 and the lid blocks 1: the Knight takes nothing, the Mage nothing either.
125 expect(seen).toContain("blocked");
126 expect(knight.hp).toBe(rulesOf(c, "knight").hp);
127 expect(mage.hp).toBe(rulesOf(c, "mage").hp);
128 // Once is all a cooldown allows.
129 order(w, 1, { type: "ability" });
130 expect(run(c, w, 1)).not.toContain("ability");
131 });
132
133 test("Skewer Storm looses a fan of skewers", () => {
134 const c = kitchen(LONG);
135 const w = createWorld(c, 1);
136 addHero(c, w, 1, "a", "archer");
137 order(w, 1, { type: "ability" });
138 step(c, w);
139 const volley = classOf(c, "archer").ability.count!;
140 const mine = w.shots.filter((s) => s.by === 1);
141 expect(mine.length).toBe(volley);
142 expect(new Set(mine.map((s) => s.dz)).size).toBe(volley);
143 expect(snapshot(w).shots[0]).toMatchObject({ by: 1 });
144 });
145
146 test("Soup's On! heals whoever is close and gets the downed up", () => {
147 const c = kitchen(["#######", "#S...E#", "#######"]);
148 const w = createWorld(c, 1);
149 const mage = addHero(c, w, 1, "m", "mage");
150 const archer = addHero(c, w, 2, "a", "archer");
151 const knight = addHero(c, w, 3, "k", "knight");
152 archer.hp = 1;
153 knight.hp = 0;
154 knight.downed = true;
155 for (const h of [archer, knight]) {
156 h.x = mage.x + 0.5;
157 h.z = mage.z;
158 }
159 order(w, 1, { type: "ability" });
160 const seen = run(c, w, 1);
161 expect(seen).toContain("revived");
162 expect(knight.downed).toBe(false);
163 expect(archer.hp).toBe(Math.min(rulesOf(c, "archer").hp, 1 + classOf(c, "mage").ability.heal!));
164 // Nobody is healed past their class's hearts.
165 expect(mage.hp).toBe(rulesOf(c, "mage").hp);
166 });
167
168 test("the same seed, the same classes, the same orders: the same run", () => {
169 const play = () => {
170 const w = createWorld(game, 99);
171 addHero(game, w, 1, "a", "knight");
172 addHero(game, w, 2, "b", "archer");
173 addHero(game, w, 3, "c", "mage");
174 for (let t = 0; t < 600; t++) {
175 if (t % 30 === 0) for (const id of [1, 2, 3]) order(w, id, { type: "attack", target: w.monsters[0]?.id });
176 if (t % 90 === 0) for (const id of [1, 2, 3]) order(w, id, { type: "ability" });
177 step(game, w);
178 }
179 return JSON.stringify(snapshot(w));
180 };
181 expect(play()).toBe(play());
182 });
183
184 test("a class the sim could not play does not boot", () => {
185 const files = (classes: unknown) => ({
186 rules: { tickHz: 20, roomsPerRun: 1, maxTicks: 10, hero: game.rules.hero, score: game.rules.score, hub: { ...game.hub, props: undefined } },
187 monsters: { monsters: game.monsters },
188 traps: { traps: game.traps },
189 loot: { loot: game.loot },
190 rooms: { rooms: game.rooms },
191 hub: { props: game.hub.props },
192 classes,
193 });
194 expect(check(files({ classes: game.classes }))).toEqual([]);
195 const bad = {
196 ...game.classes[1]!,
197 id: "bard",
198 look: "lute",
199 paint: "plaid",
200 hero: { hp: -1, charisma: 9 },
201 attack: { kind: "song" },
202 ability: { kind: "heal", name: "x", says: "y", cooldown: 10 },
203 };
204 const problems = check(files({ classes: [...game.classes, bad] })).join("\n");
205 expect(problems).toContain("look must be one of");
206 expect(problems).toContain("paint plaid is not a colour token");
207 expect(problems).toContain("hp must be a positive number");
208 expect(problems).toContain("charisma is not one of");
209 expect(problems).toContain("kind must be one of melee, shot");
210 expect(problems).toContain("radius must be a positive number");
211 expect(check(files({ classes: [] }))).toContain("classes: must be a non-empty list");
212 });
src/content.ts +42 −2
@@ -1,6 +1,6 @@
11 /**
22 * The game's content, as data: `content/*.json` — the rules, the monsters,
3 * the traps, the loot, the rooms and the hub's furniture — read once at
3 * the traps, the loot, the rooms, the classes and the hub's furniture — read once at
44 * boot and checked whole. A new monster, room or chest drop is a change to
55 * a JSON file, never to code (a new *brain* is code: `behavior` in
66 * `src/sim.ts`).
@@ -11,7 +11,7 @@
1111
1212 import { createHash } from "node:crypto";
1313 import { readFileSync } from "node:fs";
14 import type { Content } from "./sim.ts";
14 import { CLASS_STATS, type Content } from "./sim.ts";
1515 import { tokens } from "./tokens.ts";
1616
1717 export interface HubRules {
@@ -38,6 +38,9 @@ export interface Game extends Content {
3838 }
3939
4040 const BEHAVIORS = ["chase", "charge", "shoot"];
41 const ATTACKS = ["melee", "shot"];
42 const ABILITIES = ["taunt", "volley", "heal"];
43 const LOOKS = ["sword", "bow", "staff"];
4144 const RESERVED = "#.SEC";
4245
4346 const isObject = (v: unknown): v is Record<string, unknown> =>
@@ -54,6 +57,7 @@ export function check(files: {
5457 loot: unknown;
5558 rooms: unknown;
5659 hub: unknown;
60 classes: unknown;
5761 }): string[] {
5862 const problems: string[] = [];
5963 const say = (where: string, what: string) => problems.push(`${where}: ${what}`);
@@ -180,6 +184,40 @@ export function check(files: {
180184 const paints = new Set(
181185 tokens.filter((t) => t.name.startsWith("--color-")).map((t) => t.name.slice(8)),
182186 );
187
188 const classes = listOf(files.classes, "classes");
189 ids("classes", classes);
190 classes.forEach((c, i) => {
191 const where = `classes[${i}]`;
192 if (!isObject(c)) return;
193 for (const key of ["weapon", "role", "blurb", "dodge"])
194 if (typeof c[key] !== "string" || c[key] === "") say(where, `${key} is missing`);
195 if (!LOOKS.includes(String(c.look))) say(where, `look must be one of ${LOOKS.join(", ")}`);
196 if (!paints.has(String(c.paint))) say(where, `paint ${String(c.paint)} is not a colour token`);
197 const stats = isObject(c.hero) ? c.hero : undefined;
198 if (stats === undefined) say(where, "hero must be an object (empty for the base rules)");
199 else
200 for (const key of Object.keys(stats))
201 if (!(CLASS_STATS as readonly string[]).includes(key))
202 say(`${where}.hero`, `${key} is not one of ${CLASS_STATS.join(", ")}`);
203 else if (!positive(stats[key])) say(`${where}.hero`, `${key} must be a positive number`);
204 const attack = isObject(c.attack) ? c.attack : {};
205 if (!ATTACKS.includes(String(attack.kind)))
206 say(`${where}.attack`, `kind must be one of ${ATTACKS.join(", ")}`);
207 if (attack.kind === "shot") need(`${where}.attack`, attack, ["shotSpeed"]);
208 if (attack.splash !== undefined && !positive(attack.splash))
209 say(`${where}.attack`, "splash must be a positive number when given");
210 const ability = isObject(c.ability) ? c.ability : {};
211 if (!ABILITIES.includes(String(ability.kind)))
212 say(`${where}.ability`, `kind must be one of ${ABILITIES.join(", ")}`);
213 for (const key of ["name", "says"])
214 if (typeof ability[key] !== "string" || ability[key] === "")
215 say(`${where}.ability`, `${key} is missing`);
216 need(`${where}.ability`, ability, ["cooldown"]);
217 if (ability.kind === "taunt") need(`${where}.ability`, ability, ["radius", "ticks", "block"]);
218 if (ability.kind === "volley") need(`${where}.ability`, ability, ["count", "spread"]);
219 if (ability.kind === "heal") need(`${where}.ability`, ability, ["radius", "heal"]);
220 });
183221 const props = listOf(files.hub, "props");
184222 ids("props", props.map((p) => (isObject(p) ? { ...p, name: p.id } : p)));
185223 props.forEach((p, i) => {
@@ -203,6 +241,7 @@ function load(): Game {
203241 loot: read("loot"),
204242 rooms: read("rooms"),
205243 hub: read("hub"),
244 classes: read("classes"),
206245 };
207246 const problems = check(files);
208247 if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`);
@@ -214,6 +253,7 @@ function load(): Game {
214253 traps: f.traps!.traps as Game["traps"],
215254 loot: f.loot!.loot as Game["loot"],
216255 rooms: f.rooms!.rooms as Game["rooms"],
256 classes: f.classes!.classes as Game["classes"],
217257 hub: { ...hub, props: f.hub!.props as Prop[] },
218258 };
219259 }
src/delves.ts +10 −3
@@ -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 this chef cooks as at this table. */
50 readonly class: 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; class: 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 class: 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 class: h.class,
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 class: h.class,
134139 });
135140
136141 /** Open today's dungeon for a party. `onEnd` is told once, when it is over. */
@@ -162,21 +167,23 @@ export function open(
162167 return delve;
163168 }
164169
165 /** A chef takes a place at the table. */
170 /** A chef takes a place at the table, dressed as a class (the default, unless said). */
166171 export function enlist(
167172 delve: Delve,
168173 who: { id: string; name: string },
169174 now: number,
175 cls?: string,
170176 ): HeroRow {
177 const hero = addHero(game, delve.world, nextHero, who.name, cls);
171178 const row: HeroRow = {
172179 id: nextHero++,
173180 delve_id: delve.id,
174181 author_id: who.id,
175182 author_name: who.name,
183 class: hero.cls,
176184 seen: now,
177185 };
178186 heroes.set(row.id, row);
179 addHero(game, delve.world, row.id, who.name);
180187 if (!delve.crew.includes(who.name)) delve.crew.push(who.name);
181188 send(delve);
182189 return row;
src/game.test.ts +31
@@ -189,3 +189,34 @@ 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("the dressing room: a class the content knows, worn into the next delve", () => {
194 const me = chef();
195 const entered = enterHub(db, me);
196 if (!entered.ok) throw new Error(entered.message);
197 const id = entered.value.id;
198 expect(entered.value.class).toBe("knight");
199 expect(failed(updateAvatar(db, me, { id, class: "bard" }))).toBe("invalid");
200 expect(failed(updateAvatar(db, chef(), { id, class: "mage" }))).toBe("forbidden");
201 const dressed = updateAvatar(db, me, { id, class: "archer" });
202 expect(dressed.ok && dressed.value.class).toBe("archer");
203 // Wandering off keeps the outfit: the wardrobe remembers.
204 sweepHub(Date.now() + game.hub.idleMinutes * 60_000 + 1);
205 expect(hub.outfitOf(me.id)).toBe("archer");
206 const opened = openDelve(db, me);
207 if (!opened.ok) throw new Error(opened.message);
208 expect(opened.value.party[0]?.class).toBe("archer");
209 expect(kitchen.delveOf(opened.value.id)!.world.heroes[0]!.cls).toBe("archer");
210 });
211
212 test("a seat can be taken as a class; a class nobody voted in cannot", () => {
213 const host = chef();
214 const opened = openDelve(db, host, { class: "mage" });
215 if (!opened.ok) throw new Error(opened.message);
216 expect(opened.value.party[0]?.class).toBe("mage");
217 expect(failed(openDelve(db, chef(), { class: "necromancer" }))).toBe("invalid");
218 const joined = joinDelve(db, chef(), { delve: opened.value.id, class: "knight" });
219 expect(joined.ok && joined.value.class).toBe("knight");
220 expect(failed(joinDelve(db, chef(), { delve: opened.value.id, class: 7 }))).toBe("invalid");
221 expect(readOrder({ type: "ability" }, { w: 5, h: 5 })).toEqual({ type: "ability" });
222 });
src/hub.ts +18
@@ -18,6 +18,8 @@ export interface Avatar {
1818 readonly id: number;
1919 readonly author_id: string;
2020 readonly name: string;
21 /** The class it is dressed as, for the next delve. */
22 class: string;
2123 x: number;
2224 z: number;
2325 tx: number;
@@ -37,6 +39,7 @@ export interface AvatarView {
3739 readonly id: number;
3840 readonly author_id: string;
3941 readonly name: string;
42 readonly class: string;
4043 readonly x: number;
4144 readonly z: number;
4245 readonly tx: number;
@@ -59,6 +62,8 @@ const CHAT_KEEP = 30;
5962 const { width, depth, speed, idleMinutes } = game.hub;
6063
6164 const avatars = new Map<number, Avatar>();
65 /** What each person last changed into: kept when they wander off, so a delve still knows. */
66 const wardrobe = new Map<string, string>();
6267 const chat: Line[] = [];
6368 let nextId = 1;
6469
@@ -81,6 +86,7 @@ export const view = (a: Avatar, now: number): AvatarView => ({
8186 id: a.id,
8287 author_id: a.author_id,
8388 name: a.name,
89 class: a.class,
8490 x: round(a.x),
8591 z: round(a.z),
8692 tx: round(a.tx),
@@ -98,6 +104,10 @@ export const ofAuthor = (authorId: string): Avatar | undefined =>
98104 all().find((a) => a.author_id === authorId);
99105 export const chatLog = (): readonly Line[] => chat;
100106
107 /** The class this person last dressed as, or the default. */
108 export const outfitOf = (authorId: string): string =>
109 wardrobe.get(authorId) ?? game.classes[0]!.id;
110
101111 /** Someone steps into the square — or is already there, and stays put. */
102112 export function place(authorId: string, name: string, now: number): Avatar {
103113 const there = ofAuthor(authorId);
@@ -113,6 +123,7 @@ export function place(authorId: string, name: string, now: number): Avatar {
113123 id,
114124 author_id: authorId,
115125 name,
126 class: outfitOf(authorId),
116127 x,
117128 z,
118129 tx: x,
@@ -145,6 +156,13 @@ export function emote(a: Avatar, name: string, now: number): void {
145156 a.seen = now;
146157 }
147158
159 /** Change in the dressing room: the next delve is cooked as this class. */
160 export function dress(a: Avatar, cls: string, now: number): void {
161 a.class = cls;
162 a.seen = now;
163 wardrobe.set(a.author_id, cls);
164 }
165
148166 export function say(a: Avatar, text: string, now: number): void {
149167 a.say = text;
150168 a.sayAt = now;
src/pages/delve.ts +8 −2
@@ -11,6 +11,7 @@ import type { DelveView, HeroView } from "../delves.ts";
1111 import { publicOrigin } from "../env.ts";
1212 import { escape } from "../http.ts";
1313 import type { Who } from "../who.ts";
14 import { classOf } from "../sim.ts";
1415 import { layout, toast } from "./layout.ts";
1516
1617 export interface DelvePage {
@@ -35,12 +36,14 @@ export function delvePage(p: DelvePage): string {
3536 const path = `/delve/${delve.id}`;
3637 const room = game.rooms.find((r) => r.id === delve.room);
3738 const over = delve.over;
39 const mine = hero === undefined ? undefined : classOf(game, hero.class);
3840 const pad =
3941 hero === undefined || over !== null
4042 ? ""
41 : `<div class="pad">
43 : `<div class="pad pad-three">
4244 <button class="button button-big" type="button" data-act="attack">Attack</button>
4345 <button class="button button-big button-alt" type="button" data-act="dodge">Dodge</button>
46 <button class="button button-big button-quiet" type="button" data-act="ability">${escape(mine!.ability.name)}</button>
4447 </div>`;
4548 return layout(
4649 `<h2>${escape(delve.host_name)}'s table <small class="muted mono">${escape(delve.day)}</small></h2>
@@ -50,8 +53,10 @@ export function delvePage(p: DelvePage): string {
5053 <div class="tags" aria-hidden="true"></div>
5154 <div class="hud">
5255 <p data-hud="room">${escape(room?.name ?? delve.room)} · floor ${delve.depth + 1} of ${delve.rooms}</p>
56 <p data-hud="class">${mine === undefined ? "" : `${escape(mine.name)} · ${escape(mine.weapon)}`}</p>
5357 <p data-hud="hp">${hero === undefined ? "Watching" : ""}</p>
5458 <p data-hud="score">${delve.score} pts</p>
59 <p data-hud="party">${delve.party.map((h) => `${escape(h.name)} (${escape(classOf(game, h.class).name)})`).join(" · ")}</p>
5560 <p data-hud="ghost"></p>
5661 </div>
5762 <div class="over" id="over"${over === null ? " hidden" : ""}>
@@ -75,6 +80,7 @@ export function delvePage(p: DelvePage): string {
7580 <li><b>Walk</b>: click or tap the floor, or WASD / arrow keys.</li>
7681 <li><b>Fight</b>: click a monster (your chef keeps swinging), or <kbd>Space</kbd> for the nearest.</li>
7782 <li><b>Dodge</b>: shift-click or right-click where to roll, or <kbd>K</kbd>. Rolling dodges bites, charges, seeds and spikes.</li>
83 <li><b>Signature move</b>: <kbd>Q</kbd> or the third button. ${game.classes.map((c) => `<b>${escape(c.name)}</b>: ${escape(c.ability.name)}`).join(" · ")}. Change class in the hub's dressing room between delves.</li>
7884 <li><b>Chests</b> open to the touch. <b>Downed?</b> A teammate standing beside you brings you back.</li>
7985 <li>Clear a room and the door opens. Five floors, then the Pass.</li>
8086 </ul>
@@ -91,7 +97,7 @@ export function delvePage(p: DelvePage): string {
9197 function party(p: DelvePage, path: string): string {
9298 const { who, delve, hero } = p;
9399 const names = delve.party
94 .map((h) => `<li>${escape(h.name)}${h.author_id === delve.author_id ? " <small class=\"muted\">host</small>" : ""}</li>`)
100 .map((h) => `<li>${escape(h.name)} <small class="muted">${escape(classOf(game, h.class).name)}</small>${h.author_id === delve.author_id ? " <small class=\"muted\">host</small>" : ""}</li>`)
95101 .join("");
96102 let action = "";
97103 if (delve.over === null) {
src/pages/hub.ts +34
@@ -25,6 +25,8 @@ export interface HubPage {
2525 readonly notes: readonly Note[];
2626 /** The hero this person is playing right now, if any. */
2727 readonly playing?: HeroView | undefined;
28 /** The class this person wears into their next delve. */
29 readonly outfit: string;
2830 readonly notice?: string | undefined;
2931 }
3032
@@ -43,6 +45,7 @@ export function hubPage(p: HubPage): string {
4345 </div>
4446 </section>
4547 ${controls(p)}
48 ${dressingRoom(p)}
4649 <div class="grid">
4750 ${tables(p)}
4851 ${board(p)}
@@ -84,6 +87,37 @@ function controls(p: HubPage): string {
8487 </section>`;
8588 }
8689
90 const seconds = (ticks: number): string =>
91 `${Math.round((ticks / game.rules.tickHz) * 10) / 10}s`;
92
93 /** Cheffry's dressing room: every class, and a button to change into it. */
94 function dressingRoom(p: HubPage): string {
95 const signedIn = p.who.role !== "anon";
96 const cards = game.classes
97 .map((c) => {
98 const hp = { ...game.rules.hero, ...c.hero }.hp;
99 const on = signedIn && c.id === p.outfit;
100 return `<li class="class-card">
101 <p class="class-name"><b>${escape(c.name)}</b> <small class="muted">${escape(c.role)}</small></p>
102 <p>${escape(c.blurb)}</p>
103 <ul class="list-plain class-facts">
104 <li><b>Weapon</b> ${escape(c.weapon)}</li>
105 <li><b>Hearts</b> ${"♥".repeat(hp)}</li>
106 <li><b>Dodge</b> ${escape(c.dodge)}</li>
107 <li><b>${escape(c.ability.name)}</b> ${escape(c.ability.says)} <small class="muted">every ${seconds(c.ability.cooldown)}</small></li>
108 </ul>
109 ${signedIn ? `<button class="button${on ? "" : " button-quiet"}" name="class" value="${escape(c.id)}" data-name="${escape(c.name)}" aria-pressed="${on}">${on ? "Wearing it" : `Wear the ${escape(c.name)}`}</button>` : ""}
110 </li>`;
111 })
112 .join("");
113 const body = `<ul class="class-list">${cards}</ul>`;
114 return `<section class="card" id="dressing-room" aria-labelledby="dressing-room-title">
115 <h2 id="dressing-room-title">Cheffry's dressing room</h2>
116 <p class="muted">Community vote, called by Ben Versluys: pick a class. Swap between delves; you keep what you wear until you change. Q (or the Ability button) is your signature move in the dungeon.</p>
117 ${signedIn ? `<form method="post" action="/hub/dress" data-hub="dress" aria-label="Classes">${body}</form>` : `${body}<p class="muted"><a href="/__forge/signin?to=/">Sign in</a> to try something on.</p>`}
118 </section>`;
119 }
120
87121 function tables(p: HubPage): string {
88122 const open = p.delves.filter((d) => d.over === null);
89123 const rows = open
src/routes.ts +44 −10
@@ -61,7 +61,7 @@ import {
6161 staticFile,
6262 text,
6363 } from "./http.ts";
64 import { SAY_MAX } from "./hub.ts";
64 import { outfitOf, SAY_MAX } from "./hub.ts";
6565 import { events } from "./live.ts";
6666 import { rpc } from "./mcp.ts";
6767 import { delvePage } from "./pages/delve.ts";
@@ -149,6 +149,7 @@ export const AVATAR: Schema = {
149149 id: { type: "integer" },
150150 author_id: { type: "string" },
151151 name: { type: "string" },
152 class: { enum: game.classes.map((c) => c.id) },
152153 x: { type: "number" },
153154 z: { type: "number" },
154155 tx: { type: "number" },
@@ -159,18 +160,23 @@ export const AVATAR: Schema = {
159160 say: { type: ["string", "null"] },
160161 sayAge: { type: "integer" },
161162 },
162 required: ["id", "author_id", "name", "x", "z", "tx", "tz", "age"],
163 required: ["id", "author_id", "name", "class", "x", "z", "tx", "tz", "age"],
163164 };
164165 const AVATAR_CHANGE: Schema = {
165166 type: "object",
166 description: "Any of: where to walk (x and z together), an emote, words.",
167 description: "Any of: where to walk (x and z together), an emote, words, a class to wear into the next delve.",
167168 properties: {
168169 x: { type: "number" },
169170 z: { type: "number" },
170171 emote: { enum: [...game.hub.emotes] },
171172 say: { type: "string", minLength: 1, maxLength: SAY_MAX },
173 class: { enum: game.classes.map((c) => c.id) },
172174 },
173175 };
176 const AS_CLASS: Schema = {
177 ...{ enum: game.classes.map((c) => c.id) },
178 description: "Which class to cook as; what you wear in the hub when left out.",
179 };
174180 export const DELVE: Schema = {
175181 type: "object",
176182 properties: {
@@ -187,6 +193,7 @@ export const DELVE: Schema = {
187193 id: { type: "integer" },
188194 author_id: { type: "string" },
189195 name: { type: "string" },
196 class: { enum: game.classes.map((c) => c.id) },
190197 },
191198 },
192199 },
@@ -206,15 +213,16 @@ export const HERO: Schema = {
206213 delve_id: { type: "integer" },
207214 author_id: { type: "string" },
208215 author_name: { type: "string" },
216 class: { enum: game.classes.map((c) => c.id) },
209217 },
210 required: ["id", "delve_id", "author_id", "author_name"],
218 required: ["id", "delve_id", "author_id", "author_name", "class"],
211219 };
212220 const ORDER: Schema = {
213221 type: "object",
214222 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.",
223 "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.",
216224 properties: {
217 type: { enum: ["move", "attack", "dodge", "stop"] },
225 type: { enum: ["move", "attack", "dodge", "ability", "stop"] },
218226 x: { type: "number" },
219227 z: { type: "number" },
220228 target: { type: "integer" },
@@ -312,6 +320,7 @@ export const routes: readonly Route[] = [
312320 notes: or(listNotes(c.db, c.who, { limit: 50 })),
313321 playing:
314322 c.who.role === "anon" ? undefined : heroOfAuthor(c.who.id),
323 outfit: c.who.role === "anon" ? game.classes[0]!.id : outfitOf(c.who.id),
315324 notice,
316325 }),
317326 200,
@@ -399,6 +408,22 @@ export const routes: readonly Route[] = [
399408 );
400409 },
401410 },
411 {
412 method: "POST",
413 path: "/hub/dress",
414 handle: async (c) => {
415 const form = await readForm(c.request);
416 if (form === undefined) return text("too large", 413);
417 const me = enterHub(c.db, c.who);
418 if (!me.ok) return done(me);
419 return done(
420 updateAvatar(c.db, c.who, {
421 id: me.value.id,
422 class: String(form.get("class") ?? ""),
423 }),
424 );
425 },
426 },
402427 {
403428 method: "POST",
404429 path: "/delves",
@@ -550,11 +575,12 @@ export const routes: readonly Route[] = [
550575 z: f.z,
551576 emote: f.emote,
552577 say: f.say,
578 class: f.class,
553579 }),
554580 );
555581 },
556582 api: {
557 summary: "Walk somewhere, emote, or say something.",
583 summary: "Walk somewhere, emote, say something, or change class in the dressing room.",
558584 body: AVATAR_CHANGE,
559585 answer: AVATAR,
560586 },
@@ -576,9 +602,14 @@ export const routes: readonly Route[] = [
576602 {
577603 method: "POST",
578604 path: "/api/delves",
579 handle: (c) => answer(openDelve(c.db, c.who), 201),
605 handle: async (c) => {
606 const body = await readJson(c.request);
607 if (body === undefined) return json({ error: "too_large" }, 413);
608 return answer(openDelve(c.db, c.who, { class: fields(body).class }), 201);
609 },
580610 api: {
581611 summary: "Open today's dungeon with yourself at the table.",
612 body: { type: "object", properties: { class: AS_CLASS } },
582613 answer: DELVE,
583614 status: 201,
584615 },
@@ -605,7 +636,10 @@ export const routes: readonly Route[] = [
605636 const body = await readJson(c.request);
606637 if (body === undefined) return json({ error: "too_large" }, 413);
607638 return answer(
608 joinDelve(c.db, c.who, { delve: Number(fields(body).delve) }),
639 joinDelve(c.db, c.who, {
640 delve: Number(fields(body).delve),
641 class: fields(body).class,
642 }),
609643 201,
610644 );
611645 },
@@ -613,7 +647,7 @@ export const routes: readonly Route[] = [
613647 summary: "Take a seat at a table (four a table).",
614648 body: {
615649 type: "object",
616 properties: { delve: { type: "integer" } },
650 properties: { delve: { type: "integer" }, class: AS_CLASS },
617651 required: ["delve"],
618652 },
619653 answer: HERO,
src/server.test.ts +21
@@ -426,6 +426,27 @@ test("the hub over the API: enter, walk, emote; a stranger watches", async () =>
426426 expect(await (await fetch(base + "/")).text()).toContain("yes chef");
427427 });
428428
429 test("the dressing room works without script, and the table shows the class", async () => {
430 const dresser = as("u61", "dresser");
431 const guest = await (await fetch(base + "/")).text();
432 expect(guest).toContain("Cheffry's dressing room");
433 expect(guest).not.toContain('action="/hub/dress"');
434 const form = new FormData();
435 form.set("class", "mage");
436 const dressed = await api("/hub/dress", { method: "POST", body: form, redirect: "manual" }, dresser);
437 expect(dressed.status).toBe(303);
438 const hubPage = await (await api("/", {}, dresser)).text();
439 expect(hubPage).toMatch(/value="mage"[^>]*aria-pressed="true"/);
440 const started = await api("/delves", { method: "POST", redirect: "manual" }, dresser);
441 const table = await (await api(started.headers.get("location")!, {}, dresser)).text();
442 expect(table).toContain('data-act="ability"');
443 expect(table).toContain("Soup&#39;s On!");
444 const opened = await api("/api/delves", { method: "POST", ...jsonBody({ class: "archer" }) }, dresser);
445 expect(opened.status).toBe(201);
446 expect(((await opened.json()) as { party: { class: string }[] }).party[0]!.class).toBe("archer");
447 expect((await api("/api/delves", { method: "POST", ...jsonBody({ class: "bard" }) }, dresser)).status).toBe(422);
448 });
449
429450 test("the sim and the content are served by content, cached for a year", async () => {
430451 const page = await (await api("/delve/1", {}, alice)).text();
431452 const sim = /data-sim="(\/sim\.js\?v=[0-9a-f]{8})"/.exec(page)?.[1];
src/sim.ts +205 −8
@@ -71,6 +71,47 @@ export interface RoomDef {
7171 readonly final?: boolean;
7272 readonly map: readonly string[];
7373 }
74 /** The numbers a class may change about a hero. */
75 export const CLASS_STATS = [
76 "hp", "speed", "damage", "reach", "swing", "cooldown",
77 "dodgeSpeed", "dodgeTicks", "dodgeCooldown",
78 ] as const;
79 export type ClassStats = Pick<HeroRules, (typeof CLASS_STATS)[number]>;
80 export interface AbilityDef {
81 readonly kind: "taunt" | "volley" | "heal";
82 readonly name: string;
83 readonly says: string;
84 readonly cooldown: number;
85 /** taunt and heal: how far it reaches. */
86 readonly radius?: number;
87 /** taunt: how long monsters stay angry, and the lid stays up. */
88 readonly ticks?: number;
89 /** taunt: damage the lid takes off each hit. */
90 readonly block?: number;
91 /** volley: how many shots, and how wide the fan. */
92 readonly count?: number;
93 readonly spread?: number;
94 /** heal: hearts back. */
95 readonly heal?: number;
96 }
97 export interface ClassDef {
98 readonly id: string;
99 readonly name: string;
100 readonly weapon: string;
101 readonly role: string;
102 readonly blurb: string;
103 /** How its dodge feels, in words. */
104 readonly dodge: string;
105 readonly look: "sword" | "bow" | "staff";
106 readonly paint: string;
107 readonly hero: Partial<ClassStats>;
108 readonly attack: {
109 readonly kind: "melee" | "shot";
110 readonly shotSpeed?: number;
111 readonly splash?: number;
112 };
113 readonly ability: AbilityDef;
114 }
74115 export interface Content {
75116 readonly rules: {
76117 readonly tickHz: number;
@@ -83,6 +124,7 @@ export interface Content {
83124 readonly traps: readonly TrapDef[];
84125 readonly loot: readonly LootDef[];
85126 readonly rooms: readonly RoomDef[];
127 readonly classes: readonly ClassDef[];
86128 }
87129
88130 // ── The world ──
@@ -90,6 +132,8 @@ export interface Content {
90132 export interface Hero {
91133 readonly id: number;
92134 readonly name: string;
135 /** Which class this chef cooks as: a `ClassDef` id. */
136 readonly cls: string;
93137 x: number;
94138 z: number;
95139 fx: number;
@@ -109,6 +153,11 @@ export interface Hero {
109153 invuln: number;
110154 downed: boolean;
111155 revive: number;
156 wantAbility: boolean;
157 /** Ticks until the signature move is ready again. */
158 ability: number;
159 /** Ticks the Knight's lid stays up. */
160 guard: number;
112161 }
113162 export interface Monster {
114163 readonly id: number;
@@ -122,6 +171,9 @@ export interface Monster {
122171 dz: number;
123172 cd: number;
124173 hurt: number;
174 /** A hero who taunted it, and for how many ticks more it cares. */
175 foe: number;
176 foeT: number;
125177 }
126178 export interface Shot {
127179 readonly id: number;
@@ -131,6 +183,10 @@ export interface Shot {
131183 readonly dz: number;
132184 ttl: number;
133185 readonly damage: number;
186 /** The hero who loosed it; 0 for a monster's seed. */
187 readonly by: number;
188 /** A hero's shot hurts every monster this close to where it lands. */
189 readonly splash: number;
134190 }
135191 export interface Chest {
136192 readonly id: number;
@@ -161,6 +217,8 @@ export type Happening =
161217 | { readonly type: "hurt"; readonly hero: number; readonly damage: number }
162218 | { readonly type: "down"; readonly hero: number }
163219 | { readonly type: "revived"; readonly hero: number }
220 | { readonly type: "blocked"; readonly hero: number }
221 | { readonly type: "ability"; readonly hero: number; readonly kind: AbilityDef["kind"] }
164222 | { readonly type: "slain"; readonly monster: number; readonly kind: string }
165223 | { readonly type: "loot"; readonly hero: number; readonly loot: string }
166224 | { readonly type: "open" }
@@ -192,6 +250,7 @@ export type Order =
192250 | { readonly type: "move"; readonly x: number; readonly z: number }
193251 | { readonly type: "attack"; readonly target?: number | undefined }
194252 | { readonly type: "dodge"; readonly x: number; readonly z: number }
253 | { readonly type: "ability" }
195254 | { readonly type: "stop" };
196255
197256 // ── Randomness: mulberry32, its state kept in the world ──
@@ -277,6 +336,16 @@ const roomById = (content: Content, id: string): RoomDef => {
277336 const monsterDef = (content: Content, kind: string): MonsterDef =>
278337 content.monsters.find((m) => m.id === kind)!;
279338
339 /** A class by id; the first one (the default) for anything else. */
340 export const classOf = (content: Content, id: string): ClassDef =>
341 content.classes.find((c) => c.id === id) ?? content.classes[0]!;
342
343 /** The rules a hero plays by: the base rules, with its class's numbers laid over them. */
344 export const rulesOf = (content: Content, cls: string): HeroRules => ({
345 ...content.rules.hero,
346 ...classOf(content, cls).hero,
347 });
348
280349 // ── The world, made and changed ──
281350
282351 export function createWorld(content: Content, seed: number): World {
@@ -322,6 +391,8 @@ function enter(content: Content, world: World, depth: number): void {
322391 dz: 0,
323392 cd: Math.floor(random(world) * 30),
324393 hurt: 0,
394 foe: 0,
395 foeT: 0,
325396 }));
326397 world.chests = world.layout.chests.map((c) => ({
327398 id: world.nextId++,
@@ -350,17 +421,23 @@ function place(world: World, hero: Hero, i: number): void {
350421 hero.fz = 0;
351422 }
352423
353 /** A hero steps in. Whoever joins a delve under way starts at this floor's start. */
424 /**
425 * A hero steps in, as a class (the first, unless said). Whoever joins a
426 * delve under way starts at this floor's start.
427 */
354428 export function addHero(
355429 content: Content,
356430 world: World,
357431 id: number,
358432 name: string,
433 cls?: string,
359434 ): Hero {
360 const rules = content.rules.hero;
435 const kind = classOf(content, cls ?? "").id;
436 const rules = rulesOf(content, kind);
361437 const hero: Hero = {
362438 id,
363439 name,
440 cls: kind,
364441 x: 0,
365442 z: 0,
366443 fx: 1,
@@ -380,6 +457,9 @@ export function addHero(
380457 invuln: rules.invuln,
381458 downed: false,
382459 revive: 0,
460 wantAbility: false,
461 ability: 0,
462 guard: 0,
383463 };
384464 place(world, hero, world.heroes.length);
385465 world.heroes.push(hero);
@@ -403,6 +483,7 @@ export function order(world: World, heroId: number, o: Order): void {
403483 hero.goal = null;
404484 } else hero.wantSwing = true;
405485 } else if (o.type === "dodge") hero.wantDodge = { x: o.x, z: o.z };
486 else if (o.type === "ability") hero.wantAbility = true;
406487 else {
407488 hero.goal = null;
408489 hero.target = null;
@@ -464,10 +545,10 @@ export function step(content: Content, world: World): Happening[] {
464545 world.happened = [];
465546 if (world.over !== null) return world.happened;
466547 world.tick++;
467 const rules = content.rules.hero;
468 for (const hero of world.heroes) stepHero(content, world, hero, rules);
548 for (const hero of world.heroes)
549 stepHero(content, world, hero, rulesOf(content, hero.cls));
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);
@@ -534,8 +615,11 @@ function stepHero(
534615 if (hero.dodgeCd > 0) hero.dodgeCd--;
535616 if (hero.invuln > 0) hero.invuln--;
536617 if (hero.swing > 0) hero.swing--;
618 if (hero.ability > 0) hero.ability--;
619 if (hero.guard > 0) hero.guard--;
537620
538621 if (hero.downed) {
622 hero.wantAbility = false;
539623 // A teammate standing close brings a downed chef back.
540624 const helped = world.heroes.some(
541625 (h) => h !== hero && !h.downed && dist(h, hero) < 1.3,
@@ -564,6 +648,10 @@ function stepHero(
564648 }
565649 hero.wantDodge = null;
566650 }
651 if (hero.wantAbility) {
652 hero.wantAbility = false;
653 if (hero.ability === 0 && hero.dodge === 0) cast(content, world, hero, rules);
654 }
567655 if (hero.dodge > 0) {
568656 hero.dodge--;
569657 hero.invuln = Math.max(hero.invuln, 1);
@@ -623,6 +711,10 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo
623711 hero.swing = rules.swing;
624712 hero.cooldown = rules.cooldown;
625713 const damage = rules.damage + hero.power;
714 if (classOf(content, hero.cls).attack.kind === "shot") {
715 loose(content, world, hero, rules, { x: hero.fx, z: hero.fz });
716 return;
717 }
626718 for (const m of world.monsters) {
627719 if (m.hp <= 0) continue;
628720 const def = monsterDef(content, m.kind);
@@ -638,6 +730,71 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo
638730 }
639731 }
640732
733 /** A hero's shot, flying `dir` (a unit vector) as far as the hero reaches. */
734 function loose(
735 content: Content,
736 world: World,
737 hero: Hero,
738 rules: HeroRules,
739 dir: { x: number; z: number },
740 ): void {
741 const attack = classOf(content, hero.cls).attack;
742 const speed = attack.shotSpeed ?? 0.4;
743 world.shots.push({
744 id: world.nextId++,
745 x: hero.x + dir.x * 0.3,
746 z: hero.z + dir.z * 0.3,
747 dx: dir.x * speed,
748 dz: dir.z * speed,
749 ttl: Math.ceil(rules.reach / speed),
750 damage: rules.damage + hero.power,
751 by: hero.id,
752 splash: attack.splash ?? 0,
753 });
754 }
755
756 /** The signature move: one per class, its numbers the content's. */
757 function cast(content: Content, world: World, hero: Hero, rules: HeroRules): void {
758 const ability = classOf(content, hero.cls).ability;
759 hero.ability = ability.cooldown;
760 world.happened.push({ type: "ability", hero: hero.id, kind: ability.kind });
761 if (ability.kind === "taunt") {
762 // Every monster in earshot wants the Knight now; the lid goes up.
763 const ticks = ability.ticks ?? 60;
764 for (const m of world.monsters)
765 if (dist(m, hero) <= (ability.radius ?? 4)) {
766 m.foe = hero.id;
767 m.foeT = ticks;
768 }
769 hero.guard = ticks;
770 } else if (ability.kind === "volley") {
771 // A fan: the facing, nudged sideways a little more for each shot.
772 const count = ability.count ?? 3;
773 const spread = ability.spread ?? 0.2;
774 for (let i = 0; i < count; i++) {
775 const off = (i - (count - 1) / 2) * spread;
776 const x = hero.fx - hero.fz * off;
777 const z = hero.fz + hero.fx * off;
778 const l = Math.sqrt(x * x + z * z) || 1;
779 loose(content, world, hero, rules, { x: x / l, z: z / l });
780 }
781 } else {
782 // Soup for everyone close by; the downed sit up for it.
783 const heal = ability.heal ?? 2;
784 for (const h of world.heroes) {
785 if (dist(h, hero) > (ability.radius ?? 3)) continue;
786 const most = rulesOf(content, h.cls).hp;
787 if (h.downed) {
788 h.downed = false;
789 h.revive = 0;
790 h.hp = Math.min(most, heal);
791 h.invuln = content.rules.hero.invuln * 2;
792 world.happened.push({ type: "revived", hero: h.id });
793 } else h.hp = Math.min(most, h.hp + heal);
794 }
795 }
796 }
797
641798 function roll(content: Content, world: World) {
642799 const total = content.loot.reduce((sum, l) => sum + l.weight, 0);
643800 let pick = random(world) * total;
@@ -650,6 +807,15 @@ function roll(content: Content, world: World) {
650807
651808 function hurt(content: Content, world: World, hero: Hero, damage: number): boolean {
652809 if (hero.downed || hero.invuln > 0 || hero.dodge > 0) return false;
810 if (hero.guard > 0) {
811 const ability = classOf(content, hero.cls).ability;
812 damage = Math.max(0, damage - (ability.block ?? 0));
813 if (damage === 0) {
814 hero.invuln = content.rules.hero.invuln;
815 world.happened.push({ type: "blocked", hero: hero.id });
816 return true;
817 }
818 }
653819 hero.hp -= damage;
654820 hero.invuln = content.rules.hero.invuln;
655821 world.happened.push({ type: "hurt", hero: hero.id, damage });
@@ -665,6 +831,12 @@ function hurt(content: Content, world: World, hero: Hero, damage: number): boole
665831 }
666832
667833 function nearestHero(world: World, m: Monster): Hero | undefined {
834 if (m.foeT > 0) {
835 m.foeT--;
836 const foe = world.heroes.find((h) => h.id === m.foe && !h.downed);
837 if (foe !== undefined) return foe;
838 m.foeT = 0;
839 }
668840 let best: Hero | undefined;
669841 let bestD = Infinity;
670842 for (const h of world.heroes) {
@@ -748,16 +920,34 @@ function stepMonster(content: Content, world: World, m: Monster): void {
748920 dz: dir.z * speed,
749921 ttl: Math.ceil((range + 3) / speed),
750922 damage: def.damage,
923 by: 0,
924 splash: 0,
751925 });
752926 m.cd = def.cooldown ?? 45;
753927 }
754928 }
755929
756 function stepShots(world: World): void {
930 function stepShots(content: Content, world: World): void {
757931 world.shots = world.shots.filter((s) => {
758932 s.x += s.dx;
759933 s.z += s.dz;
760934 s.ttl--;
935 if (s.by !== 0) {
936 // A hero's shot lands on the first monster it reaches, splashing any near.
937 const struck = world.monsters.find(
938 (m) => m.hp > 0 && dist(s, m) < monsterDef(content, m.kind).radius + 0.15,
939 );
940 if (struck !== undefined) {
941 for (const m of world.monsters) {
942 if (m.hp <= 0) continue;
943 if (m !== struck && dist(s, m) > s.splash + monsterDef(content, m.kind).radius) continue;
944 m.hp -= s.damage;
945 m.hurt = 4;
946 world.happened.push({ type: "hit", by: s.by, on: m.id, damage: s.damage });
947 }
948 return false;
949 }
950 }
761951 return s.ttl > 0 && !solid(world, Math.floor(s.x), Math.floor(s.z));
762952 });
763953 }
@@ -767,7 +957,7 @@ function stepTraps(content: Content, world: World): void {
767957 for (const hero of world.heroes) {
768958 // A seed splats on whoever it reaches, unless they are rolling through it.
769959 world.shots = world.shots.filter((s) => {
770 if (hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) return true;
960 if (s.by !== 0 || hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) return true;
771961 hurt(content, world, hero, s.damage);
772962 return false;
773963 });
@@ -804,6 +994,7 @@ export function snapshot(world: World) {
804994 heroes: world.heroes.map((h) => ({
805995 id: h.id,
806996 name: h.name,
997 cls: h.cls,
807998 x: round(h.x),
808999 z: round(h.z),
8091000 fx: round(h.fx),
@@ -815,6 +1006,8 @@ export function snapshot(world: World) {
8151006 hit: h.invuln > 0 && h.dodge === 0,
8161007 downed: h.downed,
8171008 revive: h.revive,
1009 ability: h.ability,
1010 guard: h.guard > 0,
8181011 })),
8191012 monsters: world.monsters.map((m) => ({
8201013 id: m.id,
@@ -827,7 +1020,11 @@ export function snapshot(world: World) {
8271020 fx: round(m.dx),
8281021 fz: round(m.dz),
8291022 })),
830 shots: world.shots.map((s) => ({ id: s.id, x: round(s.x), z: round(s.z) })),
1023 shots: world.shots.map((s) =>
1024 s.by === 0
1025 ? { id: s.id, x: round(s.x), z: round(s.z) }
1026 : { id: s.id, x: round(s.x), z: round(s.z), by: s.by, fx: round(s.dx), fz: round(s.dz) },
1027 ),
8311028 chests: world.chests.map((c) => ({ id: c.id, x: c.x, z: c.z, open: c.open })),
8321029 };
8331030 }
static/app.css +31
@@ -384,6 +384,37 @@ kbd {
384384 .pad .button {
385385 justify-content: center;
386386 }
387 .pad-three {
388 grid-template-columns: 1fr 1fr 1fr;
389 }
390
391 /* The dressing room: one card a class, side by side when there is room. */
392 .class-list {
393 list-style: none;
394 margin: 0;
395 padding: 0;
396 display: grid;
397 grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
398 gap: var(--space-3);
399 }
400 .class-card {
401 display: grid;
402 gap: var(--space-2);
403 align-content: start;
404 padding: var(--space-3);
405 border: 1px solid var(--color-line);
406 border-radius: var(--radius-md);
407 }
408 .class-card p,
409 .class-facts {
410 margin: 0;
411 }
412 .class-name {
413 font-size: var(--text-lg);
414 }
415 .class-facts li + li {
416 margin-top: var(--space-1);
417 }
387418
388419 /* A word from the last request, shown once, gone in a moment. */
389420 .toast {
static/delve.js +34 −6
@@ -2,8 +2,9 @@
22 // second) and sends every other tick down /delves/:id/events; this file
33 // draws between the last two it heard, and sends orders back: click or tap
44 // the floor to walk, a monster to fight it, shift-click or right-click to
5 // dodge; WASD/arrows, Space to attack, K to dodge. The same sim.ts, served
6 // as /sim.js, lays out the rooms here.
5 // dodge; WASD/arrows, Space to attack, K to dodge, Q for the class's
6 // signature move. The same sim.ts, served as /sim.js, lays out the rooms
7 // here and says what each class is.
78
89 import { compose, createRenderer, drawChef, paint } from "./gl.js";
910
@@ -85,6 +86,7 @@ function blend(list, before, f) {
8586 });
8687 }
8788 const mine = () => cur?.heroes.find((h) => h.id === heroId);
89 const classOf = (id) => sim.classOf(game, id);
8890
8991 function bakeRoom() {
9092 const def = game.rooms.find((r) => r.id === cur.room);
@@ -144,7 +146,15 @@ function frame(now) {
144146 }
145147
146148 for (const m of monsters) drawMonster(m, t);
147 for (const s of shots) renderer.draw("gem", compose(s.x, 0.35, s.z, t / 3, 0.18, 0.18, 0.18), paint("game-seed"));
149 for (const s of shots) {
150 const by = s.by ? cur.heroes.find((h) => h.id === s.by) : null;
151 const look = by ? classOf(by.cls).look : null;
152 if (look === "bow")
153 renderer.draw("box", compose(s.x, 0.45, s.z, Math.atan2(s.fx, s.fz), 0.06, 0.06, 0.55), paint("game-baguette"));
154 else if (by)
155 renderer.draw("gem", compose(s.x, 0.45, s.z, t / 2, 0.3, 0.3, 0.3), paint("game-gold"));
156 else renderer.draw("gem", compose(s.x, 0.35, s.z, t / 3, 0.18, 0.18, 0.18), paint("game-seed"));
157 }
148158
149159 if (ghost) {
150160 const g = ghostAt(t);
@@ -159,12 +169,18 @@ function frame(now) {
159169 if (h.id === heroId)
160170 renderer.draw("prism", compose(h.x, 0.01, h.z, 0, 0.8, 0.02, 0.8), paint("game-apron"), 0.5);
161171 const blink = h.hit && !reduced && Math.floor(now / 80) % 2 === 0;
172 const cls = classOf(h.cls);
162173 drawChef(renderer, h.x, h.z, face, {
174 look: cls.look,
175 apron: cls.paint,
163176 swing: h.swing,
164177 lying: h.downed,
165178 lift: h.dodge ? 0.15 : 0,
166179 tint: blink ? paint("game-pepper") : undefined,
167180 });
181 // The Knight's lid is up: a glint over the hat.
182 if (h.guard && !h.downed)
183 renderer.draw("gem", compose(h.x, 1.45, h.z, t / 4, 0.22, 0.22, 0.22), paint("game-gold"));
168184 const at = renderer.project(h.x, 1.5, h.z);
169185 if (at) labels.push({ id: h.id, at, h });
170186 }
@@ -206,7 +222,7 @@ function drawTags(labels) {
206222 tagEls.set(id, el);
207223 }
208224 el.classList.toggle("tag-me", id === heroId);
209 el.firstChild.textContent = h.name;
225 el.firstChild.textContent = `${h.name} · ${classOf(h.cls).name}`;
210226 const note = h.downed ? `down · ${Math.round((h.revive / game.rules.hero.revive) * 100)}%` : "";
211227 el.lastChild.textContent = note;
212228 el.lastChild.hidden = note === "";
@@ -260,10 +276,15 @@ function showHud() {
260276 const me = mine();
261277 hud("score").textContent = `${cur.score} pts · ${cur.gold} gold · ${cur.kills} down`;
262278 hud("hp").textContent = me
263 ? `${"♥".repeat(me.hp)}${"♡".repeat(Math.max(0, game.rules.hero.hp - me.hp))}${me.power ? ` · +${me.power} whisk` : ""}`
279 ? `${"♥".repeat(me.hp)}${"♡".repeat(Math.max(0, sim.rulesOf(game, me.cls).hp - me.hp))}${me.power ? ` · +${me.power} whisk` : ""}`
264280 : "Watching";
281 if (me) {
282 const cls = classOf(me.cls);
283 const wait = Math.ceil(me.ability / game.rules.tickHz);
284 hud("class").textContent = `${cls.name} · ${cls.ability.name} ${wait > 0 ? `in ${wait}s` : "ready (Q)"}`;
285 }
265286 hud("party").textContent = cur.heroes
266 .map((h) => `${h.name} ${h.downed ? "(down)" : `${h.hp}♥`}`)
287 .map((h) => `${h.name} (${classOf(h.cls).name}) ${h.downed ? "(down)" : `${h.hp}♥`}`)
267288 .join(" · ");
268289 if (me?.downed && !cur.over) say("You're down! A teammate standing beside you brings you back.");
269290 const over = document.getElementById("over");
@@ -301,6 +322,9 @@ function attackNearest() {
301322 const m = nearestMonster(me.x, me.z, 4);
302323 orderUp(m ? { type: "attack", target: m.id } : { type: "attack" });
303324 }
325 function signature() {
326 if (mine()) orderUp({ type: "ability" });
327 }
304328 function dodgeAhead() {
305329 const me = mine();
306330 if (!me) return;
@@ -343,6 +367,9 @@ function listen() {
343367 } else if (k === "k" || k === "Shift") {
344368 e.preventDefault();
345369 dodgeAhead();
370 } else if (k === "q" || k === "l") {
371 e.preventDefault();
372 signature();
346373 }
347374 });
348375 document.addEventListener("keyup", (e) => {
@@ -351,6 +378,7 @@ function listen() {
351378 });
352379 document.querySelector("[data-act=attack]")?.addEventListener("click", attackNearest);
353380 document.querySelector("[data-act=dodge]")?.addEventListener("click", dodgeAhead);
381 document.querySelector("[data-act=ability]")?.addEventListener("click", signature);
354382 }
355383
356384 // Held keys walk a couple of tiles ahead, again while they stay held.
static/gl.js +30 −8
@@ -306,11 +306,15 @@ export function createRenderer(canvas) {
306306 };
307307 }
308308
309 /** A chef, in five shapes: whites, apron, hat and puff, and a knife when it swings. */
309 /**
310 * A chef, in a handful of shapes: whites, an apron in their class's colour,
311 * hat and puff, and their class's weapon (`look`) — a cleaver and pot lid,
312 * a bow, or a staff — sweeping or glowing while it swings.
313 */
310314 export function drawChef(r, x, z, facing, opts = {}) {
311 const { alpha = 1, swing = 0, lying = false, lift = 0, tint, spin = 0, wave = 0 } = opts;
315 const { alpha = 1, swing = 0, lying = false, lift = 0, tint, spin = 0, wave = 0, look = "sword" } = opts;
312316 const whites = tint ?? paint("game-hero");
313 const apron = tint ?? paint("game-apron");
317 const apron = tint ?? paint(opts.apron ?? "game-apron");
314318 const ry = facing + spin;
315319 const tilt = lying ? Math.PI / 2 : 0;
316320 const y = lift;
@@ -321,11 +325,29 @@ export function drawChef(r, x, z, facing, opts = {}) {
321325 r.draw("box", compose(x, y + 0.62, z, ry, 0.4, 0.28, 0.32), paint("game-seed"), alpha);
322326 r.draw("prism", compose(x, y + 0.9, z, ry, 0.34, 0.26, 0.34), whites, alpha);
323327 r.draw("gem", compose(x, y + 1.08, z, ry, 0.5, 0.3, 0.5), whites, alpha);
324 // The knife: resting at the side, sweeping across while it swings.
325 const arc = swing > 0 ? (swing / 6) * 2.4 - 1.2 : 0.9;
326 const side = ry + arc;
327 const kx = x + Math.sin(side) * 0.42, kz = z + Math.cos(side) * 0.42;
328 r.draw("box", compose(kx, y + 0.34 + wave, kz, side, 0.07, 0.07, swing > 0 ? 0.6 : 0.4), paint("game-spike"), alpha);
328 const rx = Math.cos(ry), rz = -Math.sin(ry); // the chef's right hand
329 if (look === "bow") {
330 // The bow: held out in front, drawn back while it looses.
331 const out = swing > 0 ? 0.42 : 0.3;
332 const bx = x + fx * out - rx * 0.12, bz = z + fz * out - rz * 0.12;
333 r.draw("box", compose(bx, y + 0.12 + wave, bz, ry, 0.08, 0.72, 0.06), paint("game-baguette"), alpha);
334 r.draw("box", compose(bx - fx * (swing > 0 ? 0.14 : 0.05), y + 0.14 + wave, bz - fz * (swing > 0 ? 0.14 : 0.05), ry, 0.02, 0.66, 0.02), whites, alpha);
335 } else if (look === "staff") {
336 // The ladle-staff: upright at the side, its gem flaring while it casts.
337 const sx = x + rx * 0.34, sz = z + rz * 0.34;
338 r.draw("box", compose(sx, y + wave, sz, ry, 0.07, 1.15, 0.07, swing > 0 ? 0.4 : 0), paint("game-baguette"), alpha);
339 const g = swing > 0 ? 0.3 : 0.2;
340 r.draw("gem", compose(sx + fx * (swing > 0 ? 0.45 : 0), y + 1.15 + wave, sz + fz * (swing > 0 ? 0.45 : 0), ry, g, g, g), paint("game-gold"), alpha);
341 } else {
342 // The cleaver: resting at the side, sweeping across while it swings;
343 // the pot lid on the other arm.
344 const arc = swing > 0 ? (swing / 6) * 2.4 - 1.2 : 0.9;
345 const side = ry + arc;
346 const kx = x + Math.sin(side) * 0.42, kz = z + Math.cos(side) * 0.42;
347 r.draw("box", compose(kx, y + 0.34 + wave, kz, side, 0.07, 0.07, swing > 0 ? 0.6 : 0.4), paint("game-spike"), alpha);
348 if (look === "sword")
349 r.draw("box", compose(x - rx * 0.32 + fx * 0.08, y + 0.18, z - rz * 0.32 + fz * 0.08, ry, 0.06, 0.4, 0.4), paint("game-spike"), alpha);
350 }
329351 } else {
330352 r.draw("prism", compose(x, 0.1, z + 0.5, ry, 0.34, 0.3, 0.34, tilt), whites, alpha);
331353 }
static/hub.js +20 −1
@@ -121,7 +121,10 @@ function frame(now) {
121121 const since = (now - a.emoteAt) / 1000;
122122 const emote = a.view.emote && since < 3 && !reduced ? a.view.emote : null;
123123 const bob = p.moving && !reduced ? Math.abs(Math.sin(now / 90)) * 0.08 : 0;
124 const cls = classOf(a.view.class);
124125 drawChef(renderer, p.x, p.z, face, {
126 look: cls?.look,
127 apron: cls?.paint,
125128 lift: bob + (emote === "cheer" ? Math.abs(Math.sin(since * 9)) * 0.5 : 0),
126129 spin: emote === "dance" ? since * 8 : 0,
127130 wave: emote === "wave" ? Math.sin(since * 14) * 0.25 + 0.3 : 0,
@@ -135,6 +138,8 @@ function frame(now) {
135138 drawTags(labels, now);
136139 }
137140
141 const classOf = (id) => game?.classes.find((c) => c.id === id);
142
138143 // Name tags and speech bubbles are HTML over the canvas: real text, set
139144 // with textContent, never markup.
140145 const tagEls = new Map();
@@ -151,7 +156,8 @@ function drawTags(labels, now) {
151156 tagEls.set(id, el);
152157 }
153158 el.classList.toggle("tag-me", mine);
154 el.firstChild.textContent = a.view.name;
159 const cls = classOf(a.view.class);
160 el.firstChild.textContent = cls ? `${a.view.name} · ${cls.name}` : a.view.name;
155161 const talking = a.view.say && now - a.sayAt < 7000;
156162 el.lastChild.textContent = talking ? a.view.say : "";
157163 el.lastChild.hidden = !talking;
@@ -197,10 +203,23 @@ function listen() {
197203 learn(view);
198204 form.reset();
199205 say("");
206 if (form.dataset.hub === "dress") wearing(view.class);
200207 }
201208 });
202209 }
203210
211 // The dressing room shows what you have on.
212 function wearing(id) {
213 for (const b of document.querySelectorAll("form[data-hub=dress] button")) {
214 const on = b.value === id;
215 b.setAttribute("aria-pressed", String(on));
216 b.classList.toggle("button-quiet", !on);
217 b.textContent = on ? "Wearing it" : `Wear the ${b.dataset.name}`;
218 }
219 const cls = classOf(id);
220 if (cls) say(`Dressed as the ${cls.name}. Your next delve, you're cooking with the ${cls.weapon}.`);
221 }
222
204223 async function start() {
205224 if (!renderer) return;
206225 root.classList.add("ready");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs