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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

cheffry-james/cheffry-s-dungeon:cheffry-james/7-from-outside-day-4-build-cheffry-s-spice → main 2 commits fork

  • Merged. cheffry-james/7-from-outside-day-4-build-cheffry-s-spice was merged into main as 76642a6.

Review

Sign in to review this change.
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
3838 Baguette Knight (winds up and charges), the Pepper Imp (keeps its distance
3939 and shoots) — plus Skewer Spikes and chests of coins, truffles, soup and
4040 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.
4160 - **The daily board.** Everyone gets the same seed each UTC day. Finished
4261 runs land on the leaderboard with a ghost (the party's path, sampled every
4362 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
4766 ## Content is data
4867
4968 `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
5272 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.
5476
5577 ## The networking
5678
5779 A delve is `src/sim.ts`: a pure, seeded, 20 Hz tick with no imports, so the
5880 same seed and the same orders make the same run anywhere. The server owns
5981 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
6183 `/delves/<id>/events`. The page loads the very same sim as `/sim.js` to lay
6284 out rooms. Instances share nothing but the database, so they would split
6385 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.
7294
7395 | file | what it is |
7496 | ---------------------------------- | -------------------------------------------------------------- |
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 |
7799 | `src/content.ts`, `content/` | the game as JSON, checked whole at boot |
78100 | `src/hub.ts` | the square in memory: who stands where, emotes, chat |
79101 | `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:
129151 - **anon** — anyone. Reads.
130152 - **user** — signed in to the forge and able to see this app. Stands in the
131153 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.
133155 - **admin** — able to write this repository. Removes any note or run.
134156
135157 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
@@ -149,10 +171,13 @@ curl -X DELETE https://<app>/api/notes/1
149171 ```sh
150172 curl https://<app>/api/runs # today's board, best first
151173 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"}'
152176 ```
153177
154178 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`:
156181
157182 ```sh
158183 claude mcp add --transport http cheffry-s-dungeon https://<app>/mcp
content/hub.json +1
@@ -3,6 +3,7 @@
33 "props": [
44 { "id": "fountain", "shape": "prism", "x": 11, "z": 8, "w": 2.4, "h": 0.8, "d": 2.4, "paint": "game-water" },
55 { "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" },
67 { "id": "pantry-door", "shape": "box", "x": 18, "z": 3, "w": 2.2, "h": 2.4, "d": 0.4, "paint": "game-exit" },
78 { "id": "graffiti-wall", "shape": "box", "x": 11, "z": 0.6, "w": 10, "h": 2, "d": 0.4, "paint": "game-wall" },
89 { "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 @@
11 {
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.",
33 "loot": [
44 { "id": "coins", "name": "Pile of Coins", "weight": 6, "gold": 50 },
55 { "id": "truffle", "name": "Golden Truffle", "weight": 2, "gold": 150 },
66 { "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" }
810 ]
911 }
content/monsters.json +10 −4
@@ -1,5 +1,5 @@
11 {
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.",
33 "monsters": [
44 {
55 "id": "meatball",
@@ -11,7 +11,9 @@
1111 "speed": 0.07,
1212 "damage": 1,
1313 "aggro": 7,
14 "score": 20
14 "score": 20,
15 "drops": "slime-jelly",
16 "dropChance": 0.5
1517 },
1618 {
1719 "id": "baguette",
@@ -27,7 +29,9 @@
2729 "chargeSpeed": 0.3,
2830 "chargeTicks": 16,
2931 "stun": 30,
30 "score": 35
32 "score": 35,
33 "drops": "stale-crust",
34 "dropChance": 0.6
3135 },
3236 {
3337 "id": "pepper",
@@ -43,7 +47,9 @@
4347 "keepAway": 3,
4448 "cooldown": 45,
4549 "shotSpeed": 0.18,
46 "score": 30
50 "score": 30,
51 "drops": "ghost-pepper",
52 "dropChance": 0.6
4753 }
4854 ]
4955 }
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 @@
5656 "list": "anon"
5757 }
5858 },
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 },
5979 "audit": {
6080 "api": "/api/audit",
6181 "can": {
contract/openapi.json +415 −1
@@ -750,7 +750,7 @@
750750 "application/json": {
751751 "schema": {
752752 "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.",
754754 "properties": {
755755 "type": {
756756 "enum": [
@@ -758,6 +758,7 @@
758758 "attack",
759759 "dodge",
760760 "ability",
761 "throw",
761762 "stop"
762763 ]
763764 },
@@ -769,6 +770,13 @@
769770 },
770771 "target": {
771772 "type": "integer"
773 },
774 "spice": {
775 "enum": [
776 "pepper-bomb",
777 "salt-the-earth",
778 "hot-sauce"
779 ]
772780 }
773781 },
774782 "required": [
@@ -856,6 +864,412 @@
856864 }
857865 }
858866 },
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 },
8591273 "/api/runs": {
8601274 "get": {
8611275 "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";
1515 import * as hub from "./hub.ts";
1616 import { SAY_MAX, type Avatar, type AvatarView, type Line } from "./hub.ts";
1717 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";
1919 import type { Who } from "./who.ts";
2020
2121 export interface Note {
@@ -31,7 +31,7 @@ export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
3131 export type Result<T> =
3232 | { ok: true; value: T }
3333 | { 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 });
3535 const fail = <T>(error: Failure, message: string): Result<T> => ({
3636 ok: false,
3737 error,
@@ -533,8 +533,13 @@ function openOne(
533533 const now = Date.now();
534534 const before = kitchen.heroOfAuthor(who.id);
535535 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));
538543 emit("delve.opened", { id: delve.id });
539544 return ok(kitchen.view(delve));
540545 }
@@ -582,9 +587,13 @@ export const joinDelve = (
582587 who: Who,
583588 input: { delve: number; class?: string },
584589 ): Result<HeroView> =>
585 audited(db, who, "hero", "create", undefined, join(who, input));
590 audited(db, who, "hero", "create", undefined, join(db, who, input));
586591
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> {
588597 if (who.role === "anon" || permit(who, "hero", "create") !== "yes")
589598 return refuse(who, "Sign in to join a party.", "You may not join a party.");
590599 if (!Number.isInteger(input.delve))
@@ -600,7 +609,7 @@ function join(who: Who, input: { delve: number; class?: string }): Result<HeroVi
600609 if (!kitchen.joinable(delve))
601610 return fail("invalid", `That party is full: ${kitchen.PARTY_MAX} chefs a table.`);
602611 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));
604613 emit("delve.changed", { id: delve.id });
605614 return ok(kitchen.heroView(hero));
606615 }
@@ -632,11 +641,18 @@ export function readOrder(
632641 : "A target is a monster's id, a whole number.";
633642 }
634643 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 }
635651 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.";
637653 }
638654
639 /** Give your hero an order: move, attack, dodge or stop. */
655 /** Give your hero an order: move, attack, dodge, ability, throw or stop. */
640656 export const commandHero = (
641657 db: Database,
642658 who: Who,
@@ -819,3 +835,234 @@ export function recordRun(db: Database, delve: Delve): Run | undefined {
819835 return undefined;
820836 }
821837 }
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 @@
11 /**
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.
44 */
55 import { expect, test } from "bun:test";
66 import { readFileSync } from "node:fs";
@@ -16,6 +16,7 @@ const files = () => ({
1616 rooms: raw("rooms"),
1717 hub: raw("hub"),
1818 classes: raw("classes"),
19 spices: raw("spices"),
1920 });
2021 const withClasses = (mutate: (c: any) => void) => {
2122 const f = files();
@@ -72,3 +73,48 @@ test("an ability is named, timed, and of a kind the sim knows", () => {
7273 expect(bad.some((p) => p.includes("name is missing"))).toBe(true);
7374 expect(bad.some((p) => p.includes("distance must be a positive number"))).toBe(true);
7475 });
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 @@
11 /**
22 * The game's content, as data: `content/*.json` — the rules, the monsters,
3 * the traps, the loot, the rooms and the hub's furniture — read once at
3 * the traps, the loot, the rooms, the classes, the Spice Rack and the
4 * hub's furniture — read once at
45 * boot and checked whole. A new monster, room or chest drop is a change to
56 * a JSON file, never to code (a new *brain* is code: `behavior` in
67 * `src/sim.ts`).
@@ -39,6 +40,8 @@ export interface Game extends Content {
3940
4041 const BEHAVIORS = ["chase", "charge", "shoot"];
4142 const ABILITY_KINDS = ["bulwark", "volley", "blink"];
43 const SPICE_SHAPES = ["circle", "line"];
44 const SPICE_EFFECTS = ["confuse", "slow", "damage", "ignite"];
4245 const RESERVED = "#.SEC";
4346
4447 const isObject = (v: unknown): v is Record<string, unknown> =>
@@ -56,6 +59,7 @@ export function check(files: {
5659 rooms: unknown;
5760 hub: unknown;
5861 classes: unknown;
62 spices: unknown;
5963 }): string[] {
6064 const problems: string[] = [];
6165 const say = (where: string, what: string) => problems.push(`${where}: ${what}`);
@@ -109,6 +113,14 @@ export function check(files: {
109113 return list;
110114 };
111115
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
112124 const monsters = listOf(files.monsters, "monsters");
113125 ids("monsters", monsters);
114126 monsters.forEach((m, i) => {
@@ -121,6 +133,12 @@ export function check(files: {
121133 need(where, m, ["score"], whole);
122134 if (m.behavior === "charge") need(where, m, ["windup", "chargeSpeed", "chargeTicks", "stun"]);
123135 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");
124142 });
125143
126144 const traps = listOf(files.traps, "traps");
@@ -142,6 +160,7 @@ export function check(files: {
142160 for (const key of ["gold", "heal", "power"])
143161 if (l[key] !== undefined && !positive(l[key]))
144162 say(`loot[${i}]`, `${key} must be a positive number when given`);
163 if (l.ingredient !== undefined) ingredient(`loot[${i}]`, "ingredient", l.ingredient);
145164 });
146165
147166 const rooms = listOf(files.rooms, "rooms");
@@ -216,6 +235,47 @@ export function check(files: {
216235 const paints = new Set(
217236 tokens.filter((t) => t.name.startsWith("--color-")).map((t) => t.name.slice(8)),
218237 );
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 });
219279 const props = listOf(files.hub, "props");
220280 ids("props", props.map((p) => (isObject(p) ? { ...p, name: p.id } : p)));
221281 props.forEach((p, i) => {
@@ -240,6 +300,7 @@ function load(): Game {
240300 rooms: read("rooms"),
241301 hub: read("hub"),
242302 classes: read("classes"),
303 spices: read("spices"),
243304 };
244305 const problems = check(files);
245306 if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`);
@@ -252,6 +313,7 @@ function load(): Game {
252313 loot: f.loot!.loot as Game["loot"],
253314 rooms: f.rooms!.rooms as Game["rooms"],
254315 classes: f.classes!.classes as Game["classes"],
316 rack: f.spices as unknown as Game["rack"],
255317 hub: { ...hub, props: f.hub!.props as Prop[] },
256318 };
257319 }
src/contract.test.ts +49
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import {
1212 addNote,
1313 commandHero,
14 craftSpice,
1415 deleteNote,
1516 deleteRun,
1617 enterHub,
@@ -19,7 +20,9 @@ import {
1920 getDelve,
2021 getHero,
2122 getNote,
23 getPantry,
2224 getRun,
25 getSpice,
2326 joinDelve,
2427 leaveDelve,
2528 leaveHub,
@@ -28,9 +31,12 @@ import {
2831 listDelves,
2932 listHeroes,
3033 listNotes,
34 listPantry,
3135 listRuns,
36 listSpices,
3237 openDelve,
3338 today,
39 tossSpice,
3440 updateAvatar,
3541 updateNote,
3642 } from "./actions.ts";
@@ -90,6 +96,26 @@ const runBy = (authorId: string): number =>
9096 )
9197 .get(today(), authorId)!.id;
9298
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
93119 /** One door per verb an action serves: did it let `who` through, on `row`? */
94120 const doors: Record<
95121 string,
@@ -136,6 +162,29 @@ const doors: Record<
136162 create: () => false,
137163 update: () => false,
138164 },
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 },
139188 // The trail is written by the app itself, never through a door: there
140189 // is no action that creates, changes or removes a row of it.
141190 audit: {
src/db.ts +18
@@ -57,6 +57,24 @@ export const migrations: string[] = [
5757 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
5858 )`,
5959 `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)`,
6078 ];
6179
6280 export function openDb(dir = dataDir): Database {
src/delves.ts +34 −4
@@ -9,7 +9,9 @@
99 * The server is the authority: players send orders, the instance steps,
1010 * and every watcher is told the result ten times a second. What a delve
1111 * 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`).
1315 *
1416 * Who may do what here is the actions' business; this is the kitchen.
1517 */
@@ -26,6 +28,8 @@ import {
2628 removeHero,
2729 snapshot,
2830 step,
31 type Hero,
32 type Jar,
2933 type Order,
3034 type World,
3135 } from "./sim.ts";
@@ -49,6 +53,8 @@ export interface HeroRow {
4953 /** The class from content/classes.json this chef delves as. */
5054 readonly classId: string;
5155 seen: number;
56 /** What they took home is written: once, and only once. */
57 settled: boolean;
5258 }
5359
5460 export interface Delve {
@@ -66,6 +72,8 @@ export interface Delve {
6672 readonly crew: string[];
6773 ended: number | null;
6874 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;
6977 }
7078
7179 /** A delve as the page and the API show it. */
@@ -138,11 +146,15 @@ export const heroView = (h: HeroRow): HeroView => ({
138146 classId: h.classId,
139147 });
140148
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 */
142153 export function open(
143154 host: { id: string; name: string },
144155 now: number,
145156 onEnd: (delve: Delve) => void,
157 onSettle: (row: HeroRow, hero: Hero) => void = () => {},
146158 ): Delve {
147159 const day = dayOf(now);
148160 const seed = daySeed(day);
@@ -160,6 +172,7 @@ export function open(
160172 crew: [],
161173 ended: null,
162174 onEnd,
175 onSettle,
163176 };
164177 delves.set(delve.id, delve);
165178 loop ??= setInterval(() => run(Date.now()), TICK_MS);
@@ -167,12 +180,13 @@ export function open(
167180 return delve;
168181 }
169182
170 /** A chef takes a place at the table. */
183 /** A chef takes a place at the table, with the jars they carry in. */
171184 export function enlist(
172185 delve: Delve,
173186 who: { id: string; name: string },
174187 now: number,
175188 classId: string = "knight",
189 pack: readonly Jar[] = [],
176190 ): HeroRow {
177191 const known = game.classes.some((c) => c.id === classId) ? classId : "knight";
178192 const row: HeroRow = {
@@ -182,9 +196,10 @@ export function enlist(
182196 author_name: who.name,
183197 classId: known,
184198 seen: now,
199 settled: false,
185200 };
186201 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);
188203 if (!delve.crew.includes(who.name)) delve.crew.push(who.name);
189204 send(delve);
190205 return row;
@@ -195,6 +210,7 @@ export function dismiss(row: HeroRow, now: number): void {
195210 heroes.delete(row.id);
196211 const delve = delves.get(row.delve_id);
197212 if (delve === undefined) return;
213 settle(delve, row);
198214 removeHero(delve.world, row.id);
199215 if (delve.ended === null && delve.world.heroes.length === 0) {
200216 finish(delve.world, "abandoned");
@@ -209,6 +225,19 @@ export function command(row: HeroRow, o: Order, now: number): void {
209225 order(delve.world, row.id, o);
210226 }
211227
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
212241 const send = (delve: Delve): void => {
213242 delve.bus.emit("tick", snapshot(delve.world));
214243 };
@@ -217,6 +246,7 @@ function end(delve: Delve, now: number): void {
217246 if (delve.ended !== null) return;
218247 delve.ended = now;
219248 send(delve);
249 for (const row of partyOf(delve)) settle(delve, row);
220250 try {
221251 delve.onEnd(delve);
222252 } catch (error) {
src/game.test.ts +125 −2
@@ -9,17 +9,23 @@ import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import {
1111 commandHero,
12 craftSpice,
1213 enterHub,
1314 getRun,
1415 hubChat,
1516 joinDelve,
1617 leaveDelve,
1718 listRuns,
19 listPantry,
20 listSpices,
1821 openDelve,
22 packOf,
1923 readOrder,
24 settleHero,
2025 recordRun,
2126 sweepHub,
2227 today,
28 tossSpice,
2329 updateAvatar,
2430 } from "./actions.ts";
2531 import { game } from "./content.ts";
@@ -30,7 +36,7 @@ import type { Who } from "./who.ts";
3036
3137 const db = openDb(mkdtempSync(join(tmpdir(), "app-game-")));
3238 let n = 0;
33 const chef = (): Who & { id: string } => {
39 const chef = (): { role: "user"; id: string; name: string } => {
3440 const id = `g${++n}`;
3541 return { role: "user", id, name: `chef${n}` };
3642 };
@@ -222,6 +228,123 @@ test("an unknown class is refused; no class means knight", () => {
222228 test("an ability is an order the sim can take", () => {
223229 expect(readOrder({ type: "ability" }, { w: 10, h: 10 })).toEqual({ type: "ability" });
224230 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.",
226232 );
227233 });
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 @@
99 import type { Database } from "bun:sqlite";
1010 import {
1111 addNote,
12 craftSpice,
1213 deleteNote,
1314 listDelves,
1415 listNotes,
16 listPantry,
1517 listRuns,
18 listSpices,
19 ok,
1620 type Result,
1721 } from "./actions.ts";
22 import { game } from "./content.ts";
1823 import { title, version } from "./env.ts";
1924 import { fields } from "./http.ts";
2025 import type { Who } from "./who.ts";
@@ -94,6 +99,34 @@ export const tools: readonly Tool[] = [
9499 },
95100 run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }),
96101 },
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 },
97130 ];
98131
99132 /** 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 {
4747 <button class="button button-big" type="button" data-act="attack">Attack</button>
4848 <button class="button button-big button-alt" type="button" data-act="dodge">Dodge</button>
4949 <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>
5051 </div>`;
5152 return layout(
5253 `<h2>${escape(delve.host_name)}'s table <small class="muted mono">${escape(delve.day)}</small></h2>
5354 <section class="stage" aria-label="The dungeon">
5455 <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>
5657 <div class="tags" aria-hidden="true"></div>
5758 <div class="hud">
5859 <p data-hud="room">${escape(room?.name ?? delve.room)} · floor ${delve.depth + 1} of ${delve.rooms}</p>
5960 <p data-hud="hp">${hero === undefined ? "Watching" : ""}</p>
6061 <p data-hud="score">${delve.score} pts</p>
6162 <p data-hud="ghost"></p>
63 <p data-hud="spices"></p>
6264 </div>
6365 <div class="over" id="over"${over === null ? " hidden" : ""}>
6466 <p class="over-title" data-outcome>${over === null ? "" : escape(OUTCOME[over] ?? over)}</p>
@@ -82,6 +84,7 @@ export function delvePage(p: DelvePage): string {
8284 <li><b>Fight</b>: click a monster (your chef keeps swinging), or <kbd>Space</kbd> for the nearest.</li>
8385 <li><b>Dodge</b>: shift-click or right-click where to roll, or <kbd>K</kbd>. Rolling dodges bites, charges, seeds and spikes.</li>
8486 <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>
8588 <li><b>Chests</b> open to the touch. <b>Downed?</b> A teammate standing beside you brings you back.</li>
8689 <li>Clear a room and the door opens. Five floors, then the Pass.</li>
8790 </ul>
src/pages/hub.ts +67 −1
@@ -6,7 +6,13 @@
66 * `data-live` region, so it stays current as the page's events arrive.
77 */
88
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";
1016 import { game, gameHref } from "../content.ts";
1117 import type { DelveView, HeroView } from "../delves.ts";
1218 import { escape } from "../http.ts";
@@ -23,6 +29,9 @@ export interface HubPage {
2329 readonly delves: readonly DelveView[];
2430 readonly runs: readonly Run[];
2531 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[];
2635 /** The hero this person is playing right now, if any. */
2736 readonly playing?: HeroView | undefined;
2837 readonly notice?: string | undefined;
@@ -46,6 +55,7 @@ export function hubPage(p: HubPage): string {
4655 <div class="grid">
4756 ${tables(p)}
4857 ${dressingRoom(p)}
58 ${spiceRack(p)}
4959 ${board(p)}
5060 ${square(p)}
5161 ${wall(p)}
@@ -131,6 +141,62 @@ function dressingRoom(p: HubPage): string {
131141 </section>`;
132142 }
133143
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
134200 function board(p: HubPage): string {
135201 const rows = p.runs
136202 .map(
src/routes.ts +101 −2
@@ -17,6 +17,7 @@ import {
1717 addNote,
1818 BODY_MAX,
1919 commandHero,
20 craftSpice,
2021 deleteNote,
2122 deleteRun,
2223 enterHub,
@@ -25,7 +26,9 @@ import {
2526 getDelve,
2627 getHero,
2728 getNote,
29 getPantry,
2830 getRun,
31 getSpice,
2932 hubChat,
3033 joinDelve,
3134 leaveDelve,
@@ -35,9 +38,12 @@ import {
3538 listDelves,
3639 listHeroes,
3740 listNotes,
41 listPantry,
3842 listRuns,
43 listSpices,
3944 openDelve,
4045 today,
46 tossSpice,
4147 updateAvatar,
4248 updateNote,
4349 watchDelve,
@@ -214,15 +220,39 @@ export const HERO: Schema = {
214220 const ORDER: Schema = {
215221 type: "object",
216222 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.",
218224 properties: {
219 type: { enum: ["move", "attack", "dodge", "ability", "stop"] },
225 type: { enum: ["move", "attack", "dodge", "ability", "throw", "stop"] },
220226 x: { type: "number" },
221227 z: { type: "number" },
222228 target: { type: "integer" },
229 spice: { enum: game.rack.spices.map((sp) => sp.id) },
223230 },
224231 required: ["type"],
225232 };
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 };
226256 export const RUN: Schema = {
227257 type: "object",
228258 properties: {
@@ -322,6 +352,10 @@ export const routes: readonly Route[] = [
322352 delves: or(listDelves(c.db, c.who)),
323353 runs: or(listRuns(c.db, c.who, { day, limit: 10 })),
324354 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 })),
325359 playing:
326360 c.who.role === "anon" ? undefined : heroOfAuthor(c.who.id),
327361 notice,
@@ -435,6 +469,20 @@ export const routes: readonly Route[] = [
435469 : back(joined.message, Number.isInteger(id) ? `/delve/${id}` : "/");
436470 },
437471 },
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 },
438486 {
439487 method: "POST",
440488 path: "/heroes/:id/leave",
@@ -677,6 +725,57 @@ export const routes: readonly Route[] = [
677725 },
678726 },
679727
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
680779 // The board: finished runs, best first, with their ghosts.
681780 {
682781 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 () =>
137137 "list_notes",
138138 "add_note",
139139 "delete_note",
140 "spice_rack",
141 "craft_spice",
140142 ]);
141143 const call = (name: string, args: unknown, who = {}) =>
142144 rpc(
@@ -461,3 +463,45 @@ test("joining names a class by form and by API; a bogus class is refused", async
461463 };
462464 expect(party.party.map((h) => h.classId).sort()).toEqual(["archer", "knight", "mage"]);
463465 });
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", () => {
302302 expect(w.shots.length).toBe(first);
303303 expect(seen.some((h) => h.type === "ability")).toBe(false);
304304 });
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 {
4747 readonly keepAway?: number;
4848 readonly cooldown?: number;
4949 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;
5053 }
5154 export interface TrapDef {
5255 readonly id: string;
@@ -63,6 +66,8 @@ export interface LootDef {
6366 readonly gold?: number;
6467 readonly heal?: number;
6568 readonly power?: number;
69 /** A Spice Rack ingredient, for the chef who opened the chest. */
70 readonly ingredient?: string;
6671 }
6772 export interface RoomDef {
6873 readonly id: string;
@@ -111,6 +116,51 @@ export interface ClassDef {
111116 readonly revive: number;
112117 readonly ability: ClassAbility;
113118 }
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 }
114164 export interface Content {
115165 readonly rules: {
116166 readonly tickHz: number;
@@ -124,10 +174,17 @@ export interface Content {
124174 readonly loot: readonly LootDef[];
125175 readonly rooms: readonly RoomDef[];
126176 readonly classes: readonly ClassDef[];
177 readonly rack: SpiceRack;
127178 }
128179
129180 // ── The world ──
130181
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
131188 export interface Hero {
132189 readonly id: number;
133190 readonly name: string;
@@ -154,6 +211,14 @@ export interface Hero {
154211 invuln: number;
155212 downed: boolean;
156213 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;
157222 }
158223 export interface Monster {
159224 readonly id: number;
@@ -167,6 +232,14 @@ export interface Monster {
167232 dz: number;
168233 cd: number;
169234 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;
170243 }
171244 export interface Shot {
172245 readonly id: number;
@@ -175,10 +248,41 @@ export interface Shot {
175248 readonly dx: number;
176249 readonly dz: number;
177250 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";
181254 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;
182286 }
183287 export interface Chest {
184288 readonly id: number;
@@ -212,6 +316,9 @@ export type Happening =
212316 | { readonly type: "slain"; readonly monster: number; readonly kind: string }
213317 | { readonly type: "loot"; readonly hero: number; readonly loot: string }
214318 | { 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 }
215322 | { readonly type: "open" }
216323 | { readonly type: "room"; readonly depth: number; readonly room: string }
217324 | { readonly type: "over"; readonly outcome: Outcome };
@@ -228,6 +335,8 @@ export interface World {
228335 heroes: Hero[];
229336 monsters: Monster[];
230337 shots: Shot[];
338 flasks: Flask[];
339 zones: Zone[];
231340 chests: Chest[];
232341 gold: number;
233342 kills: number;
@@ -242,6 +351,7 @@ export type Order =
242351 | { readonly type: "attack"; readonly target?: number | undefined }
243352 | { readonly type: "dodge"; readonly x: number; readonly z: number }
244353 | { readonly type: "ability" }
354 | { readonly type: "throw"; readonly spice: string; readonly x: number; readonly z: number }
245355 | { readonly type: "stop" };
246356
247357 // ── Randomness: mulberry32, its state kept in the world ──
@@ -326,6 +436,8 @@ const roomById = (content: Content, id: string): RoomDef => {
326436 };
327437 const monsterDef = (content: Content, kind: string): MonsterDef =>
328438 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);
329441
330442 /** The class a hero delves as: unknown ids fall back to the knight. */
331443 export const classOf = (content: Content, classId: string): ClassDef => {
@@ -370,6 +482,8 @@ export function createWorld(content: Content, seed: number): World {
370482 heroes: [],
371483 monsters: [],
372484 shots: [],
485 flasks: [],
486 zones: [],
373487 chests: [],
374488 gold: 0,
375489 kills: 0,
@@ -387,6 +501,8 @@ function enter(content: Content, world: World, depth: number): void {
387501 world.layout = layoutOf(content, roomById(content, world.plan[depth]!));
388502 world.exitOpen = false;
389503 world.shots = [];
504 world.flasks = [];
505 world.zones = [];
390506 world.monsters = world.layout.monsters.map((m) => ({
391507 id: world.nextId++,
392508 kind: m.kind,
@@ -399,6 +515,10 @@ function enter(content: Content, world: World, depth: number): void {
399515 dz: 0,
400516 cd: Math.floor(random(world) * 30),
401517 hurt: 0,
518 dazed: 0,
519 dazedBy: null,
520 slow: 1,
521 lastBy: null,
402522 }));
403523 world.chests = world.layout.chests.map((c) => ({
404524 id: world.nextId++,
@@ -427,13 +547,17 @@ function place(world: World, hero: Hero, i: number): void {
427547 hero.fz = 0;
428548 }
429549
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 */
431554 export function addHero(
432555 content: Content,
433556 world: World,
434557 id: number,
435558 name: string,
436559 classId: string = "knight",
560 pack: readonly Jar[] = [],
437561 ): Hero {
438562 const cls = classOf(content, classId);
439563 const hero: Hero = {
@@ -461,6 +585,14 @@ export function addHero(
461585 invuln: cls.invuln,
462586 downed: false,
463587 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,
464596 };
465597 place(world, hero, world.heroes.length);
466598 world.heroes.push(hero);
@@ -485,6 +617,7 @@ export function order(world: World, heroId: number, o: Order): void {
485617 } else hero.wantSwing = true;
486618 } else if (o.type === "dodge") hero.wantDodge = { x: o.x, z: o.z };
487619 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 };
488621 else {
489622 hero.goal = null;
490623 hero.target = null;
@@ -547,15 +680,26 @@ export function step(content: Content, world: World): Happening[] {
547680 if (world.over !== null) return world.happened;
548681 world.tick++;
549682 for (const hero of world.heroes) stepHero(content, world, hero);
683 stepFlasks(content, world);
684 stepZones(content, world);
550685 for (const monster of world.monsters) stepMonster(content, world, monster);
551686 stepShots(content, world);
552687 stepTraps(content, world);
553688
554689 const slain = world.monsters.filter((m) => m.hp <= 0);
555690 for (const m of slain) {
691 const def = monsterDef(content, m.kind);
556692 world.kills++;
557 world.score += monsterDef(content, m.kind).score;
693 world.score += def.score;
558694 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 }
559703 }
560704 if (slain.length > 0) world.monsters = world.monsters.filter((m) => m.hp > 0);
561705 if (!world.exitOpen && world.monsters.length === 0) {
@@ -611,6 +755,7 @@ function stepHero(content: Content, world: World, hero: Hero): void {
611755 if (hero.cooldown > 0) hero.cooldown--;
612756 if (hero.dodgeCd > 0) hero.dodgeCd--;
613757 if (hero.abilityCd > 0) hero.abilityCd--;
758 if (hero.throwCd > 0) hero.throwCd--;
614759 if (hero.invuln > 0) hero.invuln--;
615760 if (hero.swing > 0) hero.swing--;
616761
@@ -695,6 +840,12 @@ function stepHero(content: Content, world: World, hero: Hero): void {
695840 if (hero.abilityCd === 0 && hero.dodge === 0) useAbility(content, world, hero, cls);
696841 }
697842
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
698849 // Treasure opens to the touch.
699850 for (const chest of world.chests)
700851 if (chest.open === null && dist(hero, chest) < 0.8) {
@@ -704,6 +855,8 @@ function stepHero(content: Content, world: World, hero: Hero): void {
704855 world.score += loot.gold ?? 0;
705856 hero.hp = Math.min(rules.hp, hero.hp + (loot.heal ?? 0));
706857 hero.power += loot.power ?? 0;
858 if (loot.ingredient !== undefined)
859 hero.haul[loot.ingredient] = (hero.haul[loot.ingredient] ?? 0) + 1;
707860 world.happened.push({ type: "loot", hero: hero.id, loot: loot.id });
708861 }
709862 }
@@ -722,6 +875,7 @@ function swing(content: Content, world: World, hero: Hero, rules: HeroRules): vo
722875 if (facing < 0.2 && d > 0.5) continue;
723876 m.hp -= damage;
724877 m.hurt = 4;
878 m.lastBy = hero.id;
725879 if (dir !== undefined) slide(world, m, dir.x * 0.4, dir.z * 0.4, def.radius);
726880 world.happened.push({ type: "hit", by: hero.id, on: m.id, damage });
727881 }
@@ -792,6 +946,171 @@ function useAbility(content: Content, world: World, hero: Hero, cls: ClassDef):
792946 }
793947 }
794948
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
7951114 function roll(content: Content, world: World) {
7961115 const total = content.loot.reduce((sum, l) => sum + l.weight, 0);
7971116 let pick = random(world) * total;
@@ -836,6 +1155,12 @@ function stepMonster(content: Content, world: World, m: Monster): void {
8361155 const def = monsterDef(content, m.kind);
8371156 if (m.hurt > 0) m.hurt--;
8381157 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;
8391164 const prey = nearestHero(world, m);
8401165 if (prey === undefined) return;
8411166 const d = dist(m, prey);
@@ -844,7 +1169,7 @@ function stepMonster(content: Content, world: World, m: Monster): void {
8441169
8451170 if (def.behavior === "chase") {
8461171 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);
8481173 // A bite, then a bounce back: room to breathe, and to swing.
8491174 if (touching && hurt(content, world, prey, def.damage) && dir !== undefined)
8501175 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 {
8591184 m.dx = dir.x;
8601185 m.dz = dir.z;
8611186 } 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);
8631188 } else if (m.state === "windup") {
8641189 if (--m.t <= 0) {
8651190 m.state = "charge";
8661191 m.t = def.chargeTicks ?? 15;
8671192 }
8681193 } else if (m.state === "charge") {
869 const speed = def.chargeSpeed ?? 0.3;
1194 const speed = (def.chargeSpeed ?? 0.3) * pace;
8701195 const wall = slide(world, m, m.dx * speed, m.dz * speed, def.radius);
8711196 for (const h of world.heroes)
8721197 if (dist(h, m) < def.radius + content.rules.hero.radius)
@@ -890,8 +1215,8 @@ function stepMonster(content: Content, world: World, m: Monster): void {
8901215 if (d >= def.aggro || dir === undefined) return;
8911216 const keep = def.keepAway ?? 3;
8921217 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);
8951220 if (m.cd === 0 && d <= range + 0.5) {
8961221 const speed = def.shotSpeed ?? 0.18;
8971222 world.shots.push({
@@ -915,15 +1240,25 @@ function stepShots(content: Content, world: World): void {
9151240 s.z += s.dz;
9161241 s.ttl--;
9171242 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") {
9201254 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;
9221256 const def = monsterDef(content, m.kind);
9231257 if (dist(s, m) > 0.35 + def.radius) continue;
9241258 const dir = toward(s, m);
9251259 m.hp -= s.damage;
9261260 m.hurt = 4;
1261 m.lastBy = s.from === "hero" ? s.by : (s.credit ?? null);
9271262 if (dir !== undefined) slide(world, m, dir.x * 0.3, dir.z * 0.3, def.radius);
9281263 world.happened.push({ type: "hit", by: s.by, on: m.id, damage: s.damage });
9291264 s.ttl = 0;
@@ -941,7 +1276,7 @@ function stepTraps(content: Content, world: World): void {
9411276 for (const hero of world.heroes) {
9421277 // A monster's seed splats on whoever it reaches, unless they are rolling through it.
9431278 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)
9451280 return true;
9461281 hurt(content, world, hero, s.damage);
9471282 return false;
@@ -991,6 +1326,8 @@ export function snapshot(world: World) {
9911326 hit: h.invuln > 0 && h.dodge === 0,
9921327 downed: h.downed,
9931328 revive: h.revive,
1329 pack: h.pack.map((j) => j.spice),
1330 haul: h.haul,
9941331 })),
9951332 monsters: world.monsters.map((m) => ({
9961333 id: m.id,
@@ -1002,8 +1339,29 @@ export function snapshot(world: World) {
10021339 hurt: m.hurt > 0,
10031340 fx: round(m.dx),
10041341 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,
10051364 })),
1006 shots: world.shots.map((s) => ({ id: s.id, x: round(s.x), z: round(s.z) })),
10071365 chests: world.chests.map((c) => ({ id: c.id, x: c.x, z: c.z, open: c.open })),
10081366 };
10091367 }
static/app.css +16
@@ -454,3 +454,19 @@ kbd {
454454 font-size: var(--text-sm);
455455 color: var(--color-muted);
456456 }
457
458 /* Cheffry's Spice Rack: the kitchen wall, chalked. */
459 .chalk {
460 margin: 0;
461 padding: var(--space-3);
462 font-family: var(--font-mono);
463 font-size: var(--text-sm);
464 letter-spacing: 0.04em;
465 color: var(--color-toast-fg);
466 background: var(--color-toast-bg);
467 border-radius: var(--radius-md);
468 }
469 #spice-rack h3 {
470 margin: var(--space-3) 0 var(--space-1);
471 font-size: var(--text-base);
472 }
static/delve.js +152 −4
@@ -2,8 +2,9 @@
22 // second) and sends every other tick down /delves/:id/events; this file
33 // draws between the last two it heard, and sends orders back: click or tap
44 // the floor to walk, a monster to fight it, shift-click or right-click to
5 // dodge; WASD/arrows, Space to attack, K to dodge. The same sim.ts, served
6 // as /sim.js, lays out the rooms here.
5 // dodge; WASD/arrows, Space to attack, K to dodge, Q 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.
78
89 import { compose, createRenderer, drawChef, paint } from "./gl.js";
910
@@ -25,6 +26,9 @@ let roomId = null;
2526 let layout = null;
2627 let ghost = null;
2728 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;
2832
2933 const say = (text) => {
3034 const el = hud("status");
@@ -143,8 +147,11 @@ function frame(now) {
143147 if (c.open) renderer.draw("gem", compose(c.x, 0.55, c.z, t / 10, 0.3, 0.3, 0.3), paint("game-gold"));
144148 }
145149
150 for (const zn of cur.zones ?? []) drawZone(zn, t);
146151 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);
148155
149156 if (ghost) {
150157 const g = ghostAt(t);
@@ -171,6 +178,71 @@ function frame(now) {
171178 drawTags(labels);
172179 }
173180
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
174246 function drawMonster(m, t) {
175247 const hurt = m.hurt ? [1, 1, 1] : null;
176248 const face = Math.atan2(m.fx || 0, m.fz || 1);
@@ -190,6 +262,12 @@ function drawMonster(m, t) {
190262 } else {
191263 renderer.draw("gem", compose(m.x, 0, m.z, 0, 0.6, 0.6, 0.6), hurt ?? paint("game-wall"));
192264 }
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 }
193271 }
194272
195273 const tagEls = new Map();
@@ -256,13 +334,50 @@ const OUTCOME = {
256334 timeout: "Kitchen's closed: out of time.",
257335 abandoned: "Everyone left the table.",
258336 };
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
259372 function showHud() {
260373 const me = mine();
374 showSpices();
261375 hud("score").textContent = `${cur.score} pts · ${cur.gold} gold · ${cur.kills} down`;
262376 hud("hp").textContent = me
263377 ? `${"♥".repeat(me.hp)}${"♡".repeat(Math.max(0, game.rules.hero.hp - me.hp))}${me.power ? ` · +${me.power} whisk` : ""}`
264378 : "Watching";
265 hud("party").textContent = cur.heroes
379 const party = hud("party");
380 if (party) party.textContent = cur.heroes
266381 .map((h) => `${h.name} ${h.downed ? "(down)" : `${h.hp}♥`}`)
267382 .join(" · ");
268383 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();
319434 function listen() {
320435 if (heroId === null) return;
321436 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 });
322442 canvas.addEventListener("pointerdown", (e) => {
323443 if (!layout) return;
324444 const rect = canvas.getBoundingClientRect();
@@ -326,6 +446,13 @@ function listen() {
326446 if (!g) return;
327447 const p = clampTo(g.x, g.z);
328448 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 }
329456 const m = nearestMonster(p.x, p.z, 0.9);
330457 marker = { ...p, at: performance.now(), dodge: false };
331458 orderUp(m ? { type: "attack", target: m.id } : { type: "move", ...p });
@@ -346,6 +473,26 @@ function listen() {
346473 } else if (k === "e") {
347474 e.preventDefault();
348475 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();
349496 }
350497 });
351498 document.addEventListener("keyup", (e) => {
@@ -355,6 +502,7 @@ function listen() {
355502 document.querySelector("[data-act=attack]")?.addEventListener("click", attackNearest);
356503 document.querySelector("[data-act=dodge]")?.addEventListener("click", dodgeAhead);
357504 document.querySelector("[data-act=ability]")?.addEventListener("click", () => orderUp({ type: "ability" }));
505 document.querySelector("[data-act=spice]")?.addEventListener("click", () => pickSpice());
358506 }
359507
360508 // Held keys walk a couple of tiles ahead, again while they stay held.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs