From b82f979b4c96215d37c0262122fd5a1632284c30 Mon Sep 17 00:00:00 2001
From: rope
Date: Sun, 18 Oct 2026 22:40:36 +0100
Subject: [PATCH] Only donate what the city uses; sell useless overflow
Co-Authored-By: Claude Opus 5.5
---
game.py | 32 +++++++++++++++++++++++++++-----
static/app.js | 16 ++++++++++++++--
tests/test_game.py | 22 ++++++++++++++++++++++
3 files changed, 63 insertions(+), 7 deletions(-)
diff --git a/game.py b/game.py
index a3fa585..5187793 100644
--- a/game.py
+++ b/game.py
@@ -539,13 +539,31 @@ def bank_room(world: dict, player: dict, item: str) -> int:
return max(0, stack_size(world) - have)
-def bank_add(world: dict, player: dict, item: str, qty: int, now: float, overflow: dict) -> int:
- """Store what fits; the rest goes to the city (or is lost). Returns qty kept."""
+def city_uses(item: str) -> list[str]:
+ """What the city does with an item: the buildings that need it, the granary, the armoury."""
+ uses = [spec["name"] for spec in BUILDINGS.values()
+ if any(item in spec.get(key, {}) for key in ("base", "advanced", "late", "final"))]
+ spec = ITEMS[item]
+ if "food" in spec:
+ uses.append("the granary")
+ if spec.get("slot") in ("weapon", "shield") or item == "arrows":
+ uses.append("the armoury")
+ return uses
+
+
+def bank_add(world: dict, player: dict, item: str, qty: int, now: float, summary: dict) -> int:
+ """Store what fits; the rest goes to the city if it has a use, else is sold. Returns qty kept."""
keep = min(qty, bank_room(world, player, item))
if keep:
player["bank"][item] = player["bank"].get(item, 0) + keep
spill = qty - keep
- if spill and player["overflow"] == "city":
+ if spill and player["overflow"] == "city" and not city_uses(item):
+ gold = int(spill * sell_price(world, item, now))
+ player["gold"] += gold
+ summary["sold"][item] = summary["sold"].get(item, 0) + spill
+ summary["sold_gold"] += gold
+ elif spill and player["overflow"] == "city":
+ overflow = summary["overflow"]
stockpile = world["city"]["stockpile"]
stockpile[item] = stockpile.get(item, 0) + spill
gain = spill * ITEMS[item]["value"]
@@ -717,7 +735,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
event["recon"] += action["recon"]
for item, qty in outputs.items():
- kept = bank_add(world, player, item, qty, now, summary["overflow"])
+ kept = bank_add(world, player, item, qty, now, summary)
if kept:
summary["items"][item] = summary["items"].get(item, 0) + kept
gain_xp(world, player, action["skill"], xp, now, summary)
@@ -739,7 +757,8 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
- "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None}
+ "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
+ "sold": {}, "sold_gold": 0}
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
@@ -828,6 +847,8 @@ def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str |
gain = qty
label = f"{qty:,} gold"
else:
+ if not city_uses(item):
+ return "The city has no use for that"
if player["bank"].get(item, 0) < qty:
return "You don't have that many"
bank_take(player, item, qty)
@@ -1411,5 +1432,6 @@ def content() -> dict:
return {
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
+ "city_uses": {item: city_uses(item) for item in ITEMS},
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
}
diff --git a/static/app.js b/static/app.js
index a32adde..16ecacb 100644
--- a/static/app.js
+++ b/static/app.js
@@ -133,6 +133,8 @@ function showSummary(sum) {
if (sum.lucky) toast(sum.lucky === 1 ? "โจ Double yield!" : `โจ ${sum.lucky} double yields!`, "level");
const spilled = Object.entries(sum.overflow);
if (spilled.length) toast(`๐ฆ Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`);
+ const sold = Object.entries(sum.sold || {});
+ if (sold.length) toast(`๐ฆ Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`);
for (const [skill, level] of sum.levels) toast(`๐ ${skillOf(skill).name} level ${level}!`, "level");
if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad");
if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`);
@@ -143,6 +145,7 @@ function awayModal(sum) {
const xp = Object.entries(sum.xp).map(([id, q]) => `${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP `).join("");
const levels = sum.levels.map(([id, l]) => `๐ ${esc(skillOf(id).name)} reached level ${l} `).join("");
const spilled = Object.entries(sum.overflow).map(([id, q]) => `${item(id).icon} ${fmt(q)} ${esc(item(id).name)} `).join("");
+ const sold = Object.entries(sum.sold || {}).map(([id, q]) => `${item(id).icon} ${fmt(q)} ${esc(item(id).name)} `).join("");
openModal(`
While you were away
You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.
@@ -151,6 +154,7 @@ function awayModal(sum) {
${sum.gold ? `+${fmt(sum.gold)} gold
` : ""}
${xp ? `` : ""}
${levels ? `` : ""}
+ ${sold ? `Your bank was full, so this was sold for ${fmt(sum.sold_gold)} gold:
` : ""}
${spilled ? `Your bank was full, so this went to the city stockpile (for half the favour of giving it yourself):
` : ""}
${sum.stopped ? `Stopped: ${esc(sum.stopped)}
` : ""}
Back to work
`);
@@ -453,7 +457,8 @@ function bank() {
When an item doesn't fit:
Send it to the city
Stop working
- Raids never touch your bank. Overflow sent to the city earns half the favour of giving it yourself.
+ Raids never touch your bank. Overflow sent to the city earns half the favour of giving it yourself;
+ anything the city has no use for is sold instead.
${favourPanel()}
@@ -476,13 +481,20 @@ function itemPanel(id) {
${it.heal ? `Heals ${it.heal} in fights.
${S.me.food === id ? `
Your fighting food ` : `
Eat this first in fights `}
` : ""}
${it.food ? `Feeds ${it.food} townsfolk for two days in the city granary.
` : ""}
- Donate to the city ยท +${it.value} favour each
${qtyBtns("data-donate")}
+ ${C.city_uses[id].length
+ ? `Donate to the city ยท +${it.value} favour each
${qtyBtns("data-donate")}
+
Used for ${esc(listing(C.city_uses[id]))}.
`
+ : `The city has no use for this. Sell it, or give it to someone who can use it.
`}
${others.length ? `Give to ${others.map((p) => `${esc(p.name)} `).join("")}
${qtyBtns("data-give")}
` : ""}
Sell
${qtyBtns("data-sell")}
Close
${goldPanel()}`;
}
+function listing(words) {
+ return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
+}
+
function equipRow(id, it) {
const [skill, level] = it.req || [];
const short = skill && S.me.levels[skill] < level;
diff --git a/tests/test_game.py b/tests/test_game.py
index 3051cc4..68d4037 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -109,6 +109,28 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["favour"], 1.5) # half favour for overflow
self.assertEqual(p["donated"], 3)
+ def test_full_bank_sells_what_the_city_cannot_use(self):
+ world = world_with(("fred", "warrior"))
+ p = world["players"]["fred"]
+ p["bank"] = {"copper_ore": game.BASE_STACK - 1}
+ game.start_action(world, p, "mine_copper", T0)
+ summary = game.process_player(world, p, T0 + 100, random.Random(1))
+ self.assertEqual(summary["sold"], {"copper_ore": 3})
+ self.assertEqual(p["gold"], 3 * game.ITEMS["copper_ore"]["value"])
+ self.assertNotIn("copper_ore", world["city"]["stockpile"])
+ self.assertEqual(p["favour"], 0)
+
+ def test_only_useful_things_can_be_donated(self):
+ world = world_with(("fred", "warrior"))
+ p = world["players"]["fred"]
+ p["bank"] = {"copper_ore": 10, "bricks": 10, "bread": 5, "iron_helmet": 1, "steel_sword": 1}
+ self.assertEqual(game.donate(world, p, "copper_ore", 10, T0), "The city has no use for that")
+ self.assertEqual(game.donate(world, p, "iron_helmet", 1, T0), "The city has no use for that")
+ for item, qty in (("bricks", 10), ("bread", 5), ("steel_sword", 1)):
+ self.assertIsNone(game.donate(world, p, item, qty, T0))
+ self.assertIn("Walls", game.city_uses("bricks"))
+ self.assertEqual(game.city_uses("bread"), ["the granary"])
+
def test_full_bank_can_stop_instead(self):
world = world_with(("fred", "warrior"))
p = world["players"]["fred"]