Public: anyone can read this; 1 person and the crew can change it.
From outside: Day 4 build — Cheffry's Spice Rack (throwable consumables) #8
Check · Review · Land · Live done — Landed and live in production.
cheffry-james/cheffry-s-dungeon:cheffry-james/7-from-outside-day-4-build-cheffry-s-spice → main 2 commits fork
23 files
changed +2073
−56
README.md +33 −8
| @@ -38,6 +38,25 @@ it: the preview comes up the moment the app answers, and says `live`, or | ||
| 38 | 38 | Baguette Knight (winds up and charges), the Pepper Imp (keeps its distance |
| 39 | 39 | and shoots) — plus Skewer Spikes and chests of coins, truffles, soup and |
| 40 | 40 | the Whisk of Power. |
| 41 | - **Cheffry's Spice Rack** (day 4 — the room went quiet, so the chef picked | |
| 42 | this one himself). Monsters drop ingredients for whoever finishes them — | |
| 43 | Ghost Pepper from Pepper Imps, Slime Jelly from Meatball Slimes, Stale | |
| 44 | Crust from Baguette Knights — and chests hold Rock Salt and Torch | |
| 45 | Mushrooms. They land in your pantry when you leave the table. At the rack | |
| 46 | on the hub you cook them into throwables: | |
| 47 | - **Pepper Bomb** (ghost pepper + slime jelly): a sneezing cloud; monsters | |
| 48 | in it flail at each other for a few seconds. | |
| 49 | - **Salt the Earth** (rock salt + stale crust): a salt line across your | |
| 50 | throw; burns Baguette Knights (stale bread is the undead here), slows | |
| 51 | everything else. | |
| 52 | - **Hot Sauce Flask** (torch mushroom + ghost pepper): a burning puddle; | |
| 53 | an arrow flying through it catches fire and hits harder. | |
| 54 | ||
| 55 | You carry up to three jars into a delve (one of each kind first) and only | |
| 56 | the ones you throw are spent. In a delve, `Q` (or the **Spice** button) | |
| 57 | picks a jar and a click or tap throws it along the arc shown; `Enter` | |
| 58 | lobs it ahead, `Esc` puts it away. The next spice is yours to call — tell | |
| 59 | the chef what to cook. | |
| 41 | 60 | - **The daily board.** Everyone gets the same seed each UTC day. Finished |
| 42 | 61 | runs land on the leaderboard with a ghost (the party's path, sampled every |
| 43 | 62 | ten ticks); your next party races the best one on screen. |
| @@ -47,17 +66,20 @@ it: the preview comes up the moment the app answers, and says `live`, or | ||
| 47 | 66 | ## Content is data |
| 48 | 67 | |
| 49 | 68 | `content/*.json` is the game: `rooms.json` (layouts, spawns, traps, chests), |
| 50 | `monsters.json`, `traps.json`, `loot.json`, `rules.json` (every tuning number, | |
| 51 | in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole | |
| 69 | `monsters.json`, `traps.json`, `loot.json`, `classes.json`, `spices.json` | |
| 70 | (ingredients, recipes, the throw, and what each zone does), `rules.json` | |
| 71 | (every tuning number, in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole | |
| 52 | 72 | at boot and refuses to start on a bad one, naming the field. A new room or a |
| 53 | new monster built from an existing brain is a JSON edit and nothing else. | |
| 73 | new monster built from an existing brain is a JSON edit and nothing else; | |
| 74 | so is a new spice that mixes the effects the sim knows (`confuse`, `slow`, | |
| 75 | `damage`/`burns`, `ignite`) in a circle or a line. | |
| 54 | 76 | |
| 55 | 77 | ## The networking |
| 56 | 78 | |
| 57 | 79 | A delve is `src/sim.ts`: a pure, seeded, 20 Hz tick with no imports, so the |
| 58 | 80 | same seed and the same orders make the same run anywhere. The server owns |
| 59 | 81 | every instance (`src/delves.ts`) and is the authority; clients send orders |
| 60 | (`move`, `attack`, `dodge`, `stop`) and receive snapshots over | |
| 82 | (`move`, `attack`, `dodge`, `ability`, `throw`, `stop`) and receive snapshots over | |
| 61 | 83 | `/delves/<id>/events`. The page loads the very same sim as `/sim.js` to lay |
| 62 | 84 | out rooms. Instances share nothing but the database, so they would split |
| 63 | 85 | across processes by delve id when one process is no longer enough — v1 |
| @@ -72,8 +94,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 72 | 94 | |
| 73 | 95 | | file | what it is | |
| 74 | 96 | | ---------------------------------- | -------------------------------------------------------------- | |
| 75 | | `src/actions.ts` | notes, avatars, delves, heroes, runs; the audit trail, read | | |
| 76 | | `src/sim.ts` | the delve: a pure seeded tick — classes, shots, abilities, brains | | |
| 97 | | `src/actions.ts` | notes, avatars, delves, heroes, runs, pantries, spices; the audit trail, read | | |
| 98 | | `src/sim.ts` | the delve: a pure seeded tick — classes, shots, abilities, brains, thrown spices and their zones | | |
| 77 | 99 | | `src/content.ts`, `content/` | the game as JSON, checked whole at boot | |
| 78 | 100 | | `src/hub.ts` | the square in memory: who stands where, emotes, chat | |
| 79 | 101 | | `src/delves.ts` | the live instances, their parties, ticking; runs and ghosts | |
| @@ -129,7 +151,7 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | ||
| 129 | 151 | - **anon** — anyone. Reads. |
| 130 | 152 | - **user** — signed in to the forge and able to see this app. Stands in the |
| 131 | 153 | hub, opens and joins delves, writes on the wall — as themselves — and |
| 132 | removes their own. | |
| 154 | removes their own. Sees and spends only their own pantry and spice shelf. | |
| 133 | 155 | - **admin** — able to write this repository. Removes any note or run. |
| 134 | 156 | |
| 135 | 157 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| @@ -149,10 +171,13 @@ curl -X DELETE https://<app>/api/notes/1 | ||
| 149 | 171 | sh |
| 150 | 172 | curl https://<app>/api/runs # today's board, best first |
| 151 | 173 | curl https://<app>/api/delves # open tables |
| 174 | curl https://<app>/api/pantry # your ingredients (signed in) | |
| 175 | curl -X POST https://<app>/api/spices -H 'content-type: application/json' -d '{"spice":"pepper-bomb"}' | |
| 152 | 176 | |
| 153 | 177 | |
| 154 | 178 | As an MCP server, for Claude Code or Claude Desktop — `leaderboard`, |
| 155 | `list_delves`, `list_notes`, `add_note`, `delete_note`: | |
| 179 | `list_delves`, `list_notes`, `add_note`, `delete_note`, `spice_rack`, | |
| 180 | `craft_spice`: | |
| 156 | 181 | |
| 157 | 182 | sh |
| 158 | 183 | claude mcp add --transport http cheffry-s-dungeon https://<app>/mcp |
content/hub.json +1
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | "props": [ |
| 4 | 4 | { "id": "fountain", "shape": "prism", "x": 11, "z": 8, "w": 2.4, "h": 0.8, "d": 2.4, "paint": "game-water" }, |
| 5 | 5 | { "id": "stove", "shape": "box", "x": 4, "z": 3, "w": 3, "h": 1.1, "d": 1.4, "paint": "game-chest" }, |
| 6 | { "id": "spice-rack", "shape": "box", "x": 7.2, "z": 1.4, "w": 2.2, "h": 1.8, "d": 0.5, "paint": "game-pepper" }, | |
| 6 | 7 | { "id": "pantry-door", "shape": "box", "x": 18, "z": 3, "w": 2.2, "h": 2.4, "d": 0.4, "paint": "game-exit" }, |
| 7 | 8 | { "id": "graffiti-wall", "shape": "box", "x": 11, "z": 0.6, "w": 10, "h": 2, "d": 0.4, "paint": "game-wall" }, |
| 8 | 9 | { "id": "barrel-1", "shape": "prism", "x": 3, "z": 13, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" }, |
content/loot.json +4 −2
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | { |
| 2 | "$description": "What a chest holds, by weight. `gold` is score, `heal` is hearts back, `power` is damage added to every swing for the rest of the run.", | |
| 2 | "$description": "What a chest holds, by weight. `gold` is score, `heal` is hearts back, `power` is damage added to every swing for the rest of the run, `ingredient` is a Spice Rack ingredient (content/spices.json) for the chef who opened it.", | |
| 3 | 3 | "loot": [ |
| 4 | 4 | { "id": "coins", "name": "Pile of Coins", "weight": 6, "gold": 50 }, |
| 5 | 5 | { "id": "truffle", "name": "Golden Truffle", "weight": 2, "gold": 150 }, |
| 6 | 6 | { "id": "soup", "name": "Hearty Soup", "weight": 4, "heal": 2 }, |
| 7 | { "id": "whisk", "name": "Whisk of Power", "weight": 2, "power": 1 } | |
| 7 | { "id": "whisk", "name": "Whisk of Power", "weight": 2, "power": 1 }, | |
| 8 | { "id": "rock-salt", "name": "Rock Salt", "weight": 3, "ingredient": "rock-salt" }, | |
| 9 | { "id": "torch-mushroom", "name": "Torch Mushroom", "weight": 3, "ingredient": "torch-mushroom" } | |
| 8 | 10 | ] |
| 9 | 11 | } |
content/monsters.json +10 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "$description": "The kitchen's rejects. `glyph` is how a room map places one; `behavior` is which brain it gets (chase, charge or shoot) and the numbers beside it tune that brain.", | |
| 2 | "$description": "The kitchen's rejects. `glyph` is how a room map places one; `behavior` is which brain it gets (chase, charge or shoot) and the numbers beside it tune that brain. `drops` is the Spice Rack ingredient (content/spices.json) it leaves for whoever finished it, `dropChance` of the time.", | |
| 3 | 3 | "monsters": [ |
| 4 | 4 | { |
| 5 | 5 | "id": "meatball", |
| @@ -11,7 +11,9 @@ | ||
| 11 | 11 | "speed": 0.07, |
| 12 | 12 | "damage": 1, |
| 13 | 13 | "aggro": 7, |
| 14 | "score": 20 | |
| 14 | "score": 20, | |
| 15 | "drops": "slime-jelly", | |
| 16 | "dropChance": 0.5 | |
| 15 | 17 | }, |
| 16 | 18 | { |
| 17 | 19 | "id": "baguette", |
| @@ -27,7 +29,9 @@ | ||
| 27 | 29 | "chargeSpeed": 0.3, |
| 28 | 30 | "chargeTicks": 16, |
| 29 | 31 | "stun": 30, |
| 30 | "score": 35 | |
| 32 | "score": 35, | |
| 33 | "drops": "stale-crust", | |
| 34 | "dropChance": 0.6 | |
| 31 | 35 | }, |
| 32 | 36 | { |
| 33 | 37 | "id": "pepper", |
| @@ -43,7 +47,9 @@ | ||
| 43 | 47 | "keepAway": 3, |
| 44 | 48 | "cooldown": 45, |
| 45 | 49 | "shotSpeed": 0.18, |
| 46 | "score": 30 | |
| 50 | "score": 30, | |
| 51 | "drops": "ghost-pepper", | |
| 52 | "dropChance": 0.6 | |
| 47 | 53 | } |
| 48 | 54 | ] |
| 49 | 55 | } |
content/spices.json added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | { | |
| 2 | "$description": "Cheffry's Spice Rack: throwables crafted in the hub from ingredients chefs bring home from delves. An ingredient drops from a monster (`drops` in monsters.json) or a chest (`ingredient` in loot.json) and lands in the chef's pantry when they leave the table. A recipe spends ingredients for one jar in the satchel; a chef carries up to `carry` jars into a delve and loses only the ones they throw. A thrown jar flies `throw.speed` tiles a tick (at most `throw.range` tiles) and bursts into a zone: a `circle` of `radius`, or a `line` of `length` by `width` laid across the throw. A zone lasts `duration` ticks and does what its effects say — `confuse` (ticks a monster flails at its own kind after it leaves the zone), `slow` (the speed a monster keeps, 0 to 1), `damage` every `every` ticks (only to the monsters in `burns`, when given, and those are not slowed), `ignite` (damage a hero's shot gains flying through). A new spice that mixes these effects is a new entry here and nothing else. `paint` is a colour token, without `--color-`.", | |
| 3 | "carry": 3, | |
| 4 | "satchel": 9, | |
| 5 | "pantry": 30, | |
| 6 | "throw": { "range": 6, "speed": 0.4, "arc": 1.8, "cooldown": 16 }, | |
| 7 | "ingredients": [ | |
| 8 | { "id": "ghost-pepper", "name": "Ghost Pepper", "from": "Pepper Imps drop them" }, | |
| 9 | { "id": "slime-jelly", "name": "Slime Jelly", "from": "Meatball Slimes drop it" }, | |
| 10 | { "id": "stale-crust", "name": "Stale Crust", "from": "Baguette Knights drop it — the dungeon's undead, in bread terms" }, | |
| 11 | { "id": "rock-salt", "name": "Rock Salt", "from": "found in chests" }, | |
| 12 | { "id": "torch-mushroom", "name": "Torch Mushroom", "from": "found in chests" } | |
| 13 | ], | |
| 14 | "spices": [ | |
| 15 | { | |
| 16 | "id": "pepper-bomb", | |
| 17 | "name": "Pepper Bomb", | |
| 18 | "description": "Bursts into a sneezing cloud: monsters inside flail blindly and go for each other instead of you.", | |
| 19 | "recipe": { "ghost-pepper": 1, "slime-jelly": 1 }, | |
| 20 | "paint": "game-pepper", | |
| 21 | "shape": "circle", | |
| 22 | "radius": 1.8, | |
| 23 | "duration": 60, | |
| 24 | "confuse": 80 | |
| 25 | }, | |
| 26 | { | |
| 27 | "id": "salt-the-earth", | |
| 28 | "name": "Salt the Earth", | |
| 29 | "description": "A salt line across the floor. Burns stale things standing in it; everything else wades through slow.", | |
| 30 | "recipe": { "rock-salt": 1, "stale-crust": 1 }, | |
| 31 | "paint": "game-seed", | |
| 32 | "shape": "line", | |
| 33 | "length": 4.5, | |
| 34 | "width": 0.8, | |
| 35 | "duration": 200, | |
| 36 | "slow": 0.4, | |
| 37 | "damage": 1, | |
| 38 | "every": 20, | |
| 39 | "burns": ["baguette"] | |
| 40 | }, | |
| 41 | { | |
| 42 | "id": "hot-sauce", | |
| 43 | "name": "Hot Sauce Flask", | |
| 44 | "description": "Shatters into a burning puddle that cooks whatever stands in it. Arrows flying through catch fire.", | |
| 45 | "recipe": { "torch-mushroom": 1, "ghost-pepper": 1 }, | |
| 46 | "paint": "game-meatball", | |
| 47 | "shape": "circle", | |
| 48 | "radius": 1.4, | |
| 49 | "duration": 140, | |
| 50 | "damage": 1, | |
| 51 | "every": 15, | |
| 52 | "ignite": 1 | |
| 53 | } | |
| 54 | ] | |
| 55 | } |
contract.json +20
| @@ -56,6 +56,26 @@ | ||
| 56 | 56 | "list": "anon" |
| 57 | 57 | } |
| 58 | 58 | }, |
| 59 | "pantry": { | |
| 60 | "api": "/api/pantry", | |
| 61 | "can": { | |
| 62 | "create": "nobody", | |
| 63 | "read": "author", | |
| 64 | "update": "nobody", | |
| 65 | "delete": "nobody", | |
| 66 | "list": "author" | |
| 67 | } | |
| 68 | }, | |
| 69 | "spice": { | |
| 70 | "api": "/api/spices", | |
| 71 | "can": { | |
| 72 | "create": "user", | |
| 73 | "read": "author", | |
| 74 | "update": "nobody", | |
| 75 | "delete": "author", | |
| 76 | "list": "author" | |
| 77 | } | |
| 78 | }, | |
| 59 | 79 | "audit": { |
| 60 | 80 | "api": "/api/audit", |
| 61 | 81 | "can": { |
contract/openapi.json +415 −1
| @@ -750,7 +750,7 @@ | ||
| 750 | 750 | "application/json": { |
| 751 | 751 | "schema": { |
| 752 | 752 | "type": "object", |
| 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.", | |
| 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. throw: lob a carried `spice` toward (x, z). stop.", | |
| 754 | 754 | "properties": { |
| 755 | 755 | "type": { |
| 756 | 756 | "enum": [ |
| @@ -758,6 +758,7 @@ | ||
| 758 | 758 | "attack", |
| 759 | 759 | "dodge", |
| 760 | 760 | "ability", |
| 761 | "throw", | |
| 761 | 762 | "stop" |
| 762 | 763 | ] |
| 763 | 764 | }, |
| @@ -769,6 +770,13 @@ | ||
| 769 | 770 | }, |
| 770 | 771 | "target": { |
| 771 | 772 | "type": "integer" |
| 773 | }, | |
| 774 | "spice": { | |
| 775 | "enum": [ | |
| 776 | "pepper-bomb", | |
| 777 | "salt-the-earth", | |
| 778 | "hot-sauce" | |
| 779 | ] | |
| 772 | 780 | } |
| 773 | 781 | }, |
| 774 | 782 | "required": [ |
| @@ -856,6 +864,412 @@ | ||
| 856 | 864 | } |
| 857 | 865 | } |
| 858 | 866 | }, |
| 867 | "/api/pantry": { | |
| 868 | "get": { | |
| 869 | "summary": "Your pantry: the ingredients you have brought home.", | |
| 870 | "operationId": "getApiPantry", | |
| 871 | "responses": { | |
| 872 | "200": { | |
| 873 | "description": "Your pantry: the ingredients you have brought home.", | |
| 874 | "content": { | |
| 875 | "application/json": { | |
| 876 | "schema": { | |
| 877 | "type": "array", | |
| 878 | "items": { | |
| 879 | "type": "object", | |
| 880 | "description": "How much of one ingredient a chef has brought home.", | |
| 881 | "properties": { | |
| 882 | "id": { | |
| 883 | "type": "integer" | |
| 884 | }, | |
| 885 | "author_id": { | |
| 886 | "type": "string" | |
| 887 | }, | |
| 888 | "ingredient": { | |
| 889 | "enum": [ | |
| 890 | "ghost-pepper", | |
| 891 | "slime-jelly", | |
| 892 | "stale-crust", | |
| 893 | "rock-salt", | |
| 894 | "torch-mushroom" | |
| 895 | ] | |
| 896 | }, | |
| 897 | "count": { | |
| 898 | "type": "integer", | |
| 899 | "minimum": 0 | |
| 900 | } | |
| 901 | }, | |
| 902 | "required": [ | |
| 903 | "id", | |
| 904 | "author_id", | |
| 905 | "ingredient", | |
| 906 | "count" | |
| 907 | ] | |
| 908 | } | |
| 909 | } | |
| 910 | } | |
| 911 | } | |
| 912 | }, | |
| 913 | "default": { | |
| 914 | "description": "{ error, message }: why not", | |
| 915 | "content": { | |
| 916 | "application/json": { | |
| 917 | "schema": { | |
| 918 | "$ref": "#/components/schemas/Error" | |
| 919 | } | |
| 920 | } | |
| 921 | } | |
| 922 | } | |
| 923 | }, | |
| 924 | "x-contract": { | |
| 925 | "resource": "pantry", | |
| 926 | "verb": "list", | |
| 927 | "least": "author" | |
| 928 | } | |
| 929 | } | |
| 930 | }, | |
| 931 | "/api/pantry/{id}": { | |
| 932 | "get": { | |
| 933 | "summary": "One ingredient in your pantry.", | |
| 934 | "operationId": "getApiPantryId", | |
| 935 | "parameters": [ | |
| 936 | { | |
| 937 | "name": "id", | |
| 938 | "in": "path", | |
| 939 | "required": true, | |
| 940 | "schema": { | |
| 941 | "type": "integer" | |
| 942 | } | |
| 943 | } | |
| 944 | ], | |
| 945 | "responses": { | |
| 946 | "200": { | |
| 947 | "description": "One ingredient in your pantry.", | |
| 948 | "content": { | |
| 949 | "application/json": { | |
| 950 | "schema": { | |
| 951 | "type": "object", | |
| 952 | "description": "How much of one ingredient a chef has brought home.", | |
| 953 | "properties": { | |
| 954 | "id": { | |
| 955 | "type": "integer" | |
| 956 | }, | |
| 957 | "author_id": { | |
| 958 | "type": "string" | |
| 959 | }, | |
| 960 | "ingredient": { | |
| 961 | "enum": [ | |
| 962 | "ghost-pepper", | |
| 963 | "slime-jelly", | |
| 964 | "stale-crust", | |
| 965 | "rock-salt", | |
| 966 | "torch-mushroom" | |
| 967 | ] | |
| 968 | }, | |
| 969 | "count": { | |
| 970 | "type": "integer", | |
| 971 | "minimum": 0 | |
| 972 | } | |
| 973 | }, | |
| 974 | "required": [ | |
| 975 | "id", | |
| 976 | "author_id", | |
| 977 | "ingredient", | |
| 978 | "count" | |
| 979 | ] | |
| 980 | } | |
| 981 | } | |
| 982 | } | |
| 983 | }, | |
| 984 | "default": { | |
| 985 | "description": "{ error, message }: why not", | |
| 986 | "content": { | |
| 987 | "application/json": { | |
| 988 | "schema": { | |
| 989 | "$ref": "#/components/schemas/Error" | |
| 990 | } | |
| 991 | } | |
| 992 | } | |
| 993 | } | |
| 994 | }, | |
| 995 | "x-contract": { | |
| 996 | "resource": "pantry", | |
| 997 | "verb": "read", | |
| 998 | "least": "author" | |
| 999 | } | |
| 1000 | } | |
| 1001 | }, | |
| 1002 | "/api/spices": { | |
| 1003 | "get": { | |
| 1004 | "summary": "Your shelf: the jars you have crafted and not thrown.", | |
| 1005 | "operationId": "getApiSpices", | |
| 1006 | "responses": { | |
| 1007 | "200": { | |
| 1008 | "description": "Your shelf: the jars you have crafted and not thrown.", | |
| 1009 | "content": { | |
| 1010 | "application/json": { | |
| 1011 | "schema": { | |
| 1012 | "type": "array", | |
| 1013 | "items": { | |
| 1014 | "type": "object", | |
| 1015 | "description": "A crafted jar on a chef's shelf, until it is thrown.", | |
| 1016 | "properties": { | |
| 1017 | "id": { | |
| 1018 | "type": "integer" | |
| 1019 | }, | |
| 1020 | "kind": { | |
| 1021 | "enum": [ | |
| 1022 | "pepper-bomb", | |
| 1023 | "salt-the-earth", | |
| 1024 | "hot-sauce" | |
| 1025 | ] | |
| 1026 | }, | |
| 1027 | "author_id": { | |
| 1028 | "type": "string" | |
| 1029 | }, | |
| 1030 | "author_name": { | |
| 1031 | "type": "string" | |
| 1032 | }, | |
| 1033 | "created_at": { | |
| 1034 | "type": "string", | |
| 1035 | "format": "date-time" | |
| 1036 | } | |
| 1037 | }, | |
| 1038 | "required": [ | |
| 1039 | "id", | |
| 1040 | "kind", | |
| 1041 | "author_id", | |
| 1042 | "author_name", | |
| 1043 | "created_at" | |
| 1044 | ] | |
| 1045 | } | |
| 1046 | } | |
| 1047 | } | |
| 1048 | } | |
| 1049 | }, | |
| 1050 | "default": { | |
| 1051 | "description": "{ error, message }: why not", | |
| 1052 | "content": { | |
| 1053 | "application/json": { | |
| 1054 | "schema": { | |
| 1055 | "$ref": "#/components/schemas/Error" | |
| 1056 | } | |
| 1057 | } | |
| 1058 | } | |
| 1059 | } | |
| 1060 | }, | |
| 1061 | "x-contract": { | |
| 1062 | "resource": "spice", | |
| 1063 | "verb": "list", | |
| 1064 | "least": "author" | |
| 1065 | } | |
| 1066 | }, | |
| 1067 | "post": { | |
| 1068 | "summary": "Craft a spice: its recipe comes out of your pantry.", | |
| 1069 | "operationId": "postApiSpices", | |
| 1070 | "requestBody": { | |
| 1071 | "required": true, | |
| 1072 | "content": { | |
| 1073 | "application/json": { | |
| 1074 | "schema": { | |
| 1075 | "type": "object", | |
| 1076 | "properties": { | |
| 1077 | "spice": { | |
| 1078 | "enum": [ | |
| 1079 | "pepper-bomb", | |
| 1080 | "salt-the-earth", | |
| 1081 | "hot-sauce" | |
| 1082 | ] | |
| 1083 | } | |
| 1084 | }, | |
| 1085 | "required": [ | |
| 1086 | "spice" | |
| 1087 | ] | |
| 1088 | } | |
| 1089 | } | |
| 1090 | } | |
| 1091 | }, | |
| 1092 | "responses": { | |
| 1093 | "201": { | |
| 1094 | "description": "Craft a spice: its recipe comes out of your pantry.", | |
| 1095 | "content": { | |
| 1096 | "application/json": { | |
| 1097 | "schema": { | |
| 1098 | "type": "object", | |
| 1099 | "description": "A crafted jar on a chef's shelf, until it is thrown.", | |
| 1100 | "properties": { | |
| 1101 | "id": { | |
| 1102 | "type": "integer" | |
| 1103 | }, | |
| 1104 | "kind": { | |
| 1105 | "enum": [ | |
| 1106 | "pepper-bomb", | |
| 1107 | "salt-the-earth", | |
| 1108 | "hot-sauce" | |
| 1109 | ] | |
| 1110 | }, | |
| 1111 | "author_id": { | |
| 1112 | "type": "string" | |
| 1113 | }, | |
| 1114 | "author_name": { | |
| 1115 | "type": "string" | |
| 1116 | }, | |
| 1117 | "created_at": { | |
| 1118 | "type": "string", | |
| 1119 | "format": "date-time" | |
| 1120 | } | |
| 1121 | }, | |
| 1122 | "required": [ | |
| 1123 | "id", | |
| 1124 | "kind", | |
| 1125 | "author_id", | |
| 1126 | "author_name", | |
| 1127 | "created_at" | |
| 1128 | ] | |
| 1129 | } | |
| 1130 | } | |
| 1131 | } | |
| 1132 | }, | |
| 1133 | "default": { | |
| 1134 | "description": "{ error, message }: why not", | |
| 1135 | "content": { | |
| 1136 | "application/json": { | |
| 1137 | "schema": { | |
| 1138 | "$ref": "#/components/schemas/Error" | |
| 1139 | } | |
| 1140 | } | |
| 1141 | } | |
| 1142 | } | |
| 1143 | }, | |
| 1144 | "x-contract": { | |
| 1145 | "resource": "spice", | |
| 1146 | "verb": "create", | |
| 1147 | "least": "user" | |
| 1148 | } | |
| 1149 | } | |
| 1150 | }, | |
| 1151 | "/api/spices/{id}": { | |
| 1152 | "get": { | |
| 1153 | "summary": "One jar on your shelf.", | |
| 1154 | "operationId": "getApiSpicesId", | |
| 1155 | "parameters": [ | |
| 1156 | { | |
| 1157 | "name": "id", | |
| 1158 | "in": "path", | |
| 1159 | "required": true, | |
| 1160 | "schema": { | |
| 1161 | "type": "integer" | |
| 1162 | } | |
| 1163 | } | |
| 1164 | ], | |
| 1165 | "responses": { | |
| 1166 | "200": { | |
| 1167 | "description": "One jar on your shelf.", | |
| 1168 | "content": { | |
| 1169 | "application/json": { | |
| 1170 | "schema": { | |
| 1171 | "type": "object", | |
| 1172 | "description": "A crafted jar on a chef's shelf, until it is thrown.", | |
| 1173 | "properties": { | |
| 1174 | "id": { | |
| 1175 | "type": "integer" | |
| 1176 | }, | |
| 1177 | "kind": { | |
| 1178 | "enum": [ | |
| 1179 | "pepper-bomb", | |
| 1180 | "salt-the-earth", | |
| 1181 | "hot-sauce" | |
| 1182 | ] | |
| 1183 | }, | |
| 1184 | "author_id": { | |
| 1185 | "type": "string" | |
| 1186 | }, | |
| 1187 | "author_name": { | |
| 1188 | "type": "string" | |
| 1189 | }, | |
| 1190 | "created_at": { | |
| 1191 | "type": "string", | |
| 1192 | "format": "date-time" | |
| 1193 | } | |
| 1194 | }, | |
| 1195 | "required": [ | |
| 1196 | "id", | |
| 1197 | "kind", | |
| 1198 | "author_id", | |
| 1199 | "author_name", | |
| 1200 | "created_at" | |
| 1201 | ] | |
| 1202 | } | |
| 1203 | } | |
| 1204 | } | |
| 1205 | }, | |
| 1206 | "default": { | |
| 1207 | "description": "{ error, message }: why not", | |
| 1208 | "content": { | |
| 1209 | "application/json": { | |
| 1210 | "schema": { | |
| 1211 | "$ref": "#/components/schemas/Error" | |
| 1212 | } | |
| 1213 | } | |
| 1214 | } | |
| 1215 | } | |
| 1216 | }, | |
| 1217 | "x-contract": { | |
| 1218 | "resource": "spice", | |
| 1219 | "verb": "read", | |
| 1220 | "least": "author" | |
| 1221 | } | |
| 1222 | }, | |
| 1223 | "delete": { | |
| 1224 | "summary": "Tip a jar out.", | |
| 1225 | "operationId": "deleteApiSpicesId", | |
| 1226 | "parameters": [ | |
| 1227 | { | |
| 1228 | "name": "id", | |
| 1229 | "in": "path", | |
| 1230 | "required": true, | |
| 1231 | "schema": { | |
| 1232 | "type": "integer" | |
| 1233 | } | |
| 1234 | } | |
| 1235 | ], | |
| 1236 | "responses": { | |
| 1237 | "200": { | |
| 1238 | "description": "Tip a jar out.", | |
| 1239 | "content": { | |
| 1240 | "application/json": { | |
| 1241 | "schema": { | |
| 1242 | "type": "object", | |
| 1243 | "properties": { | |
| 1244 | "id": { | |
| 1245 | "type": "integer" | |
| 1246 | } | |
| 1247 | }, | |
| 1248 | "required": [ | |
| 1249 | "id" | |
| 1250 | ] | |
| 1251 | } | |
| 1252 | } | |
| 1253 | } | |
| 1254 | }, | |
| 1255 | "default": { | |
| 1256 | "description": "{ error, message }: why not", | |
| 1257 | "content": { | |
| 1258 | "application/json": { | |
| 1259 | "schema": { | |
| 1260 | "$ref": "#/components/schemas/Error" | |
| 1261 | } | |
| 1262 | } | |
| 1263 | } | |
| 1264 | } | |
| 1265 | }, | |
| 1266 | "x-contract": { | |
| 1267 | "resource": "spice", | |
| 1268 | "verb": "delete", | |
| 1269 | "least": "author" | |
| 1270 | } | |
| 1271 | } | |
| 1272 | }, | |
| 859 | 1273 | "/api/runs": { |
| 860 | 1274 | "get": { |
| 861 | 1275 | "summary": "A day's leaderboard (today unless `day`), best first.", |
src/actions.ts +256 −9
| @@ -15,7 +15,7 @@ import type { Delve, DelveView, HeroRow, HeroView } from "./delves.ts"; | ||
| 15 | 15 | import * as hub from "./hub.ts"; |
| 16 | 16 | import { SAY_MAX, type Avatar, type AvatarView, type Line } from "./hub.ts"; |
| 17 | 17 | import { emit, type Bus } from "./live.ts"; |
| 18 | import { dayOf, type Order } from "./sim.ts"; | |
| 18 | import { dayOf, type Hero, type Jar, type Order } from "./sim.ts"; | |
| 19 | 19 | import type { Who } from "./who.ts"; |
| 20 | 20 | |
| 21 | 21 | export interface Note { |
| @@ -31,7 +31,7 @@ export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | ||
| 31 | 31 | export type Result<T> = |
| 32 | 32 | | { ok: true; value: T } |
| 33 | 33 | | { ok: false; error: Failure; message: string }; |
| 34 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 34 | export const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 35 | 35 | const fail = <T>(error: Failure, message: string): Result<T> => ({ |
| 36 | 36 | ok: false, |
| 37 | 37 | error, |
| @@ -533,8 +533,13 @@ function openOne( | ||
| 533 | 533 | const now = Date.now(); |
| 534 | 534 | const before = kitchen.heroOfAuthor(who.id); |
| 535 | 535 | if (before !== undefined) kitchen.dismiss(before, now); |
| 536 | const delve = kitchen.open(who, now, (d) => recordRun(db, d)); | |
| 537 | kitchen.enlist(delve, who, now, picked.value); | |
| 536 | const delve = kitchen.open( | |
| 537 | who, | |
| 538 | now, | |
| 539 | (d) => recordRun(db, d), | |
| 540 | (row, hero) => settleHero(db, row, hero), | |
| 541 | ); | |
| 542 | kitchen.enlist(delve, who, now, picked.value, packOf(db, who.id)); | |
| 538 | 543 | emit("delve.opened", { id: delve.id }); |
| 539 | 544 | return ok(kitchen.view(delve)); |
| 540 | 545 | } |
| @@ -582,9 +587,13 @@ export const joinDelve = ( | ||
| 582 | 587 | who: Who, |
| 583 | 588 | input: { delve: number; class?: string }, |
| 584 | 589 | ): Result<HeroView> => |
| 585 | audited(db, who, "hero", "create", undefined, join(who, input)); | |
| 590 | audited(db, who, "hero", "create", undefined, join(db, who, input)); | |
| 586 | 591 | |
| 587 | function join(who: Who, input: { delve: number; class?: string }): Result<HeroView> { | |
| 592 | function join( | |
| 593 | db: Database, | |
| 594 | who: Who, | |
| 595 | input: { delve: number; class?: string }, | |
| 596 | ): Result<HeroView> { | |
| 588 | 597 | if (who.role === "anon" || permit(who, "hero", "create") !== "yes") |
| 589 | 598 | return refuse(who, "Sign in to join a party.", "You may not join a party."); |
| 590 | 599 | if (!Number.isInteger(input.delve)) |
| @@ -600,7 +609,7 @@ function join(who: Who, input: { delve: number; class?: string }): Result<HeroVi | ||
| 600 | 609 | if (!kitchen.joinable(delve)) |
| 601 | 610 | return fail("invalid", `That party is full: ${kitchen.PARTY_MAX} chefs a table.`); |
| 602 | 611 | if (before !== undefined) kitchen.dismiss(before, now); |
| 603 | const hero = kitchen.enlist(delve, who, now, picked.value); | |
| 612 | const hero = kitchen.enlist(delve, who, now, picked.value, packOf(db, who.id)); | |
| 604 | 613 | emit("delve.changed", { id: delve.id }); |
| 605 | 614 | return ok(kitchen.heroView(hero)); |
| 606 | 615 | } |
| @@ -632,11 +641,18 @@ export function readOrder( | ||
| 632 | 641 | : "A target is a monster's id, a whole number."; |
| 633 | 642 | } |
| 634 | 643 | if (type === "ability") return { type: "ability" }; |
| 644 | if (type === "throw") { | |
| 645 | const spice = game.rack.spices.find((sp) => sp.id === input.spice); | |
| 646 | if (spice === undefined) | |
| 647 | return `A throw names a spice: ${game.rack.spices.map((sp) => sp.id).join(", ")}.`; | |
| 648 | const at = place(); | |
| 649 | return typeof at === "string" ? at : { type, spice: spice.id, ...at }; | |
| 650 | } | |
| 635 | 651 | if (type === "stop") return { type: "stop" }; |
| 636 | return "An order is move, attack, dodge, ability or stop."; | |
| 652 | return "An order is move, attack, dodge, ability, throw or stop."; | |
| 637 | 653 | } |
| 638 | 654 | |
| 639 | /** Give your hero an order: move, attack, dodge or stop. */ | |
| 655 | /** Give your hero an order: move, attack, dodge, ability, throw or stop. */ | |
| 640 | 656 | export const commandHero = ( |
| 641 | 657 | db: Database, |
| 642 | 658 | who: Who, |
| @@ -819,3 +835,234 @@ export function recordRun(db: Database, delve: Delve): Run | undefined { | ||
| 819 | 835 | return undefined; |
| 820 | 836 | } |
| 821 | 837 | } |
| 838 | ||
| 839 | // ── Cheffry's Spice Rack: a pantry of ingredients, a shelf of jars ── | |
| 840 | ||
| 841 | export interface PantryRow { | |
| 842 | readonly id: number; | |
| 843 | readonly author_id: string; | |
| 844 | readonly ingredient: string; | |
| 845 | readonly count: number; | |
| 846 | } | |
| 847 | ||
| 848 | /** A crafted spice on a chef's shelf. `kind` is its id in content/spices.json. */ | |
| 849 | export interface SpiceJar { | |
| 850 | readonly id: number; | |
| 851 | readonly kind: string; | |
| 852 | readonly author_id: string; | |
| 853 | readonly author_name: string; | |
| 854 | readonly created_at: string; | |
| 855 | } | |
| 856 | ||
| 857 | /** | |
| 858 | * Rows of a table a `list` answer lets `who` see, newest first: every row | |
| 859 | * for "yes" (only `author`'s, when asked for), and for a role a row holds, | |
| 860 | * the rows whose named columns hold them. The table and column names are | |
| 861 | * this file's; the values are bound. | |
| 862 | */ | |
| 863 | function listed<T>( | |
| 864 | db: Database, | |
| 865 | who: Who, | |
| 866 | may: Exclude<ReturnType<typeof permit>, "no">, | |
| 867 | table: "pantry" | "spice", | |
| 868 | author: string | undefined, | |
| 869 | ): T[] { | |
| 870 | if (may === "yes") | |
| 871 | return author === undefined | |
| 872 | ? db.query<T, []>(`SELECT * FROM ${table} ORDER BY id DESC LIMIT 500`).all() | |
| 873 | : db | |
| 874 | .query<T, [string]>(`SELECT * FROM ${table} WHERE author_id = ?1 ORDER BY id DESC LIMIT 500`) | |
| 875 | .all(author); | |
| 876 | const mine = may.where.map((column) => `${column} = ?1`).join(" OR "); | |
| 877 | return db | |
| 878 | .query<T, [string]>(`SELECT * FROM ${table} WHERE ${mine} ORDER BY id DESC LIMIT 500`) | |
| 879 | .all(who.role === "anon" ? "" : who.id); | |
| 880 | } | |
| 881 | ||
| 882 | /** Your pantry: what you have brought home from delves, an ingredient a row. */ | |
| 883 | export function listPantry( | |
| 884 | db: Database, | |
| 885 | who: Who, | |
| 886 | input: { author?: string } = {}, | |
| 887 | ): Result<PantryRow[]> { | |
| 888 | const may = permit(who, "pantry", "list"); | |
| 889 | if (may === "no") | |
| 890 | return refuse(who, "Sign in to see your pantry.", "You may not see the pantries."); | |
| 891 | return ok(listed<PantryRow>(db, who, may, "pantry", input.author)); | |
| 892 | } | |
| 893 | ||
| 894 | export function getPantry( | |
| 895 | db: Database, | |
| 896 | who: Who, | |
| 897 | input: { id: number }, | |
| 898 | ): Result<PantryRow> { | |
| 899 | const row = Number.isInteger(input.id) | |
| 900 | ? db.query<PantryRow, [number]>("SELECT * FROM pantry WHERE id = ?1").get(input.id) | |
| 901 | : null; | |
| 902 | const probe: Readonly<Record<string, unknown>> = | |
| 903 | row === null ? { author_id: who.role === "anon" ? null : who.id } : { ...row }; | |
| 904 | if (permit(who, "pantry", "read", probe) !== "yes") | |
| 905 | return refuse(who, "Sign in to see your pantry.", "That is someone else's pantry."); | |
| 906 | if (row !== null) return ok(row); | |
| 907 | if (!Number.isInteger(input.id)) | |
| 908 | return fail("invalid", "A pantry id is a whole number."); | |
| 909 | return fail("not_found", "No such shelf in the pantry."); | |
| 910 | } | |
| 911 | ||
| 912 | /** Your jars: spices crafted and not yet thrown. */ | |
| 913 | export function listSpices( | |
| 914 | db: Database, | |
| 915 | who: Who, | |
| 916 | input: { author?: string } = {}, | |
| 917 | ): Result<SpiceJar[]> { | |
| 918 | const may = permit(who, "spice", "list"); | |
| 919 | if (may === "no") | |
| 920 | return refuse(who, "Sign in to see your spices.", "You may not see the spice shelves."); | |
| 921 | return ok(listed<SpiceJar>(db, who, may, "spice", input.author)); | |
| 922 | } | |
| 923 | ||
| 924 | function jarFor( | |
| 925 | db: Database, | |
| 926 | who: Who, | |
| 927 | verb: "read" | "delete", | |
| 928 | id: number, | |
| 929 | said: { signIn: string; no: string }, | |
| 930 | ): Result<SpiceJar> { | |
| 931 | const jar = Number.isInteger(id) | |
| 932 | ? db.query<SpiceJar, [number]>("SELECT * FROM spice WHERE id = ?1").get(id) | |
| 933 | : null; | |
| 934 | const probe: Readonly<Record<string, unknown>> = | |
| 935 | jar === null ? { author_id: who.role === "anon" ? null : who.id } : { ...jar }; | |
| 936 | if (permit(who, "spice", verb, probe) !== "yes") return refuse(who, said.signIn, said.no); | |
| 937 | if (jar !== null) return ok(jar); | |
| 938 | if (!Number.isInteger(id)) return fail("invalid", "A jar id is a whole number."); | |
| 939 | return fail("not_found", "No such jar on the shelf."); | |
| 940 | } | |
| 941 | ||
| 942 | export function getSpice( | |
| 943 | db: Database, | |
| 944 | who: Who, | |
| 945 | input: { id: number }, | |
| 946 | ): Result<SpiceJar> { | |
| 947 | return jarFor(db, who, "read", input.id, { | |
| 948 | signIn: "Sign in to see your spices.", | |
| 949 | no: "That is someone else's jar.", | |
| 950 | }); | |
| 951 | } | |
| 952 | ||
| 953 | const ingredientName = (id: string): string => | |
| 954 | game.rack.ingredients.find((g) => g.id === id)?.name ?? id; | |
| 955 | ||
| 956 | /** Craft a spice at the rack: the recipe comes out of your pantry, the jar goes on your shelf. */ | |
| 957 | export const craftSpice = ( | |
| 958 | db: Database, | |
| 959 | who: Who, | |
| 960 | input: { spice: unknown }, | |
| 961 | ): Result<SpiceJar> => | |
| 962 | audited(db, who, "spice", "create", undefined, craft(db, who, input)); | |
| 963 | ||
| 964 | function craft(db: Database, who: Who, input: { spice: unknown }): Result<SpiceJar> { | |
| 965 | if (who.role === "anon" || permit(who, "spice", "create") !== "yes") | |
| 966 | return refuse(who, "Sign in to use the Spice Rack.", "You may not use the Spice Rack."); | |
| 967 | const def = game.rack.spices.find((sp) => sp.id === input.spice); | |
| 968 | if (def === undefined) | |
| 969 | return fail( | |
| 970 | "invalid", | |
| 971 | `No such spice. The rack makes ${game.rack.spices.map((sp) => sp.id).join(", ")}.`, | |
| 972 | ); | |
| 973 | return db.transaction((): Result<SpiceJar> => { | |
| 974 | const held = db | |
| 975 | .query<{ n: number }, [string]>("SELECT count(*) AS n FROM spice WHERE author_id = ?1") | |
| 976 | .get(who.id)!.n; | |
| 977 | if (held >= game.rack.satchel) | |
| 978 | return fail("invalid", `Your shelf is full: ${game.rack.satchel} jars. Go throw some!`); | |
| 979 | const stock = new Map( | |
| 980 | db | |
| 981 | .query<PantryRow, [string]>("SELECT * FROM pantry WHERE author_id = ?1") | |
| 982 | .all(who.id) | |
| 983 | .map((r) => [r.ingredient, r.count]), | |
| 984 | ); | |
| 985 | const short = Object.entries(def.recipe).filter(([g, n]) => (stock.get(g) ?? 0) < n); | |
| 986 | if (short.length > 0) | |
| 987 | return fail( | |
| 988 | "invalid", | |
| 989 | `Not enough in the pantry for ${def.name}: it takes ${Object.entries(def.recipe) | |
| 990 | .map(([g, n]) => `${n} ${ingredientName(g)}`) | |
| 991 | .join(" and ")}.`, | |
| 992 | ); | |
| 993 | for (const [g, n] of Object.entries(def.recipe)) | |
| 994 | db.query("UPDATE pantry SET count = count - ?1 WHERE author_id = ?2 AND ingredient = ?3").run( | |
| 995 | n, | |
| 996 | who.id, | |
| 997 | g, | |
| 998 | ); | |
| 999 | const jar = db | |
| 1000 | .query<SpiceJar, [string, string, string]>( | |
| 1001 | "INSERT INTO spice (kind, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *", | |
| 1002 | ) | |
| 1003 | .get(def.id, who.id, who.name)!; | |
| 1004 | return ok(jar); | |
| 1005 | })(); | |
| 1006 | } | |
| 1007 | ||
| 1008 | /** Tip a jar out: it is gone, and the ingredients with it. */ | |
| 1009 | export const tossSpice = ( | |
| 1010 | db: Database, | |
| 1011 | who: Who, | |
| 1012 | input: { id: number }, | |
| 1013 | ): Result<{ id: number }> => | |
| 1014 | audited(db, who, "spice", "delete", input.id, toss(db, who, input)); | |
| 1015 | ||
| 1016 | function toss(db: Database, who: Who, input: { id: number }): Result<{ id: number }> { | |
| 1017 | const found = jarFor(db, who, "delete", input.id, { | |
| 1018 | signIn: "Sign in to tip out a jar.", | |
| 1019 | no: "That is someone else's jar.", | |
| 1020 | }); | |
| 1021 | if (!found.ok) return found; | |
| 1022 | db.query("DELETE FROM spice WHERE id = ?1").run(found.value.id); | |
| 1023 | return ok({ id: found.value.id }); | |
| 1024 | } | |
| 1025 | ||
| 1026 | /** | |
| 1027 | * The jars a chef carries into a delve: up to the rack's `carry`, one of | |
| 1028 | * each kind in turn, oldest first — a spread, not three of the same. | |
| 1029 | */ | |
| 1030 | export function packOf(db: Database, authorId: string): Jar[] { | |
| 1031 | const jars = db | |
| 1032 | .query<{ id: number; kind: string }, [string]>( | |
| 1033 | "SELECT id, kind FROM spice WHERE author_id = ?1 ORDER BY id", | |
| 1034 | ) | |
| 1035 | .all(authorId) | |
| 1036 | .filter((j) => game.rack.spices.some((sp) => sp.id === j.kind)); | |
| 1037 | const pack: Jar[] = []; | |
| 1038 | while (pack.length < game.rack.carry && jars.length > 0) { | |
| 1039 | const kinds = new Set(pack.map((j) => j.spice)); | |
| 1040 | const at = jars.findIndex((j) => !kinds.has(j.kind)); | |
| 1041 | const [next] = jars.splice(at < 0 ? 0 : at, 1); | |
| 1042 | pack.push({ jar: next!.id, spice: next!.kind }); | |
| 1043 | } | |
| 1044 | return pack; | |
| 1045 | } | |
| 1046 | ||
| 1047 | /** | |
| 1048 | * A chef leaves the table, or the delve ends: the jars they threw come off | |
| 1049 | * their shelf and what they picked up goes into their pantry, up to the | |
| 1050 | * rack's limit. The app writes this, not a person, like a run. | |
| 1051 | */ | |
| 1052 | export function settleHero(db: Database, row: HeroRow, hero: Hero): void { | |
| 1053 | try { | |
| 1054 | db.transaction(() => { | |
| 1055 | for (const jar of hero.thrown) | |
| 1056 | db.query("DELETE FROM spice WHERE id = ?1 AND author_id = ?2").run(jar, row.author_id); | |
| 1057 | for (const [g, n] of Object.entries(hero.haul)) { | |
| 1058 | if (!(n > 0) || !game.rack.ingredients.some((i) => i.id === g)) continue; | |
| 1059 | db.query( | |
| 1060 | `INSERT INTO pantry (author_id, ingredient, count) VALUES (?1, ?2, min(?3, ?4)) | |
| 1061 | ON CONFLICT (author_id, ingredient) DO UPDATE SET count = min(count + excluded.count, ?4)`, | |
| 1062 | ).run(row.author_id, g, n, game.rack.pantry); | |
| 1063 | } | |
| 1064 | })(); | |
| 1065 | } catch (error) { | |
| 1066 | console.error(error); | |
| 1067 | } | |
| 1068 | } |
src/content.test.ts +48 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 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. | |
| 2 | * The classes and the Spice Rack, checked like every other content file: | |
| 3 | * bad data names its field, and the app will not start on it. | |
| 4 | 4 | */ |
| 5 | 5 | import { expect, test } from "bun:test"; |
| 6 | 6 | import { readFileSync } from "node:fs"; |
| @@ -16,6 +16,7 @@ const files = () => ({ | ||
| 16 | 16 | rooms: raw("rooms"), |
| 17 | 17 | hub: raw("hub"), |
| 18 | 18 | classes: raw("classes"), |
| 19 | spices: raw("spices"), | |
| 19 | 20 | }); |
| 20 | 21 | const withClasses = (mutate: (c: any) => void) => { |
| 21 | 22 | const f = files(); |
| @@ -72,3 +73,48 @@ test("an ability is named, timed, and of a kind the sim knows", () => { | ||
| 72 | 73 | expect(bad.some((p) => p.includes("name is missing"))).toBe(true); |
| 73 | 74 | expect(bad.some((p) => p.includes("distance must be a positive number"))).toBe(true); |
| 74 | 75 | }); |
| 76 | ||
| 77 | const withSpices = (mutate: (rack: any, f: any) => void) => { | |
| 78 | const f = files() as any; | |
| 79 | mutate(f.spices, f); | |
| 80 | return check(f); | |
| 81 | }; | |
| 82 | ||
| 83 | test("a spice is made of ingredients the rack knows, and does something", () => { | |
| 84 | const bad = withSpices((rack) => { | |
| 85 | rack.spices[0].recipe = { "dragon-egg": 1 }; | |
| 86 | delete rack.spices[1].confuse; | |
| 87 | delete rack.spices[1].slow; | |
| 88 | delete rack.spices[1].damage; | |
| 89 | rack.spices[2].shape = "triangle"; | |
| 90 | }); | |
| 91 | expect(bad.some((p) => p.includes("dragon-egg is not an ingredient"))).toBe(true); | |
| 92 | expect(bad.some((p) => p.includes("needs at least one effect"))).toBe(true); | |
| 93 | expect(bad.some((p) => p.includes("shape must be one of"))).toBe(true); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a spice's numbers make sense: slow is a share, burns names monsters, paint is a token", () => { | |
| 97 | const bad = withSpices((rack) => { | |
| 98 | rack.spices[1].slow = 1.5; | |
| 99 | rack.spices[1].burns = ["skeleton"]; | |
| 100 | rack.spices[0].paint = "neon"; | |
| 101 | delete rack.spices[2].every; | |
| 102 | rack.carry = 0; | |
| 103 | }); | |
| 104 | expect(bad.some((p) => p.includes("slow must be above 0 and below 1"))).toBe(true); | |
| 105 | expect(bad.some((p) => p.includes("burns must be a list of monster ids"))).toBe(true); | |
| 106 | expect(bad.some((p) => p.includes("paint neon is not a colour token"))).toBe(true); | |
| 107 | expect(bad.some((p) => p.includes("every must be a positive number"))).toBe(true); | |
| 108 | expect(bad.some((p) => p.includes("carry must be a positive number"))).toBe(true); | |
| 109 | }); | |
| 110 | ||
| 111 | test("what drops, monster or chest, is an ingredient the rack knows", () => { | |
| 112 | const bad = withSpices((_rack, f) => { | |
| 113 | f.monsters.monsters[0].drops = "unobtainium"; | |
| 114 | f.monsters.monsters[1].dropChance = 2; | |
| 115 | f.loot.loot[0].ingredient = "moonbeam"; | |
| 116 | }); | |
| 117 | expect(bad.some((p) => p.includes("drops unobtainium is not an ingredient"))).toBe(true); | |
| 118 | expect(bad.some((p) => p.includes("dropChance must be above 0 and at most 1"))).toBe(true); | |
| 119 | expect(bad.some((p) => p.includes("ingredient moonbeam is not an ingredient"))).toBe(true); | |
| 120 | }); |
src/content.ts +63 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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, the Spice Rack and the | |
| 4 | * hub's furniture — read once at | |
| 4 | 5 | * boot and checked whole. A new monster, room or chest drop is a change to |
| 5 | 6 | * a JSON file, never to code (a new *brain* is code: `behavior` in |
| 6 | 7 | * `src/sim.ts`). |
| @@ -39,6 +40,8 @@ export interface Game extends Content { | ||
| 39 | 40 | |
| 40 | 41 | const BEHAVIORS = ["chase", "charge", "shoot"]; |
| 41 | 42 | const ABILITY_KINDS = ["bulwark", "volley", "blink"]; |
| 43 | const SPICE_SHAPES = ["circle", "line"]; | |
| 44 | const SPICE_EFFECTS = ["confuse", "slow", "damage", "ignite"]; | |
| 42 | 45 | const RESERVED = "#.SEC"; |
| 43 | 46 | |
| 44 | 47 | const isObject = (v: unknown): v is Record<string, unknown> => |
| @@ -56,6 +59,7 @@ export function check(files: { | ||
| 56 | 59 | rooms: unknown; |
| 57 | 60 | hub: unknown; |
| 58 | 61 | classes: unknown; |
| 62 | spices: unknown; | |
| 59 | 63 | }): string[] { |
| 60 | 64 | const problems: string[] = []; |
| 61 | 65 | const say = (where: string, what: string) => problems.push(`${where}: ${what}`); |
| @@ -109,6 +113,14 @@ export function check(files: { | ||
| 109 | 113 | return list; |
| 110 | 114 | }; |
| 111 | 115 | |
| 116 | const rack = isObject(files.spices) ? files.spices : {}; | |
| 117 | const ingredients = listOf(rack, "ingredients"); | |
| 118 | ids("ingredients", ingredients); | |
| 119 | const known = new Set(ingredients.map((g) => (isObject(g) ? g.id : undefined))); | |
| 120 | const ingredient = (where: string, key: string, v: unknown) => { | |
| 121 | if (!known.has(v)) say(where, `${key} ${String(v)} is not an ingredient in spices.json`); | |
| 122 | }; | |
| 123 | ||
| 112 | 124 | const monsters = listOf(files.monsters, "monsters"); |
| 113 | 125 | ids("monsters", monsters); |
| 114 | 126 | monsters.forEach((m, i) => { |
| @@ -121,6 +133,12 @@ export function check(files: { | ||
| 121 | 133 | need(where, m, ["score"], whole); |
| 122 | 134 | if (m.behavior === "charge") need(where, m, ["windup", "chargeSpeed", "chargeTicks", "stun"]); |
| 123 | 135 | if (m.behavior === "shoot") need(where, m, ["range", "keepAway", "cooldown", "shotSpeed"]); |
| 136 | if (m.drops !== undefined) ingredient(where, "drops", m.drops); | |
| 137 | if ( | |
| 138 | m.dropChance !== undefined && | |
| 139 | !(positive(m.dropChance) && (m.dropChance as number) <= 1) | |
| 140 | ) | |
| 141 | say(where, "dropChance must be above 0 and at most 1"); | |
| 124 | 142 | }); |
| 125 | 143 | |
| 126 | 144 | const traps = listOf(files.traps, "traps"); |
| @@ -142,6 +160,7 @@ export function check(files: { | ||
| 142 | 160 | for (const key of ["gold", "heal", "power"]) |
| 143 | 161 | if (l[key] !== undefined && !positive(l[key])) |
| 144 | 162 | say(`loot[${i}]`, `${key} must be a positive number when given`); |
| 163 | if (l.ingredient !== undefined) ingredient(`loot[${i}]`, "ingredient", l.ingredient); | |
| 145 | 164 | }); |
| 146 | 165 | |
| 147 | 166 | const rooms = listOf(files.rooms, "rooms"); |
| @@ -216,6 +235,47 @@ export function check(files: { | ||
| 216 | 235 | const paints = new Set( |
| 217 | 236 | tokens.filter((t) => t.name.startsWith("--color-")).map((t) => t.name.slice(8)), |
| 218 | 237 | ); |
| 238 | ||
| 239 | need("spices", rack, ["carry", "satchel", "pantry"], (v) => whole(v) && (v as number) > 0); | |
| 240 | need("spices.throw", isObject(rack.throw) ? rack.throw : {}, ["range", "speed", "arc", "cooldown"]); | |
| 241 | ingredients.forEach((g, i) => { | |
| 242 | if (isObject(g) && (typeof g.from !== "string" || g.from === "")) | |
| 243 | say(`ingredients[${i}]`, "from is missing: say where a chef finds it"); | |
| 244 | }); | |
| 245 | const monsterIds = new Set(monsters.map((m) => (isObject(m) ? m.id : undefined))); | |
| 246 | const spices = listOf(rack, "spices"); | |
| 247 | ids("spices", spices); | |
| 248 | spices.forEach((sp, i) => { | |
| 249 | const where = `spices[${i}]`; | |
| 250 | if (!isObject(sp)) return; | |
| 251 | if (typeof sp.description !== "string" || sp.description === "") | |
| 252 | say(where, "description is missing"); | |
| 253 | if (!paints.has(String(sp.paint))) say(where, `paint ${String(sp.paint)} is not a colour token`); | |
| 254 | const recipe = isObject(sp.recipe) ? sp.recipe : {}; | |
| 255 | if (Object.keys(recipe).length === 0) say(where, "recipe must name at least one ingredient"); | |
| 256 | for (const [g, n] of Object.entries(recipe)) { | |
| 257 | ingredient(where, "recipe ingredient", g); | |
| 258 | if (!(whole(n) && (n as number) > 0)) say(where, `recipe needs a whole number of ${g}`); | |
| 259 | } | |
| 260 | if (!SPICE_SHAPES.includes(String(sp.shape))) | |
| 261 | say(where, `shape must be one of ${SPICE_SHAPES.join(", ")}`); | |
| 262 | if (sp.shape === "circle") need(where, sp, ["radius"]); | |
| 263 | if (sp.shape === "line") need(where, sp, ["length", "width"]); | |
| 264 | need(where, sp, ["duration"]); | |
| 265 | if (!SPICE_EFFECTS.some((e) => sp[e] !== undefined)) | |
| 266 | say(where, `needs at least one effect: ${SPICE_EFFECTS.join(", ")}`); | |
| 267 | for (const key of ["confuse", "damage", "ignite"]) | |
| 268 | if (sp[key] !== undefined && !positive(sp[key])) | |
| 269 | say(where, `${key} must be a positive number when given`); | |
| 270 | if (sp.slow !== undefined && !(positive(sp.slow) && (sp.slow as number) < 1)) | |
| 271 | say(where, "slow must be above 0 and below 1"); | |
| 272 | if (sp.damage !== undefined) need(where, sp, ["every"]); | |
| 273 | if ( | |
| 274 | sp.burns !== undefined && | |
| 275 | !(Array.isArray(sp.burns) && sp.burns.length > 0 && sp.burns.every((b) => monsterIds.has(b))) | |
| 276 | ) | |
| 277 | say(where, "burns must be a list of monster ids"); | |
| 278 | }); | |
| 219 | 279 | const props = listOf(files.hub, "props"); |
| 220 | 280 | ids("props", props.map((p) => (isObject(p) ? { ...p, name: p.id } : p))); |
| 221 | 281 | props.forEach((p, i) => { |
| @@ -240,6 +300,7 @@ function load(): Game { | ||
| 240 | 300 | rooms: read("rooms"), |
| 241 | 301 | hub: read("hub"), |
| 242 | 302 | classes: read("classes"), |
| 303 | spices: read("spices"), | |
| 243 | 304 | }; |
| 244 | 305 | const problems = check(files); |
| 245 | 306 | if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`); |
| @@ -252,6 +313,7 @@ function load(): Game { | ||
| 252 | 313 | loot: f.loot!.loot as Game["loot"], |
| 253 | 314 | rooms: f.rooms!.rooms as Game["rooms"], |
| 254 | 315 | classes: f.classes!.classes as Game["classes"], |
| 316 | rack: f.spices as unknown as Game["rack"], | |
| 255 | 317 | hub: { ...hub, props: f.hub!.props as Prop[] }, |
| 256 | 318 | }; |
| 257 | 319 | } |
src/contract.test.ts +49
| @@ -11,6 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { |
| 12 | 12 | addNote, |
| 13 | 13 | commandHero, |
| 14 | craftSpice, | |
| 14 | 15 | deleteNote, |
| 15 | 16 | deleteRun, |
| 16 | 17 | enterHub, |
| @@ -19,7 +20,9 @@ import { | ||
| 19 | 20 | getDelve, |
| 20 | 21 | getHero, |
| 21 | 22 | getNote, |
| 23 | getPantry, | |
| 22 | 24 | getRun, |
| 25 | getSpice, | |
| 23 | 26 | joinDelve, |
| 24 | 27 | leaveDelve, |
| 25 | 28 | leaveHub, |
| @@ -28,9 +31,12 @@ import { | ||
| 28 | 31 | listDelves, |
| 29 | 32 | listHeroes, |
| 30 | 33 | listNotes, |
| 34 | listPantry, | |
| 31 | 35 | listRuns, |
| 36 | listSpices, | |
| 32 | 37 | openDelve, |
| 33 | 38 | today, |
| 39 | tossSpice, | |
| 34 | 40 | updateAvatar, |
| 35 | 41 | updateNote, |
| 36 | 42 | } from "./actions.ts"; |
| @@ -90,6 +96,26 @@ const runBy = (authorId: string): number => | ||
| 90 | 96 | ) |
| 91 | 97 | .get(today(), authorId)!.id; |
| 92 | 98 | |
| 99 | /** A chef's pantry, stocked for one pepper bomb; the row for the ghost pepper. */ | |
| 100 | const pantryBy = (authorId: string): number => { | |
| 101 | for (const g of ["ghost-pepper", "slime-jelly"]) | |
| 102 | db.query( | |
| 103 | "INSERT INTO pantry (author_id, ingredient, count) VALUES (?1, ?2, 1) ON CONFLICT (author_id, ingredient) DO UPDATE SET count = count + 1", | |
| 104 | ).run(authorId, g); | |
| 105 | return db | |
| 106 | .query<{ id: number }, [string]>( | |
| 107 | "SELECT id FROM pantry WHERE author_id = ?1 AND ingredient = 'ghost-pepper'", | |
| 108 | ) | |
| 109 | .get(authorId)!.id; | |
| 110 | }; | |
| 111 | ||
| 112 | const jarBy = (authorId: string): number => | |
| 113 | db | |
| 114 | .query<{ id: number }, [string]>( | |
| 115 | "INSERT INTO spice (kind, author_id, author_name) VALUES ('pepper-bomb', ?1, ?1) RETURNING id", | |
| 116 | ) | |
| 117 | .get(authorId)!.id; | |
| 118 | ||
| 93 | 119 | /** One door per verb an action serves: did it let `who` through, on `row`? */ |
| 94 | 120 | const doors: Record< |
| 95 | 121 | string, |
| @@ -136,6 +162,29 @@ const doors: Record< | ||
| 136 | 162 | create: () => false, |
| 137 | 163 | update: () => false, |
| 138 | 164 | }, |
| 165 | // A pantry is stocked by the app when a chef leaves a delve, and spent by | |
| 166 | // crafting: nobody writes one through its own door. | |
| 167 | pantry: { | |
| 168 | list: (who) => listPantry(db, who).ok, | |
| 169 | read: (who, authorId) => getPantry(db, who, { id: pantryBy(authorId) }).ok, | |
| 170 | create: () => false, | |
| 171 | update: () => false, | |
| 172 | delete: () => false, | |
| 173 | }, | |
| 174 | // A jar is crafted from your pantry, and thrown (or tipped out); never changed. | |
| 175 | spice: { | |
| 176 | create: (who) => { | |
| 177 | if (who.role !== "anon") { | |
| 178 | pantryBy(who.id); | |
| 179 | db.query("DELETE FROM spice WHERE author_id = ?1").run(who.id); | |
| 180 | } | |
| 181 | return craftSpice(db, who, { spice: "pepper-bomb" }).ok; | |
| 182 | }, | |
| 183 | list: (who) => listSpices(db, who).ok, | |
| 184 | read: (who, authorId) => getSpice(db, who, { id: jarBy(authorId) }).ok, | |
| 185 | update: () => false, | |
| 186 | delete: (who, authorId) => tossSpice(db, who, { id: jarBy(authorId) }).ok, | |
| 187 | }, | |
| 139 | 188 | // The trail is written by the app itself, never through a door: there |
| 140 | 189 | // is no action that creates, changes or removes a row of it. |
| 141 | 190 | audit: { |
src/db.ts +18
| @@ -57,6 +57,24 @@ export const migrations: string[] = [ | ||
| 57 | 57 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 58 | 58 | )`, |
| 59 | 59 | `CREATE INDEX run_board ON run (day, score DESC, ticks)`, |
| 60 | // Cheffry's Spice Rack. A chef's pantry is one row an ingredient, stocked | |
| 61 | // by the app when they leave a delve; a jar is a crafted spice on their | |
| 62 | // shelf, gone when it is thrown (src/actions.ts settleHero). | |
| 63 | `CREATE TABLE pantry ( | |
| 64 | id INTEGER PRIMARY KEY, | |
| 65 | author_id TEXT NOT NULL, | |
| 66 | ingredient TEXT NOT NULL CHECK (length(ingredient) BETWEEN 1 AND 32), | |
| 67 | count INTEGER NOT NULL CHECK (count >= 0), | |
| 68 | UNIQUE (author_id, ingredient) | |
| 69 | )`, | |
| 70 | `CREATE TABLE spice ( | |
| 71 | id INTEGER PRIMARY KEY, | |
| 72 | kind TEXT NOT NULL CHECK (length(kind) BETWEEN 1 AND 32), | |
| 73 | author_id TEXT NOT NULL, | |
| 74 | author_name TEXT NOT NULL, | |
| 75 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 76 | )`, | |
| 77 | `CREATE INDEX spice_author ON spice (author_id)`, | |
| 60 | 78 | ]; |
| 61 | 79 | |
| 62 | 80 | export function openDb(dir = dataDir): Database { |
src/delves.ts +34 −4
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | * The server is the authority: players send orders, the instance steps, |
| 10 | 10 | * and every watcher is told the result ten times a second. What a delve |
| 11 | 11 | * leaves behind — the run, its score, its ghost — is written by whoever |
| 12 | * opened it (`onEnd`), the actions, into the database. | |
| 12 | * opened it (`onEnd`), the actions, into the database; what a chef takes | |
| 13 | * home — ingredients picked up, jars thrown — is settled once, when they | |
| 14 | * leave the table or the delve ends (`onSettle`). | |
| 13 | 15 | * |
| 14 | 16 | * Who may do what here is the actions' business; this is the kitchen. |
| 15 | 17 | */ |
| @@ -26,6 +28,8 @@ import { | ||
| 26 | 28 | removeHero, |
| 27 | 29 | snapshot, |
| 28 | 30 | step, |
| 31 | type Hero, | |
| 32 | type Jar, | |
| 29 | 33 | type Order, |
| 30 | 34 | type World, |
| 31 | 35 | } from "./sim.ts"; |
| @@ -49,6 +53,8 @@ export interface HeroRow { | ||
| 49 | 53 | /** The class from content/classes.json this chef delves as. */ |
| 50 | 54 | readonly classId: string; |
| 51 | 55 | seen: number; |
| 56 | /** What they took home is written: once, and only once. */ | |
| 57 | settled: boolean; | |
| 52 | 58 | } |
| 53 | 59 | |
| 54 | 60 | export interface Delve { |
| @@ -66,6 +72,8 @@ export interface Delve { | ||
| 66 | 72 | readonly crew: string[]; |
| 67 | 73 | ended: number | null; |
| 68 | 74 | readonly onEnd: (delve: Delve) => void; |
| 75 | /** A chef is done here: what they picked up and threw, for the database. */ | |
| 76 | readonly onSettle: (row: HeroRow, hero: Hero) => void; | |
| 69 | 77 | } |
| 70 | 78 | |
| 71 | 79 | /** A delve as the page and the API show it. */ |
| @@ -138,11 +146,15 @@ export const heroView = (h: HeroRow): HeroView => ({ | ||
| 138 | 146 | classId: h.classId, |
| 139 | 147 | }); |
| 140 | 148 | |
| 141 | /** Open today's dungeon for a party. `onEnd` is told once, when it is over. */ | |
| 149 | /** | |
| 150 | * Open today's dungeon for a party. `onEnd` is told once, when it is over; | |
| 151 | * `onSettle` once for every chef, when they leave or it ends. | |
| 152 | */ | |
| 142 | 153 | export function open( |
| 143 | 154 | host: { id: string; name: string }, |
| 144 | 155 | now: number, |
| 145 | 156 | onEnd: (delve: Delve) => void, |
| 157 | onSettle: (row: HeroRow, hero: Hero) => void = () => {}, | |
| 146 | 158 | ): Delve { |
| 147 | 159 | const day = dayOf(now); |
| 148 | 160 | const seed = daySeed(day); |
| @@ -160,6 +172,7 @@ export function open( | ||
| 160 | 172 | crew: [], |
| 161 | 173 | ended: null, |
| 162 | 174 | onEnd, |
| 175 | onSettle, | |
| 163 | 176 | }; |
| 164 | 177 | delves.set(delve.id, delve); |
| 165 | 178 | loop ??= setInterval(() => run(Date.now()), TICK_MS); |
| @@ -167,12 +180,13 @@ export function open( | ||
| 167 | 180 | return delve; |
| 168 | 181 | } |
| 169 | 182 | |
| 170 | /** A chef takes a place at the table. */ | |
| 183 | /** A chef takes a place at the table, with the jars they carry in. */ | |
| 171 | 184 | export function enlist( |
| 172 | 185 | delve: Delve, |
| 173 | 186 | who: { id: string; name: string }, |
| 174 | 187 | now: number, |
| 175 | 188 | classId: string = "knight", |
| 189 | pack: readonly Jar[] = [], | |
| 176 | 190 | ): HeroRow { |
| 177 | 191 | const known = game.classes.some((c) => c.id === classId) ? classId : "knight"; |
| 178 | 192 | const row: HeroRow = { |
| @@ -182,9 +196,10 @@ export function enlist( | ||
| 182 | 196 | author_name: who.name, |
| 183 | 197 | classId: known, |
| 184 | 198 | seen: now, |
| 199 | settled: false, | |
| 185 | 200 | }; |
| 186 | 201 | heroes.set(row.id, row); |
| 187 | addHero(game, delve.world, row.id, who.name, known); | |
| 202 | addHero(game, delve.world, row.id, who.name, known, pack); | |
| 188 | 203 | if (!delve.crew.includes(who.name)) delve.crew.push(who.name); |
| 189 | 204 | send(delve); |
| 190 | 205 | return row; |
| @@ -195,6 +210,7 @@ export function dismiss(row: HeroRow, now: number): void { | ||
| 195 | 210 | heroes.delete(row.id); |
| 196 | 211 | const delve = delves.get(row.delve_id); |
| 197 | 212 | if (delve === undefined) return; |
| 213 | settle(delve, row); | |
| 198 | 214 | removeHero(delve.world, row.id); |
| 199 | 215 | if (delve.ended === null && delve.world.heroes.length === 0) { |
| 200 | 216 | finish(delve.world, "abandoned"); |
| @@ -209,6 +225,19 @@ export function command(row: HeroRow, o: Order, now: number): void { | ||
| 209 | 225 | order(delve.world, row.id, o); |
| 210 | 226 | } |
| 211 | 227 | |
| 228 | /** Tell the delve's owner what this chef takes home, once. */ | |
| 229 | function settle(delve: Delve, row: HeroRow): void { | |
| 230 | if (row.settled) return; | |
| 231 | row.settled = true; | |
| 232 | const hero = delve.world.heroes.find((h) => h.id === row.id); | |
| 233 | if (hero === undefined) return; | |
| 234 | try { | |
| 235 | delve.onSettle(row, hero); | |
| 236 | } catch (error) { | |
| 237 | console.error(error); | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 212 | 241 | const send = (delve: Delve): void => { |
| 213 | 242 | delve.bus.emit("tick", snapshot(delve.world)); |
| 214 | 243 | }; |
| @@ -217,6 +246,7 @@ function end(delve: Delve, now: number): void { | ||
| 217 | 246 | if (delve.ended !== null) return; |
| 218 | 247 | delve.ended = now; |
| 219 | 248 | send(delve); |
| 249 | for (const row of partyOf(delve)) settle(delve, row); | |
| 220 | 250 | try { |
| 221 | 251 | delve.onEnd(delve); |
| 222 | 252 | } catch (error) { |
src/game.test.ts +125 −2
| @@ -9,17 +9,23 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { |
| 11 | 11 | commandHero, |
| 12 | craftSpice, | |
| 12 | 13 | enterHub, |
| 13 | 14 | getRun, |
| 14 | 15 | hubChat, |
| 15 | 16 | joinDelve, |
| 16 | 17 | leaveDelve, |
| 17 | 18 | listRuns, |
| 19 | listPantry, | |
| 20 | listSpices, | |
| 18 | 21 | openDelve, |
| 22 | packOf, | |
| 19 | 23 | readOrder, |
| 24 | settleHero, | |
| 20 | 25 | recordRun, |
| 21 | 26 | sweepHub, |
| 22 | 27 | today, |
| 28 | tossSpice, | |
| 23 | 29 | updateAvatar, |
| 24 | 30 | } from "./actions.ts"; |
| 25 | 31 | import { game } from "./content.ts"; |
| @@ -30,7 +36,7 @@ import type { Who } from "./who.ts"; | ||
| 30 | 36 | |
| 31 | 37 | const db = openDb(mkdtempSync(join(tmpdir(), "app-game-"))); |
| 32 | 38 | let n = 0; |
| 33 | const chef = (): Who & { id: string } => { | |
| 39 | const chef = (): { role: "user"; id: string; name: string } => { | |
| 34 | 40 | const id = `g${++n}`; |
| 35 | 41 | return { role: "user", id, name: `chef${n}` }; |
| 36 | 42 | }; |
| @@ -222,6 +228,123 @@ test("an unknown class is refused; no class means knight", () => { | ||
| 222 | 228 | test("an ability is an order the sim can take", () => { |
| 223 | 229 | expect(readOrder({ type: "ability" }, { w: 10, h: 10 })).toEqual({ type: "ability" }); |
| 224 | 230 | expect(readOrder({ type: "dance" }, { w: 10, h: 10 })).toBe( |
| 225 | "An order is move, attack, dodge, ability or stop.", | |
| 231 | "An order is move, attack, dodge, ability, throw or stop.", | |
| 226 | 232 | ); |
| 227 | 233 | }); |
| 234 | ||
| 235 | // ── Cheffry's Spice Rack ── | |
| 236 | ||
| 237 | /** Bring home ingredients the way a delve does: through a settled hero. */ | |
| 238 | const stock = (who: { id: string; name: string }, haul: Record<string, number>) => { | |
| 239 | const delve = kitchen.open(who, Date.now(), () => {}, (row, hero) => settleHero(db, row, hero)); | |
| 240 | const row = kitchen.enlist(delve, who, Date.now()); | |
| 241 | delve.world.heroes.find((h) => h.id === row.id)!.haul = { ...haul }; | |
| 242 | kitchen.dismiss(row, Date.now()); | |
| 243 | }; | |
| 244 | const pantryOf = (who: Who): Record<string, number> => { | |
| 245 | const rows = listPantry(db, who); | |
| 246 | return rows.ok ? Object.fromEntries(rows.value.map((r) => [r.ingredient, r.count])) : {}; | |
| 247 | }; | |
| 248 | ||
| 249 | test("a throw is an order naming a spice the rack knows, inside the room", () => { | |
| 250 | const room = { w: 15, h: 11 }; | |
| 251 | expect(readOrder({ type: "throw", spice: "pepper-bomb", x: 3, z: 4 }, room)).toEqual({ | |
| 252 | type: "throw", | |
| 253 | spice: "pepper-bomb", | |
| 254 | x: 3, | |
| 255 | z: 4, | |
| 256 | }); | |
| 257 | for (const bad of [ | |
| 258 | { type: "throw", spice: "saffron-nuke", x: 3, z: 4 }, | |
| 259 | { type: "throw", x: 3, z: 4 }, | |
| 260 | { type: "throw", spice: "pepper-bomb", x: 99, z: 4 }, | |
| 261 | { type: "throw", spice: "pepper-bomb" }, | |
| 262 | ]) | |
| 263 | expect(typeof readOrder(bad, room)).toBe("string"); | |
| 264 | }); | |
| 265 | ||
| 266 | test("what a chef picks up in a delve lands in their pantry when they leave, up to the limit", () => { | |
| 267 | const me = chef(); | |
| 268 | expect(pantryOf(me)).toEqual({}); | |
| 269 | stock(me, { "ghost-pepper": 2, "slime-jelly": 1, "dragon-egg": 5 }); | |
| 270 | expect(pantryOf(me)).toEqual({ "ghost-pepper": 2, "slime-jelly": 1 }); | |
| 271 | stock(me, { "ghost-pepper": 1000 }); | |
| 272 | expect(pantryOf(me)["ghost-pepper"]).toBe(game.rack.pantry); | |
| 273 | // Somebody else's pantry is theirs. | |
| 274 | expect(pantryOf(chef())).toEqual({}); | |
| 275 | expect(failed(listPantry(db, anon))).toBe("signin_required"); | |
| 276 | }); | |
| 277 | ||
| 278 | test("crafting spends the recipe from your pantry and puts a jar on your shelf", () => { | |
| 279 | const me = chef(); | |
| 280 | const broke = craftSpice(db, me, { spice: "pepper-bomb" }); | |
| 281 | expect(failed(broke)).toBe("invalid"); | |
| 282 | if (!broke.ok) expect(broke.message).toContain("Ghost Pepper"); | |
| 283 | expect(failed(craftSpice(db, me, { spice: "saffron-nuke" }))).toBe("invalid"); | |
| 284 | expect(failed(craftSpice(db, anon, { spice: "pepper-bomb" }))).toBe("signin_required"); | |
| 285 | ||
| 286 | stock(me, { "ghost-pepper": 2, "slime-jelly": 1 }); | |
| 287 | const jar = craftSpice(db, me, { spice: "pepper-bomb" }); | |
| 288 | if (!jar.ok) throw new Error(jar.message); | |
| 289 | expect(jar.value.kind).toBe("pepper-bomb"); | |
| 290 | expect(jar.value.author_id).toBe(me.id); | |
| 291 | expect(pantryOf(me)).toEqual({ "ghost-pepper": 1, "slime-jelly": 0 }); | |
| 292 | // Half a recipe is no recipe, and spends nothing. | |
| 293 | expect(failed(craftSpice(db, me, { spice: "pepper-bomb" }))).toBe("invalid"); | |
| 294 | expect(pantryOf(me)).toEqual({ "ghost-pepper": 1, "slime-jelly": 0 }); | |
| 295 | const shelf = listSpices(db, me); | |
| 296 | expect(shelf.ok && shelf.value.map((j) => j.id)).toEqual([jar.value.id]); | |
| 297 | // Only the chef tips out their own jar. | |
| 298 | expect(failed(tossSpice(db, chef(), { id: jar.value.id }))).toBe("forbidden"); | |
| 299 | expect(failed(tossSpice(db, me, { id: jar.value.id }))).toBe("ok"); | |
| 300 | expect(failed(tossSpice(db, me, { id: jar.value.id }))).toBe("not_found"); | |
| 301 | }); | |
| 302 | ||
| 303 | test("the shelf holds the rack's limit of jars", () => { | |
| 304 | const me = chef(); | |
| 305 | const n = game.rack.satchel; | |
| 306 | stock(me, { "ghost-pepper": n + 1, "slime-jelly": n + 1 }); | |
| 307 | for (let i = 0; i < n; i++) expect(failed(craftSpice(db, me, { spice: "pepper-bomb" }))).toBe("ok"); | |
| 308 | const full = craftSpice(db, me, { spice: "pepper-bomb" }); | |
| 309 | expect(failed(full)).toBe("invalid"); | |
| 310 | expect(pantryOf(me)["ghost-pepper"]).toBe(1); | |
| 311 | }); | |
| 312 | ||
| 313 | test("a chef carries a spread of jars in, and only the thrown ones are spent", () => { | |
| 314 | const me = chef(); | |
| 315 | stock(me, { "ghost-pepper": 3, "slime-jelly": 2, "torch-mushroom": 1 }); | |
| 316 | for (const spice of ["pepper-bomb", "pepper-bomb", "hot-sauce"]) | |
| 317 | expect(failed(craftSpice(db, me, { spice }))).toBe("ok"); | |
| 318 | expect(packOf(db, me.id).map((j) => j.spice)).toEqual(["pepper-bomb", "hot-sauce", "pepper-bomb"]); | |
| 319 | ||
| 320 | const opened = openDelve(db, me); | |
| 321 | if (!opened.ok) throw new Error(opened.message); | |
| 322 | const delve = kitchen.delveOf(opened.value.id)!; | |
| 323 | const row = kitchen.heroOfAuthor(me.id)!; | |
| 324 | const hero = delve.world.heroes.find((h) => h.id === row.id)!; | |
| 325 | expect(hero.pack.map((j) => j.spice)).toEqual(["pepper-bomb", "hot-sauce", "pepper-bomb"]); | |
| 326 | expect(failed(commandHero(db, me, { id: row.id, order: { type: "throw", spice: "hot-sauce", x: 5, z: 5 } }))).toBe("ok"); | |
| 327 | kitchen.tick(delve, Date.now()); | |
| 328 | expect(hero.thrown).toHaveLength(1); | |
| 329 | hero.haul = { "rock-salt": 1 }; | |
| 330 | expect(failed(leaveDelve(db, me, { id: row.id }))).toBe("ok"); | |
| 331 | const shelf = listSpices(db, me); | |
| 332 | expect(shelf.ok && shelf.value.map((j) => j.kind)).toEqual(["pepper-bomb", "pepper-bomb"]); | |
| 333 | expect(pantryOf(me)["rock-salt"]).toBe(1); | |
| 334 | }); | |
| 335 | ||
| 336 | test("a delve that ends settles everyone still at the table, once", () => { | |
| 337 | const me = chef(); | |
| 338 | const opened = openDelve(db, me); | |
| 339 | if (!opened.ok) throw new Error(opened.message); | |
| 340 | const delve = kitchen.delveOf(opened.value.id)!; | |
| 341 | const row = kitchen.heroOfAuthor(me.id)!; | |
| 342 | delve.world.heroes[0]!.haul = { "stale-crust": 2 }; | |
| 343 | delve.world.heroes.forEach((h) => (h.downed = true)); | |
| 344 | kitchen.tick(delve, Date.now()); | |
| 345 | expect(delve.world.over).toBe("wiped"); | |
| 346 | expect(pantryOf(me)["stale-crust"]).toBe(2); | |
| 347 | // Leaving the finished table afterwards takes nothing home twice. | |
| 348 | kitchen.dismiss(row, Date.now()); | |
| 349 | expect(pantryOf(me)["stale-crust"]).toBe(2); | |
| 350 | }); |
src/mcp.ts +33
| @@ -9,12 +9,17 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { |
| 11 | 11 | addNote, |
| 12 | craftSpice, | |
| 12 | 13 | deleteNote, |
| 13 | 14 | listDelves, |
| 14 | 15 | listNotes, |
| 16 | listPantry, | |
| 15 | 17 | listRuns, |
| 18 | listSpices, | |
| 19 | ok, | |
| 16 | 20 | type Result, |
| 17 | 21 | } from "./actions.ts"; |
| 22 | import { game } from "./content.ts"; | |
| 18 | 23 | import { title, version } from "./env.ts"; |
| 19 | 24 | import { fields } from "./http.ts"; |
| 20 | 25 | import type { Who } from "./who.ts"; |
| @@ -94,6 +99,34 @@ export const tools: readonly Tool[] = [ | ||
| 94 | 99 | }, |
| 95 | 100 | run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }), |
| 96 | 101 | }, |
| 102 | { | |
| 103 | name: "spice_rack", | |
| 104 | description: | |
| 105 | "Cheffry's Spice Rack for the signed-in chef: the recipes, the ingredients in their pantry, and the jars on their shelf.", | |
| 106 | inputSchema: { type: "object", properties: {} }, | |
| 107 | run: (db, who) => { | |
| 108 | const pantry = listPantry(db, who, who.role === "anon" ? {} : { author: who.id }); | |
| 109 | if (!pantry.ok) return pantry; | |
| 110 | const shelf = listSpices(db, who, who.role === "anon" ? {} : { author: who.id }); | |
| 111 | if (!shelf.ok) return shelf; | |
| 112 | return ok({ | |
| 113 | recipes: game.rack.spices.map(({ id, name, description, recipe }) => ({ id, name, description, recipe })), | |
| 114 | pantry: Object.fromEntries(pantry.value.map((r) => [r.ingredient, r.count])), | |
| 115 | shelf: shelf.value.map((j) => ({ id: j.id, spice: j.kind })), | |
| 116 | }); | |
| 117 | }, | |
| 118 | }, | |
| 119 | { | |
| 120 | name: "craft_spice", | |
| 121 | description: | |
| 122 | "Craft a throwable at the Spice Rack as the signed-in chef: the recipe comes out of their pantry.", | |
| 123 | inputSchema: { | |
| 124 | type: "object", | |
| 125 | properties: { spice: { enum: game.rack.spices.map((s) => s.id) } }, | |
| 126 | required: ["spice"], | |
| 127 | }, | |
| 128 | run: (db, who, args) => craftSpice(db, who, { spice: args.spice }), | |
| 129 | }, | |
| 97 | 130 | ]; |
| 98 | 131 | |
| 99 | 132 | /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ |
src/pages/delve.ts +4 −1
| @@ -47,18 +47,20 @@ export function delvePage(p: DelvePage): string { | ||
| 47 | 47 | <button class="button button-big" type="button" data-act="attack">Attack</button> |
| 48 | 48 | <button class="button button-big button-alt" type="button" data-act="dodge">Dodge</button> |
| 49 | 49 | <button class="button button-big" type="button" data-act="ability">${escape(abilityName(hero.classId))}</button> |
| 50 | <button class="button button-big button-alt" type="button" data-act="spice" aria-pressed="false">Spice</button> | |
| 50 | 51 | </div>`; |
| 51 | 52 | return layout( |
| 52 | 53 | `<h2>${escape(delve.host_name)}'s table <small class="muted mono">${escape(delve.day)}</small></h2> |
| 53 | 54 | <section class="stage" aria-label="The dungeon"> |
| 54 | 55 | <div class="viewport" id="delve-view" data-delve="${delve.id}" data-hero="${hero?.id ?? ""}" data-sim="${escape(p.simHref)}" data-content="${gameHref}" data-day="${escape(delve.day)}"> |
| 55 | <canvas tabindex="0" aria-label="The dungeon. Click or tap to walk, click a monster to fight, shift-click to dodge."></canvas> | |
| 56 | <canvas tabindex="0" aria-label="The dungeon. Click or tap to walk, click a monster to fight, shift-click to dodge, Q then click to throw a spice."></canvas> | |
| 56 | 57 | <div class="tags" aria-hidden="true"></div> |
| 57 | 58 | <div class="hud"> |
| 58 | 59 | <p data-hud="room">${escape(room?.name ?? delve.room)} · floor ${delve.depth + 1} of ${delve.rooms}</p> |
| 59 | 60 | <p data-hud="hp">${hero === undefined ? "Watching" : ""}</p> |
| 60 | 61 | <p data-hud="score">${delve.score} pts</p> |
| 61 | 62 | <p data-hud="ghost"></p> |
| 63 | <p data-hud="spices"></p> | |
| 62 | 64 | </div> |
| 63 | 65 | <div class="over" id="over"${over === null ? " hidden" : ""}> |
| 64 | 66 | <p class="over-title" data-outcome>${over === null ? "" : escape(OUTCOME[over] ?? over)}</p> |
| @@ -82,6 +84,7 @@ export function delvePage(p: DelvePage): string { | ||
| 82 | 84 | <li><b>Fight</b>: click a monster (your chef keeps swinging), or <kbd>Space</kbd> for the nearest.</li> |
| 83 | 85 | <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 | 86 | <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> |
| 87 | <li><b>Spices</b>: <kbd>Q</kbd> (or <b>Spice</b>) picks a jar from Cheffry's Spice Rack, then click or tap where to throw it (or <kbd>Enter</kbd> to lob it ahead) — the arc shows where it lands. <kbd>Q</kbd> again for the next jar, <kbd>Esc</kbd> to put it away. ${game.rack.spices.map((s) => `<b>${escape(s.name)}</b>`).join(" · ")}.</li> | |
| 85 | 88 | <li><b>Chests</b> open to the touch. <b>Downed?</b> A teammate standing beside you brings you back.</li> |
| 86 | 89 | <li>Clear a room and the door opens. Five floors, then the Pass.</li> |
| 87 | 90 | </ul> |
src/pages/hub.ts +67 −1
| @@ -6,7 +6,13 @@ | ||
| 6 | 6 | * `data-live` region, so it stays current as the page's events arrive. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { canDelete, type Note, type Run } from "../actions.ts"; | |
| 9 | import { | |
| 10 | canDelete, | |
| 11 | type Note, | |
| 12 | type PantryRow, | |
| 13 | type Run, | |
| 14 | type SpiceJar, | |
| 15 | } from "../actions.ts"; | |
| 10 | 16 | import { game, gameHref } from "../content.ts"; |
| 11 | 17 | import type { DelveView, HeroView } from "../delves.ts"; |
| 12 | 18 | import { escape } from "../http.ts"; |
| @@ -23,6 +29,9 @@ export interface HubPage { | ||
| 23 | 29 | readonly delves: readonly DelveView[]; |
| 24 | 30 | readonly runs: readonly Run[]; |
| 25 | 31 | readonly notes: readonly Note[]; |
| 32 | /** This person's pantry and shelf, for the Spice Rack. */ | |
| 33 | readonly pantry: readonly PantryRow[]; | |
| 34 | readonly jars: readonly SpiceJar[]; | |
| 26 | 35 | /** The hero this person is playing right now, if any. */ |
| 27 | 36 | readonly playing?: HeroView | undefined; |
| 28 | 37 | readonly notice?: string | undefined; |
| @@ -46,6 +55,7 @@ export function hubPage(p: HubPage): string { | ||
| 46 | 55 | <div class="grid"> |
| 47 | 56 | ${tables(p)} |
| 48 | 57 | ${dressingRoom(p)} |
| 58 | ${spiceRack(p)} | |
| 49 | 59 | ${board(p)} |
| 50 | 60 | ${square(p)} |
| 51 | 61 | ${wall(p)} |
| @@ -131,6 +141,62 @@ function dressingRoom(p: HubPage): string { | ||
| 131 | 141 | </section>`; |
| 132 | 142 | } |
| 133 | 143 | |
| 144 | const ingredientName = (id: string): string => | |
| 145 | game.rack.ingredients.find((g) => g.id === id)?.name ?? id; | |
| 146 | const spiceName = (id: string): string => | |
| 147 | game.rack.spices.find((s) => s.id === id)?.name ?? id; | |
| 148 | ||
| 149 | /** | |
| 150 | * Cheffry's Spice Rack: the recipes for everyone to read; for a chef, what | |
| 151 | * is in their pantry, a Craft button a recipe, and the jars on their shelf. | |
| 152 | */ | |
| 153 | function spiceRack(p: HubPage): string { | |
| 154 | const signedIn = p.who.role !== "anon"; | |
| 155 | const stock = new Map(p.pantry.map((r) => [r.ingredient, r.count])); | |
| 156 | const full = p.jars.length >= game.rack.satchel; | |
| 157 | const recipes = game.rack.spices | |
| 158 | .map((s) => { | |
| 159 | const needs = Object.entries(s.recipe); | |
| 160 | const short = needs.some(([g, n]) => (stock.get(g) ?? 0) < n); | |
| 161 | const cost = needs | |
| 162 | .map(([g, n]) => `${n} ${escape(ingredientName(g))}${signedIn ? ` <small class="muted">(${stock.get(g) ?? 0})</small>` : ""}`) | |
| 163 | .join(" + "); | |
| 164 | const craft = signedIn | |
| 165 | ? `<form method="post" action="/spices"><input type="hidden" name="spice" value="${escape(s.id)}"><button class="button${short || full ? " button-quiet" : ""}">Craft</button></form>` | |
| 166 | : ""; | |
| 167 | return `<li class="item"> | |
| 168 | <div> | |
| 169 | <p><b>${escape(s.name)}</b> <small class="muted">${cost}</small></p> | |
| 170 | <small class="muted">${escape(s.description)}</small> | |
| 171 | </div> | |
| 172 | ${craft} | |
| 173 | </li>`; | |
| 174 | }) | |
| 175 | .join(""); | |
| 176 | const pantry = game.rack.ingredients | |
| 177 | .map((g) => `<li><b>${stock.get(g.id) ?? 0}</b> ${escape(g.name)} <small class="muted">— ${escape(g.from)}</small></li>`) | |
| 178 | .join(""); | |
| 179 | const shelf = p.jars | |
| 180 | .map( | |
| 181 | (j) => `<li class="item"><span>${escape(spiceName(j.kind))}</span><form method="post" action="/spices/${j.id}/toss"><button class="button button-quiet" aria-label="Tip out this ${escape(spiceName(j.kind))}">Tip out</button></form></li>`, | |
| 182 | ) | |
| 183 | .join(""); | |
| 184 | const yours = signedIn | |
| 185 | ? `<h3>Your pantry</h3> | |
| 186 | <ul class="list-plain">${pantry}</ul> | |
| 187 | <h3>Your shelf <small class="muted">${p.jars.length} of ${game.rack.satchel}</small></h3> | |
| 188 | ${shelf === "" ? `<p class="empty">No jars yet. Delve, loot, come back, craft.</p>` : `<ul class="list">${shelf}</ul>`} | |
| 189 | <p class="muted">You carry up to ${game.rack.carry} jars into a delve — one of each kind first. Only the ones you throw are spent.</p>` | |
| 190 | : `<p class="muted"><a href="/__forge/signin?to=/">Sign in</a> to stock a pantry and craft.</p>`; | |
| 191 | return `<section class="card" id="spice-rack"> | |
| 192 | <h2>Cheffry's Spice Rack</h2> | |
| 193 | <p class="chalk">THREW THE PEPPERS MYSELF. NEXT SPICE IS YOURS — TELL ME WHAT TO COOK.</p> | |
| 194 | <p class="muted">Monsters and chests drop ingredients; bring them home and cook them into throwables. In a delve, <kbd>Q</kbd> picks a jar and a click throws it.</p> | |
| 195 | <ul class="list">${recipes}</ul> | |
| 196 | ${yours} | |
| 197 | </section>`; | |
| 198 | } | |
| 199 | ||
| 134 | 200 | function board(p: HubPage): string { |
| 135 | 201 | const rows = p.runs |
| 136 | 202 | .map( |
src/routes.ts +101 −2
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | addNote, |
| 18 | 18 | BODY_MAX, |
| 19 | 19 | commandHero, |
| 20 | craftSpice, | |
| 20 | 21 | deleteNote, |
| 21 | 22 | deleteRun, |
| 22 | 23 | enterHub, |
| @@ -25,7 +26,9 @@ import { | ||
| 25 | 26 | getDelve, |
| 26 | 27 | getHero, |
| 27 | 28 | getNote, |
| 29 | getPantry, | |
| 28 | 30 | getRun, |
| 31 | getSpice, | |
| 29 | 32 | hubChat, |
| 30 | 33 | joinDelve, |
| 31 | 34 | leaveDelve, |
| @@ -35,9 +38,12 @@ import { | ||
| 35 | 38 | listDelves, |
| 36 | 39 | listHeroes, |
| 37 | 40 | listNotes, |
| 41 | listPantry, | |
| 38 | 42 | listRuns, |
| 43 | listSpices, | |
| 39 | 44 | openDelve, |
| 40 | 45 | today, |
| 46 | tossSpice, | |
| 41 | 47 | updateAvatar, |
| 42 | 48 | updateNote, |
| 43 | 49 | watchDelve, |
| @@ -214,15 +220,39 @@ export const HERO: Schema = { | ||
| 214 | 220 | const ORDER: Schema = { |
| 215 | 221 | type: "object", |
| 216 | 222 | description: |
| 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.", | |
| 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. throw: lob a carried `spice` toward (x, z). stop.", | |
| 218 | 224 | properties: { |
| 219 | type: { enum: ["move", "attack", "dodge", "ability", "stop"] }, | |
| 225 | type: { enum: ["move", "attack", "dodge", "ability", "throw", "stop"] }, | |
| 220 | 226 | x: { type: "number" }, |
| 221 | 227 | z: { type: "number" }, |
| 222 | 228 | target: { type: "integer" }, |
| 229 | spice: { enum: game.rack.spices.map((sp) => sp.id) }, | |
| 223 | 230 | }, |
| 224 | 231 | required: ["type"], |
| 225 | 232 | }; |
| 233 | export const PANTRY: Schema = { | |
| 234 | type: "object", | |
| 235 | description: "How much of one ingredient a chef has brought home.", | |
| 236 | properties: { | |
| 237 | id: { type: "integer" }, | |
| 238 | author_id: { type: "string" }, | |
| 239 | ingredient: { enum: game.rack.ingredients.map((g) => g.id) }, | |
| 240 | count: { type: "integer", minimum: 0 }, | |
| 241 | }, | |
| 242 | required: ["id", "author_id", "ingredient", "count"], | |
| 243 | }; | |
| 244 | export const SPICE: Schema = { | |
| 245 | type: "object", | |
| 246 | description: "A crafted jar on a chef's shelf, until it is thrown.", | |
| 247 | properties: { | |
| 248 | id: { type: "integer" }, | |
| 249 | kind: { enum: game.rack.spices.map((sp) => sp.id) }, | |
| 250 | author_id: { type: "string" }, | |
| 251 | author_name: { type: "string" }, | |
| 252 | created_at: { type: "string", format: "date-time" }, | |
| 253 | }, | |
| 254 | required: ["id", "kind", "author_id", "author_name", "created_at"], | |
| 255 | }; | |
| 226 | 256 | export const RUN: Schema = { |
| 227 | 257 | type: "object", |
| 228 | 258 | properties: { |
| @@ -322,6 +352,10 @@ export const routes: readonly Route[] = [ | ||
| 322 | 352 | delves: or(listDelves(c.db, c.who)), |
| 323 | 353 | runs: or(listRuns(c.db, c.who, { day, limit: 10 })), |
| 324 | 354 | notes: or(listNotes(c.db, c.who, { limit: 50 })), |
| 355 | pantry: | |
| 356 | c.who.role === "anon" ? [] : or(listPantry(c.db, c.who, { author: c.who.id })), | |
| 357 | jars: | |
| 358 | c.who.role === "anon" ? [] : or(listSpices(c.db, c.who, { author: c.who.id })), | |
| 325 | 359 | playing: |
| 326 | 360 | c.who.role === "anon" ? undefined : heroOfAuthor(c.who.id), |
| 327 | 361 | notice, |
| @@ -435,6 +469,20 @@ export const routes: readonly Route[] = [ | ||
| 435 | 469 | : back(joined.message, Number.isInteger(id) ? `/delve/${id}` : "/"); |
| 436 | 470 | }, |
| 437 | 471 | }, |
| 472 | { | |
| 473 | method: "POST", | |
| 474 | path: "/spices", | |
| 475 | handle: async (c) => { | |
| 476 | const form = await readForm(c.request); | |
| 477 | if (form === undefined) return text("too large", 413); | |
| 478 | return done(craftSpice(c.db, c.who, { spice: String(form.get("spice") ?? "") })); | |
| 479 | }, | |
| 480 | }, | |
| 481 | { | |
| 482 | method: "POST", | |
| 483 | path: "/spices/:id/toss", | |
| 484 | handle: (c) => done(tossSpice(c.db, c.who, { id: Number(c.params.id) })), | |
| 485 | }, | |
| 438 | 486 | { |
| 439 | 487 | method: "POST", |
| 440 | 488 | path: "/heroes/:id/leave", |
| @@ -677,6 +725,57 @@ export const routes: readonly Route[] = [ | ||
| 677 | 725 | }, |
| 678 | 726 | }, |
| 679 | 727 | |
| 728 | // Cheffry's Spice Rack: your pantry, your jars, and the craft between them. | |
| 729 | { | |
| 730 | method: "GET", | |
| 731 | path: "/api/pantry", | |
| 732 | handle: (c) => answer(listPantry(c.db, c.who)), | |
| 733 | api: { summary: "Your pantry: the ingredients you have brought home.", answer: list(PANTRY) }, | |
| 734 | }, | |
| 735 | { | |
| 736 | method: "GET", | |
| 737 | path: "/api/pantry/:id", | |
| 738 | handle: (c) => answer(getPantry(c.db, c.who, { id: Number(c.params.id) })), | |
| 739 | api: { summary: "One ingredient in your pantry.", answer: PANTRY }, | |
| 740 | }, | |
| 741 | { | |
| 742 | method: "GET", | |
| 743 | path: "/api/spices", | |
| 744 | handle: (c) => answer(listSpices(c.db, c.who)), | |
| 745 | api: { summary: "Your shelf: the jars you have crafted and not thrown.", answer: list(SPICE) }, | |
| 746 | }, | |
| 747 | { | |
| 748 | method: "POST", | |
| 749 | path: "/api/spices", | |
| 750 | handle: async (c) => { | |
| 751 | const body = await readJson(c.request); | |
| 752 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 753 | return answer(craftSpice(c.db, c.who, { spice: fields(body).spice }), 201); | |
| 754 | }, | |
| 755 | api: { | |
| 756 | summary: "Craft a spice: its recipe comes out of your pantry.", | |
| 757 | body: { | |
| 758 | type: "object", | |
| 759 | properties: { spice: { enum: game.rack.spices.map((sp) => sp.id) } }, | |
| 760 | required: ["spice"], | |
| 761 | }, | |
| 762 | answer: SPICE, | |
| 763 | status: 201, | |
| 764 | }, | |
| 765 | }, | |
| 766 | { | |
| 767 | method: "GET", | |
| 768 | path: "/api/spices/:id", | |
| 769 | handle: (c) => answer(getSpice(c.db, c.who, { id: Number(c.params.id) })), | |
| 770 | api: { summary: "One jar on your shelf.", answer: SPICE }, | |
| 771 | }, | |
| 772 | { | |
| 773 | method: "DELETE", | |
| 774 | path: "/api/spices/:id", | |
| 775 | handle: (c) => answer(tossSpice(c.db, c.who, { id: Number(c.params.id) })), | |
| 776 | api: { summary: "Tip a jar out.", answer: GONE }, | |
| 777 | }, | |
| 778 | ||
| 680 | 779 | // The board: finished runs, best first, with their ghosts. |
| 681 | 780 | { |
| 682 | 781 | method: "GET", |
src/server.test.ts +44
| @@ -137,6 +137,8 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 137 | 137 | "list_notes", |
| 138 | 138 | "add_note", |
| 139 | 139 | "delete_note", |
| 140 | "spice_rack", | |
| 141 | "craft_spice", | |
| 140 | 142 | ]); |
| 141 | 143 | const call = (name: string, args: unknown, who = {}) => |
| 142 | 144 | rpc( |
| @@ -461,3 +463,45 @@ test("joining names a class by form and by API; a bogus class is refused", async | ||
| 461 | 463 | }; |
| 462 | 464 | expect(party.party.map((h) => h.classId).sort()).toEqual(["archer", "knight", "mage"]); |
| 463 | 465 | }); |
| 466 | ||
| 467 | test("Cheffry's Spice Rack: on the hub, over the API, and as an order", async () => { | |
| 468 | const guest = await (await fetch(base + "/")).text(); | |
| 469 | expect(guest).toContain("Cheffry's Spice Rack"); | |
| 470 | expect(guest).toContain("NEXT SPICE IS YOURS"); | |
| 471 | expect(guest).toContain("Pepper Bomb"); | |
| 472 | expect(guest).not.toContain('action="/spices"'); | |
| 473 | const cook = as("u71", "cook"); | |
| 474 | const page = await (await api("/", {}, cook)).text(); | |
| 475 | expect(page).toContain("Your pantry"); | |
| 476 | expect(page).toContain('action="/spices"'); | |
| 477 | ||
| 478 | expect((await fetch(base + "/api/pantry")).status).toBe(401); | |
| 479 | expect((await fetch(base + "/api/spices")).status).toBe(401); | |
| 480 | expect(await (await api("/api/pantry", {}, cook)).json()).toEqual([]); | |
| 481 | expect(await (await api("/api/spices", {}, cook)).json()).toEqual([]); | |
| 482 | const broke = await api("/api/spices", { method: "POST", ...jsonBody({ spice: "pepper-bomb" }) }, cook); | |
| 483 | expect(broke.status).toBe(422); | |
| 484 | expect(((await broke.json()) as { message: string }).message).toContain("Not enough"); | |
| 485 | const bogus = await api("/api/spices", { method: "POST", ...jsonBody({ spice: "<b>nuke</b>" }) }, cook); | |
| 486 | expect(bogus.status).toBe(422); | |
| 487 | // Nobody changes a jar; a jar that is not there is not there. | |
| 488 | expect((await api("/api/spices/1", { method: "PATCH", ...jsonBody({}) }, cook)).status).toBe(403); | |
| 489 | expect((await api("/api/spices/99999", { method: "DELETE" }, cook)).status).toBe(404); | |
| 490 | const form = new FormData(); | |
| 491 | form.set("spice", "hot-sauce"); | |
| 492 | const crafted = await api("/spices", { method: "POST", body: form, redirect: "manual" }, cook); | |
| 493 | expect(crafted.status).toBe(303); | |
| 494 | const rack = await rpc({ jsonrpc: "2.0", id: 9, method: "tools/call", params: { name: "spice_rack", arguments: {} } }, cook); | |
| 495 | expect(JSON.parse(rack.result.content[0].text)).toMatchObject({ pantry: {}, shelf: [] }); | |
| 496 | const stranger = await rpc({ jsonrpc: "2.0", id: 10, method: "tools/call", params: { name: "craft_spice", arguments: { spice: "hot-sauce" } } }); | |
| 497 | expect(stranger.result.isError).toBe(true); | |
| 498 | ||
| 499 | const opened = await api("/api/delves", { method: "POST" }, cook); | |
| 500 | const delve = (await opened.json()) as { party: { id: number }[] }; | |
| 501 | const hero = delve.party[0]!.id; | |
| 502 | const order = (body: unknown) => | |
| 503 | api(`/api/heroes/${hero}`, { method: "PATCH", ...jsonBody(body) }, cook); | |
| 504 | expect((await order({ type: "throw", spice: "pepper-bomb", x: 4, z: 4 })).status).toBe(200); | |
| 505 | expect((await order({ type: "throw", spice: "saffron", x: 4, z: 4 })).status).toBe(422); | |
| 506 | expect((await api(`/api/heroes/${hero}`, { method: "DELETE" }, cook)).status).toBe(200); | |
| 507 | }); |
src/sim.test.ts +152
| @@ -302,3 +302,155 @@ test("an ability on cooldown fizzles quietly", () => { | ||
| 302 | 302 | expect(w.shots.length).toBe(first); |
| 303 | 303 | expect(seen.some((h) => h.type === "ability")).toBe(false); |
| 304 | 304 | }); |
| 305 | ||
| 306 | // ── Cheffry's Spice Rack ── | |
| 307 | ||
| 308 | /** The same kitchen, with monsters that do not notice anyone (unless told). */ | |
| 309 | const calm = (c: Content, except: string[] = []): Content => ({ | |
| 310 | ...c, | |
| 311 | monsters: c.monsters.map((m) => (except.includes(m.id) ? m : { ...m, aggro: 0.01 })), | |
| 312 | }); | |
| 313 | const jars = (...kinds: string[]) => kinds.map((spice, i) => ({ jar: 100 + i, spice })); | |
| 314 | /** Tick until the jar in the air lands; what happened meanwhile. */ | |
| 315 | const untilSplash = (c: Content, w: World): string[] => { | |
| 316 | const seen: string[] = []; | |
| 317 | for (let i = 0; i < 40 && !seen.includes("splash"); i++) | |
| 318 | for (const h of step(c, w)) seen.push(h.type); | |
| 319 | return seen; | |
| 320 | }; | |
| 321 | ||
| 322 | test("a chef carries at most the rack's limit, and only spices the rack knows", () => { | |
| 323 | const c = kitchen(["#######", "#S...E#", "#######"]); | |
| 324 | const w = createWorld(c, 1); | |
| 325 | const hero = addHero(c, w, 1, "a", "knight", [ | |
| 326 | ...jars("pepper-bomb", "saffron-nuke", "hot-sauce", "salt-the-earth", "hot-sauce"), | |
| 327 | ]); | |
| 328 | expect(hero.pack.map((j) => j.spice)).toEqual(["pepper-bomb", "hot-sauce", "salt-the-earth"]); | |
| 329 | expect(c.rack.carry).toBe(3); | |
| 330 | expect(snapshot(w).heroes[0]!.pack).toEqual(["pepper-bomb", "hot-sauce", "salt-the-earth"]); | |
| 331 | }); | |
| 332 | ||
| 333 | test("a throw arcs through the air, lands no farther than the range, and spends that jar", () => { | |
| 334 | const c = kitchen(["####################", "#S................E#", "####################"]); | |
| 335 | const w = createWorld(c, 1); | |
| 336 | const hero = addHero(c, w, 1, "a", "knight", jars("hot-sauce", "pepper-bomb")); | |
| 337 | order(w, 1, { type: "throw", spice: "pepper-bomb", x: 18, z: 1.5 }); | |
| 338 | step(c, w); | |
| 339 | expect(w.flasks).toHaveLength(1); | |
| 340 | const flask = w.flasks[0]!; | |
| 341 | expect(flask.x1 - flask.x0).toBeCloseTo(c.rack.throw.range, 5); | |
| 342 | expect(hero.pack.map((j) => j.spice)).toEqual(["hot-sauce"]); | |
| 343 | expect(hero.thrown).toEqual([101]); | |
| 344 | expect(snapshot(w).flasks).toHaveLength(1); | |
| 345 | const seen = untilSplash(c, w); | |
| 346 | expect(seen).toContain("splash"); | |
| 347 | expect(w.flasks).toHaveLength(0); | |
| 348 | expect(w.zones).toHaveLength(1); | |
| 349 | expect(w.zones[0]!.x).toBeCloseTo(1.5 + c.rack.throw.range, 5); | |
| 350 | }); | |
| 351 | ||
| 352 | test("a throw without that jar in hand fizzles; so does one on cooldown", () => { | |
| 353 | const c = kitchen(["##########", "#S......E#", "##########"]); | |
| 354 | const w = createWorld(c, 1); | |
| 355 | const hero = addHero(c, w, 1, "a", "knight", jars("hot-sauce", "hot-sauce")); | |
| 356 | order(w, 1, { type: "throw", spice: "pepper-bomb", x: 5, z: 1.5 }); | |
| 357 | expect(run(c, w, 1)).not.toContain("throw"); | |
| 358 | order(w, 1, { type: "throw", spice: "hot-sauce", x: 5, z: 1.5 }); | |
| 359 | expect(run(c, w, 1)).toContain("throw"); | |
| 360 | order(w, 1, { type: "throw", spice: "hot-sauce", x: 5, z: 1.5 }); | |
| 361 | expect(run(c, w, 1)).not.toContain("throw"); | |
| 362 | expect(hero.pack).toHaveLength(1); | |
| 363 | }); | |
| 364 | ||
| 365 | test("Pepper Bomb: monsters in the cloud sneeze and go for each other", () => { | |
| 366 | const c = calm(kitchen(["###########", "#S.......E#", "#....mm...#", "###########"])); | |
| 367 | const w = createWorld(c, 1); | |
| 368 | const hero = addHero(c, w, 1, "a", "knight", jars("pepper-bomb")); | |
| 369 | order(w, 1, { type: "throw", spice: "pepper-bomb", x: 6, z: 2.5 }); | |
| 370 | untilSplash(c, w); | |
| 371 | run(c, w, 1); | |
| 372 | expect(w.monsters.every((m) => m.dazed > 0)).toBe(true); | |
| 373 | expect(snapshot(w).monsters.every((m) => m.dazed)).toBe(true); | |
| 374 | const seen = run(c, w, 60); | |
| 375 | expect(seen).toContain("hit"); | |
| 376 | const hp = c.monsters.find((m) => m.id === "meatball")!.hp; | |
| 377 | expect(w.monsters.length < 2 || w.monsters.some((m) => m.hp < hp)).toBe(true); | |
| 378 | // Nobody touched the chef, who never swung. | |
| 379 | expect(hero.hp).toBe(c.classes.find((k) => k.id === "knight")!.hp); | |
| 380 | }); | |
| 381 | ||
| 382 | test("Salt the Earth: the line burns the stale and slows everything else", () => { | |
| 383 | const room = kitchen(["###########", "#....b....#", "#S.......E#", "#....m....#", "###########"]); | |
| 384 | const c = calm(room); | |
| 385 | const w = createWorld(c, 1); | |
| 386 | addHero(c, w, 1, "a", "knight", jars("salt-the-earth")); | |
| 387 | const knight = w.monsters.find((m) => m.kind === "baguette")!; | |
| 388 | const meatball = w.monsters.find((m) => m.kind === "meatball")!; | |
| 389 | order(w, 1, { type: "throw", spice: "salt-the-earth", x: 5.5, z: 2.5 }); | |
| 390 | untilSplash(c, w); | |
| 391 | const zone = w.zones[0]!; | |
| 392 | // Thrown east, it lies north to south: across the throw. | |
| 393 | expect(Math.abs(zone.az)).toBeCloseTo(1, 5); | |
| 394 | const baguetteHp = c.monsters.find((m) => m.id === "baguette")!.hp; | |
| 395 | const meatballHp = c.monsters.find((m) => m.id === "meatball")!.hp; | |
| 396 | const before = { x: meatball.x, z: meatball.z }; | |
| 397 | // Now it notices the chef, and wades toward them through the salt. | |
| 398 | step(calm(room, ["meatball"]), w); | |
| 399 | expect(knight.hp).toBe(baguetteHp - 1); | |
| 400 | expect(knight.slow).toBe(1); | |
| 401 | expect(meatball.slow).toBe(0.4); | |
| 402 | const moved = Math.hypot(meatball.x - before.x, meatball.z - before.z); | |
| 403 | const speed = c.monsters.find((m) => m.id === "meatball")!.speed; | |
| 404 | expect(moved).toBeLessThan(speed * 0.5); | |
| 405 | expect(moved).toBeGreaterThan(0); | |
| 406 | run(c, w, 20); | |
| 407 | expect(knight.hp).toBe(baguetteHp - 2); | |
| 408 | expect(meatball.hp).toBe(meatballHp); | |
| 409 | }); | |
| 410 | ||
| 411 | test("Hot Sauce Flask: a burning puddle, and arrows through it catch fire", () => { | |
| 412 | const c = calm(kitchen(["#############", "#S.......m..#", "#....m.....E#", "#############"])); | |
| 413 | const w = createWorld(c, 1); | |
| 414 | addHero(c, w, 1, "a", "archer", jars("hot-sauce")); | |
| 415 | const far = w.monsters.find((m) => m.z < 2)!; | |
| 416 | const near = w.monsters.find((m) => m.z > 2)!; | |
| 417 | const hp = c.monsters.find((m) => m.id === "meatball")!.hp; | |
| 418 | order(w, 1, { type: "throw", spice: "hot-sauce", x: 5.5, z: 1.5 }); | |
| 419 | untilSplash(c, w); | |
| 420 | run(c, w, 1); | |
| 421 | expect(near.hp).toBe(hp - 1); | |
| 422 | // The archer's arrow flies east, through the puddle beside the cooked | |
| 423 | // meatball, and hits the far one for more. | |
| 424 | const hits: number[] = []; | |
| 425 | order(w, 1, { type: "attack" }); | |
| 426 | for (let i = 0; i < 60; i++) | |
| 427 | for (const h of step(c, w)) | |
| 428 | if (h.type === "hit" && h.on === far.id) hits.push(h.damage); | |
| 429 | const archer = c.classes.find((k) => k.id === "archer")!; | |
| 430 | expect(hits.length).toBeGreaterThan(0); | |
| 431 | expect(hits[0]).toBe(archer.damage + 1); | |
| 432 | }); | |
| 433 | ||
| 434 | test("whoever finishes a monster picks up what it drops; a chest can hold an ingredient", () => { | |
| 435 | const sure: Content = { | |
| 436 | ...kitchen(["#########", "#S..m..E#", "#########"]), | |
| 437 | monsters: game.monsters.map((m) => ({ ...m, dropChance: 1 })), | |
| 438 | }; | |
| 439 | const w = createWorld(sure, 1); | |
| 440 | const hero = addHero(sure, w, 1, "a"); | |
| 441 | order(w, 1, { type: "attack", target: w.monsters[0]!.id }); | |
| 442 | const seen = run(sure, w, 200); | |
| 443 | expect(seen).toContain("drop"); | |
| 444 | expect(hero.haul).toEqual({ "slime-jelly": 1 }); | |
| 445 | expect(snapshot(w).heroes[0]!.haul).toEqual({ "slime-jelly": 1 }); | |
| 446 | ||
| 447 | const salty: Content = { | |
| 448 | ...kitchen(["#######", "#SC..E#", "#######"]), | |
| 449 | loot: game.loot.filter((l) => l.ingredient === "rock-salt"), | |
| 450 | }; | |
| 451 | const w2 = createWorld(salty, 1); | |
| 452 | const opener = addHero(salty, w2, 1, "a"); | |
| 453 | order(w2, 1, { type: "move", x: 2.5, z: 1.5 }); | |
| 454 | run(salty, w2, 20); | |
| 455 | expect(opener.haul).toEqual({ "rock-salt": 1 }); | |
| 456 | }); |
src/sim.ts +373 −15
| @@ -47,6 +47,9 @@ export interface MonsterDef { | ||
| 47 | 47 | readonly keepAway?: number; |
| 48 | 48 | readonly cooldown?: number; |
| 49 | 49 | readonly shotSpeed?: number; |
| 50 | /** The Spice Rack ingredient it leaves for whoever finished it, `dropChance` of the time. */ | |
| 51 | readonly drops?: string; | |
| 52 | readonly dropChance?: number; | |
| 50 | 53 | } |
| 51 | 54 | export interface TrapDef { |
| 52 | 55 | readonly id: string; |
| @@ -63,6 +66,8 @@ export interface LootDef { | ||
| 63 | 66 | readonly gold?: number; |
| 64 | 67 | readonly heal?: number; |
| 65 | 68 | readonly power?: number; |
| 69 | /** A Spice Rack ingredient, for the chef who opened the chest. */ | |
| 70 | readonly ingredient?: string; | |
| 66 | 71 | } |
| 67 | 72 | export interface RoomDef { |
| 68 | 73 | readonly id: string; |
| @@ -111,6 +116,51 @@ export interface ClassDef { | ||
| 111 | 116 | readonly revive: number; |
| 112 | 117 | readonly ability: ClassAbility; |
| 113 | 118 | } |
| 119 | /** | |
| 120 | * A throwable from Cheffry's Spice Rack, as data: what it costs, the shape | |
| 121 | * it bursts into, and what that zone does to whatever stands in it. The | |
| 122 | * sim knows the effects (confuse, slow, damage, ignite) and the two shapes; | |
| 123 | * a new spice that mixes them needs no code. | |
| 124 | */ | |
| 125 | export interface SpiceDef { | |
| 126 | readonly id: string; | |
| 127 | readonly name: string; | |
| 128 | readonly description: string; | |
| 129 | readonly recipe: Readonly<Record<string, number>>; | |
| 130 | /** A colour token, without `--color-`. */ | |
| 131 | readonly paint: string; | |
| 132 | readonly shape: "circle" | "line"; | |
| 133 | readonly radius?: number; | |
| 134 | readonly length?: number; | |
| 135 | readonly width?: number; | |
| 136 | readonly duration: number; | |
| 137 | /** Ticks a monster flails at its own kind after leaving the zone. */ | |
| 138 | readonly confuse?: number; | |
| 139 | /** The share of its speed a monster keeps in the zone, 0 to 1. */ | |
| 140 | readonly slow?: number; | |
| 141 | readonly damage?: number; | |
| 142 | readonly every?: number; | |
| 143 | /** Who the damage is for (monster ids); everyone when absent. Burned monsters are not slowed. */ | |
| 144 | readonly burns?: readonly string[]; | |
| 145 | /** Damage a hero's shot gains flying through the zone. */ | |
| 146 | readonly ignite?: number; | |
| 147 | } | |
| 148 | export interface SpiceRack { | |
| 149 | /** Jars a chef carries into a delve. */ | |
| 150 | readonly carry: number; | |
| 151 | /** Jars a chef keeps on the shelf. */ | |
| 152 | readonly satchel: number; | |
| 153 | /** The most of one ingredient a pantry holds. */ | |
| 154 | readonly pantry: number; | |
| 155 | readonly throw: { | |
| 156 | readonly range: number; | |
| 157 | readonly speed: number; | |
| 158 | readonly arc: number; | |
| 159 | readonly cooldown: number; | |
| 160 | }; | |
| 161 | readonly ingredients: readonly { readonly id: string; readonly name: string; readonly from: string }[]; | |
| 162 | readonly spices: readonly SpiceDef[]; | |
| 163 | } | |
| 114 | 164 | export interface Content { |
| 115 | 165 | readonly rules: { |
| 116 | 166 | readonly tickHz: number; |
| @@ -124,10 +174,17 @@ export interface Content { | ||
| 124 | 174 | readonly loot: readonly LootDef[]; |
| 125 | 175 | readonly rooms: readonly RoomDef[]; |
| 126 | 176 | readonly classes: readonly ClassDef[]; |
| 177 | readonly rack: SpiceRack; | |
| 127 | 178 | } |
| 128 | 179 | |
| 129 | 180 | // ── The world ── |
| 130 | 181 | |
| 182 | /** A jar from a chef's satchel, carried into the delve: `jar` is its row in the database. */ | |
| 183 | export interface Jar { | |
| 184 | readonly jar: number; | |
| 185 | readonly spice: string; | |
| 186 | } | |
| 187 | ||
| 131 | 188 | export interface Hero { |
| 132 | 189 | readonly id: number; |
| 133 | 190 | readonly name: string; |
| @@ -154,6 +211,14 @@ export interface Hero { | ||
| 154 | 211 | invuln: number; |
| 155 | 212 | downed: boolean; |
| 156 | 213 | revive: number; |
| 214 | /** Jars still in hand. */ | |
| 215 | pack: Jar[]; | |
| 216 | /** Jars thrown, by row: what leaving the table spends. */ | |
| 217 | thrown: number[]; | |
| 218 | /** Ingredients picked up this delve, by id: what leaving the table takes home. */ | |
| 219 | haul: Record<string, number>; | |
| 220 | wantThrow: { spice: string; x: number; z: number } | null; | |
| 221 | throwCd: number; | |
| 157 | 222 | } |
| 158 | 223 | export interface Monster { |
| 159 | 224 | readonly id: number; |
| @@ -167,6 +232,14 @@ export interface Monster { | ||
| 167 | 232 | dz: number; |
| 168 | 233 | cd: number; |
| 169 | 234 | hurt: number; |
| 235 | /** Ticks left flailing at its own kind (a pepper cloud). */ | |
| 236 | dazed: number; | |
| 237 | /** The hero whose pepper did it: who a dazed monster's victims count for. */ | |
| 238 | dazedBy: number | null; | |
| 239 | /** The share of its speed it keeps this tick (a salt line). */ | |
| 240 | slow: number; | |
| 241 | /** The hero whose blow, shot or spice touched it last: who its drop goes to. */ | |
| 242 | lastBy: number | null; | |
| 170 | 243 | } |
| 171 | 244 | export interface Shot { |
| 172 | 245 | readonly id: number; |
| @@ -175,10 +248,41 @@ export interface Shot { | ||
| 175 | 248 | readonly dx: number; |
| 176 | 249 | readonly dz: number; |
| 177 | 250 | ttl: number; |
| 178 | readonly damage: number; | |
| 179 | /** Who fired it: a hero's shots bite monsters, a monster's bite heroes. */ | |
| 180 | readonly from: "hero" | "monster"; | |
| 251 | damage: number; | |
| 252 | /** Who fired it: a hero's shots bite monsters, a monster's bite heroes, a dazed monster's (`wild`) bite its own kind. */ | |
| 253 | readonly from: "hero" | "monster" | "wild"; | |
| 181 | 254 | readonly by: number; |
| 255 | /** Caught fire flying through hot sauce: it only does once. */ | |
| 256 | lit?: boolean; | |
| 257 | /** For a wild shot: the hero whose spice set it off. */ | |
| 258 | readonly credit?: number | null; | |
| 259 | } | |
| 260 | /** A jar in the air, from (x0, z0) to (x1, z1) over `flight` ticks. */ | |
| 261 | export interface Flask { | |
| 262 | readonly id: number; | |
| 263 | readonly spice: string; | |
| 264 | readonly by: number; | |
| 265 | readonly x0: number; | |
| 266 | readonly z0: number; | |
| 267 | readonly x1: number; | |
| 268 | readonly z1: number; | |
| 269 | /** The throw's direction: a line lies across it. */ | |
| 270 | readonly dx: number; | |
| 271 | readonly dz: number; | |
| 272 | t: number; | |
| 273 | readonly flight: number; | |
| 274 | } | |
| 275 | /** A spice on the floor. A line runs along (ax, az). */ | |
| 276 | export interface Zone { | |
| 277 | readonly id: number; | |
| 278 | readonly spice: string; | |
| 279 | readonly by: number; | |
| 280 | readonly x: number; | |
| 281 | readonly z: number; | |
| 282 | readonly ax: number; | |
| 283 | readonly az: number; | |
| 284 | age: number; | |
| 285 | ttl: number; | |
| 182 | 286 | } |
| 183 | 287 | export interface Chest { |
| 184 | 288 | readonly id: number; |
| @@ -212,6 +316,9 @@ export type Happening = | ||
| 212 | 316 | | { readonly type: "slain"; readonly monster: number; readonly kind: string } |
| 213 | 317 | | { readonly type: "loot"; readonly hero: number; readonly loot: string } |
| 214 | 318 | | { readonly type: "ability"; readonly hero: number; readonly ability: string } |
| 319 | | { readonly type: "throw"; readonly hero: number; readonly spice: string } | |
| 320 | | { readonly type: "splash"; readonly spice: string; readonly x: number; readonly z: number } | |
| 321 | | { readonly type: "drop"; readonly hero: number; readonly ingredient: string } | |
| 215 | 322 | | { readonly type: "open" } |
| 216 | 323 | | { readonly type: "room"; readonly depth: number; readonly room: string } |
| 217 | 324 | | { readonly type: "over"; readonly outcome: Outcome }; |
| @@ -228,6 +335,8 @@ export interface World { | ||
| 228 | 335 | heroes: Hero[]; |
| 229 | 336 | monsters: Monster[]; |
| 230 | 337 | shots: Shot[]; |
| 338 | flasks: Flask[]; | |
| 339 | zones: Zone[]; | |
| 231 | 340 | chests: Chest[]; |
| 232 | 341 | gold: number; |
| 233 | 342 | kills: number; |
| @@ -242,6 +351,7 @@ export type Order = | ||
| 242 | 351 | | { readonly type: "attack"; readonly target?: number | undefined } |
| 243 | 352 | | { readonly type: "dodge"; readonly x: number; readonly z: number } |
| 244 | 353 | | { readonly type: "ability" } |
| 354 | | { readonly type: "throw"; readonly spice: string; readonly x: number; readonly z: number } | |
| 245 | 355 | | { readonly type: "stop" }; |
| 246 | 356 | |
| 247 | 357 | // ── Randomness: mulberry32, its state kept in the world ── |
| @@ -326,6 +436,8 @@ const roomById = (content: Content, id: string): RoomDef => { | ||
| 326 | 436 | }; |
| 327 | 437 | const monsterDef = (content: Content, kind: string): MonsterDef => |
| 328 | 438 | content.monsters.find((m) => m.id === kind)!; |
| 439 | const spiceDef = (content: Content, id: string): SpiceDef | undefined => | |
| 440 | content.rack.spices.find((s) => s.id === id); | |
| 329 | 441 | |
| 330 | 442 | /** The class a hero delves as: unknown ids fall back to the knight. */ |
| 331 | 443 | export const classOf = (content: Content, classId: string): ClassDef => { |
| @@ -370,6 +482,8 @@ export function createWorld(content: Content, seed: number): World { | ||
| 370 | 482 | heroes: [], |
| 371 | 483 | monsters: [], |
| 372 | 484 | shots: [], |
| 485 | flasks: [], | |
| 486 | zones: [], | |
| 373 | 487 | chests: [], |
| 374 | 488 | gold: 0, |
| 375 | 489 | kills: 0, |
| @@ -387,6 +501,8 @@ function enter(content: Content, world: World, depth: number): void { | ||
| 387 | 501 | world.layout = layoutOf(content, roomById(content, world.plan[depth]!)); |
| 388 | 502 | world.exitOpen = false; |
| 389 | 503 | world.shots = []; |
| 504 | world.flasks = []; | |
| 505 | world.zones = []; | |
| 390 | 506 | world.monsters = world.layout.monsters.map((m) => ({ |
| 391 | 507 | id: world.nextId++, |
| 392 | 508 | kind: m.kind, |
| @@ -399,6 +515,10 @@ function enter(content: Content, world: World, depth: number): void { | ||
| 399 | 515 | dz: 0, |
| 400 | 516 | cd: Math.floor(random(world) * 30), |
| 401 | 517 | hurt: 0, |
| 518 | dazed: 0, | |
| 519 | dazedBy: null, | |
| 520 | slow: 1, | |
| 521 | lastBy: null, | |
| 402 | 522 | })); |
| 403 | 523 | world.chests = world.layout.chests.map((c) => ({ |
| 404 | 524 | id: world.nextId++, |
| @@ -427,13 +547,17 @@ function place(world: World, hero: Hero, i: number): void { | ||
| 427 | 547 | hero.fz = 0; |
| 428 | 548 | } |
| 429 | 549 | |
| 430 | /** A hero steps in. Whoever joins a delve under way starts at this floor's start. */ | |
| 550 | /** | |
| 551 | * A hero steps in, with the jars they carry (at most the rack's `carry`). | |
| 552 | * Whoever joins a delve under way starts at this floor's start. | |
| 553 | */ | |
| 431 | 554 | export function addHero( |
| 432 | 555 | content: Content, |
| 433 | 556 | world: World, |
| 434 | 557 | id: number, |
| 435 | 558 | name: string, |
| 436 | 559 | classId: string = "knight", |
| 560 | pack: readonly Jar[] = [], | |
| 437 | 561 | ): Hero { |
| 438 | 562 | const cls = classOf(content, classId); |
| 439 | 563 | const hero: Hero = { |
| @@ -461,6 +585,14 @@ export function addHero( | ||
| 461 | 585 | invuln: cls.invuln, |
| 462 | 586 | downed: false, |
| 463 | 587 | revive: 0, |
| 588 | pack: pack | |
| 589 | .filter((j) => content.rack.spices.some((s) => s.id === j.spice)) | |
| 590 | .slice(0, content.rack.carry) | |
| 591 | .map((j) => ({ jar: j.jar, spice: j.spice })), | |
| 592 | thrown: [], | |
| 593 | haul: {}, | |
| 594 | wantThrow: null, | |
| 595 | throwCd: 0, | |
| 464 | 596 | }; |
| 465 | 597 | place(world, hero, world.heroes.length); |
| 466 | 598 | world.heroes.push(hero); |
| @@ -485,6 +617,7 @@ export function order(world: World, heroId: number, o: Order): void { | ||
| 485 | 617 | } else hero.wantSwing = true; |
| 486 | 618 | } else if (o.type === "dodge") hero.wantDodge = { x: o.x, z: o.z }; |
| 487 | 619 | else if (o.type === "ability") hero.wantAbility = true; |
| 620 | else if (o.type === "throw") hero.wantThrow = { spice: o.spice, x: o.x, z: o.z }; | |
| 488 | 621 | else { |
| 489 | 622 | hero.goal = null; |
| 490 | 623 | hero.target = null; |
| @@ -547,15 +680,26 @@ export function step(content: Content, world: World): Happening[] { | ||
| 547 | 680 | if (world.over !== null) return world.happened; |
| 548 | 681 | world.tick++; |
| 549 | 682 | for (const hero of world.heroes) stepHero(content, world, hero); |
| 683 | stepFlasks(content, world); | |
| 684 | stepZones(content, world); | |
| 550 | 685 | for (const monster of world.monsters) stepMonster(content, world, monster); |
| 551 | 686 | stepShots(content, world); |
| 552 | 687 | stepTraps(content, world); |
| 553 | 688 | |
| 554 | 689 | const slain = world.monsters.filter((m) => m.hp <= 0); |
| 555 | 690 | for (const m of slain) { |
| 691 | const def = monsterDef(content, m.kind); | |
| 556 | 692 | world.kills++; |
| 557 | world.score += monsterDef(content, m.kind).score; | |
| 693 | world.score += def.score; | |
| 558 | 694 | world.happened.push({ type: "slain", monster: m.id, kind: m.kind }); |
| 695 | // Whoever finished it picks up what it dropped, for the Spice Rack. | |
| 696 | if (def.drops !== undefined && random(world) < (def.dropChance ?? 1)) { | |
| 697 | const hero = world.heroes.find((h) => h.id === m.lastBy); | |
| 698 | if (hero !== undefined) { | |
| 699 | hero.haul[def.drops] = (hero.haul[def.drops] ?? 0) + 1; | |
| 700 | world.happened.push({ type: "drop", hero: hero.id, ingredient: def.drops }); | |
| 701 | } | |
| 702 | } | |
| 559 | 703 | } |
| 560 | 704 | if (slain.length > 0) world.monsters = world.monsters.filter((m) => m.hp > 0); |
| 561 | 705 | if (!world.exitOpen && world.monsters.length === 0) { |
| @@ -611,6 +755,7 @@ function stepHero(content: Content, world: World, hero: Hero): void { | ||
| 611 | 755 | if (hero.cooldown > 0) hero.cooldown--; |
| 612 | 756 | if (hero.dodgeCd > 0) hero.dodgeCd--; |
| 613 | 757 | if (hero.abilityCd > 0) hero.abilityCd--; |
| 758 | if (hero.throwCd > 0) hero.throwCd--; | |
| 614 | 759 | if (hero.invuln > 0) hero.invuln--; |
| 615 | 760 | if (hero.swing > 0) hero.swing--; |
| 616 | 761 | |
| @@ -695,6 +840,12 @@ function stepHero(content: Content, world: World, hero: Hero): void { | ||
| 695 | 840 | if (hero.abilityCd === 0 && hero.dodge === 0) useAbility(content, world, hero, cls); |
| 696 | 841 | } |
| 697 | 842 | |
| 843 | if (hero.wantThrow !== null) { | |
| 844 | const aim = hero.wantThrow; | |
| 845 | hero.wantThrow = null; | |
| 846 | if (hero.throwCd === 0 && hero.dodge === 0) throwSpice(content, world, hero, rules, aim); | |
| 847 | } | |
| 848 | ||
| 698 | 849 | // Treasure opens to the touch. |
| 699 | 850 | for (const chest of world.chests) |
| 700 | 851 | if (chest.open === null && dist(hero, chest) < 0.8) { |
| @@ -704,6 +855,8 @@ function stepHero(content: Content, world: World, hero: Hero): void { | ||
| 704 | 855 | world.score += loot.gold ?? 0; |
| 705 | 856 | hero.hp = Math.min(rules.hp, hero.hp + (loot.heal ?? 0)); |
| 706 | 857 | hero.power += loot.power ?? 0; |
| 858 | if (loot.ingredient !== undefined) | |
| 859 | hero.haul[loot.ingredient] = (hero.haul[loot.ingredient] ?? 0) + 1; | |
| 707 | 860 | world.happened.push({ type: "loot", hero: hero.id, loot: loot.id }); |
| 708 | 861 | } |
| 709 | 862 | } |
| @@ -722,6 +875,7 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo | ||
| 722 | 875 | if (facing < 0.2 && d > 0.5) continue; |
| 723 | 876 | m.hp -= damage; |
| 724 | 877 | m.hurt = 4; |
| 878 | m.lastBy = hero.id; | |
| 725 | 879 | if (dir !== undefined) slide(world, m, dir.x * 0.4, dir.z * 0.4, def.radius); |
| 726 | 880 | world.happened.push({ type: "hit", by: hero.id, on: m.id, damage }); |
| 727 | 881 | } |
| @@ -792,6 +946,171 @@ function useAbility(content: Content, world: World, hero: Hero, cls: ClassDef): | ||
| 792 | 946 | } |
| 793 | 947 | } |
| 794 | 948 | |
| 949 | // ── The Spice Rack: a jar in the air, then a zone on the floor ── | |
| 950 | ||
| 951 | /** Lob a jar from the pack toward (x, z), no farther than the rack's range. */ | |
| 952 | function throwSpice( | |
| 953 | content: Content, | |
| 954 | world: World, | |
| 955 | hero: Hero, | |
| 956 | rules: HeroRules, | |
| 957 | aim: { spice: string; x: number; z: number }, | |
| 958 | ): void { | |
| 959 | const at = hero.pack.findIndex((j) => j.spice === aim.spice); | |
| 960 | if (at < 0 || spiceDef(content, aim.spice) === undefined) return; | |
| 961 | const jar = hero.pack.splice(at, 1)[0]!; | |
| 962 | hero.thrown.push(jar.jar); | |
| 963 | const t = content.rack.throw; | |
| 964 | const dir = toward(hero, aim) ?? { x: hero.fx, z: hero.fz }; | |
| 965 | const reach = Math.min(dist(hero, aim), t.range); | |
| 966 | const { w, h } = world.layout; | |
| 967 | hero.fx = dir.x; | |
| 968 | hero.fz = dir.z; | |
| 969 | hero.throwCd = t.cooldown; | |
| 970 | hero.swing = rules.swing; | |
| 971 | world.flasks.push({ | |
| 972 | id: world.nextId++, | |
| 973 | spice: jar.spice, | |
| 974 | by: hero.id, | |
| 975 | x0: hero.x, | |
| 976 | z0: hero.z, | |
| 977 | x1: Math.min(w - 0.5, Math.max(0.5, hero.x + dir.x * reach)), | |
| 978 | z1: Math.min(h - 0.5, Math.max(0.5, hero.z + dir.z * reach)), | |
| 979 | dx: dir.x, | |
| 980 | dz: dir.z, | |
| 981 | t: 0, | |
| 982 | flight: Math.max(4, Math.ceil(reach / t.speed)), | |
| 983 | }); | |
| 984 | world.happened.push({ type: "throw", hero: hero.id, spice: jar.spice }); | |
| 985 | } | |
| 986 | ||
| 987 | function stepFlasks(content: Content, world: World): void { | |
| 988 | for (const f of world.flasks) { | |
| 989 | if (++f.t < f.flight) continue; | |
| 990 | const def = spiceDef(content, f.spice); | |
| 991 | if (def === undefined) continue; | |
| 992 | world.zones.push({ | |
| 993 | id: world.nextId++, | |
| 994 | spice: f.spice, | |
| 995 | by: f.by, | |
| 996 | x: f.x1, | |
| 997 | z: f.z1, | |
| 998 | // A line lies across the throw. | |
| 999 | ax: -f.dz, | |
| 1000 | az: f.dx, | |
| 1001 | age: 0, | |
| 1002 | ttl: def.duration, | |
| 1003 | }); | |
| 1004 | world.happened.push({ type: "splash", spice: f.spice, x: round(f.x1), z: round(f.z1) }); | |
| 1005 | } | |
| 1006 | world.flasks = world.flasks.filter((f) => f.t < f.flight); | |
| 1007 | } | |
| 1008 | ||
| 1009 | /** Whether a circle of radius `r` at `body` touches a zone. */ | |
| 1010 | export function inZone( | |
| 1011 | def: SpiceDef, | |
| 1012 | zone: { x: number; z: number; ax: number; az: number }, | |
| 1013 | body: { x: number; z: number }, | |
| 1014 | r: number, | |
| 1015 | ): boolean { | |
| 1016 | const ox = body.x - zone.x; | |
| 1017 | const oz = body.z - zone.z; | |
| 1018 | if (def.shape === "line") { | |
| 1019 | const along = ox * zone.ax + oz * zone.az; | |
| 1020 | const across = -ox * zone.az + oz * zone.ax; | |
| 1021 | return ( | |
| 1022 | Math.abs(along) <= (def.length ?? 3) / 2 + r && | |
| 1023 | Math.abs(across) <= (def.width ?? 0.8) / 2 + r | |
| 1024 | ); | |
| 1025 | } | |
| 1026 | return Math.sqrt(ox * ox + oz * oz) <= (def.radius ?? 1.5) + r; | |
| 1027 | } | |
| 1028 | ||
| 1029 | /** What every zone does to every monster standing in it, this tick. */ | |
| 1030 | function stepZones(content: Content, world: World): void { | |
| 1031 | for (const m of world.monsters) m.slow = 1; | |
| 1032 | for (const zone of world.zones) { | |
| 1033 | const def = spiceDef(content, zone.spice); | |
| 1034 | zone.ttl--; | |
| 1035 | if (def === undefined) continue; | |
| 1036 | zone.age++; | |
| 1037 | const biting = def.damage !== undefined && (zone.age - 1) % (def.every ?? 20) === 0; | |
| 1038 | for (const m of world.monsters) { | |
| 1039 | if (m.hp <= 0) continue; | |
| 1040 | if (!inZone(def, zone, m, monsterDef(content, m.kind).radius * 0.5)) continue; | |
| 1041 | const burns = def.burns === undefined || def.burns.includes(m.kind); | |
| 1042 | if (def.confuse !== undefined) { | |
| 1043 | // A sneeze breaks a charge: it forgets who it was running at. | |
| 1044 | if (m.dazed === 0 && m.state !== "stun") m.state = "idle"; | |
| 1045 | m.dazed = Math.max(m.dazed, def.confuse); | |
| 1046 | m.dazedBy = zone.by; | |
| 1047 | } | |
| 1048 | if (def.slow !== undefined && !(def.burns !== undefined && burns)) | |
| 1049 | m.slow = Math.min(m.slow, def.slow); | |
| 1050 | if (biting && burns) { | |
| 1051 | m.hp -= def.damage!; | |
| 1052 | m.hurt = 4; | |
| 1053 | m.lastBy = zone.by; | |
| 1054 | world.happened.push({ type: "hit", by: zone.by, on: m.id, damage: def.damage! }); | |
| 1055 | } | |
| 1056 | } | |
| 1057 | } | |
| 1058 | world.zones = world.zones.filter((z) => z.ttl > 0); | |
| 1059 | } | |
| 1060 | ||
| 1061 | /** | |
| 1062 | * A dazed monster's brain: it goes for the nearest of its own kind with | |
| 1063 | * whatever it has — a bite, a charge's shove, a seed — and any kill counts | |
| 1064 | * for the chef whose pepper started it. | |
| 1065 | */ | |
| 1066 | function flail(content: Content, world: World, m: Monster, def: MonsterDef): void { | |
| 1067 | let other: Monster | undefined; | |
| 1068 | let best = Infinity; | |
| 1069 | for (const o of world.monsters) { | |
| 1070 | if (o === m || o.hp <= 0) continue; | |
| 1071 | const d = dist(m, o); | |
| 1072 | if (d < best) { | |
| 1073 | other = o; | |
| 1074 | best = d; | |
| 1075 | } | |
| 1076 | } | |
| 1077 | if (other === undefined) return; | |
| 1078 | const dir = toward(m, other); | |
| 1079 | if (dir === undefined) return; | |
| 1080 | const speed = def.speed * m.slow; | |
| 1081 | if (def.behavior === "shoot") { | |
| 1082 | const range = def.range ?? 6; | |
| 1083 | if (best > range) slide(world, m, dir.x * speed, dir.z * speed, def.radius); | |
| 1084 | else if (m.cd === 0) { | |
| 1085 | const shot = def.shotSpeed ?? 0.18; | |
| 1086 | world.shots.push({ | |
| 1087 | id: world.nextId++, | |
| 1088 | x: m.x, | |
| 1089 | z: m.z, | |
| 1090 | dx: dir.x * shot, | |
| 1091 | dz: dir.z * shot, | |
| 1092 | ttl: Math.ceil((range + 3) / shot), | |
| 1093 | damage: def.damage, | |
| 1094 | from: "wild", | |
| 1095 | by: m.id, | |
| 1096 | credit: m.dazedBy, | |
| 1097 | }); | |
| 1098 | m.cd = def.cooldown ?? 45; | |
| 1099 | } | |
| 1100 | return; | |
| 1101 | } | |
| 1102 | const touching = best < def.radius + monsterDef(content, other.kind).radius; | |
| 1103 | if (!touching) slide(world, m, dir.x * speed, dir.z * speed, def.radius); | |
| 1104 | else if (m.cd === 0) { | |
| 1105 | other.hp -= def.damage; | |
| 1106 | other.hurt = 4; | |
| 1107 | other.lastBy = m.dazedBy; | |
| 1108 | world.happened.push({ type: "hit", by: m.id, on: other.id, damage: def.damage }); | |
| 1109 | m.cd = 20; | |
| 1110 | slide(world, m, -dir.x * 0.4, -dir.z * 0.4, def.radius); | |
| 1111 | } | |
| 1112 | } | |
| 1113 | ||
| 795 | 1114 | function roll(content: Content, world: World) { |
| 796 | 1115 | const total = content.loot.reduce((sum, l) => sum + l.weight, 0); |
| 797 | 1116 | let pick = random(world) * total; |
| @@ -836,6 +1155,12 @@ function stepMonster(content: Content, world: World, m: Monster): void { | ||
| 836 | 1155 | const def = monsterDef(content, m.kind); |
| 837 | 1156 | if (m.hurt > 0) m.hurt--; |
| 838 | 1157 | if (m.cd > 0) m.cd--; |
| 1158 | if (m.dazed > 0) { | |
| 1159 | m.dazed--; | |
| 1160 | flail(content, world, m, def); | |
| 1161 | return; | |
| 1162 | } | |
| 1163 | const pace = m.slow; | |
| 839 | 1164 | const prey = nearestHero(world, m); |
| 840 | 1165 | if (prey === undefined) return; |
| 841 | 1166 | const d = dist(m, prey); |
| @@ -844,7 +1169,7 @@ function stepMonster(content: Content, world: World, m: Monster): void { | ||
| 844 | 1169 | |
| 845 | 1170 | if (def.behavior === "chase") { |
| 846 | 1171 | if (d < def.aggro && dir !== undefined && !touching) |
| 847 | slide(world, m, dir.x * def.speed, dir.z * def.speed, def.radius); | |
| 1172 | slide(world, m, dir.x * def.speed * pace, dir.z * def.speed * pace, def.radius); | |
| 848 | 1173 | // A bite, then a bounce back: room to breathe, and to swing. |
| 849 | 1174 | if (touching && hurt(content, world, prey, def.damage) && dir !== undefined) |
| 850 | 1175 | slide(world, m, -dir.x * 0.6, -dir.z * 0.6, def.radius); |
| @@ -859,14 +1184,14 @@ function stepMonster(content: Content, world: World, m: Monster): void { | ||
| 859 | 1184 | m.dx = dir.x; |
| 860 | 1185 | m.dz = dir.z; |
| 861 | 1186 | } else if (d < def.aggro * 1.5 && dir !== undefined && !touching) |
| 862 | slide(world, m, dir.x * def.speed, dir.z * def.speed, def.radius); | |
| 1187 | slide(world, m, dir.x * def.speed * pace, dir.z * def.speed * pace, def.radius); | |
| 863 | 1188 | } else if (m.state === "windup") { |
| 864 | 1189 | if (--m.t <= 0) { |
| 865 | 1190 | m.state = "charge"; |
| 866 | 1191 | m.t = def.chargeTicks ?? 15; |
| 867 | 1192 | } |
| 868 | 1193 | } else if (m.state === "charge") { |
| 869 | const speed = def.chargeSpeed ?? 0.3; | |
| 1194 | const speed = (def.chargeSpeed ?? 0.3) * pace; | |
| 870 | 1195 | const wall = slide(world, m, m.dx * speed, m.dz * speed, def.radius); |
| 871 | 1196 | for (const h of world.heroes) |
| 872 | 1197 | if (dist(h, m) < def.radius + content.rules.hero.radius) |
| @@ -890,8 +1215,8 @@ function stepMonster(content: Content, world: World, m: Monster): void { | ||
| 890 | 1215 | if (d >= def.aggro || dir === undefined) return; |
| 891 | 1216 | const keep = def.keepAway ?? 3; |
| 892 | 1217 | const range = def.range ?? 6; |
| 893 | if (d < keep) slide(world, m, -dir.x * def.speed, -dir.z * def.speed, def.radius); | |
| 894 | else if (d > range) slide(world, m, dir.x * def.speed, dir.z * def.speed, def.radius); | |
| 1218 | if (d < keep) slide(world, m, -dir.x * def.speed * pace, -dir.z * def.speed * pace, def.radius); | |
| 1219 | else if (d > range) slide(world, m, dir.x * def.speed * pace, dir.z * def.speed * pace, def.radius); | |
| 895 | 1220 | if (m.cd === 0 && d <= range + 0.5) { |
| 896 | 1221 | const speed = def.shotSpeed ?? 0.18; |
| 897 | 1222 | world.shots.push({ |
| @@ -915,15 +1240,25 @@ function stepShots(content: Content, world: World): void { | ||
| 915 | 1240 | s.z += s.dz; |
| 916 | 1241 | s.ttl--; |
| 917 | 1242 | if (s.ttl <= 0) continue; |
| 918 | // A hero's shot bites the first monster it reaches. | |
| 919 | if (s.from === "hero") { | |
| 1243 | // A hero's arrow through hot sauce comes out burning. | |
| 1244 | if (s.from === "hero" && s.lit !== true) | |
| 1245 | for (const zone of world.zones) { | |
| 1246 | const def = spiceDef(content, zone.spice); | |
| 1247 | if (def?.ignite === undefined || !inZone(def, zone, s, 0)) continue; | |
| 1248 | s.damage += def.ignite; | |
| 1249 | s.lit = true; | |
| 1250 | break; | |
| 1251 | } | |
| 1252 | // A hero's shot (or a dazed monster's) bites the first monster it reaches. | |
| 1253 | if (s.from !== "monster") { | |
| 920 | 1254 | for (const m of world.monsters) { |
| 921 | if (m.hp <= 0) continue; | |
| 1255 | if (m.hp <= 0 || (s.from === "wild" && m.id === s.by)) continue; | |
| 922 | 1256 | const def = monsterDef(content, m.kind); |
| 923 | 1257 | if (dist(s, m) > 0.35 + def.radius) continue; |
| 924 | 1258 | const dir = toward(s, m); |
| 925 | 1259 | m.hp -= s.damage; |
| 926 | 1260 | m.hurt = 4; |
| 1261 | m.lastBy = s.from === "hero" ? s.by : (s.credit ?? null); | |
| 927 | 1262 | if (dir !== undefined) slide(world, m, dir.x * 0.3, dir.z * 0.3, def.radius); |
| 928 | 1263 | world.happened.push({ type: "hit", by: s.by, on: m.id, damage: s.damage }); |
| 929 | 1264 | s.ttl = 0; |
| @@ -941,7 +1276,7 @@ function stepTraps(content: Content, world: World): void { | ||
| 941 | 1276 | for (const hero of world.heroes) { |
| 942 | 1277 | // A monster's seed splats on whoever it reaches, unless they are rolling through it. |
| 943 | 1278 | world.shots = world.shots.filter((s) => { |
| 944 | if (s.from === "hero" || hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) | |
| 1279 | if (s.from !== "monster" || hero.downed || hero.dodge > 0 || dist(s, hero) > 0.4) | |
| 945 | 1280 | return true; |
| 946 | 1281 | hurt(content, world, hero, s.damage); |
| 947 | 1282 | return false; |
| @@ -991,6 +1326,8 @@ export function snapshot(world: World) { | ||
| 991 | 1326 | hit: h.invuln > 0 && h.dodge === 0, |
| 992 | 1327 | downed: h.downed, |
| 993 | 1328 | revive: h.revive, |
| 1329 | pack: h.pack.map((j) => j.spice), | |
| 1330 | haul: h.haul, | |
| 994 | 1331 | })), |
| 995 | 1332 | monsters: world.monsters.map((m) => ({ |
| 996 | 1333 | id: m.id, |
| @@ -1002,8 +1339,29 @@ export function snapshot(world: World) { | ||
| 1002 | 1339 | hurt: m.hurt > 0, |
| 1003 | 1340 | fx: round(m.dx), |
| 1004 | 1341 | fz: round(m.dz), |
| 1342 | dazed: m.dazed > 0, | |
| 1343 | slow: m.slow < 1, | |
| 1344 | })), | |
| 1345 | shots: world.shots.map((s) => ({ id: s.id, x: round(s.x), z: round(s.z), lit: s.lit === true })), | |
| 1346 | flasks: world.flasks.map((f) => ({ | |
| 1347 | id: f.id, | |
| 1348 | spice: f.spice, | |
| 1349 | x0: round(f.x0), | |
| 1350 | z0: round(f.z0), | |
| 1351 | x1: round(f.x1), | |
| 1352 | z1: round(f.z1), | |
| 1353 | t: f.t, | |
| 1354 | flight: f.flight, | |
| 1355 | })), | |
| 1356 | zones: world.zones.map((z) => ({ | |
| 1357 | id: z.id, | |
| 1358 | spice: z.spice, | |
| 1359 | x: round(z.x), | |
| 1360 | z: round(z.z), | |
| 1361 | ax: round(z.ax), | |
| 1362 | az: round(z.az), | |
| 1363 | ttl: z.ttl, | |
| 1005 | 1364 | })), |
| 1006 | shots: world.shots.map((s) => ({ id: s.id, x: round(s.x), z: round(s.z) })), | |
| 1007 | 1365 | chests: world.chests.map((c) => ({ id: c.id, x: c.x, z: c.z, open: c.open })), |
| 1008 | 1366 | }; |
| 1009 | 1367 | } |
static/app.css +16
| @@ -454,3 +454,19 @@ kbd { | ||
| 454 | 454 | font-size: var(); |
| 455 | 455 | color: var(); |
| 456 | 456 | } |
| 457 | ||
| 458 | /* Cheffry's Spice Rack: the kitchen wall, chalked. */ | |
| 459 | .chalk { | |
| 460 | margin: 0; | |
| 461 | padding: var(); | |
| 462 | font-family: var(); | |
| 463 | font-size: var(); | |
| 464 | letter-spacing: 0.04em; | |
| 465 | color: var(); | |
| 466 | background: var(); | |
| 467 | border-radius: var(); | |
| 468 | } | |
| 469 | #spice-rack h3 { | |
| 470 | margin: var() 0 var(); | |
| 471 | font-size: var(); | |
| 472 | } |
static/delve.js +152 −4
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | // second) and sends every other tick down /delves/:id/events; this file |
| 3 | 3 | // draws between the last two it heard, and sends orders back: click or tap |
| 4 | 4 | // 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 to pick a spice and a | |
| 6 | // click to throw it. The same sim.ts, served as /sim.js, lays out the rooms | |
| 7 | // here. | |
| 7 | 8 | |
| 8 | 9 | import { compose, createRenderer, drawChef, paint } from "./gl.js"; |
| 9 | 10 | |
| @@ -25,6 +26,9 @@ let roomId = null; | ||
| 25 | 26 | let layout = null; |
| 26 | 27 | let ghost = null; |
| 27 | 28 | let marker = null; |
| 29 | // The spice in hand, aimed at the floor under the pointer until a click throws it. | |
| 30 | let armed = null; | |
| 31 | let aim = null; | |
| 28 | 32 | |
| 29 | 33 | const say = (text) => { |
| 30 | 34 | const el = hud("status"); |
| @@ -143,8 +147,11 @@ function frame(now) { | ||
| 143 | 147 | if (c.open) renderer.draw("gem", compose(c.x, 0.55, c.z, t / 10, 0.3, 0.3, 0.3), paint("game-gold")); |
| 144 | 148 | } |
| 145 | 149 | |
| 150 | for (const zn of cur.zones ?? []) drawZone(zn, t); | |
| 146 | 151 | 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")); | |
| 152 | for (const s of shots) renderer.draw("gem", compose(s.x, 0.35, s.z, t / 3, 0.18, 0.18, 0.18), paint(s.lit ? "game-gold" : "game-seed")); | |
| 153 | for (const fl of cur.flasks ?? []) drawFlask(fl, f); | |
| 154 | drawAim(heroes); | |
| 148 | 155 | |
| 149 | 156 | if (ghost) { |
| 150 | 157 | const g = ghostAt(t); |
| @@ -171,6 +178,71 @@ function frame(now) { | ||
| 171 | 178 | drawTags(labels); |
| 172 | 179 | } |
| 173 | 180 | |
| 181 | // ── Cheffry's Spice Rack, drawn: zones on the floor, jars in the air, the aim ── | |
| 182 | ||
| 183 | const spiceOf = (id) => game.rack.spices.find((s) => s.id === id); | |
| 184 | ||
| 185 | function footprint(def, x, z, ax, az, alpha) { | |
| 186 | const color = paint(def.paint); | |
| 187 | if (def.shape === "line") | |
| 188 | renderer.draw("box", compose(x, 0.01, z, Math.atan2(ax, az), def.width, 0.05, def.length), color, alpha); | |
| 189 | else renderer.draw("prism", compose(x, 0.01, z, 0, def.radius * 2, 0.04, def.radius * 2), color, alpha); | |
| 190 | } | |
| 191 | ||
| 192 | function drawZone(zn, t) { | |
| 193 | const def = spiceOf(zn.spice); | |
| 194 | if (!def) return; | |
| 195 | // A zone fades in its last second. | |
| 196 | const fade = Math.min(1, zn.ttl / 20); | |
| 197 | footprint(def, zn.x, zn.z, zn.ax, zn.az, 0.45 * fade); | |
| 198 | if (def.confuse && !reduced) | |
| 199 | for (let i = 0; i < 5; i++) { | |
| 200 | const a = t / 9 + i * 1.26; | |
| 201 | const r = def.radius * (0.35 + 0.15 * Math.sin(t / 5 + i)); | |
| 202 | renderer.draw("gem", compose(zn.x + Math.cos(a) * r, 0.4 + 0.2 * Math.sin(t / 4 + i), zn.z + Math.sin(a) * r, a, 0.45, 0.35, 0.45), paint(def.paint), 0.3 * fade); | |
| 203 | } | |
| 204 | if (def.ignite && !reduced) | |
| 205 | for (let i = 0; i < 4; i++) { | |
| 206 | const a = i * 1.57 + t / 20; | |
| 207 | const lick = 0.25 + 0.15 * Math.abs(Math.sin(t / 3 + i * 2)); | |
| 208 | renderer.draw("pyramid", compose(zn.x + Math.cos(a) * def.radius * 0.5, 0, zn.z + Math.sin(a) * def.radius * 0.5, a, 0.25, lick, 0.25), paint("game-gold"), 0.7 * fade); | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | function drawFlask(fl, f) { | |
| 213 | const def = spiceOf(fl.spice); | |
| 214 | if (!def) return; | |
| 215 | const p = Math.min(1, (fl.t + f * 2) / fl.flight); | |
| 216 | const x = lerp(fl.x0, fl.x1, p); | |
| 217 | const z = lerp(fl.z0, fl.z1, p); | |
| 218 | const y = 0.5 + game.rack.throw.arc * 4 * p * (1 - p); | |
| 219 | renderer.draw("gem", compose(x, y, z, p * 12, 0.3, 0.38, 0.3), paint(def.paint)); | |
| 220 | } | |
| 221 | ||
| 222 | /** The arc a throw would take from my chef to the pointer, and where it would burst. */ | |
| 223 | function landing(me) { | |
| 224 | const dx = aim.x - me.x; | |
| 225 | const dz = aim.z - me.z; | |
| 226 | const d = Math.hypot(dx, dz); | |
| 227 | const reach = Math.min(d, game.rack.throw.range); | |
| 228 | const ux = d > 1e-6 ? dx / d : me.fx; | |
| 229 | const uz = d > 1e-6 ? dz / d : me.fz; | |
| 230 | return { x: me.x + ux * reach, z: me.z + uz * reach, ux, uz }; | |
| 231 | } | |
| 232 | ||
| 233 | function drawAim(heroes) { | |
| 234 | const me = heroes.find((h) => h.id === heroId); | |
| 235 | const def = armed && spiceOf(armed); | |
| 236 | if (!me || !def || !aim || me.downed) return; | |
| 237 | const end = landing(me); | |
| 238 | for (let i = 1; i < 10; i++) { | |
| 239 | const p = i / 10; | |
| 240 | const y = 0.5 + game.rack.throw.arc * 4 * p * (1 - p); | |
| 241 | renderer.draw("gem", compose(lerp(me.x, end.x, p), y, lerp(me.z, end.z, p), 0, 0.1, 0.1, 0.1), paint(def.paint), 0.6); | |
| 242 | } | |
| 243 | footprint(def, end.x, end.z, -end.uz, end.ux, 0.25); | |
| 244 | } | |
| 245 | ||
| 174 | 246 | function drawMonster(m, t) { |
| 175 | 247 | const hurt = m.hurt ? [1, 1, 1] : null; |
| 176 | 248 | const face = Math.atan2(m.fx || 0, m.fz || 1); |
| @@ -190,6 +262,12 @@ function drawMonster(m, t) { | ||
| 190 | 262 | } else { |
| 191 | 263 | renderer.draw("gem", compose(m.x, 0, m.z, 0, 0.6, 0.6, 0.6), hurt ?? paint("game-wall")); |
| 192 | 264 | } |
| 265 | // Sneezing: little peppers orbit its head. | |
| 266 | if (m.dazed) | |
| 267 | for (let i = 0; i < 3; i++) { | |
| 268 | const a = (reduced ? 0 : t / 4) + i * 2.09; | |
| 269 | renderer.draw("gem", compose(m.x + Math.cos(a) * 0.35, 1.15, m.z + Math.sin(a) * 0.35, a, 0.12, 0.16, 0.12), paint("game-pepper")); | |
| 270 | } | |
| 193 | 271 | } |
| 194 | 272 | |
| 195 | 273 | const tagEls = new Map(); |
| @@ -256,13 +334,50 @@ const OUTCOME = { | ||
| 256 | 334 | timeout: "Kitchen's closed: out of time.", |
| 257 | 335 | abandoned: "Everyone left the table.", |
| 258 | 336 | }; |
| 337 | const ingredientName = (id) => game.rack.ingredients.find((g) => g.id === id)?.name ?? id; | |
| 338 | ||
| 339 | /** Arm the next jar in hand (Q), or the n-th; again past the last puts it away. */ | |
| 340 | function pickSpice(n) { | |
| 341 | const pack = mine()?.pack ?? []; | |
| 342 | const kinds = [...new Set(pack)]; | |
| 343 | if (kinds.length === 0) { | |
| 344 | armed = null; | |
| 345 | say("No spices in hand. Craft some at Cheffry's Spice Rack in the hub."); | |
| 346 | } else if (n !== undefined) armed = kinds[n] ?? armed; | |
| 347 | else { | |
| 348 | const i = kinds.indexOf(armed); | |
| 349 | armed = i + 1 < kinds.length ? kinds[i + 1] : i === -1 ? kinds[0] : null; | |
| 350 | } | |
| 351 | if (armed) say(`${spiceOf(armed).name} in hand — click or tap where to throw it.`); | |
| 352 | showSpices(); | |
| 353 | } | |
| 354 | ||
| 355 | function showSpices() { | |
| 356 | const me = mine(); | |
| 357 | const pack = me?.pack ?? []; | |
| 358 | if (armed && !pack.includes(armed)) armed = null; | |
| 359 | const counts = new Map(); | |
| 360 | for (const id of pack) counts.set(id, (counts.get(id) ?? 0) + 1); | |
| 361 | const hand = [...counts].map(([id, n]) => `${id === armed ? "▸ " : ""}${spiceOf(id)?.name ?? id}${n > 1 ? ` ×${n}` : ""}`); | |
| 362 | const haul = Object.entries(me?.haul ?? {}).map(([g, n]) => `${n} ${ingredientName(g)}`); | |
| 363 | const el = hud("spices"); | |
| 364 | if (el) el.textContent = [hand.length ? `Spices: ${hand.join(", ")}` : "", haul.length ? `Haul: ${haul.join(", ")}` : ""].filter(Boolean).join(" · "); | |
| 365 | const button = document.querySelector("[data-act=spice]"); | |
| 366 | if (button) { | |
| 367 | button.textContent = armed ? spiceOf(armed).name : pack.length ? `Spice (${pack.length})` : "No spice"; | |
| 368 | button.setAttribute("aria-pressed", armed ? "true" : "false"); | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 259 | 372 | function showHud() { |
| 260 | 373 | const me = mine(); |
| 374 | showSpices(); | |
| 261 | 375 | hud("score").textContent = `${cur.score} pts · ${cur.gold} gold · ${cur.kills} down`; |
| 262 | 376 | hud("hp").textContent = me |
| 263 | 377 | ? `${"♥".repeat(me.hp)}${"♡".repeat(Math.max(0, game.rules.hero.hp - me.hp))}${me.power ? ` · +${me.power} whisk` : ""}` |
| 264 | 378 | : "Watching"; |
| 265 | hud("party").textContent = cur.heroes | |
| 379 | const party = hud("party"); | |
| 380 | if (party) party.textContent = cur.heroes | |
| 266 | 381 | .map((h) => `${h.name} ${h.downed ? "(down)" : `${h.hp}♥`}`) |
| 267 | 382 | .join(" · "); |
| 268 | 383 | if (me?.downed && !cur.over) say("You're down! A teammate standing beside you brings you back."); |
| @@ -319,6 +434,11 @@ const held = new Set(); | ||
| 319 | 434 | function listen() { |
| 320 | 435 | if (heroId === null) return; |
| 321 | 436 | canvas.addEventListener("contextmenu", (e) => e.preventDefault()); |
| 437 | canvas.addEventListener("pointermove", (e) => { | |
| 438 | if (!armed) return; | |
| 439 | const rect = canvas.getBoundingClientRect(); | |
| 440 | aim = renderer.ground(e.clientX - rect.left, e.clientY - rect.top); | |
| 441 | }); | |
| 322 | 442 | canvas.addEventListener("pointerdown", (e) => { |
| 323 | 443 | if (!layout) return; |
| 324 | 444 | const rect = canvas.getBoundingClientRect(); |
| @@ -326,6 +446,13 @@ function listen() { | ||
| 326 | 446 | if (!g) return; |
| 327 | 447 | const p = clampTo(g.x, g.z); |
| 328 | 448 | if (e.button === 2 || e.shiftKey) return dodgeToward(p.x, p.z); |
| 449 | if (armed) { | |
| 450 | orderUp({ type: "throw", spice: armed, ...p }); | |
| 451 | armed = null; | |
| 452 | aim = null; | |
| 453 | showSpices(); | |
| 454 | return; | |
| 455 | } | |
| 329 | 456 | const m = nearestMonster(p.x, p.z, 0.9); |
| 330 | 457 | marker = { ...p, at: performance.now(), dodge: false }; |
| 331 | 458 | orderUp(m ? { type: "attack", target: m.id } : { type: "move", ...p }); |
| @@ -346,6 +473,26 @@ function listen() { | ||
| 346 | 473 | } else if (k === "e") { |
| 347 | 474 | e.preventDefault(); |
| 348 | 475 | orderUp({ type: "ability" }); |
| 476 | } else if (k === "q") { | |
| 477 | e.preventDefault(); | |
| 478 | pickSpice(); | |
| 479 | } else if (k === "1" || k === "2" || k === "3") { | |
| 480 | e.preventDefault(); | |
| 481 | pickSpice(Number(k) - 1); | |
| 482 | } else if (k === "Enter" && armed) { | |
| 483 | // No pointer: lob it ahead, two-thirds of the way out. | |
| 484 | e.preventDefault(); | |
| 485 | const me = mine(); | |
| 486 | if (!me) return; | |
| 487 | const [dx, dz] = lastDir ?? [me.fx, me.fz]; | |
| 488 | const reach = (game.rack.throw.range * 2) / 3; | |
| 489 | orderUp({ type: "throw", spice: armed, ...clampTo(me.x + dx * reach, me.z + dz * reach) }); | |
| 490 | armed = null; | |
| 491 | aim = null; | |
| 492 | showSpices(); | |
| 493 | } else if (k === "Escape" && armed) { | |
| 494 | armed = null; | |
| 495 | showSpices(); | |
| 349 | 496 | } |
| 350 | 497 | }); |
| 351 | 498 | document.addEventListener("keyup", (e) => { |
| @@ -355,6 +502,7 @@ function listen() { | ||
| 355 | 502 | document.querySelector("[data-act=attack]")?.addEventListener("click", attackNearest); |
| 356 | 503 | document.querySelector("[data-act=dodge]")?.addEventListener("click", dodgeAhead); |
| 357 | 504 | document.querySelector("[data-act=ability]")?.addEventListener("click", () => orderUp({ type: "ability" })); |
| 505 | document.querySelector("[data-act=spice]")?.addEventListener("click", () => pickSpice()); | |
| 358 | 506 | } |
| 359 | 507 | |
| 360 | 508 | // Held keys walk a couple of tiles ahead, again while they stay held. |
new above
offline