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

Merged opened this in answer to

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

Merged

cheffry-james/cheffry-s-dungeon:cheffry-james/9-from-outside-day-5-build-cheffry-s-bread → main 2 commits fork

  • Merged. cheffry-james/9-from-outside-day-5-build-cheffry-s-bread was merged into main as df197f5.

Review

Sign in to review this change.
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
5757 picks a jar and a click or tap throws it along the arc shown; `Enter`
5858 lobs it ahead, `Esc` puts it away. The next spice is yours to call — tell
5959 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.
6075 - **The daily board.** Everyone gets the same seed each UTC day. Finished
6176 runs land on the leaderboard with a ghost (the party's path, sampled every
6277 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
6782
6883 `content/*.json` is the game: `rooms.json` (layouts, spawns, traps, chests),
6984 `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`
7188 (every tuning number, in tiles and ticks) and `hub.json`. `src/content.ts` checks every file whole
7289 at boot and refuses to start on a bad one, naming the field. A new room or a
7390 new monster built from an existing brain is a JSON edit and nothing else;
7491 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.
7696
7797 ## The networking
7898
@@ -94,7 +114,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
94114
95115 | file | what it is |
96116 | ---------------------------------- | -------------------------------------------------------------- |
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 |
98118 | `src/sim.ts` | the delve: a pure seeded tick — classes, shots, abilities, brains, thrown spices and their zones |
99119 | `src/content.ts`, `content/` | the game as JSON, checked whole at boot |
100120 | `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:
151171 - **anon** — anyone. Reads.
152172 - **user** — signed in to the forge and able to see this app. Stands in the
153173 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.
155177 - **admin** — able to write this repository. Removes any note or run.
156178
157179 "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
173195 curl https://<app>/api/delves # open tables
174196 curl https://<app>/api/pantry # your ingredients (signed in)
175197 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"}'
176202 ```
177203
178204 As an MCP server, for Claude Code or Claude Desktop — `leaderboard`,
179205 `list_delves`, `list_notes`, `add_note`, `delete_note`, `spice_rack`,
180 `craft_spice`:
206 `craft_spice`, `bread_barons`, `bread_exchange`, `trade_bread`:
181207
182208 ```sh
183209 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 @@
88 { "id": "graffiti-wall", "shape": "box", "x": 11, "z": 0.6, "w": 10, "h": 2, "d": 0.4, "paint": "game-wall" },
99 { "id": "barrel-1", "shape": "prism", "x": 3, "z": 13, "w": 0.9, "h": 1, "d": 0.9, "paint": "game-baguette" },
1010 { "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" }
1216 ]
1317 }
contract.json +20
@@ -76,6 +76,26 @@
7676 "list": "author"
7777 }
7878 },
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 },
7999 "audit": {
80100 "api": "/api/audit",
81101 "can": {
contract/openapi.json +440
@@ -1270,6 +1270,446 @@
12701270 }
12711271 }
12721272 },
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 },
12731713 "/api/runs": {
12741714 "get": {
12751715 "summary": "A day's leaderboard (today unless `day`), best first.",
src/actions.ts +289 −6
@@ -537,7 +537,7 @@ function openOne(
537537 who,
538538 now,
539539 (d) => recordRun(db, d),
540 (row, hero) => settleHero(db, row, hero),
540 (row, hero, over) => settleHero(db, row, hero, over),
541541 );
542542 kitchen.enlist(delve, who, now, picked.value, packOf(db, who.id));
543543 emit("delve.opened", { id: delve.id });
@@ -864,7 +864,7 @@ function listed<T>(
864864 db: Database,
865865 who: Who,
866866 may: Exclude<ReturnType<typeof permit>, "no">,
867 table: "pantry" | "spice",
867 table: "pantry" | "spice" | "trade",
868868 author: string | undefined,
869869 ): T[] {
870870 if (may === "yes")
@@ -1046,23 +1046,306 @@ export function packOf(db: Database, authorId: string): Jar[] {
10461046
10471047 /**
10481048 * 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.
10511052 */
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 {
10531059 try {
1054 db.transaction(() => {
1060 const paid = db.transaction((): Trade | undefined => {
10551061 for (const jar of hero.thrown)
10561062 db.query("DELETE FROM spice WHERE id = ?1 AND author_id = ?2").run(jar, row.author_id);
1063 const overflow: Record<string, number> = {};
10571064 for (const [g, n] of Object.entries(hero.haul)) {
10581065 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;
10591074 db.query(
10601075 `INSERT INTO pantry (author_id, ingredient, count) VALUES (?1, ?2, min(?3, ?4))
10611076 ON CONFLICT (author_id, ingredient) DO UPDATE SET count = min(count + excluded.count, ?4)`,
10621077 ).run(row.author_id, g, n, game.rack.pantry);
10631078 }
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 });
10641093 })();
1094 if (paid !== undefined) emit("bread.changed", { author_id: paid.author_id });
10651095 } catch (error) {
10661096 console.error(error);
10671097 }
10681098 }
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 = () => ({
1717 hub: raw("hub"),
1818 classes: raw("classes"),
1919 spices: raw("spices"),
20 bread: raw("bread"),
2021 });
2122 const withClasses = (mutate: (c: any) => void) => {
2223 const f = files();
@@ -118,3 +119,47 @@ test("what drops, monster or chest, is an ingredient the rack knows", () => {
118119 expect(bad.some((p) => p.includes("dropChance must be above 0 and at most 1"))).toBe(true);
119120 expect(bad.some((p) => p.includes("ingredient moonbeam is not an ingredient"))).toBe(true);
120121 });
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 @@
11 /**
22 * 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
55 * boot and checked whole. A new monster, room or chest drop is a change to
66 * a JSON file, never to code (a new *brain* is code: `behavior` in
77 * `src/sim.ts`).
@@ -34,14 +34,46 @@ export interface Prop {
3434 /** A colour token, without `--color-`. */
3535 readonly paint: string;
3636 }
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 }
3767 export interface Game extends Content {
3868 readonly hub: HubRules & { readonly props: readonly Prop[] };
69 readonly bread: Bread;
3970 }
4071
4172 const BEHAVIORS = ["chase", "charge", "shoot"];
4273 const ABILITY_KINDS = ["bulwark", "volley", "blink"];
4374 const SPICE_SHAPES = ["circle", "line"];
4475 const SPICE_EFFECTS = ["confuse", "slow", "damage", "ignite"];
76 const PERK_KINDS = ["cut", "crumbs", "transmute"];
4577 const RESERVED = "#.SEC";
4678
4779 const isObject = (v: unknown): v is Record<string, unknown> =>
@@ -60,6 +92,7 @@ export function check(files: {
6092 hub: unknown;
6193 classes: unknown;
6294 spices: unknown;
95 bread: unknown;
6396 }): string[] {
6497 const problems: string[] = [];
6598 const say = (where: string, what: string) => problems.push(`${where}: ${what}`);
@@ -276,6 +309,46 @@ export function check(files: {
276309 )
277310 say(where, "burns must be a list of monster ids");
278311 });
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
279352 const props = listOf(files.hub, "props");
280353 ids("props", props.map((p) => (isObject(p) ? { ...p, name: p.id } : p)));
281354 props.forEach((p, i) => {
@@ -301,6 +374,7 @@ function load(): Game {
301374 hub: read("hub"),
302375 classes: read("classes"),
303376 spices: read("spices"),
377 bread: read("bread"),
304378 };
305379 const problems = check(files);
306380 if (problems.length > 0) throw new Error(`content/: ${problems.join("; ")}`);
@@ -315,6 +389,7 @@ function load(): Game {
315389 classes: f.classes!.classes as Game["classes"],
316390 rack: f.spices as unknown as Game["rack"],
317391 hub: { ...hub, props: f.hub!.props as Prop[] },
392 bread: f.bread as unknown as Bread,
318393 };
319394 }
320395
src/contract.test.ts +52
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import {
1212 addNote,
13 buySpice,
1314 commandHero,
1415 craftSpice,
1516 deleteNote,
@@ -23,6 +24,8 @@ import {
2324 getPantry,
2425 getRun,
2526 getSpice,
27 getTrade,
28 getWallet,
2629 joinDelve,
2730 leaveDelve,
2831 leaveHub,
@@ -34,7 +37,10 @@ import {
3437 listPantry,
3538 listRuns,
3639 listSpices,
40 listTrades,
41 listWallets,
3742 openDelve,
43 sellIngredient,
3844 today,
3945 tossSpice,
4046 updateAvatar,
@@ -116,6 +122,20 @@ const jarBy = (authorId: string): number =>
116122 )
117123 .get(authorId)!.id;
118124
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
119139 /** One door per verb an action serves: did it let `who` through, on `row`? */
120140 const doors: Record<
121141 string,
@@ -185,6 +205,27 @@ const doors: Record<
185205 update: () => false,
186206 delete: (who, authorId) => tossSpice(db, who, { id: jarBy(authorId) }).ok,
187207 },
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 },
188229 // The trail is written by the app itself, never through a door: there
189230 // is no action that creates, changes or removes a row of it.
190231 audit: {
@@ -234,3 +275,14 @@ test("every door answers as the table says, as every identity", () => {
234275 }
235276 expect(asked).toBeGreaterThan(0);
236277 });
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[] = [
7575 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
7676 )`,
7777 `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)`,
78101 ];
79102
80103 export function openDb(dir = dataDir): Database {
src/delves.ts +4 −4
@@ -72,8 +72,8 @@ export interface Delve {
7272 readonly crew: string[];
7373 ended: number | null;
7474 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;
7777 }
7878
7979 /** A delve as the page and the API show it. */
@@ -154,7 +154,7 @@ export function open(
154154 host: { id: string; name: string },
155155 now: number,
156156 onEnd: (delve: Delve) => void,
157 onSettle: (row: HeroRow, hero: Hero) => void = () => {},
157 onSettle: (row: HeroRow, hero: Hero, over: string | null) => void = () => {},
158158 ): Delve {
159159 const day = dayOf(now);
160160 const seed = daySeed(day);
@@ -232,7 +232,7 @@ function settle(delve: Delve, row: HeroRow): void {
232232 const hero = delve.world.heroes.find((h) => h.id === row.id);
233233 if (hero === undefined) return;
234234 try {
235 delve.onSettle(row, hero);
235 delve.onSettle(row, hero, delve.world.over);
236236 } catch (error) {
237237 console.error(error);
238238 }
src/game.test.ts +139
@@ -8,7 +8,10 @@ import { mkdtempSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import {
11 balanceOf,
12 buySpice,
1113 commandHero,
14 earningsOf,
1215 craftSpice,
1316 enterHub,
1417 getRun,
@@ -18,7 +21,10 @@ import {
1821 listRuns,
1922 listPantry,
2023 listSpices,
24 listTrades,
25 listWallets,
2126 openDelve,
27 sellIngredient,
2228 packOf,
2329 readOrder,
2430 settleHero,
@@ -348,3 +354,136 @@ test("a delve that ends settles everyone still at the table, once", () => {
348354 kitchen.dismiss(row, Date.now());
349355 expect(pantryOf(me)["stale-crust"]).toBe(2);
350356 });
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 @@
99 import type { Database } from "bun:sqlite";
1010 import {
1111 addNote,
12 balanceOf,
13 buySpice,
1214 craftSpice,
1315 deleteNote,
1416 listDelves,
@@ -16,7 +18,10 @@ import {
1618 listPantry,
1719 listRuns,
1820 listSpices,
21 listTrades,
22 listWallets,
1923 ok,
24 sellIngredient,
2025 type Result,
2126 } from "./actions.ts";
2227 import { game } from "./content.ts";
@@ -127,6 +132,60 @@ export const tools: readonly Tool[] = [
127132 },
128133 run: (db, who, args) => craftSpice(db, who, { spice: args.spice }),
129134 },
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 },
130189 ];
131190
132191 /** 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 {
1212 type PantryRow,
1313 type Run,
1414 type SpiceJar,
15 type Trade,
16 type Wallet,
1517 } from "../actions.ts";
1618 import { game, gameHref } from "../content.ts";
1719 import type { DelveView, HeroView } from "../delves.ts";
@@ -32,6 +34,10 @@ export interface HubPage {
3234 /** This person's pantry and shelf, for the Spice Rack. */
3335 readonly pantry: readonly PantryRow[];
3436 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[];
3541 /** The hero this person is playing right now, if any. */
3642 readonly playing?: HeroView | undefined;
3743 readonly notice?: string | undefined;
@@ -56,13 +62,15 @@ export function hubPage(p: HubPage): string {
5662 ${tables(p)}
5763 ${dressingRoom(p)}
5864 ${spiceRack(p)}
65 ${exchange(p)}
66 ${vault(p)}
5967 ${board(p)}
6068 ${square(p)}
6169 ${wall(p)}
6270 </div>
6371 ${toast(p.notice)}`,
6472 who,
65 { scripts: ["hub.js"] },
73 { scripts: ["hub.js"], loaves: p.loaves },
6674 );
6775 }
6876
@@ -197,6 +205,108 @@ function spiceRack(p: HubPage): string {
197205 </section>`;
198206 }
199207
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
200310 function board(p: HubPage): string {
201311 const rows = p.runs
202312 .map(
src/pages/layout.ts +3 −1
@@ -26,6 +26,8 @@ export interface Frame {
2626 readonly heading?: string;
2727 /** Scripts under static/ this page runs, as modules. */
2828 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;
2931 }
3032
3133 export function layout(body: string, who: Who, frame: Frame = {}): string {
@@ -37,7 +39,7 @@ export function layout(body: string, who: Who, frame: Frame = {}): string {
3739 const door =
3840 who.role === "anon"
3941 ? `<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>
4143 <a class="button button-quiet" href="/__forge/signout">Sign out</a>`;
4244 const scripts = ["live.js", ...(frame.scripts ?? [])]
4345 .map((s) => `<script type="module" src="${asset(s)}"></script>`)
src/routes.ts +126
@@ -15,7 +15,9 @@ import type { Database } from "bun:sqlite";
1515 import { existsSync, readFileSync } from "node:fs";
1616 import {
1717 addNote,
18 balanceOf,
1819 BODY_MAX,
20 buySpice,
1921 commandHero,
2022 craftSpice,
2123 deleteNote,
@@ -29,6 +31,8 @@ import {
2931 getPantry,
3032 getRun,
3133 getSpice,
34 getTrade,
35 getWallet,
3236 hubChat,
3337 joinDelve,
3438 leaveDelve,
@@ -41,7 +45,10 @@ import {
4145 listPantry,
4246 listRuns,
4347 listSpices,
48 listTrades,
49 listWallets,
4450 openDelve,
51 sellIngredient,
4552 today,
4653 tossSpice,
4754 updateAvatar,
@@ -253,6 +260,45 @@ export const SPICE: Schema = {
253260 },
254261 required: ["id", "kind", "author_id", "author_name", "created_at"],
255262 };
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 };
256302 export const RUN: Schema = {
257303 type: "object",
258304 properties: {
@@ -356,6 +402,12 @@ export const routes: readonly Route[] = [
356402 c.who.role === "anon" ? [] : or(listPantry(c.db, c.who, { author: c.who.id })),
357403 jars:
358404 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),
359411 playing:
360412 c.who.role === "anon" ? undefined : heroOfAuthor(c.who.id),
361413 notice,
@@ -483,6 +535,30 @@ export const routes: readonly Route[] = [
483535 path: "/spices/:id/toss",
484536 handle: (c) => done(tossSpice(c.db, c.who, { id: Number(c.params.id) })),
485537 },
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 },
486562 {
487563 method: "POST",
488564 path: "/heroes/:id/leave",
@@ -776,6 +852,56 @@ export const routes: readonly Route[] = [
776852 api: { summary: "Tip a jar out.", answer: GONE },
777853 },
778854
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
779905 // The board: finished runs, best first, with their ghosts.
780906 {
781907 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 () =>
139139 "delete_note",
140140 "spice_rack",
141141 "craft_spice",
142 "bread_barons",
143 "bread_exchange",
144 "trade_bread",
142145 ]);
143146 const call = (name: string, args: unknown, who = {}) =>
144147 rpc(
@@ -505,3 +508,50 @@ test("Cheffry's Spice Rack: on the hub, over the API, and as an order", async ()
505508 expect((await order({ type: "throw", spice: "saffron", x: 4, z: 4 })).status).toBe(422);
506509 expect((await api(`/api/heroes/${hero}`, { method: "DELETE" }, cook)).status).toBe(200);
507510 });
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&#39;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
454454 run(salty, w2, 20);
455455 expect(opener.haul).toEqual({ "rock-salt": 1 });
456456 });
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 {
217217 thrown: number[];
218218 /** Ingredients picked up this delve, by id: what leaving the table takes home. */
219219 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;
220223 wantThrow: { spice: string; x: number; z: number } | null;
221224 throwCd: number;
222225 }
@@ -591,6 +594,8 @@ export function addHero(
591594 .map((j) => ({ jar: j.jar, spice: j.spice })),
592595 thrown: [],
593596 haul: {},
597 rooms: 0,
598 kills: 0,
594599 wantThrow: null,
595600 throwCd: 0,
596601 };
@@ -692,9 +697,11 @@ export function step(content: Content, world: World): Happening[] {
692697 world.kills++;
693698 world.score += def.score;
694699 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++;
695702 // Whoever finished it picks up what it dropped, for the Spice Rack.
696703 if (def.drops !== undefined && random(world) < (def.dropChance ?? 1)) {
697 const hero = world.heroes.find((h) => h.id === m.lastBy);
704 const hero = finisher;
698705 if (hero !== undefined) {
699706 hero.haul[def.drops] = (hero.haul[def.drops] ?? 0) + 1;
700707 world.happened.push({ type: "drop", hero: hero.id, ingredient: def.drops });
@@ -713,6 +720,7 @@ export function step(content: Content, world: World): Happening[] {
713720 world.heroes.some((h) => !h.downed && dist(h, world.layout.exit) < 0.7);
714721 if (out) {
715722 world.score += content.rules.score.room;
723 for (const h of world.heroes) h.rooms++;
716724 if (world.depth + 1 >= world.plan.length) {
717725 world.score += content.rules.score.victory;
718726 finish(world, "victory");
static/app.css +19 −1
@@ -466,7 +466,25 @@ kbd {
466466 background: var(--color-toast-bg);
467467 border-radius: var(--radius-md);
468468 }
469 #spice-rack h3 {
469 #spice-rack h3,
470 #exchange h3,
471 #vault h3 {
470472 margin: var(--space-3) 0 var(--space-1);
471473 font-size: var(--text-base);
472474 }
475
476 /* Cheffry's Bread Vault: the door chalked shut, the house on top. */
477 .chalk + .chalk {
478 margin-top: var(--space-2);
479 }
480 .house {
481 border-left: 4px solid var(--color-accent);
482 padding-left: var(--space-2);
483 }
484 .wallet {
485 font-size: var(--text-lg);
486 }
487 .loaves {
488 font-family: var(--font-mono);
489 white-space: nowrap;
490 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs