Bank: a full stack spills into another slot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 10:52:07 +01:00
parent fb37bcef98
commit 71d7ca704b
5 changed files with 41 additions and 15 deletions

View file

@ -23,8 +23,9 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
city). Gear needs a combat level to wear, tools a skill level to use. city). Gear needs a combat level to wear, tools a skill level to use.
- **Classes:** warrior, scout, artisan, pioneer. Every class can train every - **Classes:** warrior, scout, artisan, pioneer. Every class can train every
skill; a class makes some skills 20% faster and has one signature ability. skill; a class makes some skills 20% faster and has one signature ability.
- **Bank:** 12 slots of up to 1,000 each to start. When something doesn't - **Bank:** 12 slots of 1,000 each to start; more than a stack of one item
fit it goes to the city stockpile (or the action stops, if you prefer). spills into another slot. When something doesn't fit it goes to the city
stockpile (or the action stops, if you prefer).
Raids never touch your bank. Players can give items to each other. Raids never touch your bank. Players can give items to each other.
- **City:** each building collects its own fund, from players' banks or the - **City:** each building collects its own fund, from players' banks or the
city's stockpile and treasury. Funds are safe from raiders, and a funded city's stockpile and treasury. Funds are safe from raiders, and a funded

1
app.py
View file

@ -107,6 +107,7 @@ def player_view(world: dict, player: dict, now: float) -> dict:
view["labour_per_action"] = game.labour_per_action(player) view["labour_per_action"] = game.labour_per_action(player)
view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}
view["slots"] = game.bank_slots(world, player) view["slots"] = game.bank_slots(world, player)
view["slots_used"] = game.slots_used(world, player)
view["stack"] = game.stack_size(world) view["stack"] = game.stack_size(world)
view["power"] = game.combat_power(player) view["power"] = game.combat_power(player)
view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]} view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]}

15
game.py
View file

