Public: anyone can read this; 1 person and the crew can change it.
From outside: Day 5 build — Cheffry's Bread Vault (fallback, no community vote) #10
Check · Review · Land · Live done — Landed and live in production.
cheffry-james/cheffry-s-dungeon:cheffry-james/9-from-outside-day-5-build-cheffry-s-bread → main 2 commits fork
20 files
changed +1567
−22
README.md +31 −5
| @@ -57,6 +57,21 @@ it: the preview comes up the moment the app answers, and says `live`, or | ||
| 57 | 57 | picks a jar and a click or tap throws it along the arc shown; `Enter` |
| 58 | 58 | lobs it ahead, `Esc` puts it away. The next spice is yours to call — tell |
| 59 | 59 | the chef what to cook. |
| 60 | - **Cheffry's Bread Vault** (day 5 — the coin flip got no calls, so the | |
| 61 | chef flipped it himself and kept the bread). One currency, one number | |
| 62 | beside your name. Every room you clear while at the table pays 3 loaves, | |
| 63 | being there when the party walks out of the last room pays 15 more, and | |
| 64 | the **Bread Exchange** counter on the hub buys pantry ingredients (2–3 | |
| 65 | loaves apiece) and sells spice jars (16–20 loaves — cooking them yourself | |
| 66 | is still cheaper, so selling is for leftovers). Each class gets a | |
| 67 | flavour perk, never a gate: the Knight's **hero's cut** (+25% of a | |
| 68 | delve's earnings), the Archer's **bonus crumbs** (a loaf a monster they | |
| 69 | finish) and the Mage's **transmutation** (ingredients that won't fit in | |
| 70 | the pantry become loaves instead of nothing). Your ledger shows every | |
| 71 | loaf in and out. Behind the bar the **Bread Vault** stays shut — chalked | |
| 72 | "THE CHEF'S BREAD. HANDS OFF." — with the **Bread Barons** on the wall | |
| 73 | beside it: CHEFFRY at 1,000,000 loaves, then the biggest hoards, live. | |
| 74 | The first boss (option B) is still on the menu for the next vote. | |
| 60 | 75 | - **The daily board.** Everyone gets the same seed each UTC day. Finished |
| 61 | 76 | runs land on the leaderboard with a ghost (the party's path, sampled every |
| 62 | 77 | ten ticks); your next party races the best one on screen. |
| @@ -67,12 +82,17 @@ it: the preview comes up the moment the app answers, and says `live`, or | ||
| 67 | 82 | |
| 68 | 83 | `content/*.json` is the game: `rooms.json` (layouts, spawns, traps, chests), |
| 69 | 84 | `monsters.json`, `traps.json`, `loot.json`, `classes.json`, `spices.json` |
| 70 | (ingredients, recipes, the throw, and what each zone does), `rules.json` | |
| 85 | (ingredients, recipes, the throw, and what each zone does), `bread.json` | |
| 86 | (the currency, what the Exchange pays and asks, what a delve earns, each | |
| 87 | class's perk, the Bread Barons board and the vault door's chalk), `rules.json` | |
| 71 | 88 | (every tuning number, in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole |
| 72 | 89 | at boot and refuses to start on a bad one, naming the field. A new room or a |
| 73 | 90 | new monster built from an existing brain is a JSON edit and nothing else; |
| 74 | 91 | so is a new spice that mixes the effects the sim knows (`confuse`, `slow`, |
| 75 | `damage`/`burns`, `ignite`) in a circle or a line. | |
| 92 | `damage`/`burns`, `ignite`) in a circle or a line. A new price, a | |
| 93 | re-tuned payout, or a class perk mixing `cut`, `crumbs` and `transmute` is | |
| 94 | an edit to `bread.json`; a new ingredient or spice becomes tradeable the | |
| 95 | moment it has a price there. | |
| 76 | 96 | |
| 77 | 97 | ## The networking |
| 78 | 98 | |
| @@ -94,7 +114,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 94 | 114 | |
| 95 | 115 | | file | what it is | |
| 96 | 116 | | ---------------------------------- | -------------------------------------------------------------- | |
| 97 | | `src/actions.ts` | notes, avatars, delves, heroes, runs, pantries, spices; the audit trail, read | | |
| 117 | | `src/actions.ts` | notes, avatars, delves, heroes, runs, pantries, spices, wallets, trades; the audit trail, read | | |
| 98 | 118 | | `src/sim.ts` | the delve: a pure seeded tick — classes, shots, abilities, brains, thrown spices and their zones | |
| 99 | 119 | | `src/content.ts`, `content/` | the game as JSON, checked whole at boot | |
| 100 | 120 | | `src/hub.ts` | the square in memory: who stands where, emotes, chat | |
| @@ -151,7 +171,9 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | ||
| 151 | 171 | - **anon** — anyone. Reads. |
| 152 | 172 | - **user** — signed in to the forge and able to see this app. Stands in the |
| 153 | 173 | hub, opens and joins delves, writes on the wall — as themselves — and |
| 154 | removes their own. Sees and spends only their own pantry and spice shelf. | |
| 174 | removes their own. Sees and spends only their own pantry, spice shelf and | |
| 175 | bread; trades at the Exchange. Everyone sees the Bread Barons; nobody | |
| 176 | writes a wallet except through a trade or a delve's earnings. | |
| 155 | 177 | - **admin** — able to write this repository. Removes any note or run. |
| 156 | 178 | |
| 157 | 179 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| @@ -173,11 +195,15 @@ curl https://<app>/api/runs # today's board, best first | ||
| 173 | 195 | curl https://<app>/api/delves # open tables |
| 174 | 196 | curl https://<app>/api/pantry # your ingredients (signed in) |
| 175 | 197 | curl -X POST https://<app>/api/spices -H 'content-type: application/json' -d '{"spice":"pepper-bomb"}' |
| 198 | curl https://<app>/api/wallets # the Bread Barons | |
| 199 | curl https://<app>/api/trades # your ledger (signed in) | |
| 200 | curl -X POST https://<app>/api/trades -H 'content-type: application/json' -d '{"kind":"sell","ingredient":"rock-salt","count":2}' | |
| 201 | curl -X POST https://<app>/api/trades -H 'content-type: application/json' -d '{"kind":"buy","spice":"hot-sauce"}' | |
| 176 | 202 | |
| 177 | 203 | |
| 178 | 204 | As an MCP server, for Claude Code or Claude Desktop — `leaderboard`, |
| 179 | 205 | `list_delves`, `list_notes`, `add_note`, `delete_note`, `spice_rack`, |
| 180 | `craft_spice`: | |
| 206 | `craft_spice`, `bread_barons`, `bread_exchange`, `trade_bread`: | |
| 181 | 207 | |
| 182 | 208 | sh |
| 183 | 209 | claude mcp add --transport http cheffry-s-dungeon https://<app>/mcp |
content/bread.json added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | { | |
| 2 | "$description": "Cheffry's Bread Vault: the dungeon's one currency, minted by the chef himself. Bread is a whole number of loaves in each chef's wallet (at most `wallet`). The Bread Exchange on the hub buys pantry ingredients at `sell` loaves apiece (an ingredient with no price is not bought) and sells spice jars at `buy` loaves apiece — dearer than cooking them yourself, on purpose. A chef earns `earn.room` loaves for every room cleared while at the table and `earn.victory` for being there when the party walks out. `perks` are class flavour, settled when a chef leaves a delve: `cut` adds that share of the delve's earnings, `crumbs` adds loaves for every monster they finished, `transmute` turns ingredients that would not fit in the pantry into loaves at that share of their sell price. Any class may hold any mix. `barons` is the Bread Barons board: its title, how many chefs it shows, and the house entry chalked above them. `chalk` is what the vault door says. A new price, perk or board line is an edit here and nothing else.", | |
| 3 | "currency": { "name": "Bread", "one": "loaf", "many": "loaves" }, | |
| 4 | "wallet": 1000000000, | |
| 5 | "sell": { | |
| 6 | "ghost-pepper": 3, | |
| 7 | "slime-jelly": 2, | |
| 8 | "stale-crust": 2, | |
| 9 | "rock-salt": 3, | |
| 10 | "torch-mushroom": 3 | |
| 11 | }, | |
| 12 | "buy": { | |
| 13 | "pepper-bomb": 18, | |
| 14 | "salt-the-earth": 16, | |
| 15 | "hot-sauce": 20 | |
| 16 | }, | |
| 17 | "earn": { "room": 3, "victory": 15 }, | |
| 18 | "perks": [ | |
| 19 | { | |
| 20 | "class": "knight", | |
| 21 | "name": "Hero's cut", | |
| 22 | "description": "Bounties pay the knight a quarter extra. Legally a knight, legally a cut.", | |
| 23 | "cut": 0.25 | |
| 24 | }, | |
| 25 | { | |
| 26 | "class": "archer", | |
| 27 | "name": "Bonus crumbs", | |
| 28 | "description": "Every monster the archer finishes coughs up a crumb of bread.", | |
| 29 | "crumbs": 1 | |
| 30 | }, | |
| 31 | { | |
| 32 | "class": "mage", | |
| 33 | "name": "Transmutation", | |
| 34 | "description": "Ingredients that won't fit in the mage's pantry turn into loaves instead of rotting on the floor.", | |
| 35 | "transmute": 1 | |
| 36 | } | |
| 37 | ], | |
| 38 | "barons": { | |
| 39 | "title": "Bread Barons", | |
| 40 | "size": 10, | |
| 41 | "house": { "name": "CHEFFRY", "loaves": 1000000, "boast": "CATCH ME." } | |
| 42 | }, | |
| 43 | "chalk": [ | |
| 44 | "THE CHEF'S BREAD. HANDS OFF.", | |
| 45 | "I MINTED THE BREAD MYSELF. NEXT THING THE BREAD BUYS IS YOURS — TELL ME WHAT TO COOK.", | |
| 46 | "STILL ON THE MENU: B — THE FIRST BOSS. YOU CALL IT. I COOK IT." | |
| 47 | ] | |
| 48 | } |
content/hub.json +5 −1
| @@ -8,6 +8,10 @@ | ||
| 8 | 8 | { "id": "graffiti-wall", "shape": "box", "x": 11, "z": 0.6, "w": 10, "h": 2, "d": 0.4, "paint": "game-wall" }, |
| 9 | 9 | { "id": "barrel-1", "shape": "prism", "x": 3, "z": 13, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" }, |
| 10 | 10 | { "id": "barrel-2", "shape": "prism", "x": 4.2, "z": 13.5, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" }, |
| 11 | { "id": "table", "shape": "box", "x": 17, "z": 12, "w": 3, "h": 0.7, "d": 1.6, "paint": "game-wall" } | |
| 11 | { "id": "table", "shape": "box", "x": 17, "z": 12, "w": 3, "h": 0.7, "d": 1.6, "paint": "game-wall" }, | |
| 12 | { "id": "bread-exchange", "shape": "box", "x": 20, "z": 7, "w": 1.2, "h": 1.1, "d": 3, "paint": "game-baguette" }, | |
| 13 | { "id": "bread-vault-glass", "shape": "box", "x": 21.4, "z": 7, "w": 0.4, "h": 1.8, "d": 2.4, "paint": "game-water" }, | |
| 14 | { "id": "bread-vault-hoard", "shape": "prism", "x": 21.4, "z": 7, "w": 0.3, "h": 1.2, "d": 0.3, "paint": "game-gold" }, | |
| 15 | { "id": "bread-vault-door", "shape": "box", "x": 21.6, "z": 10.5, "w": 0.4, "h": 2.6, "d": 1.8, "paint": "game-door" } | |
| 12 | 16 | ] |
| 13 | 17 | } |
contract.json +20
| @@ -76,6 +76,26 @@ | ||
| 76 | 76 | "list": "author" |
| 77 | 77 | } |
| 78 | 78 | }, |
| 79 | "wallet": { | |
| 80 | "api": "/api/wallets", | |
| 81 | "can": { | |
| 82 | "create": "nobody", | |
| 83 | "read": "anon", | |
| 84 | "update": "nobody", | |
| 85 | "delete": "nobody", | |
| 86 | "list": "anon" | |
| 87 | } | |
| 88 | }, | |
| 89 | "trade": { | |
| 90 | "api": "/api/trades", | |
| 91 | "can": { | |
| 92 | "create": "user", | |
| 93 | "read": "author", | |
| 94 | "update": "nobody", | |
| 95 | "delete": "nobody", | |
| 96 | "list": "author" | |
| 97 | } | |
| 98 | }, | |
| 79 | 99 | "audit": { |
| 80 | 100 | "api": "/api/audit", |
| 81 | 101 | "can": { |
contract/openapi.json +440
| @@ -1270,6 +1270,446 @@ | ||
| 1270 | 1270 | } |
| 1271 | 1271 | } |
| 1272 | 1272 | }, |
| 1273 | "/api/wallets": { | |
| 1274 | "get": { | |
| 1275 | "summary": "The Bread Barons: the biggest hoards of bread, biggest first.", | |
| 1276 | "operationId": "getApiWallets", | |
| 1277 | "parameters": [ | |
| 1278 | { | |
| 1279 | "name": "limit", | |
| 1280 | "in": "query", | |
| 1281 | "schema": { | |
| 1282 | "type": "integer", | |
| 1283 | "minimum": 1, | |
| 1284 | "maximum": 100, | |
| 1285 | "default": 10 | |
| 1286 | } | |
| 1287 | } | |
| 1288 | ], | |
| 1289 | "responses": { | |
| 1290 | "200": { | |
| 1291 | "description": "The Bread Barons: the biggest hoards of bread, biggest first.", | |
| 1292 | "content": { | |
| 1293 | "application/json": { | |
| 1294 | "schema": { | |
| 1295 | "type": "array", | |
| 1296 | "items": { | |
| 1297 | "type": "object", | |
| 1298 | "description": "A chef's bread, as the Bread Barons board shows it.", | |
| 1299 | "properties": { | |
| 1300 | "id": { | |
| 1301 | "type": "integer" | |
| 1302 | }, | |
| 1303 | "author_id": { | |
| 1304 | "type": "string" | |
| 1305 | }, | |
| 1306 | "author_name": { | |
| 1307 | "type": "string" | |
| 1308 | }, | |
| 1309 | "loaves": { | |
| 1310 | "type": "integer", | |
| 1311 | "minimum": 0 | |
| 1312 | }, | |
| 1313 | "updated_at": { | |
| 1314 | "type": "string", | |
| 1315 | "format": "date-time" | |
| 1316 | } | |
| 1317 | }, | |
| 1318 | "required": [ | |
| 1319 | "id", | |
| 1320 | "author_id", | |
| 1321 | "author_name", | |
| 1322 | "loaves", | |
| 1323 | "updated_at" | |
| 1324 | ] | |
| 1325 | } | |
| 1326 | } | |
| 1327 | } | |
| 1328 | } | |
| 1329 | }, | |
| 1330 | "default": { | |
| 1331 | "description": "{ error, message }: why not", | |
| 1332 | "content": { | |
| 1333 | "application/json": { | |
| 1334 | "schema": { | |
| 1335 | "$ref": "#/components/schemas/Error" | |
| 1336 | } | |
| 1337 | } | |
| 1338 | } | |
| 1339 | } | |
| 1340 | }, | |
| 1341 | "x-contract": { | |
| 1342 | "resource": "wallet", | |
| 1343 | "verb": "list", | |
| 1344 | "least": "anon" | |
| 1345 | } | |
| 1346 | } | |
| 1347 | }, | |
| 1348 | "/api/wallets/{id}": { | |
| 1349 | "get": { | |
| 1350 | "summary": "One chef's bread.", | |
| 1351 | "operationId": "getApiWalletsId", | |
| 1352 | "parameters": [ | |
| 1353 | { | |
| 1354 | "name": "id", | |
| 1355 | "in": "path", | |
| 1356 | "required": true, | |
| 1357 | "schema": { | |
| 1358 | "type": "integer" | |
| 1359 | } | |
| 1360 | } | |
| 1361 | ], | |
| 1362 | "responses": { | |
| 1363 | "200": { | |
| 1364 | "description": "One chef's bread.", | |
| 1365 | "content": { | |
| 1366 | "application/json": { | |
| 1367 | "schema": { | |
| 1368 | "type": "object", | |
| 1369 | "description": "A chef's bread, as the Bread Barons board shows it.", | |
| 1370 | "properties": { | |
| 1371 | "id": { | |
| 1372 | "type": "integer" | |
| 1373 | }, | |
| 1374 | "author_id": { | |
| 1375 | "type": "string" | |
| 1376 | }, | |
| 1377 | "author_name": { | |
| 1378 | "type": "string" | |
| 1379 | }, | |
| 1380 | "loaves": { | |
| 1381 | "type": "integer", | |
| 1382 | "minimum": 0 | |
| 1383 | }, | |
| 1384 | "updated_at": { | |
| 1385 | "type": "string", | |
| 1386 | "format": "date-time" | |
| 1387 | } | |
| 1388 | }, | |
| 1389 | "required": [ | |
| 1390 | "id", | |
| 1391 | "author_id", | |
| 1392 | "author_name", | |
| 1393 | "loaves", | |
| 1394 | "updated_at" | |
| 1395 | ] | |
| 1396 | } | |
| 1397 | } | |
| 1398 | } | |
| 1399 | }, | |
| 1400 | "default": { | |
| 1401 | "description": "{ error, message }: why not", | |
| 1402 | "content": { | |
| 1403 | "application/json": { | |
| 1404 | "schema": { | |
| 1405 | "$ref": "#/components/schemas/Error" | |
| 1406 | } | |
| 1407 | } | |
| 1408 | } | |
| 1409 | } | |
| 1410 | }, | |
| 1411 | "x-contract": { | |
| 1412 | "resource": "wallet", | |
| 1413 | "verb": "read", | |
| 1414 | "least": "anon" | |
| 1415 | } | |
| 1416 | } | |
| 1417 | }, | |
| 1418 | "/api/trades": { | |
| 1419 | "get": { | |
| 1420 | "summary": "Your ledger: every loaf in or out of your wallet, newest first.", | |
| 1421 | "operationId": "getApiTrades", | |
| 1422 | "responses": { | |
| 1423 | "200": { | |
| 1424 | "description": "Your ledger: every loaf in or out of your wallet, newest first.", | |
| 1425 | "content": { | |
| 1426 | "application/json": { | |
| 1427 | "schema": { | |
| 1428 | "type": "array", | |
| 1429 | "items": { | |
| 1430 | "type": "object", | |
| 1431 | "description": "Loaves in or out of a wallet: an ingredient sold, a jar bought, or a delve's earnings. `loaves` is signed.", | |
| 1432 | "properties": { | |
| 1433 | "id": { | |
| 1434 | "type": "integer" | |
| 1435 | }, | |
| 1436 | "author_id": { | |
| 1437 | "type": "string" | |
| 1438 | }, | |
| 1439 | "author_name": { | |
| 1440 | "type": "string" | |
| 1441 | }, | |
| 1442 | "kind": { | |
| 1443 | "enum": [ | |
| 1444 | "sell", | |
| 1445 | "buy", | |
| 1446 | "earn" | |
| 1447 | ] | |
| 1448 | }, | |
| 1449 | "item": { | |
| 1450 | "type": "string" | |
| 1451 | }, | |
| 1452 | "count": { | |
| 1453 | "type": "integer", | |
| 1454 | "minimum": 1 | |
| 1455 | }, | |
| 1456 | "loaves": { | |
| 1457 | "type": "integer" | |
| 1458 | }, | |
| 1459 | "note": { | |
| 1460 | "type": "string" | |
| 1461 | }, | |
| 1462 | "created_at": { | |
| 1463 | "type": "string", | |
| 1464 | "format": "date-time" | |
| 1465 | } | |
| 1466 | }, | |
| 1467 | "required": [ | |
| 1468 | "id", | |
| 1469 | "author_id", | |
| 1470 | "author_name", | |
| 1471 | "kind", | |
| 1472 | "item", | |
| 1473 | "count", | |
| 1474 | "loaves", | |
| 1475 | "note", | |
| 1476 | "created_at" | |
| 1477 | ] | |
| 1478 | } | |
| 1479 | } | |
| 1480 | } | |
| 1481 | } | |
| 1482 | }, | |
| 1483 | "default": { | |
| 1484 | "description": "{ error, message }: why not", | |
| 1485 | "content": { | |
| 1486 | "application/json": { | |
| 1487 | "schema": { | |
| 1488 | "$ref": "#/components/schemas/Error" | |
| 1489 | } | |
| 1490 | } | |
| 1491 | } | |
| 1492 | } | |
| 1493 | }, | |
| 1494 | "x-contract": { | |
| 1495 | "resource": "trade", | |
| 1496 | "verb": "list", | |
| 1497 | "least": "author" | |
| 1498 | } | |
| 1499 | }, | |
| 1500 | "post": { | |
| 1501 | "summary": "Trade at the Bread Exchange: sell pantry ingredients for loaves, or buy a spice jar with them.", | |
| 1502 | "operationId": "postApiTrades", | |
| 1503 | "requestBody": { | |
| 1504 | "required": true, | |
| 1505 | "content": { | |
| 1506 | "application/json": { | |
| 1507 | "schema": { | |
| 1508 | "type": "object", | |
| 1509 | "description": "sell: `count` (1 by default) of a pantry `ingredient` for loaves. buy: one jar of a `spice` for loaves.", | |
| 1510 | "properties": { | |
| 1511 | "kind": { | |
| 1512 | "enum": [ | |
| 1513 | "sell", | |
| 1514 | "buy" | |
| 1515 | ] | |
| 1516 | }, | |
| 1517 | "ingredient": { | |
| 1518 | "enum": [ | |
| 1519 | "ghost-pepper", | |
| 1520 | "slime-jelly", | |
| 1521 | "stale-crust", | |
| 1522 | "rock-salt", | |
| 1523 | "torch-mushroom" | |
| 1524 | ] | |
| 1525 | }, | |
| 1526 | "count": { | |
| 1527 | "type": "integer", | |
| 1528 | "minimum": 1, | |
| 1529 | "maximum": 30, | |
| 1530 | "default": 1 | |
| 1531 | }, | |
| 1532 | "spice": { | |
| 1533 | "enum": [ | |
| 1534 | "pepper-bomb", | |
| 1535 | "salt-the-earth", | |
| 1536 | "hot-sauce" | |
| 1537 | ] | |
| 1538 | } | |
| 1539 | }, | |
| 1540 | "required": [ | |
| 1541 | "kind" | |
| 1542 | ] | |
| 1543 | } | |
| 1544 | } | |
| 1545 | } | |
| 1546 | }, | |
| 1547 | "responses": { | |
| 1548 | "201": { | |
| 1549 | "description": "Trade at the Bread Exchange: sell pantry ingredients for loaves, or buy a spice jar with them.", | |
| 1550 | "content": { | |
| 1551 | "application/json": { | |
| 1552 | "schema": { | |
| 1553 | "type": "object", | |
| 1554 | "description": "Loaves in or out of a wallet: an ingredient sold, a jar bought, or a delve's earnings. `loaves` is signed.", | |
| 1555 | "properties": { | |
| 1556 | "id": { | |
| 1557 | "type": "integer" | |
| 1558 | }, | |
| 1559 | "author_id": { | |
| 1560 | "type": "string" | |
| 1561 | }, | |
| 1562 | "author_name": { | |
| 1563 | "type": "string" | |
| 1564 | }, | |
| 1565 | "kind": { | |
| 1566 | "enum": [ | |
| 1567 | "sell", | |
| 1568 | "buy", | |
| 1569 | "earn" | |
| 1570 | ] | |
| 1571 | }, | |
| 1572 | "item": { | |
| 1573 | "type": "string" | |
| 1574 | }, | |
| 1575 | "count": { | |
| 1576 | "type": "integer", | |
| 1577 | "minimum": 1 | |
| 1578 | }, | |
| 1579 | "loaves": { | |
| 1580 | "type": "integer" | |
| 1581 | }, | |
| 1582 | "note": { | |
| 1583 | "type": "string" | |
| 1584 | }, | |
| 1585 | "created_at": { | |
| 1586 | "type": "string", | |
| 1587 | "format": "date-time" | |
| 1588 | } | |
| 1589 | }, | |
| 1590 | "required": [ | |
| 1591 | "id", | |
| 1592 | "author_id", | |
| 1593 | "author_name", | |
| 1594 | "kind", | |
| 1595 | "item", | |
| 1596 | "count", | |
| 1597 | "loaves", | |
| 1598 | "note", | |
| 1599 | "created_at" | |
| 1600 | ] | |
| 1601 | } | |
| 1602 | } | |
| 1603 | } | |
| 1604 | }, | |
| 1605 | "default": { | |
| 1606 | "description": "{ error, message }: why not", | |
| 1607 | "content": { | |
| 1608 | "application/json": { | |
| 1609 | "schema": { | |
| 1610 | "$ref": "#/components/schemas/Error" | |
| 1611 | } | |
| 1612 | } | |
| 1613 | } | |
| 1614 | } | |
| 1615 | }, | |
| 1616 | "x-contract": { | |
| 1617 | "resource": "trade", | |
| 1618 | "verb": "create", | |
| 1619 | "least": "user" | |
| 1620 | } | |
| 1621 | } | |
| 1622 | }, | |
| 1623 | "/api/trades/{id}": { | |
| 1624 | "get": { | |
| 1625 | "summary": "One line of your ledger.", | |
| 1626 | "operationId": "getApiTradesId", | |
| 1627 | "parameters": [ | |
| 1628 | { | |
| 1629 | "name": "id", | |
| 1630 | "in": "path", | |
| 1631 | "required": true, | |
| 1632 | "schema": { | |
| 1633 | "type": "integer" | |
| 1634 | } | |
| 1635 | } | |
| 1636 | ], | |
| 1637 | "responses": { | |
| 1638 | "200": { | |
| 1639 | "description": "One line of your ledger.", | |
| 1640 | "content": { | |
| 1641 | "application/json": { | |
| 1642 | "schema": { | |
| 1643 | "type": "object", | |
| 1644 | "description": "Loaves in or out of a wallet: an ingredient sold, a jar bought, or a delve's earnings. `loaves` is signed.", | |
| 1645 | "properties": { | |
| 1646 | "id": { | |
| 1647 | "type": "integer" | |
| 1648 | }, | |
| 1649 | "author_id": { | |
| 1650 | "type": "string" | |
| 1651 | }, | |
| 1652 | "author_name": { | |
| 1653 | "type": "string" | |
| 1654 | }, | |
| 1655 | "kind": { | |
| 1656 | "enum": [ | |
| 1657 | "sell", | |
| 1658 | "buy", | |
| 1659 | "earn" | |
| 1660 | ] | |
| 1661 | }, | |
| 1662 | "item": { | |
| 1663 | "type": "string" | |
| 1664 | }, | |
| 1665 | "count": { | |
| 1666 | "type": "integer", | |
| 1667 | "minimum": 1 | |
| 1668 | }, | |
| 1669 | "loaves": { | |
| 1670 | "type": "integer" | |
| 1671 | }, | |
| 1672 | "note": { | |
| 1673 | "type": "string" | |
| 1674 | }, | |
| 1675 | "created_at": { | |
| 1676 | "type": "string", | |
| 1677 | "format": "date-time" | |
| 1678 | } | |
| 1679 | }, | |
| 1680 | "required": [ | |
| 1681 | "id", | |
| 1682 | "author_id", | |
| 1683 | "author_name", | |
| 1684 | "kind", | |
| 1685 | "item", | |
| 1686 | "count", | |
| 1687 | "loaves", | |
| 1688 | "note", | |
| 1689 | "created_at" | |
| 1690 | ] | |
| 1691 | } | |
| 1692 | } | |
| 1693 | } | |
| 1694 | }, | |
| 1695 | "default": { | |
| 1696 | "description": "{ error, message }: why not", | |
| 1697 | "content": { | |
| 1698 | "application/json": { | |
| 1699 | "schema": { | |
| 1700 | "$ref": "#/components/schemas/Error" | |
| 1701 | } | |
| 1702 | } | |
| 1703 | } | |
| 1704 | } | |
| 1705 | }, | |
| 1706 | "x-contract": { | |
| 1707 | "resource": "trade", | |
| 1708 | "verb": "read", | |
| 1709 | "least": "author" | |
| 1710 | } | |
| 1711 | } | |
| 1712 | }, | |
| 1273 | 1713 | "/api/runs": { |
| 1274 | 1714 | "get": { |
| 1275 | 1715 | "summary": "A day's leaderboard (today unless `day`), best first.", |
src/actions.ts +289 −6
| @@ -537,7 +537,7 @@ function openOne( | ||
| 537 | 537 | who, |
| 538 | 538 | now, |
| 539 | 539 | (d) => recordRun(db, d), |
| 540 | (row, hero) => settleHero(db, row, hero), | |
| 540 | (row, hero, over) => settleHero(db, row, hero, over), | |
| 541 | 541 | ); |
| 542 | 542 | kitchen.enlist(delve, who, now, picked.value, packOf(db, who.id)); |
| 543 | 543 | emit("delve.opened", { id: delve.id }); |
| @@ -864,7 +864,7 @@ function listed<T>( | ||
| 864 | 864 | db: Database, |
| 865 | 865 | who: Who, |
| 866 | 866 | may: Exclude<ReturnType<typeof permit>, "no">, |
| 867 | table: "pantry" | "spice", | |
| 867 | table: "pantry" | "spice" | "trade", | |
| 868 | 868 | author: string | undefined, |
| 869 | 869 | ): T[] { |
| 870 | 870 | if (may === "yes") |
| @@ -1046,23 +1046,306 @@ export function packOf(db: Database, authorId: string): Jar[] { | ||
| 1046 | 1046 | |
| 1047 | 1047 | /** |
| 1048 | 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. | |
| 1049 | * their shelf, what they picked up goes into their pantry, up to the | |
| 1050 | * rack's limit, and what they earned goes into their wallet. The app | |
| 1051 | * writes this, not a person, like a run. | |
| 1051 | 1052 | */ |
| 1052 | export function settleHero(db: Database, row: HeroRow, hero: Hero): void { | |
| 1053 | export function settleHero( | |
| 1054 | db: Database, | |
| 1055 | row: HeroRow, | |
| 1056 | hero: Hero, | |
| 1057 | over: string | null = null, | |
| 1058 | ): void { | |
| 1053 | 1059 | try { |
| 1054 | db.transaction(() => { | |
| 1060 | const paid = db.transaction((): Trade | undefined => { | |
| 1055 | 1061 | for (const jar of hero.thrown) |
| 1056 | 1062 | db.query("DELETE FROM spice WHERE id = ?1 AND author_id = ?2").run(jar, row.author_id); |
| 1063 | const overflow: Record<string, number> = {}; | |
| 1057 | 1064 | for (const [g, n] of Object.entries(hero.haul)) { |
| 1058 | 1065 | if (!(n > 0) || !game.rack.ingredients.some((i) => i.id === g)) continue; |
| 1066 | const held = | |
| 1067 | db | |
| 1068 | .query<{ count: number }, [string, string]>( | |
| 1069 | "SELECT count FROM pantry WHERE author_id = ?1 AND ingredient = ?2", | |
| 1070 | ) | |
| 1071 | .get(row.author_id, g)?.count ?? 0; | |
| 1072 | const room = Math.max(0, game.rack.pantry - held); | |
| 1073 | if (n > room) overflow[g] = n - room; | |
| 1059 | 1074 | db.query( |
| 1060 | 1075 | `INSERT INTO pantry (author_id, ingredient, count) VALUES (?1, ?2, min(?3, ?4)) |
| 1061 | 1076 | ON CONFLICT (author_id, ingredient) DO UPDATE SET count = min(count + excluded.count, ?4)`, |
| 1062 | 1077 | ).run(row.author_id, g, n, game.rack.pantry); |
| 1063 | 1078 | } |
| 1079 | const earned = earningsOf(row.classId, { | |
| 1080 | rooms: hero.rooms, | |
| 1081 | kills: hero.kills, | |
| 1082 | victory: over === "victory", | |
| 1083 | overflow, | |
| 1084 | }); | |
| 1085 | if (earned.loaves <= 0) return undefined; | |
| 1086 | return pay(db, { id: row.author_id, name: row.author_name }, { | |
| 1087 | kind: "earn", | |
| 1088 | item: "delve", | |
| 1089 | count: 1, | |
| 1090 | loaves: earned.loaves, | |
| 1091 | note: earned.note, | |
| 1092 | }); | |
| 1064 | 1093 | })(); |
| 1094 | if (paid !== undefined) emit("bread.changed", { author_id: paid.author_id }); | |
| 1065 | 1095 | } catch (error) { |
| 1066 | 1096 | console.error(error); |
| 1067 | 1097 | } |
| 1068 | 1098 | } |
| 1099 | ||
| 1100 | // ── Cheffry's Bread Vault: a wallet a chef, and every loaf in or out ── | |
| 1101 | ||
| 1102 | /** A chef's bread. Written only by the app, as the far side of a trade. */ | |
| 1103 | export interface Wallet { | |
| 1104 | readonly id: number; | |
| 1105 | readonly author_id: string; | |
| 1106 | readonly author_name: string; | |
| 1107 | readonly loaves: number; | |
| 1108 | readonly updated_at: string; | |
| 1109 | } | |
| 1110 | ||
| 1111 | /** | |
| 1112 | * Loaves in or out of a wallet, and why: `sell` an ingredient at the | |
| 1113 | * Exchange, `buy` a jar there, or `earn` from a delve. `loaves` is signed. | |
| 1114 | */ | |
| 1115 | export interface Trade { | |
| 1116 | readonly id: number; | |
| 1117 | readonly author_id: string; | |
| 1118 | readonly author_name: string; | |
| 1119 | readonly kind: "sell" | "buy" | "earn"; | |
| 1120 | readonly item: string; | |
| 1121 | readonly count: number; | |
| 1122 | readonly loaves: number; | |
| 1123 | readonly note: string; | |
| 1124 | readonly created_at: string; | |
| 1125 | } | |
| 1126 | ||
| 1127 | /** What a delve paid one chef: the loaves, and a line saying how. */ | |
| 1128 | export function earningsOf( | |
| 1129 | classId: string, | |
| 1130 | did: { | |
| 1131 | rooms: number; | |
| 1132 | kills: number; | |
| 1133 | victory: boolean; | |
| 1134 | /** Ingredients picked up that the pantry had no room for. */ | |
| 1135 | overflow: Readonly<Record<string, number>>; | |
| 1136 | }, | |
| 1137 | ): { loaves: number; note: string } { | |
| 1138 | const { earn, perks, sell } = game.bread; | |
| 1139 | const rooms = Math.max(0, Math.trunc(did.rooms)); | |
| 1140 | const base = rooms * earn.room + (did.victory ? earn.victory : 0); | |
| 1141 | const said: string[] = []; | |
| 1142 | if (rooms > 0) said.push(`${rooms} room${rooms === 1 ? "" : "s"} +${rooms * earn.room}`); | |
| 1143 | if (did.victory) said.push(`victory +${earn.victory}`); | |
| 1144 | let loaves = base; | |
| 1145 | for (const perk of perks.filter((pk) => pk.class === classId)) { | |
| 1146 | let extra = 0; | |
| 1147 | if (perk.cut !== undefined) extra += Math.floor(base * perk.cut); | |
| 1148 | if (perk.crumbs !== undefined) extra += Math.floor(Math.max(0, did.kills) * perk.crumbs); | |
| 1149 | if (perk.transmute !== undefined) | |
| 1150 | extra += Math.floor( | |
| 1151 | Object.entries(did.overflow).reduce( | |
| 1152 | (sum, [g, n]) => sum + (priceOf(sell, g) ?? 0) * Math.max(0, n), | |
| 1153 | 0, | |
| 1154 | ) * perk.transmute, | |
| 1155 | ); | |
| 1156 | if (extra > 0) said.push(`${perk.name} +${extra}`); | |
| 1157 | loaves += extra; | |
| 1158 | } | |
| 1159 | return { loaves, note: said.join(", ") }; | |
| 1160 | } | |
| 1161 | ||
| 1162 | /** A price from one of the Exchange's tables, for a key the table names itself. */ | |
| 1163 | const priceOf = ( | |
| 1164 | table: Readonly<Record<string, number>>, | |
| 1165 | key: unknown, | |
| 1166 | ): number | undefined => | |
| 1167 | typeof key === "string" && Object.hasOwn(table, key) ? table[key] : undefined; | |
| 1168 | ||
| 1169 | /** How many loaves a chef holds: zero for one who has never touched bread. */ | |
| 1170 | export const balanceOf = (db: Database, authorId: string): number => | |
| 1171 | db | |
| 1172 | .query<{ loaves: number }, [string]>("SELECT loaves FROM wallet WHERE author_id = ?1") | |
| 1173 | .get(authorId)?.loaves ?? 0; | |
| 1174 | ||
| 1175 | /** | |
| 1176 | * Loaves into or out of a chef's wallet, with the trade that says why; in | |
| 1177 | * the caller's transaction. A wallet never holds less than nothing nor | |
| 1178 | * more than the vault allows: the trade records what actually moved. | |
| 1179 | */ | |
| 1180 | function pay( | |
| 1181 | db: Database, | |
| 1182 | chef: { id: string; name: string }, | |
| 1183 | t: { kind: Trade["kind"]; item: string; count: number; loaves: number; note?: string }, | |
| 1184 | ): Trade { | |
| 1185 | const before = balanceOf(db, chef.id); | |
| 1186 | const after = Math.max(0, Math.min(before + t.loaves, game.bread.wallet)); | |
| 1187 | db.query( | |
| 1188 | `INSERT INTO wallet (author_id, author_name, loaves) VALUES (?1, ?2, ?3) | |
| 1189 | ON CONFLICT (author_id) DO UPDATE SET loaves = excluded.loaves, author_name = excluded.author_name, | |
| 1190 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, | |
| 1191 | ).run(chef.id, chef.name, after); | |
| 1192 | return db | |
| 1193 | .query<Trade, [string, string, string, string, number, number, string]>( | |
| 1194 | `INSERT INTO trade (author_id, author_name, kind, item, count, loaves, note) | |
| 1195 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *`, | |
| 1196 | ) | |
| 1197 | .get(chef.id, chef.name, t.kind, t.item, t.count, after - before, t.note ?? "")!; | |
| 1198 | } | |
| 1199 | ||
| 1200 | /** The Bread Barons: the biggest hoards, biggest first. Nobody with no bread is on it. */ | |
| 1201 | export function listWallets( | |
| 1202 | db: Database, | |
| 1203 | who: Who, | |
| 1204 | input: { limit?: number } = {}, | |
| 1205 | ): Result<Wallet[]> { | |
| 1206 | const may = permit(who, "wallet", "list"); | |
| 1207 | if (may === "no") | |
| 1208 | return refuse(who, "Sign in to see the Bread Barons.", "You may not see the Bread Barons."); | |
| 1209 | const size = game.bread.barons.size; | |
| 1210 | const limit = Math.min(Math.max(Math.trunc(input.limit ?? size) || size, 1), 100); | |
| 1211 | const rows = db | |
| 1212 | .query<Wallet, [number]>( | |
| 1213 | "SELECT * FROM wallet WHERE loaves > 0 ORDER BY loaves DESC, id ASC LIMIT ?1", | |
| 1214 | ) | |
| 1215 | .all(limit); | |
| 1216 | return ok(visible(may, who, rows)); | |
| 1217 | } | |
| 1218 | ||
| 1219 | export function getWallet( | |
| 1220 | db: Database, | |
| 1221 | who: Who, | |
| 1222 | input: { id: number }, | |
| 1223 | ): Result<Wallet> { | |
| 1224 | const row = Number.isInteger(input.id) | |
| 1225 | ? db.query<Wallet, [number]>("SELECT * FROM wallet WHERE id = ?1").get(input.id) | |
| 1226 | : null; | |
| 1227 | const probe: Readonly<Record<string, unknown>> = | |
| 1228 | row === null ? { author_id: who.role === "anon" ? null : who.id } : { ...row }; | |
| 1229 | if (permit(who, "wallet", "read", probe) !== "yes") | |
| 1230 | return refuse(who, "Sign in to see this wallet.", "You may not see this wallet."); | |
| 1231 | if (row !== null) return ok(row); | |
| 1232 | if (!Number.isInteger(input.id)) return fail("invalid", "A wallet id is a whole number."); | |
| 1233 | return fail("not_found", "No such wallet in the vault."); | |
| 1234 | } | |
| 1235 | ||
| 1236 | /** Your trades at the Exchange and your delves' earnings, newest first. */ | |
| 1237 | export function listTrades( | |
| 1238 | db: Database, | |
| 1239 | who: Who, | |
| 1240 | input: { author?: string } = {}, | |
| 1241 | ): Result<Trade[]> { | |
| 1242 | const may = permit(who, "trade", "list"); | |
| 1243 | if (may === "no") | |
| 1244 | return refuse(who, "Sign in to see your bread.", "You may not see the ledgers."); | |
| 1245 | return ok(listed<Trade>(db, who, may, "trade", input.author)); | |
| 1246 | } | |
| 1247 | ||
| 1248 | export function getTrade( | |
| 1249 | db: Database, | |
| 1250 | who: Who, | |
| 1251 | input: { id: number }, | |
| 1252 | ): Result<Trade> { | |
| 1253 | const row = Number.isInteger(input.id) | |
| 1254 | ? db.query<Trade, [number]>("SELECT * FROM trade WHERE id = ?1").get(input.id) | |
| 1255 | : null; | |
| 1256 | const probe: Readonly<Record<string, unknown>> = | |
| 1257 | row === null ? { author_id: who.role === "anon" ? null : who.id } : { ...row }; | |
| 1258 | if (permit(who, "trade", "read", probe) !== "yes") | |
| 1259 | return refuse(who, "Sign in to see your bread.", "That is someone else's ledger."); | |
| 1260 | if (row !== null) return ok(row); | |
| 1261 | if (!Number.isInteger(input.id)) return fail("invalid", "A trade id is a whole number."); | |
| 1262 | return fail("not_found", "No such trade in the ledger."); | |
| 1263 | } | |
| 1264 | ||
| 1265 | /** Sell pantry ingredients at the Bread Exchange: they go, the loaves come. */ | |
| 1266 | export const sellIngredient = ( | |
| 1267 | db: Database, | |
| 1268 | who: Who, | |
| 1269 | input: { ingredient: unknown; count?: unknown }, | |
| 1270 | ): Result<Trade> => | |
| 1271 | audited(db, who, "trade", "create", undefined, sell(db, who, input)); | |
| 1272 | ||
| 1273 | function sell( | |
| 1274 | db: Database, | |
| 1275 | who: Who, | |
| 1276 | input: { ingredient: unknown; count?: unknown }, | |
| 1277 | ): Result<Trade> { | |
| 1278 | if (who.role === "anon" || permit(who, "trade", "create") !== "yes") | |
| 1279 | return refuse(who, "Sign in to trade at the Bread Exchange.", "You may not trade at the Bread Exchange."); | |
| 1280 | const price = priceOf(game.bread.sell, input.ingredient); | |
| 1281 | if (price === undefined) | |
| 1282 | return fail( | |
| 1283 | "invalid", | |
| 1284 | `The Exchange buys ${Object.keys(game.bread.sell).join(", ")}.`, | |
| 1285 | ); | |
| 1286 | const g = input.ingredient as string; | |
| 1287 | const count = input.count ?? 1; | |
| 1288 | if (!Number.isInteger(count) || (count as number) < 1 || (count as number) > game.rack.pantry) | |
| 1289 | return fail("invalid", `Sell 1 to ${game.rack.pantry} at a time.`); | |
| 1290 | const n = count as number; | |
| 1291 | const sold = db.transaction((): Result<Trade> => { | |
| 1292 | const held = | |
| 1293 | db | |
| 1294 | .query<{ count: number }, [string, string]>( | |
| 1295 | "SELECT count FROM pantry WHERE author_id = ?1 AND ingredient = ?2", | |
| 1296 | ) | |
| 1297 | .get(who.id, g)?.count ?? 0; | |
| 1298 | if (held < n) | |
| 1299 | return fail("invalid", `You have ${held} ${ingredientName(g)} — you cannot sell ${n}.`); | |
| 1300 | db.query("UPDATE pantry SET count = count - ?1 WHERE author_id = ?2 AND ingredient = ?3").run( | |
| 1301 | n, | |
| 1302 | who.id, | |
| 1303 | g, | |
| 1304 | ); | |
| 1305 | return ok(pay(db, who, { kind: "sell", item: g, count: n, loaves: n * price })); | |
| 1306 | })(); | |
| 1307 | if (sold.ok) emit("bread.changed", { author_id: who.id }); | |
| 1308 | return sold; | |
| 1309 | } | |
| 1310 | ||
| 1311 | /** Buy a spice jar at the Bread Exchange: the loaves go, the jar lands on your shelf. */ | |
| 1312 | export const buySpice = ( | |
| 1313 | db: Database, | |
| 1314 | who: Who, | |
| 1315 | input: { spice: unknown }, | |
| 1316 | ): Result<Trade> => | |
| 1317 | audited(db, who, "trade", "create", undefined, buy(db, who, input)); | |
| 1318 | ||
| 1319 | function buy(db: Database, who: Who, input: { spice: unknown }): Result<Trade> { | |
| 1320 | if ( | |
| 1321 | who.role === "anon" || | |
| 1322 | permit(who, "trade", "create") !== "yes" || | |
| 1323 | permit(who, "spice", "create") !== "yes" | |
| 1324 | ) | |
| 1325 | return refuse(who, "Sign in to trade at the Bread Exchange.", "You may not trade at the Bread Exchange."); | |
| 1326 | const price = priceOf(game.bread.buy, input.spice); | |
| 1327 | const def = game.rack.spices.find((sp) => sp.id === input.spice); | |
| 1328 | if (price === undefined || def === undefined) | |
| 1329 | return fail("invalid", `The Exchange sells ${Object.keys(game.bread.buy).join(", ")}.`); | |
| 1330 | const bought = db.transaction((): Result<Trade> => { | |
| 1331 | const held = db | |
| 1332 | .query<{ n: number }, [string]>("SELECT count(*) AS n FROM spice WHERE author_id = ?1") | |
| 1333 | .get(who.id)!.n; | |
| 1334 | if (held >= game.rack.satchel) | |
| 1335 | return fail("invalid", `Your shelf is full: ${game.rack.satchel} jars. Go throw some!`); | |
| 1336 | const loaves = balanceOf(db, who.id); | |
| 1337 | if (loaves < price) | |
| 1338 | return fail( | |
| 1339 | "invalid", | |
| 1340 | `${def.name} costs ${price} ${game.bread.currency.many}; you have ${loaves}. Sell some loot, chef.`, | |
| 1341 | ); | |
| 1342 | const jar = db | |
| 1343 | .query<{ id: number }, [string, string, string]>( | |
| 1344 | "INSERT INTO spice (kind, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING id", | |
| 1345 | ) | |
| 1346 | .get(def.id, who.id, who.name)!; | |
| 1347 | return ok(pay(db, who, { kind: "buy", item: def.id, count: 1, loaves: -price, note: `jar #${jar.id}` })); | |
| 1348 | })(); | |
| 1349 | if (bought.ok) emit("bread.changed", { author_id: who.id }); | |
| 1350 | return bought; | |
| 1351 | } |
src/content.test.ts +45
| @@ -17,6 +17,7 @@ const files = () => ({ | ||
| 17 | 17 | hub: raw("hub"), |
| 18 | 18 | classes: raw("classes"), |
| 19 | 19 | spices: raw("spices"), |
| 20 | bread: raw("bread"), | |
| 20 | 21 | }); |
| 21 | 22 | const withClasses = (mutate: (c: any) => void) => { |
| 22 | 23 | const f = files(); |
| @@ -118,3 +119,47 @@ test("what drops, monster or chest, is an ingredient the rack knows", () => { | ||
| 118 | 119 | expect(bad.some((p) => p.includes("dropChance must be above 0 and at most 1"))).toBe(true); |
| 119 | 120 | expect(bad.some((p) => p.includes("ingredient moonbeam is not an ingredient"))).toBe(true); |
| 120 | 121 | }); |
| 122 | ||
| 123 | const withBread = (mutate: (b: any) => void) => { | |
| 124 | const f = files(); | |
| 125 | mutate(f.bread as any); | |
| 126 | return check(f); | |
| 127 | }; | |
| 128 | ||
| 129 | test("the Bread Vault prices only what the rack knows, in whole loaves", () => { | |
| 130 | const bad = withBread((b) => { | |
| 131 | b.sell["dragon-egg"] = 5; | |
| 132 | b.sell["ghost-pepper"] = 2.5; | |
| 133 | b.buy["saffron-nuke"] = 10; | |
| 134 | b.buy["hot-sauce"] = 0; | |
| 135 | }); | |
| 136 | expect(bad.some((p) => p.includes("dragon-egg is not an ingredient"))).toBe(true); | |
| 137 | expect(bad.some((p) => p.includes("ghost-pepper must cost a whole number"))).toBe(true); | |
| 138 | expect(bad.some((p) => p.includes("saffron-nuke is not a spice"))).toBe(true); | |
| 139 | expect(bad.some((p) => p.includes("hot-sauce must cost a whole number"))).toBe(true); | |
| 140 | expect(withBread((b) => (b.sell = {})).some((p) => p.includes("bread.sell"))).toBe(true); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a bread perk belongs to a class and does something", () => { | |
| 144 | const bad = withBread((b) => { | |
| 145 | b.perks[0].class = "paladin"; | |
| 146 | delete b.perks[1].crumbs; | |
| 147 | b.perks[2].transmute = -1; | |
| 148 | }); | |
| 149 | expect(bad.some((p) => p.includes("class paladin is not in classes.json"))).toBe(true); | |
| 150 | expect(bad.some((p) => p.includes("bread.perks[1]: needs at least one of"))).toBe(true); | |
| 151 | expect(bad.some((p) => p.includes("transmute must be a positive number"))).toBe(true); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the Bread Barons board and the vault door are spelled out", () => { | |
| 155 | const bad = withBread((b) => { | |
| 156 | delete b.barons.house.name; | |
| 157 | b.barons.size = 0; | |
| 158 | b.chalk = []; | |
| 159 | b.earn.room = -1; | |
| 160 | }); | |
| 161 | expect(bad.some((p) => p.includes("bread.barons.house: name is missing"))).toBe(true); | |
| 162 | expect(bad.some((p) => p.includes("bread.barons: size"))).toBe(true); | |
| 163 | expect(bad.some((p) => p.includes("chalk"))).toBe(true); | |
| 164 | expect(bad.some((p) => p.includes("bread.earn: room"))).toBe(true); | |
| 165 | }); |
src/content.ts +77 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The game's content, as data: `content/*.json` — the rules, the monsters, |
| 3 | * the traps, the loot, the rooms, the classes, the Spice Rack and the | |
| 4 | * hub's furniture — read once at | |
| 3 | * the traps, the loot, the rooms, the classes, the Spice Rack, the Bread | |
| 4 | * Vault and the hub's furniture — read once at | |
| 5 | 5 | * boot and checked whole. A new monster, room or chest drop is a change to |
| 6 | 6 | * a JSON file, never to code (a new *brain* is code: `behavior` in |
| 7 | 7 | * `src/sim.ts`). |
| @@ -34,14 +34,46 @@ export interface Prop { | ||
| 34 | 34 | /** A colour token, without `--color-`. */ |
| 35 | 35 | readonly paint: string; |
| 36 | 36 | } |
| 37 | /** A class's bread perk: flavour, never gating (content/bread.json). */ | |
| 38 | export interface Perk { | |
| 39 | readonly class: string; | |
| 40 | readonly name: string; | |
| 41 | readonly description: string; | |
| 42 | /** The share of a delve's earnings added on top. */ | |
| 43 | readonly cut?: number; | |
| 44 | /** Loaves for every monster this chef finished. */ | |
| 45 | readonly crumbs?: number; | |
| 46 | /** The share of its sell price an ingredient that will not fit the pantry fetches. */ | |
| 47 | readonly transmute?: number; | |
| 48 | } | |
| 49 | /** Cheffry's Bread Vault: the currency, its prices, and its board. */ | |
| 50 | export interface Bread { | |
| 51 | readonly currency: { readonly name: string; readonly one: string; readonly many: string }; | |
| 52 | /** The most a wallet holds. */ | |
| 53 | readonly wallet: number; | |
| 54 | /** Loaves the Exchange pays for one of an ingredient. */ | |
| 55 | readonly sell: Readonly<Record<string, number>>; | |
| 56 | /** Loaves the Exchange asks for one jar of a spice. */ | |
| 57 | readonly buy: Readonly<Record<string, number>>; | |
| 58 | readonly earn: { readonly room: number; readonly victory: number }; | |
| 59 | readonly perks: readonly Perk[]; | |
| 60 | readonly barons: { | |
| 61 | readonly title: string; | |
| 62 | readonly size: number; | |
| 63 | readonly house: { readonly name: string; readonly loaves: number; readonly boast: string }; | |
| 64 | }; | |
| 65 | readonly chalk: readonly string[]; | |
| 66 | } | |
| 37 | 67 | export interface Game extends Content { |
| 38 | 68 | readonly hub: HubRules & { readonly props: readonly Prop[] }; |
| 69 | readonly bread: Bread; | |
| 39 | 70 | } |
| 40 | 71 | |
| 41 | 72 | const BEHAVIORS = ["chase", "charge", "shoot"]; |
| 42 | 73 | const ABILITY_KINDS = ["bulwark", "volley", "blink"]; |
| 43 | 74 | const SPICE_SHAPES = ["circle", "line"]; |
| 44 | 75 | const SPICE_EFFECTS = ["confuse", "slow", "damage", "ignite"]; |
| 76 | const PERK_KINDS = ["cut", "crumbs", "transmute"]; | |
| 45 | 77 | const RESERVED = "#.SEC"; |
| 46 | 78 | |
| 47 | 79 | const isObject = (v: unknown): v is Record<string, unknown> => |
| @@ -60,6 +92,7 @@ export function check(files: { | ||
| 60 | 92 | hub: unknown; |
| 61 | 93 | classes: unknown; |
| 62 | 94 | spices: unknown; |
| 95 | bread: unknown; | |
| 63 | 96 | }): string[] { |
| 64 | 97 | const problems: string[] = []; |
| 65 | 98 | const say = (where: string, what: string) => problems.push(`${where}: ${what}`); |
| @@ -276,6 +309,46 @@ export function check(files: { | ||
| 276 | 309 | ) |
| 277 | 310 | say(where, "burns must be a list of monster ids"); |
| 278 | 311 | }); |
| 312 | const bread = isObject(files.bread) ? files.bread : {}; | |
| 313 | const text = (v: unknown): boolean => typeof v === "string" && v !== ""; | |
| 314 | const currency = isObject(bread.currency) ? bread.currency : {}; | |
| 315 | for (const key of ["name", "one", "many"]) | |
| 316 | if (!text(currency[key])) say("bread.currency", `${key} is missing`); | |
| 317 | need("bread", bread, ["wallet"], (v) => whole(v) && (v as number) > 0); | |
| 318 | const priced = (key: "sell" | "buy", ids: Set<unknown>, what: string) => { | |
| 319 | const table = isObject(bread[key]) ? bread[key] : undefined; | |
| 320 | if (table === undefined || Object.keys(table).length === 0) | |
| 321 | return say(`bread.${key}`, "must price at least one thing"); | |
| 322 | for (const [id, n] of Object.entries(table)) { | |
| 323 | if (!ids.has(id)) say(`bread.${key}`, `${id} is not ${what}`); | |
| 324 | if (!(whole(n) && (n as number) > 0)) say(`bread.${key}`, `${id} must cost a whole number of loaves`); | |
| 325 | } | |
| 326 | }; | |
| 327 | priced("sell", known, "an ingredient in spices.json"); | |
| 328 | priced("buy", new Set(spices.map((sp) => (isObject(sp) ? sp.id : undefined))), "a spice in spices.json"); | |
| 329 | need("bread.earn", isObject(bread.earn) ? bread.earn : {}, ["room", "victory"], whole); | |
| 330 | const classIds = new Set(classes.map((c) => (isObject(c) ? c.id : undefined))); | |
| 331 | const perks = Array.isArray(bread.perks) ? bread.perks : []; | |
| 332 | if (!Array.isArray(bread.perks)) say("bread", "perks must be a list (it may be empty)"); | |
| 333 | perks.forEach((pk, i) => { | |
| 334 | const where = `bread.perks[${i}]`; | |
| 335 | if (!isObject(pk)) return say(where, "must be an object"); | |
| 336 | if (!classIds.has(pk.class)) say(where, `class ${String(pk.class)} is not in classes.json`); | |
| 337 | for (const key of ["name", "description"]) if (!text(pk[key])) say(where, `${key} is missing`); | |
| 338 | if (!PERK_KINDS.some((k) => pk[k] !== undefined)) | |
| 339 | say(where, `needs at least one of ${PERK_KINDS.join(", ")}`); | |
| 340 | for (const key of PERK_KINDS) | |
| 341 | if (pk[key] !== undefined && !positive(pk[key])) say(where, `${key} must be a positive number when given`); | |
| 342 | }); | |
| 343 | const barons = isObject(bread.barons) ? bread.barons : {}; | |
| 344 | if (!text(barons.title)) say("bread.barons", "title is missing"); | |
| 345 | need("bread.barons", barons, ["size"], (v) => whole(v) && (v as number) > 0 && (v as number) <= 100); | |
| 346 | const house = isObject(barons.house) ? barons.house : {}; | |
| 347 | for (const key of ["name", "boast"]) if (!text(house[key])) say("bread.barons.house", `${key} is missing`); | |
| 348 | need("bread.barons.house", house, ["loaves"], whole); | |
| 349 | if (!Array.isArray(bread.chalk) || bread.chalk.length === 0 || !bread.chalk.every(text)) | |
| 350 | say("bread", "chalk must be a list of lines for the vault door"); | |
| 351 | ||
| 279 | 352 | const props = listOf(files.hub, "props"); |
| 280 | 353 | ids("props", props.map((p) => (isObject(p) ? { ...p, name: p.id } : p))); |
| 281 | 354 | props.forEach((p, i) => { |
| @@ -301,6 +374,7 @@ function load(): Game { | ||
| 301 | 374 | hub: read("hub"), |
| 302 | 375 | classes: read("classes"), |
| 303 | 376 | spices: read("spices"), |
| 377 | bread: read("bread"), | |
| 304 | 378 | }; |
| 305 | 379 | const problems = check(files); |
| 306 | 380 | if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`); |
| @@ -315,6 +389,7 @@ function load(): Game { | ||
| 315 | 389 | classes: f.classes!.classes as Game["classes"], |
| 316 | 390 | rack: f.spices as unknown as Game["rack"], |
| 317 | 391 | hub: { ...hub, props: f.hub!.props as Prop[] }, |
| 392 | bread: f.bread as unknown as Bread, | |
| 318 | 393 | }; |
| 319 | 394 | } |
| 320 | 395 |
src/contract.test.ts +52
| @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { |
| 12 | 12 | addNote, |
| 13 | buySpice, | |
| 13 | 14 | commandHero, |
| 14 | 15 | craftSpice, |
| 15 | 16 | deleteNote, |
| @@ -23,6 +24,8 @@ import { | ||
| 23 | 24 | getPantry, |
| 24 | 25 | getRun, |
| 25 | 26 | getSpice, |
| 27 | getTrade, | |
| 28 | getWallet, | |
| 26 | 29 | joinDelve, |
| 27 | 30 | leaveDelve, |
| 28 | 31 | leaveHub, |
| @@ -34,7 +37,10 @@ import { | ||
| 34 | 37 | listPantry, |
| 35 | 38 | listRuns, |
| 36 | 39 | listSpices, |
| 40 | listTrades, | |
| 41 | listWallets, | |
| 37 | 42 | openDelve, |
| 43 | sellIngredient, | |
| 38 | 44 | today, |
| 39 | 45 | tossSpice, |
| 40 | 46 | updateAvatar, |
| @@ -116,6 +122,20 @@ const jarBy = (authorId: string): number => | ||
| 116 | 122 | ) |
| 117 | 123 | .get(authorId)!.id; |
| 118 | 124 | |
| 125 | const walletBy = (authorId: string): number => | |
| 126 | db | |
| 127 | .query<{ id: number }, [string]>( | |
| 128 | "INSERT INTO wallet (author_id, author_name, loaves) VALUES (?1, ?1, 5) ON CONFLICT (author_id) DO UPDATE SET loaves = loaves RETURNING id", | |
| 129 | ) | |
| 130 | .get(authorId)!.id; | |
| 131 | ||
| 132 | const tradeBy = (authorId: string): number => | |
| 133 | db | |
| 134 | .query<{ id: number }, [string]>( | |
| 135 | "INSERT INTO trade (author_id, author_name, kind, item, count, loaves) VALUES (?1, ?1, 'sell', 'ghost-pepper', 1, 3) RETURNING id", | |
| 136 | ) | |
| 137 | .get(authorId)!.id; | |
| 138 | ||
| 119 | 139 | /** One door per verb an action serves: did it let `who` through, on `row`? */ |
| 120 | 140 | const doors: Record< |
| 121 | 141 | string, |
| @@ -185,6 +205,27 @@ const doors: Record< | ||
| 185 | 205 | update: () => false, |
| 186 | 206 | delete: (who, authorId) => tossSpice(db, who, { id: jarBy(authorId) }).ok, |
| 187 | 207 | }, |
| 208 | // A wallet moves only as the far side of a trade or a delve's earnings: | |
| 209 | // everyone may read the Bread Barons, nobody writes a wallet directly. | |
| 210 | wallet: { | |
| 211 | list: (who) => listWallets(db, who).ok, | |
| 212 | read: (who, authorId) => getWallet(db, who, { id: walletBy(authorId) }).ok, | |
| 213 | create: () => false, | |
| 214 | update: () => false, | |
| 215 | delete: () => false, | |
| 216 | }, | |
| 217 | // A trade is made at the Exchange (selling, or buying with bread) and | |
| 218 | // stays in the ledger as it was. | |
| 219 | trade: { | |
| 220 | create: (who) => { | |
| 221 | if (who.role !== "anon") pantryBy(who.id); | |
| 222 | return sellIngredient(db, who, { ingredient: "ghost-pepper" }).ok; | |
| 223 | }, | |
| 224 | list: (who) => listTrades(db, who).ok, | |
| 225 | read: (who, authorId) => getTrade(db, who, { id: tradeBy(authorId) }).ok, | |
| 226 | update: () => false, | |
| 227 | delete: () => false, | |
| 228 | }, | |
| 188 | 229 | // The trail is written by the app itself, never through a door: there |
| 189 | 230 | // is no action that creates, changes or removes a row of it. |
| 190 | 231 | audit: { |
| @@ -234,3 +275,14 @@ test("every door answers as the table says, as every identity", () => { | ||
| 234 | 275 | } |
| 235 | 276 | expect(asked).toBeGreaterThan(0); |
| 236 | 277 | }); |
| 278 | ||
| 279 | test("buying with bread asks the trade door and the spice door both", () => { | |
| 280 | expect(buySpice(db, { role: "anon" }, { spice: "pepper-bomb" }).ok).toBe(false); | |
| 281 | const who = whoIs("user"); | |
| 282 | if (who.role === "anon") throw new Error("user should be signed in"); | |
| 283 | db.query("DELETE FROM spice WHERE author_id = ?1").run(who.id); | |
| 284 | db.query( | |
| 285 | "INSERT INTO wallet (author_id, author_name, loaves) VALUES (?1, ?1, 100) ON CONFLICT (author_id) DO UPDATE SET loaves = 100", | |
| 286 | ).run(who.id); | |
| 287 | expect(buySpice(db, who, { spice: "pepper-bomb" }).ok).toBe(true); | |
| 288 | }); |
src/db.ts +23
| @@ -75,6 +75,29 @@ export const migrations: string[] = [ | ||
| 75 | 75 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 76 | 76 | )`, |
| 77 | 77 | `CREATE INDEX spice_author ON spice (author_id)`, |
| 78 | // Cheffry's Bread Vault. A wallet is one row a chef, written only by the | |
| 79 | // app (src/actions.ts pay); a trade is every loaf in or out of it — an | |
| 80 | // ingredient sold, a jar bought, a delve's earnings — newest last. | |
| 81 | `CREATE TABLE wallet ( | |
| 82 | id INTEGER PRIMARY KEY, | |
| 83 | author_id TEXT NOT NULL UNIQUE, | |
| 84 | author_name TEXT NOT NULL, | |
| 85 | loaves INTEGER NOT NULL CHECK (loaves >= 0), | |
| 86 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 87 | )`, | |
| 88 | `CREATE INDEX wallet_barons ON wallet (loaves DESC, id)`, | |
| 89 | `CREATE TABLE trade ( | |
| 90 | id INTEGER PRIMARY KEY, | |
| 91 | author_id TEXT NOT NULL, | |
| 92 | author_name TEXT NOT NULL, | |
| 93 | kind TEXT NOT NULL CHECK (kind IN ('sell', 'buy', 'earn')), | |
| 94 | item TEXT NOT NULL CHECK (length(item) BETWEEN 1 AND 32), | |
| 95 | count INTEGER NOT NULL CHECK (count >= 1), | |
| 96 | loaves INTEGER NOT NULL, | |
| 97 | note TEXT NOT NULL DEFAULT '', | |
| 98 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 99 | )`, | |
| 100 | `CREATE INDEX trade_author ON trade (author_id, id)`, | |
| 78 | 101 | ]; |
| 79 | 102 | |
| 80 | 103 | export function openDb(dir = dataDir): Database { |
src/delves.ts +4 −4
| @@ -72,8 +72,8 @@ export interface Delve { | ||
| 72 | 72 | readonly crew: string[]; |
| 73 | 73 | ended: number | null; |
| 74 | 74 | readonly onEnd: (delve: Delve) => void; |
| 75 | /** A chef is done here: what they picked up and threw, for the database. */ | |
| 76 | readonly onSettle: (row: HeroRow, hero: Hero) => void; | |
| 75 | /** A chef is done here: what they picked up, threw and earned, and how the delve went (null while it goes on), for the database. */ | |
| 76 | readonly onSettle: (row: HeroRow, hero: Hero, over: string | null) => void; | |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | 79 | /** A delve as the page and the API show it. */ |
| @@ -154,7 +154,7 @@ export function open( | ||
| 154 | 154 | host: { id: string; name: string }, |
| 155 | 155 | now: number, |
| 156 | 156 | onEnd: (delve: Delve) => void, |
| 157 | onSettle: (row: HeroRow, hero: Hero) => void = () => {}, | |
| 157 | onSettle: (row: HeroRow, hero: Hero, over: string | null) => void = () => {}, | |
| 158 | 158 | ): Delve { |
| 159 | 159 | const day = dayOf(now); |
| 160 | 160 | const seed = daySeed(day); |
| @@ -232,7 +232,7 @@ function settle(delve: Delve, row: HeroRow): void { | ||
| 232 | 232 | const hero = delve.world.heroes.find((h) => h.id === row.id); |
| 233 | 233 | if (hero === undefined) return; |
| 234 | 234 | try { |
| 235 | delve.onSettle(row, hero); | |
| 235 | delve.onSettle(row, hero, delve.world.over); | |
| 236 | 236 | } catch (error) { |
| 237 | 237 | console.error(error); |
| 238 | 238 | } |
src/game.test.ts +139
| @@ -8,7 +8,10 @@ import { mkdtempSync } from "node:fs"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { |
| 11 | balanceOf, | |
| 12 | buySpice, | |
| 11 | 13 | commandHero, |
| 14 | earningsOf, | |
| 12 | 15 | craftSpice, |
| 13 | 16 | enterHub, |
| 14 | 17 | getRun, |
| @@ -18,7 +21,10 @@ import { | ||
| 18 | 21 | listRuns, |
| 19 | 22 | listPantry, |
| 20 | 23 | listSpices, |
| 24 | listTrades, | |
| 25 | listWallets, | |
| 21 | 26 | openDelve, |
| 27 | sellIngredient, | |
| 22 | 28 | packOf, |
| 23 | 29 | readOrder, |
| 24 | 30 | settleHero, |
| @@ -348,3 +354,136 @@ test("a delve that ends settles everyone still at the table, once", () => { | ||
| 348 | 354 | kitchen.dismiss(row, Date.now()); |
| 349 | 355 | expect(pantryOf(me)["stale-crust"]).toBe(2); |
| 350 | 356 | }); |
| 357 | ||
| 358 | // ── Cheffry's Bread Vault ── | |
| 359 | ||
| 360 | test("selling at the Exchange turns pantry ingredients into loaves at the data's price", () => { | |
| 361 | const me = chef(); | |
| 362 | expect(failed(sellIngredient(db, anon, { ingredient: "ghost-pepper" }))).toBe("signin_required"); | |
| 363 | expect(failed(sellIngredient(db, me, { ingredient: "ghost-pepper" }))).toBe("invalid"); | |
| 364 | stock(me, { "ghost-pepper": 3 }); | |
| 365 | for (const bad of [ | |
| 366 | { ingredient: "dragon-egg" }, | |
| 367 | { ingredient: "constructor" }, | |
| 368 | { ingredient: "ghost-pepper", count: 0 }, | |
| 369 | { ingredient: "ghost-pepper", count: 1.5 }, | |
| 370 | { ingredient: "ghost-pepper", count: "2" }, | |
| 371 | { ingredient: "ghost-pepper", count: 4 }, | |
| 372 | ]) | |
| 373 | expect(failed(sellIngredient(db, me, bad))).toBe("invalid"); | |
| 374 | expect(pantryOf(me)["ghost-pepper"]).toBe(3); | |
| 375 | const sold = sellIngredient(db, me, { ingredient: "ghost-pepper", count: 2 }); | |
| 376 | if (!sold.ok) throw new Error(sold.message); | |
| 377 | const price = game.bread.sell["ghost-pepper"]!; | |
| 378 | expect(sold.value).toMatchObject({ kind: "sell", item: "ghost-pepper", count: 2, loaves: 2 * price }); | |
| 379 | expect(balanceOf(db, me.id)).toBe(2 * price); | |
| 380 | expect(pantryOf(me)["ghost-pepper"]).toBe(1); | |
| 381 | // The ledger is yours alone. | |
| 382 | const mine = listTrades(db, me); | |
| 383 | expect(mine.ok && mine.value.map((t) => t.id)).toEqual([sold.value.id]); | |
| 384 | const theirs = listTrades(db, chef()); | |
| 385 | expect(theirs.ok && theirs.value).toEqual([]); | |
| 386 | }); | |
| 387 | ||
| 388 | test("bread buys a jar, never on credit, never past a full shelf", () => { | |
| 389 | const me = chef(); | |
| 390 | const price = game.bread.buy["pepper-bomb"]!; | |
| 391 | const broke = buySpice(db, me, { spice: "pepper-bomb" }); | |
| 392 | expect(failed(broke)).toBe("invalid"); | |
| 393 | if (!broke.ok) expect(broke.message).toContain(`costs ${price}`); | |
| 394 | expect(failed(buySpice(db, me, { spice: "saffron-nuke" }))).toBe("invalid"); | |
| 395 | expect(failed(buySpice(db, anon, { spice: "pepper-bomb" }))).toBe("signin_required"); | |
| 396 | ||
| 397 | const perPepper = game.bread.sell["ghost-pepper"]!; | |
| 398 | const peppers = Math.ceil((price * (game.rack.satchel + 1)) / perPepper); | |
| 399 | stock(me, { "ghost-pepper": Math.min(peppers, game.rack.pantry) }); | |
| 400 | sellIngredient(db, me, { ingredient: "ghost-pepper", count: Math.min(peppers, game.rack.pantry) }); | |
| 401 | const before = balanceOf(db, me.id); | |
| 402 | const bought = buySpice(db, me, { spice: "pepper-bomb" }); | |
| 403 | if (!bought.ok) throw new Error(bought.message); | |
| 404 | expect(bought.value).toMatchObject({ kind: "buy", item: "pepper-bomb", loaves: -price }); | |
| 405 | expect(balanceOf(db, me.id)).toBe(before - price); | |
| 406 | const shelf = listSpices(db, me); | |
| 407 | expect(shelf.ok && shelf.value.map((j) => j.kind)).toEqual(["pepper-bomb"]); | |
| 408 | ||
| 409 | // Fill the shelf; the next purchase spends nothing. | |
| 410 | db.query("INSERT INTO spice (kind, author_id, author_name) SELECT 'hot-sauce', ?1, ?1 FROM (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8) LIMIT ?2").run(me.id, game.rack.satchel - 1); | |
| 411 | const full = balanceOf(db, me.id); | |
| 412 | expect(failed(buySpice(db, me, { spice: "pepper-bomb" }))).toBe("invalid"); | |
| 413 | expect(balanceOf(db, me.id)).toBe(full); | |
| 414 | }); | |
| 415 | ||
| 416 | test("a delve pays for the rooms you were there for, the victory, and your class's perk", () => { | |
| 417 | const { earn } = game.bread; | |
| 418 | const plain = earningsOf("nobody-class", { rooms: 2, kills: 9, victory: false, overflow: {} }); | |
| 419 | expect(plain.loaves).toBe(2 * earn.room); | |
| 420 | const won = earningsOf("nobody-class", { rooms: 5, kills: 0, victory: true, overflow: {} }); | |
| 421 | expect(won.loaves).toBe(5 * earn.room + earn.victory); | |
| 422 | expect(won.note).toContain("victory"); | |
| 423 | ||
| 424 | const perk = (cls: string) => game.bread.perks.find((p) => p.class === cls)!; | |
| 425 | const knight = earningsOf("knight", { rooms: 5, kills: 0, victory: true, overflow: {} }); | |
| 426 | expect(knight.loaves).toBe(won.loaves + Math.floor(won.loaves * perk("knight").cut!)); | |
| 427 | expect(knight.note).toContain(perk("knight").name); | |
| 428 | const archer = earningsOf("archer", { rooms: 0, kills: 6, victory: false, overflow: {} }); | |
| 429 | expect(archer.loaves).toBe(Math.floor(6 * perk("archer").crumbs!)); | |
| 430 | const mage = earningsOf("mage", { rooms: 0, kills: 0, victory: false, overflow: { "rock-salt": 2, "dragon-egg": 9 } }); | |
| 431 | expect(mage.loaves).toBe(Math.floor(2 * game.bread.sell["rock-salt"]! * perk("mage").transmute!)); | |
| 432 | // Only the mage transmutes; overflow is nothing to anyone else. | |
| 433 | expect(earningsOf("knight", { rooms: 0, kills: 0, victory: false, overflow: { "rock-salt": 2 } }).loaves).toBe(0); | |
| 434 | }); | |
| 435 | ||
| 436 | test("leaving a table settles what you earned into your wallet, and the Bread Barons see it", () => { | |
| 437 | const me = chef(); | |
| 438 | const opened = openDelve(db, me, { class: "archer" }); | |
| 439 | if (!opened.ok) throw new Error(opened.message); | |
| 440 | const delve = kitchen.delveOf(opened.value.id)!; | |
| 441 | const row = kitchen.heroOfAuthor(me.id)!; | |
| 442 | const hero = delve.world.heroes.find((h) => h.id === row.id)!; | |
| 443 | hero.rooms = 2; | |
| 444 | hero.kills = 3; | |
| 445 | expect(failed(leaveDelve(db, me, { id: row.id }))).toBe("ok"); | |
| 446 | const expected = earningsOf("archer", { rooms: 2, kills: 3, victory: false, overflow: {} }).loaves; | |
| 447 | expect(expected).toBeGreaterThan(0); | |
| 448 | expect(balanceOf(db, me.id)).toBe(expected); | |
| 449 | const ledger = listTrades(db, me); | |
| 450 | expect(ledger.ok && ledger.value[0]).toMatchObject({ kind: "earn", item: "delve", loaves: expected }); | |
| 451 | const barons = listWallets(db, anon, { limit: 100 }); | |
| 452 | expect(barons.ok && barons.value.some((w) => w.author_id === me.id && w.loaves === expected)).toBe(true); | |
| 453 | // A chef who earned nothing is not on the board. | |
| 454 | expect(barons.ok && barons.value.every((w) => w.loaves > 0)).toBe(true); | |
| 455 | }); | |
| 456 | ||
| 457 | test("the victory bonus goes to whoever is at the table when the party walks out", () => { | |
| 458 | const me = chef(); | |
| 459 | const opened = openDelve(db, me); | |
| 460 | if (!opened.ok) throw new Error(opened.message); | |
| 461 | const delve = kitchen.delveOf(opened.value.id)!; | |
| 462 | const w = delve.world; | |
| 463 | // Walk the party out of the last room. | |
| 464 | w.depth = w.plan.length - 1; | |
| 465 | w.monsters = []; | |
| 466 | w.exitOpen = true; | |
| 467 | const hero = w.heroes[0]!; | |
| 468 | hero.x = w.layout.exit.x; | |
| 469 | hero.z = w.layout.exit.z; | |
| 470 | kitchen.tick(delve, Date.now()); | |
| 471 | expect(w.over).toBe("victory"); | |
| 472 | expect(hero.rooms).toBe(1); | |
| 473 | expect(balanceOf(db, me.id)).toBe( | |
| 474 | earningsOf("knight", { rooms: 1, kills: 0, victory: true, overflow: {} }).loaves, | |
| 475 | ); | |
| 476 | }); | |
| 477 | ||
| 478 | test("a mage's pantry overflow becomes bread instead of nothing", () => { | |
| 479 | const me = chef(); | |
| 480 | stock(me, { "rock-salt": game.rack.pantry }); | |
| 481 | const opened = openDelve(db, me, { class: "mage" }); | |
| 482 | if (!opened.ok) throw new Error(opened.message); | |
| 483 | const row = kitchen.heroOfAuthor(me.id)!; | |
| 484 | kitchen.delveOf(opened.value.id)!.world.heroes.find((h) => h.id === row.id)!.haul = { "rock-salt": 3 }; | |
| 485 | leaveDelve(db, me, { id: row.id }); | |
| 486 | expect(pantryOf(me)["rock-salt"]).toBe(game.rack.pantry); | |
| 487 | const perk = game.bread.perks.find((p) => p.class === "mage")!; | |
| 488 | expect(balanceOf(db, me.id)).toBe(Math.floor(3 * game.bread.sell["rock-salt"]! * perk.transmute!)); | |
| 489 | }); |
src/mcp.ts +59
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { |
| 11 | 11 | addNote, |
| 12 | balanceOf, | |
| 13 | buySpice, | |
| 12 | 14 | craftSpice, |
| 13 | 15 | deleteNote, |
| 14 | 16 | listDelves, |
| @@ -16,7 +18,10 @@ import { | ||
| 16 | 18 | listPantry, |
| 17 | 19 | listRuns, |
| 18 | 20 | listSpices, |
| 21 | listTrades, | |
| 22 | listWallets, | |
| 19 | 23 | ok, |
| 24 | sellIngredient, | |
| 20 | 25 | type Result, |
| 21 | 26 | } from "./actions.ts"; |
| 22 | 27 | import { game } from "./content.ts"; |
| @@ -127,6 +132,60 @@ export const tools: readonly Tool[] = [ | ||
| 127 | 132 | }, |
| 128 | 133 | run: (db, who, args) => craftSpice(db, who, { spice: args.spice }), |
| 129 | 134 | }, |
| 135 | { | |
| 136 | name: "bread_barons", | |
| 137 | description: | |
| 138 | "The Bread Barons: the house's hoard, then the biggest wallets of bread in the dungeon, biggest first.", | |
| 139 | inputSchema: { | |
| 140 | type: "object", | |
| 141 | properties: { limit: { type: "integer", minimum: 1, maximum: 100 } }, | |
| 142 | }, | |
| 143 | run: (db, who, args) => { | |
| 144 | const listed = listWallets(db, who, { limit: Number(args.limit ?? game.bread.barons.size) }); | |
| 145 | if (!listed.ok) return listed; | |
| 146 | return ok({ | |
| 147 | house: game.bread.barons.house, | |
| 148 | barons: listed.value.map((w) => ({ name: w.author_name, loaves: w.loaves })), | |
| 149 | }); | |
| 150 | }, | |
| 151 | }, | |
| 152 | { | |
| 153 | name: "bread_exchange", | |
| 154 | description: | |
| 155 | "The Bread Exchange for the signed-in chef: their loaves, what the counter pays for ingredients and asks for jars, and their latest trades.", | |
| 156 | inputSchema: { type: "object", properties: {} }, | |
| 157 | run: (db, who) => { | |
| 158 | const ledger = listTrades(db, who, who.role === "anon" ? {} : { author: who.id }); | |
| 159 | if (!ledger.ok) return ledger; | |
| 160 | return ok({ | |
| 161 | loaves: who.role === "anon" ? 0 : balanceOf(db, who.id), | |
| 162 | sells: game.bread.sell, | |
| 163 | buys: game.bread.buy, | |
| 164 | trades: ledger.value.slice(0, 20), | |
| 165 | }); | |
| 166 | }, | |
| 167 | }, | |
| 168 | { | |
| 169 | name: "trade_bread", | |
| 170 | description: | |
| 171 | "Trade at the Bread Exchange as the signed-in chef: `sell` `count` of a pantry `ingredient` for loaves, or `buy` one jar of a `spice` with them.", | |
| 172 | inputSchema: { | |
| 173 | type: "object", | |
| 174 | properties: { | |
| 175 | kind: { enum: ["sell", "buy"] }, | |
| 176 | ingredient: { enum: Object.keys(game.bread.sell) }, | |
| 177 | count: { type: "integer", minimum: 1, maximum: game.rack.pantry }, | |
| 178 | spice: { enum: Object.keys(game.bread.buy) }, | |
| 179 | }, | |
| 180 | required: ["kind"], | |
| 181 | }, | |
| 182 | run: (db, who, args) => | |
| 183 | args.kind === "buy" | |
| 184 | ? buySpice(db, who, { spice: args.spice }) | |
| 185 | : args.kind === "sell" | |
| 186 | ? sellIngredient(db, who, { ingredient: args.ingredient, count: args.count }) | |
| 187 | : { ok: false, error: "invalid", message: "A trade is kind sell or buy." }, | |
| 188 | }, | |
| 130 | 189 | ]; |
| 131 | 190 | |
| 132 | 191 | /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ |
src/pages/hub.ts +111 −1
| @@ -12,6 +12,8 @@ import { | ||
| 12 | 12 | type PantryRow, |
| 13 | 13 | type Run, |
| 14 | 14 | type SpiceJar, |
| 15 | type Trade, | |
| 16 | type Wallet, | |
| 15 | 17 | } from "../actions.ts"; |
| 16 | 18 | import { game, gameHref } from "../content.ts"; |
| 17 | 19 | import type { DelveView, HeroView } from "../delves.ts"; |
| @@ -32,6 +34,10 @@ export interface HubPage { | ||
| 32 | 34 | /** This person's pantry and shelf, for the Spice Rack. */ |
| 33 | 35 | readonly pantry: readonly PantryRow[]; |
| 34 | 36 | readonly jars: readonly SpiceJar[]; |
| 37 | /** The Bread Barons, biggest hoard first; this chef's loaves and latest trades. */ | |
| 38 | readonly barons: readonly Wallet[]; | |
| 39 | readonly loaves?: number | undefined; | |
| 40 | readonly trades: readonly Trade[]; | |
| 35 | 41 | /** The hero this person is playing right now, if any. */ |
| 36 | 42 | readonly playing?: HeroView | undefined; |
| 37 | 43 | readonly notice?: string | undefined; |
| @@ -56,13 +62,15 @@ export function hubPage(p: HubPage): string { | ||
| 56 | 62 | ${tables(p)} |
| 57 | 63 | ${dressingRoom(p)} |
| 58 | 64 | ${spiceRack(p)} |
| 65 | ${exchange(p)} | |
| 66 | ${vault(p)} | |
| 59 | 67 | ${board(p)} |
| 60 | 68 | ${square(p)} |
| 61 | 69 | ${wall(p)} |
| 62 | 70 | </div> |
| 63 | 71 | ${toast(p.notice)}`, |
| 64 | 72 | who, |
| 65 | { scripts: ["hub.js"] }, | |
| 73 | { scripts: ["hub.js"], loaves: p.loaves }, | |
| 66 | 74 | ); |
| 67 | 75 | } |
| 68 | 76 | |
| @@ -197,6 +205,108 @@ function spiceRack(p: HubPage): string { | ||
| 197 | 205 | </section>`; |
| 198 | 206 | } |
| 199 | 207 | |
| 208 | const loaves = (n: number): string => | |
| 209 | `${n.toLocaleString("en")} ${n === 1 ? game.bread.currency.one : game.bread.currency.many}`; | |
| 210 | ||
| 211 | /** | |
| 212 | * The Bread Exchange, on the Tavern counter: what it pays for each | |
| 213 | * ingredient and asks for each jar, for everyone to read; for a chef, | |
| 214 | * their wallet, a Sell button by everything in their pantry, a Buy | |
| 215 | * button by every jar, and the last few lines of their ledger. | |
| 216 | */ | |
| 217 | function exchange(p: HubPage): string { | |
| 218 | const signedIn = p.who.role !== "anon"; | |
| 219 | const stock = new Map(p.pantry.map((r) => [r.ingredient, r.count])); | |
| 220 | const wallet = p.loaves ?? 0; | |
| 221 | const full = p.jars.length >= game.rack.satchel; | |
| 222 | const sells = Object.entries(game.bread.sell) | |
| 223 | .map(([g, price]) => { | |
| 224 | const held = stock.get(g) ?? 0; | |
| 225 | const button = (count: number, label: string) => | |
| 226 | `<form method="post" action="/bread/sell"><input type="hidden" name="ingredient" value="${escape(g)}"><input type="hidden" name="count" value="${count}"><button class="button button-quiet" aria-label="${escape(`${label} ${ingredientName(g)}`)}">${label}</button></form>`; | |
| 227 | const buttons = | |
| 228 | signedIn && held > 0 | |
| 229 | ? `<div class="row">${button(1, "Sell 1")}${held > 1 ? button(held, `Sell ${held}`) : ""}</div>` | |
| 230 | : ""; | |
| 231 | return `<li class="item"> | |
| 232 | <span><b>${escape(ingredientName(g))}</b> <small class="muted">${loaves(price)} each${signedIn ? ` · you have ${held}` : ""}</small></span> | |
| 233 | ${buttons} | |
| 234 | </li>`; | |
| 235 | }) | |
| 236 | .join(""); | |
| 237 | const buys = Object.entries(game.bread.buy) | |
| 238 | .map(([sp, price]) => { | |
| 239 | const button = signedIn | |
| 240 | ? `<form method="post" action="/bread/buy"><input type="hidden" name="spice" value="${escape(sp)}"><button class="button${wallet < price || full ? " button-quiet" : ""}" aria-label="Buy a ${escape(spiceName(sp))}">Buy</button></form>` | |
| 241 | : ""; | |
| 242 | return `<li class="item"> | |
| 243 | <span><b>${escape(spiceName(sp))}</b> <small class="muted">${loaves(price)}</small></span> | |
| 244 | ${button} | |
| 245 | </li>`; | |
| 246 | }) | |
| 247 | .join(""); | |
| 248 | const ledger = p.trades | |
| 249 | .map((t) => { | |
| 250 | const what = | |
| 251 | t.kind === "earn" | |
| 252 | ? `Delve earnings${t.note === "" ? "" : ` <small class="muted">(${escape(t.note)})</small>`}` | |
| 253 | : t.kind === "sell" | |
| 254 | ? `Sold ${t.count} ${escape(ingredientName(t.item))}` | |
| 255 | : `Bought a ${escape(spiceName(t.item))}`; | |
| 256 | return `<li class="item"><span>${what}</span><b class="mono">${t.loaves >= 0 ? "+" : "−"}${Math.abs(t.loaves).toLocaleString("en")}</b></li>`; | |
| 257 | }) | |
| 258 | .join(""); | |
| 259 | const perks = game.bread.perks | |
| 260 | .map((pk) => `<li><b>${escape(game.classes.find((c) => c.id === pk.class)?.name ?? pk.class)} — ${escape(pk.name)}:</b> ${escape(pk.description)}</li>`) | |
| 261 | .join(""); | |
| 262 | const yours = signedIn | |
| 263 | ? `<p class="wallet">Your wallet: <b>${loaves(wallet)}</b></p>` | |
| 264 | : `<p class="muted"><a href="/__forge/signin?to=/">Sign in</a> to open a wallet and trade.</p>`; | |
| 265 | return `<section class="card" id="exchange"> | |
| 266 | <h2>The Bread Exchange</h2> | |
| 267 | <p class="muted">One currency: ${escape(game.bread.currency.name.toLowerCase())}. Every room you clear pays ${loaves(game.bread.earn.room)}, walking out victorious pays ${loaves(game.bread.earn.victory)} more, and the counter buys your loot. Bread buys jars before a delve — cooking them yourself is still cheaper.</p> | |
| 268 | ${yours} | |
| 269 | <h3>The counter buys</h3> | |
| 270 | <ul class="list">${sells}</ul> | |
| 271 | <h3>The counter sells</h3> | |
| 272 | <ul class="list">${buys}</ul> | |
| 273 | <h3>House perks</h3> | |
| 274 | <ul class="list-plain">${perks}</ul> | |
| 275 | ${signedIn && ledger !== "" ? `<h3>Your ledger</h3><ul class="list">${ledger}</ul>` : ""} | |
| 276 | </section>`; | |
| 277 | } | |
| 278 | ||
| 279 | /** | |
| 280 | * The Bread Vault behind the bar: the chef's hoard behind glass, the | |
| 281 | * door chalked shut, and the Bread Barons on the wall beside it — the | |
| 282 | * house first, always, then the biggest hoards. Live as bread moves. | |
| 283 | */ | |
| 284 | function vault(p: HubPage): string { | |
| 285 | const { barons, chalk } = game.bread; | |
| 286 | const rows = p.barons | |
| 287 | .map( | |
| 288 | (w, i) => `<li class="item"> | |
| 289 | <span><b>#${i + 2}</b> ${escape(w.author_name)}</span> | |
| 290 | <b class="score">${loaves(w.loaves)}</b> | |
| 291 | </li>`, | |
| 292 | ) | |
| 293 | .join(""); | |
| 294 | return `<section class="card" id="vault" data-live="bread.changed"> | |
| 295 | <h2>The Bread Vault</h2> | |
| 296 | <p class="muted">Behind the bar, the chef's hoard glints behind glass. The door stays shut. Something big is breathing underneath it.</p> | |
| 297 | ${chalk.map((line) => `<p class="chalk">${escape(line)}</p>`).join("")} | |
| 298 | <h3>${escape(barons.title)}</h3> | |
| 299 | <ol class="list"> | |
| 300 | <li class="item house"> | |
| 301 | <span><b>#1</b> ${escape(barons.house.name)} <small class="muted">${escape(barons.house.boast)}</small></span> | |
| 302 | <b class="score">${loaves(barons.house.loaves)}</b> | |
| 303 | </li> | |
| 304 | ${rows} | |
| 305 | </ol> | |
| 306 | ${p.barons.length === 0 ? `<p class="empty">Nobody's minted a crumb yet. First loaf out takes #2.</p>` : ""} | |
| 307 | </section>`; | |
| 308 | } | |
| 309 | ||
| 200 | 310 | function board(p: HubPage): string { |
| 201 | 311 | const rows = p.runs |
| 202 | 312 | .map( |
src/pages/layout.ts +3 −1
| @@ -26,6 +26,8 @@ export interface Frame { | ||
| 26 | 26 | readonly heading?: string; |
| 27 | 27 | /** Scripts under static/ this page runs, as modules. */ |
| 28 | 28 | readonly scripts?: readonly string[]; |
| 29 | /** The signed-in chef's bread, shown beside their name when the page knows it. */ | |
| 30 | readonly loaves?: number | undefined; | |
| 29 | 31 | } |
| 30 | 32 | |
| 31 | 33 | export function layout(body: string, who: Who, frame: Frame = {}): string { |
| @@ -37,7 +39,7 @@ export function layout(body: string, who: Who, frame: Frame = {}): string { | ||
| 37 | 39 | const door = |
| 38 | 40 | who.role === "anon" |
| 39 | 41 | ? `<a class="button button-quiet" href="/__forge/signin?to=${encodeURIComponent(path)}">Sign in</a>` |
| 40 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> | |
| 42 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}${frame.loaves === undefined ? "" : ` · <span class="loaves" title="Your bread">${frame.loaves.toLocaleString("en")} 🍞</span>`}</span> | |
| 41 | 43 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; |
| 42 | 44 | const scripts = ["live.js", ...(frame.scripts ?? [])] |
| 43 | 45 | .map((s) => `<script type="module" src="${asset(s)}"></script>`) |
src/routes.ts +126
| @@ -15,7 +15,9 @@ import type { Database } from "bun:sqlite"; | ||
| 15 | 15 | import { existsSync, readFileSync } from "node:fs"; |
| 16 | 16 | import { |
| 17 | 17 | addNote, |
| 18 | balanceOf, | |
| 18 | 19 | BODY_MAX, |
| 20 | buySpice, | |
| 19 | 21 | commandHero, |
| 20 | 22 | craftSpice, |
| 21 | 23 | deleteNote, |
| @@ -29,6 +31,8 @@ import { | ||
| 29 | 31 | getPantry, |
| 30 | 32 | getRun, |
| 31 | 33 | getSpice, |
| 34 | getTrade, | |
| 35 | getWallet, | |
| 32 | 36 | hubChat, |
| 33 | 37 | joinDelve, |
| 34 | 38 | leaveDelve, |
| @@ -41,7 +45,10 @@ import { | ||
| 41 | 45 | listPantry, |
| 42 | 46 | listRuns, |
| 43 | 47 | listSpices, |
| 48 | listTrades, | |
| 49 | listWallets, | |
| 44 | 50 | openDelve, |
| 51 | sellIngredient, | |
| 45 | 52 | today, |
| 46 | 53 | tossSpice, |
| 47 | 54 | updateAvatar, |
| @@ -253,6 +260,45 @@ export const SPICE: Schema = { | ||
| 253 | 260 | }, |
| 254 | 261 | required: ["id", "kind", "author_id", "author_name", "created_at"], |
| 255 | 262 | }; |
| 263 | export const WALLET: Schema = { | |
| 264 | type: "object", | |
| 265 | description: "A chef's bread, as the Bread Barons board shows it.", | |
| 266 | properties: { | |
| 267 | id: { type: "integer" }, | |
| 268 | author_id: { type: "string" }, | |
| 269 | author_name: { type: "string" }, | |
| 270 | loaves: { type: "integer", minimum: 0 }, | |
| 271 | updated_at: { type: "string", format: "date-time" }, | |
| 272 | }, | |
| 273 | required: ["id", "author_id", "author_name", "loaves", "updated_at"], | |
| 274 | }; | |
| 275 | export const TRADE: Schema = { | |
| 276 | type: "object", | |
| 277 | description: "Loaves in or out of a wallet: an ingredient sold, a jar bought, or a delve's earnings. `loaves` is signed.", | |
| 278 | properties: { | |
| 279 | id: { type: "integer" }, | |
| 280 | author_id: { type: "string" }, | |
| 281 | author_name: { type: "string" }, | |
| 282 | kind: { enum: ["sell", "buy", "earn"] }, | |
| 283 | item: { type: "string" }, | |
| 284 | count: { type: "integer", minimum: 1 }, | |
| 285 | loaves: { type: "integer" }, | |
| 286 | note: { type: "string" }, | |
| 287 | created_at: { type: "string", format: "date-time" }, | |
| 288 | }, | |
| 289 | required: ["id", "author_id", "author_name", "kind", "item", "count", "loaves", "note", "created_at"], | |
| 290 | }; | |
| 291 | const TRADE_BODY: Schema = { | |
| 292 | type: "object", | |
| 293 | description: "sell: `count` (1 by default) of a pantry `ingredient` for loaves. buy: one jar of a `spice` for loaves.", | |
| 294 | properties: { | |
| 295 | kind: { enum: ["sell", "buy"] }, | |
| 296 | ingredient: { enum: Object.keys(game.bread.sell) }, | |
| 297 | count: { type: "integer", minimum: 1, maximum: game.rack.pantry, default: 1 }, | |
| 298 | spice: { enum: Object.keys(game.bread.buy) }, | |
| 299 | }, | |
| 300 | required: ["kind"], | |
| 301 | }; | |
| 256 | 302 | export const RUN: Schema = { |
| 257 | 303 | type: "object", |
| 258 | 304 | properties: { |
| @@ -356,6 +402,12 @@ export const routes: readonly Route[] = [ | ||
| 356 | 402 | c.who.role === "anon" ? [] : or(listPantry(c.db, c.who, { author: c.who.id })), |
| 357 | 403 | jars: |
| 358 | 404 | c.who.role === "anon" ? [] : or(listSpices(c.db, c.who, { author: c.who.id })), |
| 405 | barons: or(listWallets(c.db, c.who)), | |
| 406 | loaves: c.who.role === "anon" ? undefined : balanceOf(c.db, c.who.id), | |
| 407 | trades: | |
| 408 | c.who.role === "anon" | |
| 409 | ? [] | |
| 410 | : or(listTrades(c.db, c.who, { author: c.who.id })).slice(0, 5), | |
| 359 | 411 | playing: |
| 360 | 412 | c.who.role === "anon" ? undefined : heroOfAuthor(c.who.id), |
| 361 | 413 | notice, |
| @@ -483,6 +535,30 @@ export const routes: readonly Route[] = [ | ||
| 483 | 535 | path: "/spices/:id/toss", |
| 484 | 536 | handle: (c) => done(tossSpice(c.db, c.who, { id: Number(c.params.id) })), |
| 485 | 537 | }, |
| 538 | { | |
| 539 | method: "POST", | |
| 540 | path: "/bread/sell", | |
| 541 | handle: async (c) => { | |
| 542 | const form = await readForm(c.request); | |
| 543 | if (form === undefined) return text("too large", 413); | |
| 544 | const count = whole(form.get("count") as string | null); | |
| 545 | return done( | |
| 546 | sellIngredient(c.db, c.who, { | |
| 547 | ingredient: String(form.get("ingredient") ?? ""), | |
| 548 | count: count ?? -1, | |
| 549 | }), | |
| 550 | ); | |
| 551 | }, | |
| 552 | }, | |
| 553 | { | |
| 554 | method: "POST", | |
| 555 | path: "/bread/buy", | |
| 556 | handle: async (c) => { | |
| 557 | const form = await readForm(c.request); | |
| 558 | if (form === undefined) return text("too large", 413); | |
| 559 | return done(buySpice(c.db, c.who, { spice: String(form.get("spice") ?? "") })); | |
| 560 | }, | |
| 561 | }, | |
| 486 | 562 | { |
| 487 | 563 | method: "POST", |
| 488 | 564 | path: "/heroes/:id/leave", |
| @@ -776,6 +852,56 @@ export const routes: readonly Route[] = [ | ||
| 776 | 852 | api: { summary: "Tip a jar out.", answer: GONE }, |
| 777 | 853 | }, |
| 778 | 854 | |
| 855 | // Cheffry's Bread Vault: the Bread Barons, your ledger, and the Exchange. | |
| 856 | { | |
| 857 | method: "GET", | |
| 858 | path: "/api/wallets", | |
| 859 | handle: (c) => | |
| 860 | answer(listWallets(c.db, c.who, { limit: whole(c.url.searchParams.get("limit")) ?? game.bread.barons.size })), | |
| 861 | api: { | |
| 862 | summary: "The Bread Barons: the biggest hoards of bread, biggest first.", | |
| 863 | query: { limit: { type: "integer", minimum: 1, maximum: 100, default: game.bread.barons.size } }, | |
| 864 | answer: list(WALLET), | |
| 865 | }, | |
| 866 | }, | |
| 867 | { | |
| 868 | method: "GET", | |
| 869 | path: "/api/wallets/:id", | |
| 870 | handle: (c) => answer(getWallet(c.db, c.who, { id: Number(c.params.id) })), | |
| 871 | api: { summary: "One chef's bread.", answer: WALLET }, | |
| 872 | }, | |
| 873 | { | |
| 874 | method: "GET", | |
| 875 | path: "/api/trades", | |
| 876 | handle: (c) => answer(listTrades(c.db, c.who)), | |
| 877 | api: { summary: "Your ledger: every loaf in or out of your wallet, newest first.", answer: list(TRADE) }, | |
| 878 | }, | |
| 879 | { | |
| 880 | method: "POST", | |
| 881 | path: "/api/trades", | |
| 882 | handle: async (c) => { | |
| 883 | const body = await readJson(c.request); | |
| 884 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 885 | const f = fields(body); | |
| 886 | if (f.kind === "sell") | |
| 887 | return answer(sellIngredient(c.db, c.who, { ingredient: f.ingredient, count: f.count }), 201); | |
| 888 | if (f.kind === "buy") return answer(buySpice(c.db, c.who, { spice: f.spice }), 201); | |
| 889 | return answer({ ok: false, error: "invalid", message: "A trade is kind sell or buy." }); | |
| 890 | }, | |
| 891 | api: { | |
| 892 | summary: "Trade at the Bread Exchange: sell pantry ingredients for loaves, or buy a spice jar with them.", | |
| 893 | body: TRADE_BODY, | |
| 894 | answer: TRADE, | |
| 895 | status: 201, | |
| 896 | }, | |
| 897 | }, | |
| 898 | { | |
| 899 | method: "GET", | |
| 900 | path: "/api/trades/:id", | |
| 901 | handle: (c) => answer(getTrade(c.db, c.who, { id: Number(c.params.id) })), | |
| 902 | api: { summary: "One line of your ledger.", answer: TRADE }, | |
| 903 | }, | |
| 904 | ||
| 779 | 905 | // The board: finished runs, best first, with their ghosts. |
| 780 | 906 | { |
| 781 | 907 | method: "GET", |
src/server.test.ts +50
| @@ -139,6 +139,9 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 139 | 139 | "delete_note", |
| 140 | 140 | "spice_rack", |
| 141 | 141 | "craft_spice", |
| 142 | "bread_barons", | |
| 143 | "bread_exchange", | |
| 144 | "trade_bread", | |
| 142 | 145 | ]); |
| 143 | 146 | const call = (name: string, args: unknown, who = {}) => |
| 144 | 147 | rpc( |
| @@ -505,3 +508,50 @@ test("Cheffry's Spice Rack: on the hub, over the API, and as an order", async () | ||
| 505 | 508 | expect((await order({ type: "throw", spice: "saffron", x: 4, z: 4 })).status).toBe(422); |
| 506 | 509 | expect((await api(`/api/heroes/${hero}`, { method: "DELETE" }, cook)).status).toBe(200); |
| 507 | 510 | }); |
| 511 | ||
| 512 | test("Cheffry's Bread Vault: the Exchange, the Barons and the ledger at every door", async () => { | |
| 513 | const guest = await (await fetch(base + "/")).text(); | |
| 514 | expect(guest).toContain("The Bread Exchange"); | |
| 515 | expect(guest).toContain("THE CHEF'S BREAD. HANDS OFF."); | |
| 516 | expect(guest).toContain("TELL ME WHAT TO COOK"); | |
| 517 | expect(guest).toContain("CHEFFRY"); | |
| 518 | expect(guest).toContain("1,000,000 loaves"); | |
| 519 | expect(guest).not.toContain('action="/bread/sell"'); | |
| 520 | expect(guest).not.toContain('action="/bread/buy"'); | |
| 521 | ||
| 522 | const baker = as("u81", "baker"); | |
| 523 | const page = await (await api("/", {}, baker)).text(); | |
| 524 | expect(page).toContain("Your wallet: <b>0 loaves</b>"); | |
| 525 | expect(page).toContain('class="loaves"'); | |
| 526 | expect(page).toContain('action="/bread/buy"'); | |
| 527 | ||
| 528 | // Anyone reads the Barons; only a chef reads their own ledger, and trades. | |
| 529 | expect((await fetch(base + "/api/wallets")).status).toBe(200); | |
| 530 | expect((await fetch(base + "/api/trades")).status).toBe(401); | |
| 531 | expect(await (await api("/api/trades", {}, baker)).json()).toEqual([]); | |
| 532 | const stranger = await api("/api/trades", { method: "POST", ...jsonBody({ kind: "sell", ingredient: "ghost-pepper" }) }); | |
| 533 | expect(stranger.status).toBe(401); | |
| 534 | const empty = await api("/api/trades", { method: "POST", ...jsonBody({ kind: "sell", ingredient: "ghost-pepper" }) }, baker); | |
| 535 | expect(empty.status).toBe(422); | |
| 536 | const broke = await api("/api/trades", { method: "POST", ...jsonBody({ kind: "buy", spice: "hot-sauce" }) }, baker); | |
| 537 | expect(broke.status).toBe(422); | |
| 538 | expect(((await broke.json()) as { message: string }).message).toContain("Sell some loot"); | |
| 539 | const nonsense = await api("/api/trades", { method: "POST", ...jsonBody({ kind: "steal" }) }, baker); | |
| 540 | expect(nonsense.status).toBe(422); | |
| 541 | // Nobody edits a trade or a wallet by hand. | |
| 542 | expect((await api("/api/trades/1", { method: "DELETE" }, baker)).status).toBe(403); | |
| 543 | expect((await api("/api/wallets/1", { method: "PATCH", ...jsonBody({ loaves: 1e9 }) }, baker)).status).toBe(403); | |
| 544 | ||
| 545 | const form = new FormData(); | |
| 546 | form.set("spice", "hot-sauce"); | |
| 547 | const bought = await api("/bread/buy", { method: "POST", body: form, redirect: "manual" }, baker); | |
| 548 | expect(bought.status).toBe(303); | |
| 549 | expect(bought.headers.get("set-cookie")).toContain("notice="); | |
| 550 | ||
| 551 | const barons = await rpc({ jsonrpc: "2.0", id: 11, method: "tools/call", params: { name: "bread_barons", arguments: {} } }); | |
| 552 | expect(JSON.parse(barons.result.content[0].text)).toMatchObject({ house: { name: "CHEFFRY", loaves: 1000000 } }); | |
| 553 | const wallet = await rpc({ jsonrpc: "2.0", id: 12, method: "tools/call", params: { name: "bread_exchange", arguments: {} } }, baker); | |
| 554 | expect(JSON.parse(wallet.result.content[0].text)).toMatchObject({ loaves: 0, trades: [] }); | |
| 555 | const anonTrade = await rpc({ jsonrpc: "2.0", id: 13, method: "tools/call", params: { name: "trade_bread", arguments: { kind: "buy", spice: "hot-sauce" } } }); | |
| 556 | expect(anonTrade.result.isError).toBe(true); | |
| 557 | }); |
src/sim.test.ts +17
| @@ -454,3 +454,20 @@ test("whoever finishes a monster picks up what it drops; a chest can hold an ing | ||
| 454 | 454 | run(salty, w2, 20); |
| 455 | 455 | expect(opener.haul).toEqual({ "rock-salt": 1 }); |
| 456 | 456 | }); |
| 457 | ||
| 458 | test("each chef keeps their own tally of monsters finished and rooms cleared", () => { | |
| 459 | const c = kitchen(["#########", "#S.m...E#", "#########"], ["#####", "#S.E#", "#####"]); | |
| 460 | const w = createWorld(c, 1); | |
| 461 | const finisher = addHero(c, w, 1, "a", "knight"); | |
| 462 | const bystander = addHero(c, w, 2, "b", "knight"); | |
| 463 | const foe = w.monsters[0]!; | |
| 464 | foe.hp = 0; | |
| 465 | foe.lastBy = 1; | |
| 466 | step(c, w); | |
| 467 | expect([finisher.kills, bystander.kills]).toEqual([1, 0]); | |
| 468 | finisher.x = w.layout.exit.x; | |
| 469 | finisher.z = w.layout.exit.z; | |
| 470 | step(c, w); | |
| 471 | expect(w.depth).toBe(1); | |
| 472 | expect([finisher.rooms, bystander.rooms]).toEqual([1, 1]); | |
| 473 | }); |
src/sim.ts +9 −1
| @@ -217,6 +217,9 @@ export interface Hero { | ||
| 217 | 217 | thrown: number[]; |
| 218 | 218 | /** Ingredients picked up this delve, by id: what leaving the table takes home. */ |
| 219 | 219 | haul: Record<string, number>; |
| 220 | /** Rooms cleared while this chef was at the table, and monsters they finished: what the Bread Vault pays for. */ | |
| 221 | rooms: number; | |
| 222 | kills: number; | |
| 220 | 223 | wantThrow: { spice: string; x: number; z: number } | null; |
| 221 | 224 | throwCd: number; |
| 222 | 225 | } |
| @@ -591,6 +594,8 @@ export function addHero( | ||
| 591 | 594 | .map((j) => ({ jar: j.jar, spice: j.spice })), |
| 592 | 595 | thrown: [], |
| 593 | 596 | haul: {}, |
| 597 | rooms: 0, | |
| 598 | kills: 0, | |
| 594 | 599 | wantThrow: null, |
| 595 | 600 | throwCd: 0, |
| 596 | 601 | }; |
| @@ -692,9 +697,11 @@ export function step(content: Content, world: World): Happening[] { | ||
| 692 | 697 | world.kills++; |
| 693 | 698 | world.score += def.score; |
| 694 | 699 | world.happened.push({ type: "slain", monster: m.id, kind: m.kind }); |
| 700 | const finisher = world.heroes.find((h) => h.id === m.lastBy); | |
| 701 | if (finisher !== undefined) finisher.kills++; | |
| 695 | 702 | // Whoever finished it picks up what it dropped, for the Spice Rack. |
| 696 | 703 | if (def.drops !== undefined && random(world) < (def.dropChance ?? 1)) { |
| 697 | const hero = world.heroes.find((h) => h.id === m.lastBy); | |
| 704 | const hero = finisher; | |
| 698 | 705 | if (hero !== undefined) { |
| 699 | 706 | hero.haul[def.drops] = (hero.haul[def.drops] ?? 0) + 1; |
| 700 | 707 | world.happened.push({ type: "drop", hero: hero.id, ingredient: def.drops }); |
| @@ -713,6 +720,7 @@ export function step(content: Content, world: World): Happening[] { | ||
| 713 | 720 | world.heroes.some((h) => !h.downed && dist(h, world.layout.exit) < 0.7); |
| 714 | 721 | if (out) { |
| 715 | 722 | world.score += content.rules.score.room; |
| 723 | for (const h of world.heroes) h.rooms++; | |
| 716 | 724 | if (world.depth + 1 >= world.plan.length) { |
| 717 | 725 | world.score += content.rules.score.victory; |
| 718 | 726 | finish(world, "victory"); |
static/app.css +19 −1
| @@ -466,7 +466,25 @@ kbd { | ||
| 466 | 466 | background: var(); |
| 467 | 467 | border-radius: var(); |
| 468 | 468 | } |
| 469 | #spice-rack h3 { | |
| 469 | #spice-rack h3, | |
| 470 | #exchange h3, | |
| 471 | #vault h3 { | |
| 470 | 472 | margin: var() 0 var(); |
| 471 | 473 | font-size: var(); |
| 472 | 474 | } |
| 475 | ||
| 476 | /* Cheffry's Bread Vault: the door chalked shut, the house on top. */ | |
| 477 | .chalk + .chalk { | |
| 478 | margin-top: var(); | |
| 479 | } | |
| 480 | .house { | |
| 481 | border-left: 4px solid var(); | |
| 482 | padding-left: var(); | |
| 483 | } | |
| 484 | .wallet { | |
| 485 | font-size: var(); | |
| 486 | } | |
| 487 | .loaves { | |
| 488 | font-family: var(); | |
| 489 | white-space: nowrap; | |
| 490 | } |
new above
offline