Public: anyone can read this; 1 person and the crew can change it.
test #4
classes → main 1 commit
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 | ||
| 27 | 27 | positions, emotes and chat cross the wire — a walk is sent once, as a |
| 28 | 28 | target, and every client slides the avatar there itself. |
| 29 | 29 | - **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. | |
| 34 | 37 | - **Three enemies, three brains** — the Meatball Slime (chases), the |
| 35 | 38 | Baguette Knight (winds up and charges), the Pepper Imp (keeps its distance |
| 36 | 39 | 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. | ||
| 70 | 73 | | file | what it is | |
| 71 | 74 | | ---------------------------------- | -------------------------------------------------------------- | |
| 72 | 75 | | `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 | | |
| 74 | 77 | | `src/content.ts`, `content/` | the game as JSON, checked whole at boot | |
| 75 | 78 | | `src/hub.ts` | the square in memory: who stands where, emotes, chat | |
| 76 | 79 | | `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 @@ | ||
| 648 | 648 | "properties": { |
| 649 | 649 | "delve": { |
| 650 | 650 | "type": "integer" |
| 651 | }, | |
| 652 | "class": { | |
| 653 | "type": "string" | |
| 651 | 654 | } |
| 652 | 655 | }, |
| 653 | 656 | "required": [ |
| @@ -747,13 +750,14 @@ | ||
| 747 | 750 | "application/json": { |
| 748 | 751 | "schema": { |
| 749 | 752 | "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.", | |
| 751 | 754 | "properties": { |
| 752 | 755 | "type": { |
| 753 | 756 | "enum": [ |
| 754 | 757 | "move", |
| 755 | 758 | "attack", |
| 756 | 759 | "dodge", |
| 760 | "ability", | |
| 757 | 761 | "stop" |
| 758 | 762 | ] |
| 759 | 763 | }, |
| @@ -1328,6 +1332,9 @@ | ||
| 1328 | 1332 | }, |
| 1329 | 1333 | "name": { |
| 1330 | 1334 | "type": "string" |
| 1335 | }, | |
| 1336 | "classId": { | |
| 1337 | "type": "string" | |
| 1331 | 1338 | } |
| 1332 | 1339 | } |
| 1333 | 1340 | } |
| @@ -1385,13 +1392,17 @@ | ||
| 1385 | 1392 | }, |
| 1386 | 1393 | "author_name": { |
| 1387 | 1394 | "type": "string" |
| 1395 | }, | |
| 1396 | "classId": { | |
| 1397 | "type": "string" | |
| 1388 | 1398 | } |
| 1389 | 1399 | }, |
| 1390 | 1400 | "required": [ |
| 1391 | 1401 | "id", |
| 1392 | 1402 | "delve_id", |
| 1393 | 1403 | "author_id", |
| 1394 | "author_name" | |
| 1404 | "author_name", | |
| 1405 | "classId" | |
| 1395 | 1406 | ] |
| 1396 | 1407 | }, |
| 1397 | 1408 | "Run": { |
src/actions.ts +32 −8
| @@ -503,17 +503,38 @@ export function watchDelve( | ||
| 503 | 503 | * Open today's dungeon with yourself at the table. Whoever is in another |
| 504 | 504 | * delve leaves it first: a chef cooks in one kitchen at a time. |
| 505 | 505 | */ |
| 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)); | |
| 508 | 512 | |
| 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> { | |
| 510 | 529 | if (who.role === "anon" || permit(who, "delve", "create") !== "yes") |
| 511 | 530 | 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; | |
| 512 | 533 | const now = Date.now(); |
| 513 | 534 | const before = kitchen.heroOfAuthor(who.id); |
| 514 | 535 | if (before !== undefined) kitchen.dismiss(before, now); |
| 515 | 536 | const delve = kitchen.open(who, now, (d) => recordRun(db, d)); |
| 516 | kitchen.enlist(delve, who, now); | |
| 537 | kitchen.enlist(delve, who, now, picked.value); | |
| 517 | 538 | emit("delve.opened", { id: delve.id }); |
| 518 | 539 | return ok(kitchen.view(delve)); |
| 519 | 540 | } |
| @@ -559,11 +580,11 @@ export function getHero( | ||
| 559 | 580 | export const joinDelve = ( |
| 560 | 581 | db: Database, |
| 561 | 582 | who: Who, |
| 562 | input: { delve: number }, | |
| 583 | input: { delve: number; class?: string }, | |
| 563 | 584 | ): Result<HeroView> => |
| 564 | 585 | audited(db, who, "hero", "create", undefined, join(who, input)); |
| 565 | 586 | |
| 566 | function join(who: Who, input: { delve: number }): Result<HeroView> { | |
| 587 | function join(who: Who, input: { delve: number; class?: string }): Result<HeroView> { | |
| 567 | 588 | if (who.role === "anon" || permit(who, "hero", "create") !== "yes") |
| 568 | 589 | return refuse(who, "Sign in to join a party.", "You may not join a party."); |
| 569 | 590 | if (!Number.isInteger(input.delve)) |
| @@ -571,13 +592,15 @@ function join(who: Who, input: { delve: number }): Result<HeroView> { | ||
| 571 | 592 | const delve = kitchen.delveOf(input.delve); |
| 572 | 593 | if (delve === undefined || delve.ended !== null) |
| 573 | 594 | return fail("not_found", "That delve is over, or never was."); |
| 595 | const picked = checkClass(input); | |
| 596 | if (!picked.ok) return picked; | |
| 574 | 597 | const now = Date.now(); |
| 575 | 598 | const before = kitchen.heroOfAuthor(who.id); |
| 576 | 599 | if (before?.delve_id === delve.id) return ok(kitchen.heroView(before)); |
| 577 | 600 | if (!kitchen.joinable(delve)) |
| 578 | 601 | return fail("invalid", `That party is full: ${kitchen.PARTY_MAX} chefs a table.`); |
| 579 | 602 | 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); | |
| 581 | 604 | emit("delve.changed", { id: delve.id }); |
| 582 | 605 | return ok(kitchen.heroView(hero)); |
| 583 | 606 | } |
| @@ -608,8 +631,9 @@ export function readOrder( | ||
| 608 | 631 | ? { type: "attack", target: input.target as number } |
| 609 | 632 | : "A target is a monster's id, a whole number."; |
| 610 | 633 | } |
| 634 | if (type === "ability") return { type: "ability" }; | |
| 611 | 635 | 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."; | |
| 613 | 637 | } |
| 614 | 638 | |
| 615 | 639 | /** 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 { | ||
| 38 | 38 | } |
| 39 | 39 | |
| 40 | 40 | const BEHAVIORS = ["chase", "charge", "shoot"]; |
| 41 | const ABILITY_KINDS = ["bulwark", "volley", "blink"]; | |
| 41 | 42 | const RESERVED = "#.SEC"; |
| 42 | 43 | |
| 43 | 44 | const isObject = (v: unknown): v is Record<string, unknown> => |
| @@ -54,6 +55,7 @@ export function check(files: { | ||
| 54 | 55 | loot: unknown; |
| 55 | 56 | rooms: unknown; |
| 56 | 57 | hub: unknown; |
| 58 | classes: unknown; | |
| 57 | 59 | }): string[] { |
| 58 | 60 | const problems: string[] = []; |
| 59 | 61 | const say = (where: string, what: string) => problems.push(`${where}: ${what}`); |
| @@ -177,6 +179,40 @@ export function check(files: { | ||
| 177 | 179 | }); |
| 178 | 180 | if (finals === 0) say("rooms", "at least one room must be final"); |
| 179 | 181 | |
| 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 | ||
| 180 | 216 | const paints = new Set( |
| 181 | 217 | tokens.filter((t) => t.name.startsWith("--color-")).map((t) => t.name.slice(8)), |
| 182 | 218 | ); |
| @@ -203,6 +239,7 @@ function load(): Game { | ||
| 203 | 239 | loot: read("loot"), |
| 204 | 240 | rooms: read("rooms"), |
| 205 | 241 | hub: read("hub"), |
| 242 | classes: read("classes"), | |
| 206 | 243 | }; |
| 207 | 244 | const problems = check(files); |
| 208 | 245 | if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`); |
| @@ -214,6 +251,7 @@ function load(): Game { | ||
| 214 | 251 | traps: f.traps!.traps as Game["traps"], |
| 215 | 252 | loot: f.loot!.loot as Game["loot"], |
| 216 | 253 | rooms: f.rooms!.rooms as Game["rooms"], |
| 254 | classes: f.classes!.classes as Game["classes"], | |
| 217 | 255 | hub: { ...hub, props: f.hub!.props as Prop[] }, |
| 218 | 256 | }; |
| 219 | 257 | } |
src/delves.ts +10 −2
| @@ -46,6 +46,8 @@ export interface HeroRow { | ||
| 46 | 46 | readonly delve_id: number; |
| 47 | 47 | readonly author_id: string; |
| 48 | 48 | readonly author_name: string; |
| 49 | /** The class from content/classes.json this chef delves as. */ | |
| 50 | readonly classId: string; | |
| 49 | 51 | seen: number; |
| 50 | 52 | } |
| 51 | 53 | |
| @@ -72,7 +74,7 @@ export interface DelveView { | ||
| 72 | 74 | readonly day: string; |
| 73 | 75 | readonly author_id: string; |
| 74 | 76 | 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 }[]; | |
| 76 | 78 | readonly depth: number; |
| 77 | 79 | readonly rooms: number; |
| 78 | 80 | readonly room: string; |
| @@ -86,6 +88,7 @@ export interface HeroView { | ||
| 86 | 88 | readonly delve_id: number; |
| 87 | 89 | readonly author_id: string; |
| 88 | 90 | readonly author_name: string; |
| 91 | readonly classId: string; | |
| 89 | 92 | } |
| 90 | 93 | |
| 91 | 94 | const delves = new Map<number, Delve>(); |
| @@ -117,6 +120,7 @@ export const view = (d: Delve): DelveView => ({ | ||
| 117 | 120 | id: h.id, |
| 118 | 121 | author_id: h.author_id, |
| 119 | 122 | name: h.author_name, |
| 123 | classId: h.classId, | |
| 120 | 124 | })), |
| 121 | 125 | depth: d.world.depth, |
| 122 | 126 | rooms: d.world.plan.length, |
| @@ -131,6 +135,7 @@ export const heroView = (h: HeroRow): HeroView => ({ | ||
| 131 | 135 | delve_id: h.delve_id, |
| 132 | 136 | author_id: h.author_id, |
| 133 | 137 | author_name: h.author_name, |
| 138 | classId: h.classId, | |
| 134 | 139 | }); |
| 135 | 140 | |
| 136 | 141 | /** Open today's dungeon for a party. `onEnd` is told once, when it is over. */ |
| @@ -167,16 +172,19 @@ export function enlist( | ||
| 167 | 172 | delve: Delve, |
| 168 | 173 | who: { id: string; name: string }, |
| 169 | 174 | now: number, |
| 175 | classId: string = "knight", | |
| 170 | 176 | ): HeroRow { |
| 177 | const known = game.classes.some((c) => c.id === classId) ? classId : "knight"; | |
| 171 | 178 | const row: HeroRow = { |
| 172 | 179 | id: nextHero++, |
| 173 | 180 | delve_id: delve.id, |
| 174 | 181 | author_id: who.id, |
| 175 | 182 | author_name: who.name, |
| 183 | classId: known, | |
| 176 | 184 | seen: now, |
| 177 | 185 | }; |
| 178 | 186 | heroes.set(row.id, row); |
| 179 | addHero(game, delve.world, row.id, who.name); | |
| 187 | addHero(game, delve.world, row.id, who.name, known); | |
| 180 | 188 | if (!delve.crew.includes(who.name)) delve.crew.push(who.name); |
| 181 | 189 | send(delve); |
| 182 | 190 | 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 | ||
| 189 | 189 | expect(one.ok && one.value.ghost).toBe("0,15,55;0,20,55"); |
| 190 | 190 | expect(failed(listRuns(db, anon, { day: "yesterday" }))).toBe("invalid"); |
| 191 | 191 | }); |
| 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> = { | ||
| 30 | 30 | abandoned: "Everyone left the table.", |
| 31 | 31 | }; |
| 32 | 32 | |
| 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 | ||
| 33 | 38 | export function delvePage(p: DelvePage): string { |
| 34 | 39 | const { who, delve, hero } = p; |
| 35 | 40 | const path = `/delve/${delve.id}`; |
| @@ -41,6 +46,7 @@ export function delvePage(p: DelvePage): string { | ||
| 41 | 46 | : `<div class="pad"> |
| 42 | 47 | <button class="button button-big" type="button" data-act="attack">Attack</button> |
| 43 | 48 | <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> | |
| 44 | 50 | </div>`; |
| 45 | 51 | return layout( |
| 46 | 52 | `<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 { | ||
| 75 | 81 | <li><b>Walk</b>: click or tap the floor, or WASD / arrow keys.</li> |
| 76 | 82 | <li><b>Fight</b>: click a monster (your chef keeps swinging), or <kbd>Space</kbd> for the nearest.</li> |
| 77 | 83 | <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> | |
| 78 | 85 | <li><b>Chests</b> open to the touch. <b>Downed?</b> A teammate standing beside you brings you back.</li> |
| 79 | 86 | <li>Clear a room and the door opens. Five floors, then the Pass.</li> |
| 80 | 87 | </ul> |
| @@ -91,8 +98,12 @@ export function delvePage(p: DelvePage): string { | ||
| 91 | 98 | function party(p: DelvePage, path: string): string { |
| 92 | 99 | const { who, delve, hero } = p; |
| 93 | 100 | 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>`) | |
| 95 | 102 | .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>`; | |
| 96 | 107 | let action = ""; |
| 97 | 108 | if (delve.over === null) { |
| 98 | 109 | if (who.role === "anon") |
| @@ -105,6 +116,7 @@ function party(p: DelvePage, path: string): string { | ||
| 105 | 116 | } |
| 106 | 117 | return `<section class="card"> |
| 107 | 118 | <h2>At the table <small class="muted">${delve.party.length} of 4</small></h2> |
| 119 | ${yours} | |
| 108 | 120 | ${names === "" ? `<p class="muted">Nobody left.</p>` : `<ul class="list-plain">${names}</ul>`} |
| 109 | 121 | ${action} |
| 110 | 122 | ${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 { | ||
| 45 | 45 | ${controls(p)} |
| 46 | 46 | <div class="grid"> |
| 47 | 47 | ${tables(p)} |
| 48 | ${dressingRoom(p)} | |
| 48 | 49 | ${board(p)} |
| 49 | 50 | ${square(p)} |
| 50 | 51 | ${wall(p)} |
| @@ -73,6 +74,7 @@ function controls(p: HubPage): string { | ||
| 73 | 74 | return `<section class="card controls"> |
| 74 | 75 | ${back} |
| 75 | 76 | <form method="post" action="/delves"> |
| 77 | <input type="hidden" name="class" value="knight" data-class> | |
| 76 | 78 | <button class="button button-big">Start today's delve</button> |
| 77 | 79 | </form> |
| 78 | 80 | <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 { | ||
| 93 | 95 | const join = |
| 94 | 96 | p.who.role === "anon" || seated || !d.joinable |
| 95 | 97 | ? "" |
| 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>`; | |
| 97 | 99 | return `<li class="item"> |
| 98 | 100 | <div> |
| 99 | 101 | <p><b>${escape(d.host_name)}'s table</b> · ${escape(roomName(d.room))}</p> |
| @@ -109,6 +111,26 @@ function tables(p: HubPage): string { | ||
| 109 | 111 | </section>`; |
| 110 | 112 | } |
| 111 | 113 | |
| 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 | ||
| 112 | 134 | function board(p: HubPage): string { |
| 113 | 135 | const rows = p.runs |
| 114 | 136 | .map( |
src/routes.ts +35 −9
| @@ -187,6 +187,7 @@ export const DELVE: Schema = { | ||
| 187 | 187 | id: { type: "integer" }, |
| 188 | 188 | author_id: { type: "string" }, |
| 189 | 189 | name: { type: "string" }, |
| 190 | classId: { type: "string" }, | |
| 190 | 191 | }, |
| 191 | 192 | }, |
| 192 | 193 | }, |
| @@ -206,15 +207,16 @@ export const HERO: Schema = { | ||
| 206 | 207 | delve_id: { type: "integer" }, |
| 207 | 208 | author_id: { type: "string" }, |
| 208 | 209 | author_name: { type: "string" }, |
| 210 | classId: { type: "string" }, | |
| 209 | 211 | }, |
| 210 | required: ["id", "delve_id", "author_id", "author_name"], | |
| 212 | required: ["id", "delve_id", "author_id", "author_name", "classId"], | |
| 211 | 213 | }; |
| 212 | 214 | const ORDER: Schema = { |
| 213 | 215 | type: "object", |
| 214 | 216 | 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.", | |
| 216 | 218 | properties: { |
| 217 | type: { enum: ["move", "attack", "dodge", "stop"] }, | |
| 219 | type: { enum: ["move", "attack", "dodge", "ability", "stop"] }, | |
| 218 | 220 | x: { type: "number" }, |
| 219 | 221 | z: { type: "number" }, |
| 220 | 222 | target: { type: "integer" }, |
| @@ -286,6 +288,16 @@ const done = <T>(result: Result<T>): Response => | ||
| 286 | 288 | const whole = (value: string | null): number | undefined => |
| 287 | 289 | value !== null && /^\d+$/.test(value) ? Number(value) : undefined; |
| 288 | 290 | |
| 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 | ||
| 289 | 301 | // The API's description, read from git once: what the tests hold equal to this table. |
| 290 | 302 | const OPENAPI = new URL("../contract/openapi.json", import.meta.url); |
| 291 | 303 | const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; |
| @@ -402,8 +414,8 @@ export const routes: readonly Route[] = [ | ||
| 402 | 414 | { |
| 403 | 415 | method: "POST", |
| 404 | 416 | 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) }); | |
| 407 | 419 | return opened.ok |
| 408 | 420 | ? back(undefined, `/delve/${opened.value.id}`) |
| 409 | 421 | : back(opened.message); |
| @@ -412,9 +424,12 @@ export const routes: readonly Route[] = [ | ||
| 412 | 424 | { |
| 413 | 425 | method: "POST", |
| 414 | 426 | path: "/delves/:id/join", |
| 415 | handle: (c) => { | |
| 427 | handle: async (c) => { | |
| 416 | 428 | 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 | }); | |
| 418 | 433 | return joined.ok |
| 419 | 434 | ? back(undefined, `/delve/${joined.value.delve_id}`) |
| 420 | 435 | : back(joined.message, Number.isInteger(id) ? `/delve/${id}` : "/"); |
| @@ -604,8 +619,16 @@ export const routes: readonly Route[] = [ | ||
| 604 | 619 | handle: async (c) => { |
| 605 | 620 | const body = await readJson(c.request); |
| 606 | 621 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 622 | const f = fields(body); | |
| 607 | 623 | 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 | ), | |
| 609 | 632 | 201, |
| 610 | 633 | ); |
| 611 | 634 | }, |
| @@ -613,7 +636,10 @@ export const routes: readonly Route[] = [ | ||
| 613 | 636 | summary: "Take a seat at a table (four a table).", |
| 614 | 637 | body: { |
| 615 | 638 | type: "object", |
| 616 | properties: { delve: { type: "integer" } }, | |
| 639 | properties: { | |
| 640 | delve: { type: "integer" }, | |
| 641 | class: { type: "string" }, | |
| 642 | }, | |
| 617 | 643 | required: ["delve"], |
| 618 | 644 | }, |
| 619 | 645 | 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 ( | ||
| 439 | 439 | const data = (await (await fetch(base + content!)).json()) as { monsters: { id: string }[] }; |
| 440 | 440 | expect(data.monsters.map((m) => m.id)).toEqual(["meatball", "baguette", "pepper"]); |
| 441 | 441 | }); |
| 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", () => { | ||
| 137 | 137 | const w = createWorld(c, 1); |
| 138 | 138 | const hero = addHero(c, w, 1, "a"); |
| 139 | 139 | hero.invuln = 0; |
| 140 | const startHp = hero.hp; | |
| 140 | 141 | w.monsters[0]!.cd = 0; |
| 141 | 142 | let shots = 0; |
| 142 | 143 | for (let t = 0; t < 80; t++) { |
| @@ -144,7 +145,7 @@ test("the Pepper Imp keeps its distance and throws seeds", () => { | ||
| 144 | 145 | shots = Math.max(shots, w.shots.length); |
| 145 | 146 | } |
| 146 | 147 | expect(shots).toBeGreaterThan(0); |
| 147 | expect(w.heroes[0]!.hp).toBeLessThan(c.rules.hero.hp); | |
| 148 | expect(w.heroes[0]!.hp).toBeLessThan(startHp); | |
| 148 | 149 | }); |
| 149 | 150 | |
| 150 | 151 | 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", () => | ||
| 181 | 182 | run(c, w, 2); |
| 182 | 183 | expect(w.over).toBe("wiped"); |
| 183 | 184 | }); |
| 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 { | ||
| 71 | 71 | readonly final?: boolean; |
| 72 | 72 | readonly map: readonly string[]; |
| 73 | 73 | } |
| 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 | } | |
| 74 | 114 | export interface Content { |
| 75 | 115 | readonly rules: { |
| 76 | 116 | readonly tickHz: number; |
| @@ -83,6 +123,7 @@ export interface Content { | ||
| 83 | 123 | readonly traps: readonly TrapDef[]; |
| 84 | 124 | readonly loot: readonly LootDef[]; |
| 85 | 125 | readonly rooms: readonly RoomDef[]; |
| 126 | readonly classes: readonly ClassDef[]; | |
| 86 | 127 | } |
| 87 | 128 | |
| 88 | 129 | // ── The world ── |
| @@ -90,6 +131,8 @@ export interface Content { | ||
| 90 | 131 | export interface Hero { |
| 91 | 132 | readonly id: number; |
| 92 | 133 | readonly name: string; |
| 134 | /** Which class from content/classes.json this chef delves as. */ | |
| 135 | classId: string; | |
| 93 | 136 | x: number; |
| 94 | 137 | z: number; |
| 95 | 138 | fx: number; |
| @@ -100,10 +143,12 @@ export interface Hero { | ||
| 100 | 143 | target: number | null; |
| 101 | 144 | wantSwing: boolean; |
| 102 | 145 | wantDodge: { x: number; z: number } | null; |
| 146 | wantAbility: boolean; | |
| 103 | 147 | swing: number; |
| 104 | 148 | cooldown: number; |
| 105 | 149 | dodge: number; |
| 106 | 150 | dodgeCd: number; |
| 151 | abilityCd: number; | |
| 107 | 152 | dx: number; |
| 108 | 153 | dz: number; |
| 109 | 154 | invuln: number; |
| @@ -131,6 +176,9 @@ export interface Shot { | ||
| 131 | 176 | readonly dz: number; |
| 132 | 177 | ttl: number; |
| 133 | 178 | 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; | |
| 134 | 182 | } |
| 135 | 183 | export interface Chest { |
| 136 | 184 | readonly id: number; |
| @@ -163,6 +211,7 @@ export type Happening = | ||
| 163 | 211 | | { readonly type: "revived"; readonly hero: number } |
| 164 | 212 | | { readonly type: "slain"; readonly monster: number; readonly kind: string } |
| 165 | 213 | | { readonly type: "loot"; readonly hero: number; readonly loot: string } |
| 214 | | { readonly type: "ability"; readonly hero: number; readonly ability: string } | |
| 166 | 215 | | { readonly type: "open" } |
| 167 | 216 | | { readonly type: "room"; readonly depth: number; readonly room: string } |
| 168 | 217 | | { readonly type: "over"; readonly outcome: Outcome }; |
| @@ -192,6 +241,7 @@ export type Order = | ||
| 192 | 241 | | { readonly type: "move"; readonly x: number; readonly z: number } |
| 193 | 242 | | { readonly type: "attack"; readonly target?: number | undefined } |
| 194 | 243 | | { readonly type: "dodge"; readonly x: number; readonly z: number } |
| 244 | | { readonly type: "ability" } | |
| 195 | 245 | | { readonly type: "stop" }; |
| 196 | 246 | |
| 197 | 247 | // ── Randomness: mulberry32, its state kept in the world ── |
| @@ -277,6 +327,33 @@ const roomById = (content: Content, id: string): RoomDef => { | ||
| 277 | 327 | const monsterDef = (content: Content, kind: string): MonsterDef => |
| 278 | 328 | content.monsters.find((m) => m.id === kind)!; |
| 279 | 329 | |
| 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 | ||
| 280 | 357 | // ── The world, made and changed ── |
| 281 | 358 | |
| 282 | 359 | export function createWorld(content: Content, seed: number): World { |
| @@ -356,28 +433,32 @@ export function addHero( | ||
| 356 | 433 | world: World, |
| 357 | 434 | id: number, |
| 358 | 435 | name: string, |
| 436 | classId: string = "knight", | |
| 359 | 437 | ): Hero { |
| 360 | const rules = content.rules.hero; | |
| 438 | const cls = classOf(content, classId); | |
| 361 | 439 | const hero: Hero = { |
| 362 | 440 | id, |
| 363 | 441 | name, |
| 442 | classId: cls.id, | |
| 364 | 443 | x: 0, |
| 365 | 444 | z: 0, |
| 366 | 445 | fx: 1, |
| 367 | 446 | fz: 0, |
| 368 | hp: rules.hp, | |
| 447 | hp: cls.hp, | |
| 369 | 448 | power: 0, |
| 370 | 449 | goal: null, |
| 371 | 450 | target: null, |
| 372 | 451 | wantSwing: false, |
| 373 | 452 | wantDodge: null, |
| 453 | wantAbility: false, | |
| 374 | 454 | swing: 0, |
| 375 | 455 | cooldown: 0, |
| 376 | 456 | dodge: 0, |
| 377 | 457 | dodgeCd: 0, |
| 458 | abilityCd: 0, | |
| 378 | 459 | dx: 0, |
| 379 | 460 | dz: 0, |
| 380 | invuln: rules.invuln, | |
| 461 | invuln: cls.invuln, | |
| 381 | 462 | downed: false, |
| 382 | 463 | revive: 0, |
| 383 | 464 | }; |
| @@ -403,6 +484,7 @@ export function order(world: World, heroId: number, o: Order): void { | ||
| 403 | 484 | hero.goal = null; |
| 404 | 485 | } else hero.wantSwing = true; |
| 405 | 486 | } else if (o.type === "dodge") hero.wantDodge = { x: o.x, z: o.z }; |
| 487 | else if (o.type === "ability") hero.wantAbility = true; | |
| 406 | 488 | else { |
| 407 | 489 | hero.goal = null; |
| 408 | 490 | hero.target = null; |
| @@ -464,10 +546,9 @@ export function step(content: Content, world: World): Happening[] { | ||
| 464 | 546 | world.happened = []; |
| 465 | 547 | if (world.over !== null) return world.happened; |
| 466 | 548 | 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); | |
| 469 | 550 | for (const monster of world.monsters) stepMonster(content, world, monster); |
| 470 | stepShots(world); | |
| 551 | stepShots(content, world); | |
| 471 | 552 | stepTraps(content, world); |
| 472 | 553 | |
| 473 | 554 | const slain = world.monsters.filter((m) => m.hp <= 0); |
| @@ -524,14 +605,12 @@ export function finish(world: World, outcome: Outcome): void { | ||
| 524 | 605 | world.happened.push({ type: "over", outcome }); |
| 525 | 606 | } |
| 526 | 607 | |
| 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); | |
| 533 | 611 | if (hero.cooldown > 0) hero.cooldown--; |
| 534 | 612 | if (hero.dodgeCd > 0) hero.dodgeCd--; |
| 613 | if (hero.abilityCd > 0) hero.abilityCd--; | |
| 535 | 614 | if (hero.invuln > 0) hero.invuln--; |
| 536 | 615 | if (hero.swing > 0) hero.swing--; |
| 537 | 616 | |
| @@ -573,7 +652,9 @@ function stepHero( | ||
| 573 | 652 | if (foe === undefined) hero.target = null; |
| 574 | 653 | else { |
| 575 | 654 | 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; | |
| 577 | 658 | const dir = toward(hero, foe); |
| 578 | 659 | if (dir !== undefined) { |
| 579 | 660 | hero.fx = dir.x; |
| @@ -603,7 +684,15 @@ function stepHero( | ||
| 603 | 684 | |
| 604 | 685 | if (hero.wantSwing) { |
| 605 | 686 | 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); | |
| 607 | 696 | } |
| 608 | 697 | |
| 609 | 698 | // Treasure opens to the touch. |
| @@ -638,6 +727,71 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo | ||
| 638 | 727 | } |
| 639 | 728 | } |
| 640 | 729 | |
| 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 | ||
| 641 | 795 | function roll(content: Content, world: World) { |
| 642 | 796 | const total = content.loot.reduce((sum, l) => sum + l.weight, 0); |
| 643 | 797 | let pick = random(world) * total; |
| @@ -651,7 +805,7 @@ function roll(content: Content, world: World) { | ||
| 651 | 805 | function hurt(content: Content, world: World, hero: Hero, damage: number): boolean { |
| 652 | 806 | if (hero.downed || hero.invuln > 0 || hero.dodge > 0) return false; |
| 653 | 807 | hero.hp -= damage; |
| 654 | hero.invuln = content.rules.hero.invuln; | |
| 808 | hero.invuln = heroRules(content, hero).invuln; | |
| 655 | 809 | world.happened.push({ type: "hurt", hero: hero.id, damage }); |
| 656 | 810 | if (hero.hp <= 0) { |
| 657 | 811 | hero.hp = 0; |
| @@ -748,26 +902,47 @@ function stepMonster(content: Content, world: World, m: Monster): void { | ||
| 748 | 902 | dz: dir.z * speed, |
| 749 | 903 | ttl: Math.ceil((range + 3) / speed), |
| 750 | 904 | damage: def.damage, |
| 905 | from: "monster", | |
| 906 | by: m.id, | |
| 751 | 907 | }); |
| 752 | 908 | m.cd = def.cooldown ?? 45; |
| 753 | 909 | } |
| 754 | 910 | } |
| 755 | 911 | |
| 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) { | |
| 758 | 914 | s.x += s.dx; |
| 759 | 915 | s.z += s.dz; |
| 760 | 916 | 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 | ); | |
| 763 | 937 | } |
| 764 | 938 | |
| 765 | 939 | function stepTraps(content: Content, world: World): void { |
| 766 | 940 | // Shots land here too: one pass over the heroes for everything that bites. |
| 767 | 941 | 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. | |
| 769 | 943 | 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; | |
| 771 | 946 | hurt(content, world, hero, s.damage); |
| 772 | 947 | return false; |
| 773 | 948 | }); |
| @@ -804,6 +979,7 @@ export function snapshot(world: World) { | ||
| 804 | 979 | heroes: world.heroes.map((h) => ({ |
| 805 | 980 | id: h.id, |
| 806 | 981 | name: h.name, |
| 982 | classId: h.classId, | |
| 807 | 983 | x: round(h.x), |
| 808 | 984 | z: round(h.z), |
| 809 | 985 | fx: round(h.fx), |
static/app.css +31
| @@ -423,3 +423,34 @@ kbd { | ||
| 423 | 423 | animation: none; |
| 424 | 424 | } |
| 425 | 425 | } |
| 426 | ||
| 427 | /* Cheffry's dressing room: the class picker. */ | |
| 428 | .class-pick { | |
| 429 | display: grid; | |
| 430 | gap: var(); | |
| 431 | grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); | |
| 432 | } | |
| 433 | .class-card { | |
| 434 | display: grid; | |
| 435 | gap: var(); | |
| 436 | align-content: start; | |
| 437 | padding: var(); | |
| 438 | border: 1px solid var(); | |
| 439 | border-radius: var(); | |
| 440 | cursor: pointer; | |
| 441 | min-height: var(); | |
| 442 | } | |
| 443 | .class-card:has(input:checked) { | |
| 444 | border-color: var(); | |
| 445 | outline: 2px solid var(); | |
| 446 | } | |
| 447 | .class-card input { | |
| 448 | accent-color: var(); | |
| 449 | } | |
| 450 | .class-name { | |
| 451 | font-weight: bold; | |
| 452 | } | |
| 453 | .class-tag { | |
| 454 | font-size: var(); | |
| 455 | color: var(); | |
| 456 | } |
static/delve.js +4
| @@ -343,6 +343,9 @@ function listen() { | ||
| 343 | 343 | } else if (k === "k" || k === "Shift") { |
| 344 | 344 | e.preventDefault(); |
| 345 | 345 | dodgeAhead(); |
| 346 | } else if (k === "e") { | |
| 347 | e.preventDefault(); | |
| 348 | orderUp({ type: "ability" }); | |
| 346 | 349 | } |
| 347 | 350 | }); |
| 348 | 351 | document.addEventListener("keyup", (e) => { |
| @@ -351,6 +354,7 @@ function listen() { | ||
| 351 | 354 | }); |
| 352 | 355 | document.querySelector("[data-act=attack]")?.addEventListener("click", attackNearest); |
| 353 | 356 | document.querySelector("[data-act=dodge]")?.addEventListener("click", dodgeAhead); |
| 357 | document.querySelector("[data-act=ability]")?.addEventListener("click", () => orderUp({ type: "ability" })); | |
| 354 | 358 | } |
| 355 | 359 | |
| 356 | 360 | // Held keys walk a couple of tiles ahead, again while they stay held. |
static/hub.js +23
| @@ -201,7 +201,30 @@ function listen() { | ||
| 201 | 201 | }); |
| 202 | 202 | } |
| 203 | 203 | |
| 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 | ||
| 204 | 226 | async function start() { |
| 227 | dressingRoom(); | |
| 205 | 228 | if (!renderer) return; |
| 206 | 229 | root.classList.add("ready"); |
| 207 | 230 | game = await (await fetch(root.dataset.content)).json(); |