@ -718,12 +718,19 @@ def stack_size(world: dict) -> int:
return BASE_STACK + 250 * building_level(world, "storehouse") return BASE_STACK + 250 * building_level(world, "storehouse")
def slots_used(world: dict, player: dict) -> int:
"""Bank slots taken: an item spills into another slot every full stack."""
stack = stack_size(world)
return sum(-(-qty // stack) for qty in player["bank"].values())
def bank_room(world: dict, player: dict, item: str) -> int: def bank_room(world: dict, player: dict, item: str) -> int:
"""How many more of `item` fit in the bank.""" """How many more of `item` fit: the rest of its last stack, plus any free slots."""
stack = stack_size(world)
have = player["bank"].get(item, 0) have = player["bank"].get(item, 0)
if have == 0 and len(player["bank"]) >= bank_slots(world, player): partial = -have % stack # room left in the item's last, part-filled stack
return 0 free = max(0, bank_slots(world, player) - slots_used(world, player))
return max(0, stack_size(world) - have) return partial + free * stack
def city_uses(item: str) -> list[str]: def city_uses(item: str) -> list[str]:

View file

@ -239,7 +239,7 @@ function sidebar() {
${nav("roster", "👥", "Guild roster", `${S.players.length}`)} ${nav("roster", "👥", "Guild roster", `${S.players.length}`)}
<div class="navhead">You</div> <div class="navhead">You</div>
${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`)} ${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`; ${nav("bank", "🏦", "Bank", `${me.slots_used}/${me.slots}`)}`;
let group = null; let group = null;
for (const skill of C.skills) { for (const skill of C.skills) {
if (skill.class && skill.class !== me.cls) continue; if (skill.class && skill.class !== me.cls) continue;
@ -518,16 +518,22 @@ function bank() {
const me = S.me; const me = S.me;
const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0])); const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0]));
if (selected && !me.bank[selected]) selected = null; if (selected && !me.bank[selected]) selected = null;
let slots = entries.map(([id, q]) => ` // An item past one full stack spills into more slots: 2,500 coal shows as 1,000 + 1,000 + 500.
<button class="slot ${selected === id ? "on" : ""}" data-select="${id}" title="${esc(item(id).name)}"> const stacks = entries.flatMap(([id, q]) => {
const parts = [];
for (let left = q; left > 0; left -= me.stack) parts.push([id, Math.min(left, me.stack)]);
return parts;
});
let slots = stacks.map(([id, q]) => `
<button class="slot ${selected === id ? "on" : ""}" data-select="${id}" title="${esc(item(id).name)}: ${fmt(me.bank[id])} in all">
<span class="qty ${q >= me.stack ? "full" : ""}">${fmt(q)}</span>${item(id).icon}<span class="nm">${esc(item(id).name)}</span> <span class="qty ${q >= me.stack ? "full" : ""}">${fmt(q)}</span>${item(id).icon}<span class="nm">${esc(item(id).name)}</span>
</button>`).join(""); </button>`).join("");
for (let i = entries.length; i < me.slots; i++) slots += `<div class="slot empty"></div>`; for (let i = stacks.length; i < me.slots; i++) slots += `<div class="slot empty"></div>`;
return ` return `
<div class="bank-wrap"> <div class="bank-wrap">
<div> <div>
<div class="panel"> <div class="panel">
<h2>Bank · ${entries.length} / ${me.slots} slots · up to ${fmt(me.stack)} each</h2> <h2>Bank · ${me.slots_used} / ${me.slots} slots · ${fmt(me.stack)} per stack</h2>
<div class="slots">${slots}</div> <div class="slots">${slots}</div>
<p class="small muted">When an item doesn't fit: <p class="small muted">When an item doesn't fit:
<button class="btn sm ${me.overflow === "city" ? "gold" : ""}" data-overflow="city">Send it to the city</button> <button class="btn sm ${me.overflow === "city" ? "gold" : ""}" data-overflow="city">Send it to the city</button>
@ -912,7 +918,7 @@ function characterSheet(uid) {
${mine ? `<div class="stat"><div class="k">Prayer points</div><div class="v">${fmt(p.pp)}</div><div class="note">${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}</div></div>` : ""} ${mine ? `<div class="stat"><div class="k">Prayer points</div><div class="v">${fmt(p.pp)}</div><div class="note">${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}</div></div>` : ""}
<div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less damage</div></div> <div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less damage</div></div>
<div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div> <div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div>
${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${Object.keys(p.bank).length} / ${p.slots} bank slots</div></div>` : ""} ${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${p.slots_used} / ${p.slots} bank slots</div></div>` : ""}
</div> </div>
<div class="cols"> <div class="cols">
<div> <div>

View file

@ -100,10 +100,10 @@ class ActionTest(unittest.TestCase):
def test_full_bank_overflows_to_city(self): def test_full_bank_overflows_to_city(self):
world = world_with(("fred", "warrior")) world = world_with(("fred", "warrior"))
p = world["players"]["fred"] p = world["players"]["fred"]
p["bank"] = {"stone": game.BASE_STACK - 2} p["bank"] = {"stone": game.BASE_STACK * game.BASE_SLOTS - 2}
game.start_action(world, p, "mine_stone", T0) game.start_action(world, p, "mine_stone", T0)
summary = game.process_player(world, p, T0 + 100, random.Random(1)) summary = game.process_player(world, p, T0 + 100, random.Random(1))
self.assertEqual(p["bank"]["stone"], game.BASE_STACK) self.assertEqual(p["bank"]["stone"], game.BASE_STACK * game.BASE_SLOTS)
self.assertEqual(summary["overflow"], {"stone": 3}) self.assertEqual(summary["overflow"], {"stone": 3})
self.assertEqual(world["city"]["stockpile"]["stone"], 3) self.assertEqual(world["city"]["stockpile"]["stone"], 3)
self.assertEqual(p["favour"], 1.5) # half favour for overflow self.assertEqual(p["favour"], 1.5) # half favour for overflow
@ -112,7 +112,7 @@ class ActionTest(unittest.TestCase):
def test_full_bank_sells_what_the_city_cannot_use(self): def test_full_bank_sells_what_the_city_cannot_use(self):
world = world_with(("fred", "warrior")) world = world_with(("fred", "warrior"))
p = world["players"]["fred"] p = world["players"]["fred"]
p["bank"] = {"copper_ore": game.BASE_STACK - 1} p["bank"] = {"copper_ore": game.BASE_STACK * game.BASE_SLOTS - 1}
game.start_action(world, p, "mine_copper", T0) game.start_action(world, p, "mine_copper", T0)
summary = game.process_player(world, p, T0 + 100, random.Random(1)) summary = game.process_player(world, p, T0 + 100, random.Random(1))
self.assertEqual(summary["sold"], {"copper_ore": 3}) self.assertEqual(summary["sold"], {"copper_ore": 3})
@ -131,6 +131,17 @@ class ActionTest(unittest.TestCase):
self.assertIn("Walls", game.city_uses("bricks")) self.assertIn("Walls", game.city_uses("bricks"))
self.assertEqual(game.city_uses("bread"), ["the granary"]) self.assertEqual(game.city_uses("bread"), ["the granary"])
def test_a_full_stack_spills_into_another_slot(self):
world = world_with(("fred", "warrior"))
p = world["players"]["fred"]
p["bank"] = {"coal": game.BASE_STACK, "logs": 10}
self.assertEqual(game.slots_used(world, p), 2)
self.assertEqual(game.bank_room(world, p, "coal"), (game.BASE_SLOTS - 2) * game.BASE_STACK)
self.assertEqual(game.bank_room(world, p, "logs"), game.BASE_STACK - 10 + (game.BASE_SLOTS - 2) * game.BASE_STACK)
p["bank"]["coal"] = 2500
self.assertEqual(game.slots_used(world, p), 4) # 1000 + 1000 + 500, plus the logs
self.assertEqual(game.bank_room(world, p, "coal"), 500 + (game.BASE_SLOTS - 4) * game.BASE_STACK)
def test_full_bank_can_stop_instead(self): def test_full_bank_can_stop_instead(self):
world = world_with(("fred", "warrior")) world = world_with(("fred", "warrior"))
p = world["players"]["fred"] p = world["players"]["fred"]