Raids wear out the armoury: issued swords and shields break, worst first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 14:51:05 +01:00
parent f2e4ea39de
commit 366a195c6d
4 changed files with 91 additions and 10 deletions

View file

@ -53,6 +53,8 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
where their camp is, so fighters can sabotage it. Defeat kills or drives
off people, loots the stockpile and treasury, and may damage a building.
Damaged buildings stop working until repaired but keep their level.
Every fight uses up arrows and breaks some of the swords and shields
handed out (worst first), more in a close fight or a defeat.
Now and then a dragon comes instead of bandits.
- **Happenings:** three more tracks run on their own cooldowns. Minor threats
(wolves, a mine collapse, goblin thieves…) slow or block a skill until

65
game.py
View file

@ -1448,6 +1448,45 @@ def kit_count(stock: dict, slot: str) -> int:
return sum(qty for item, qty in stock.items() if ITEMS[item].get("slot") == slot)
def wear_share(ratio: float) -> float:
"""Share of the issued swords and shields a fight breaks: 10% in an easy win, 30% in a close one, 40% in defeat."""
if ratio < 1:
return 0.4
return 0.1 + 0.2 * max(0.0, 2 - ratio)
def wear_kit(world: dict, share: float, rng: random.Random) -> dict:
"""Break `share` of the swords and shields handed to defenders, worst first. Spares are untouched."""
stock = world["city"]["stockpile"]
broken = {}
for slot, stat in (("weapon", "power"), ("shield", "armour")):
people = int(world["city"]["population"] // 2)
issued = []
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
take = min(people, stock[item])
issued.append((item, take))
people -= take
if not people:
break
breaks = int(sum(take for _item, take in issued) * share + rng.random())
for item, take in reversed(issued):
lost = min(breaks, take)
if lost:
take_stock_qty(stock, item, lost)
broken[item] = lost
breaks -= lost
return broken
def _wear_lines(broken: dict, arrows: int) -> list[str]:
lines = []
if arrows:
lines.append(f"The defenders used {amount('arrows', arrows)}.")
if broken:
lines.append(f"{upper_first(_listing(broken))} {'was' if sum(broken.values()) == 1 else 'were'} broken in the fighting.")
return lines
def equip_problem(player: dict, item: str) -> str | None:
"""Why the player can't wear or use this yet, or None."""
req = ITEMS[item].get("req")
@ -1752,9 +1791,11 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
loot = round(attack * 2)
city["treasury"] += loot
city["repelled"] += 1
_use_up(stock, "arrows", 0.3)
broken = wear_kit(world, wear_share(ratio), rng)
arrows = _use_up(stock, "arrows", 0.3)
report["title"] = f"Victory! {upper_first(event["group"])} were driven off"
lines.append(f"The raiders fled and left {loot} gold worth of plunder behind.")
lines.extend(_wear_lines(broken, arrows))
for entry in garrison:
player = world["players"][entry["id"]]
player["favour"] += 25
@ -1764,6 +1805,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
log(world, now, "raid", f"{upper_first(event["group"])} were driven off")
else:
severity = min(1.0, 1 - ratio)
broken = wear_kit(world, wear_share(ratio), rng)
shelter = 1 - 0.08 * building_level(world, "walls")
lost_people = int(city["population"] * (0.1 + 0.3 * severity))
city["population"] = max(5.0, city["population"] - lost_people)
@ -1778,9 +1820,10 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
del stock[item]
gold = int(city["treasury"] * share)
city["treasury"] -= gold
_use_up(stock, "arrows", 0.6)
arrows = _use_up(stock, "arrows", 0.6)
report["title"] = f"Defeat. {upper_first(event["group"])} broke into the city"
lines.append(f"{lost_people} townsfolk were killed or fled.")
lines.extend(_wear_lines(broken, arrows))
if gold or taken:
stolen = ", ".join(amount(item, qty) for item, qty in taken.items())
lines.append(f"The raiders carried off {gold:,} gold" + (f" and {stolen}." if stolen else "."))
@ -1817,9 +1860,11 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
if guard["total"] >= attack:
city["repelled"] += 1
city["treasury"] += round(attack)
_use_up(stock, "arrows", 0.5)
broken = wear_kit(world, min(0.5, wear_share(guard["total"] / attack) * 1.25), rng)
arrows = _use_up(stock, "arrows", 0.5)
report["title"] = f"Victory! {upper_first(event['group'])} was driven off"
lines.append(f"It fled wounded, dropping a hoard worth {round(attack):,} gold.")
lines.extend(_wear_lines(broken, arrows))
for entry in garrison:
player = world["players"][entry["id"]]
player["favour"] += 40
@ -1831,15 +1876,17 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
log(world, now, "raid", report["title"])
else:
severity = min(1.0, 1 - guard["total"] / attack)
broken = wear_kit(world, 0.5, rng)
lost = int(city["population"] * (0.05 + 0.15 * severity))
city["population"] = max(5.0, city["population"] - lost)
share = min(0.8, 0.2 + 0.4 * severity)
burned = _take_share(stock, WOOD, share * (1 - 0.25 * building_level(world, "lumber_shed")))
burned.update(_take_share(stock, _food_items(stock), share * (1 - 0.25 * building_level(world, "granary"))))
name = _damage_building(world, now, rng)
_use_up(stock, "arrows", 0.7)
arrows = _use_up(stock, "arrows", 0.7)
report["title"] = f"{upper_first(event['group'])} set the city ablaze"
lines.append(f"{lost} townsfolk were lost in the flames.")
lines.extend(_wear_lines(broken, arrows))
if burned:
lines.append(f"It burned {_listing(burned)}.")
if name:
@ -1865,11 +1912,11 @@ def _work_problem(world: dict, player: dict, action: dict, now: float) -> None:
_solve(world, problem, now, player["name"])
def _use_up(stock: dict, item: str, share: float) -> None:
if stock.get(item):
stock[item] -= int(stock[item] * share)
if not stock[item]:
del stock[item]
def _use_up(stock: dict, item: str, share: float) -> int:
used = int(stock.get(item, 0) * share)
if used:
take_stock_qty(stock, item, used)
return used
# --- happenings: weather, seasons, hardships, fortunes, minor threats ---------

View file

@ -861,7 +861,7 @@ function raidPrep(ev) {
}).join("");
return `<div class="cols">
<div class="panel"><h2>Council requests</h2>${reqs}
<p class="small muted">Arrows, swords and shields in the stockpile arm the defenders. Food keeps them fighting.</p></div>
<p class="small muted">Arrows, swords and shields in the stockpile arm the defenders: one sword and shield each, best first. Every fight uses up arrows and breaks some of the gear, more when it's close. Food keeps them fighting.</p></div>
<div class="panel"><h2>Defence</h2>
<table class="parts">${Object.entries(d.parts).map(([k, v]) => `<tr><td>${names[k]}</td><td>${fmt(v)}</td></tr>`).join("")}
${d.starving ? `<tr><td class="bad">Hungry (×0.8)</td><td></td></tr>` : ""}</table>

View file

@ -1025,6 +1025,38 @@ class HappeningsTest(unittest.TestCase):
class WearTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("fred", "warrior"))
self.city = self.world["city"]
self.city["population"] = 40.0 # 20 defenders
def test_wear_follows_how_close_the_fight_was(self):
self.assertAlmostEqual(game.wear_share(3), 0.1)
self.assertAlmostEqual(game.wear_share(1.5), 0.2)
self.assertAlmostEqual(game.wear_share(1), 0.3)
self.assertAlmostEqual(game.wear_share(0.5), 0.4)
def test_only_issued_gear_breaks_worst_first(self):
self.city["stockpile"] = {"iron_sword": 10, "copper_sword": 30}
rng = random.Random()
rng.random = lambda: 0.0
broken = game.wear_kit(self.world, 0.4, rng)
# 20 defenders take 10 iron and 10 copper; 8 break, all copper.
self.assertEqual(broken, {"copper_sword": 8})
self.assertEqual(self.city["stockpile"], {"iron_sword": 10, "copper_sword": 22})
def test_a_won_raid_breaks_some_kit_and_reports_it(self):
self.city["stockpile"] = {"copper_sword": 20, "arrows": 100}
event = {"strength": 1, "sabotage": 0, "group": "the Red Hand"}
game._resolve_raid(self.world, event, T0, random.Random(1))
self.assertLess(self.city["stockpile"]["copper_sword"], 20)
self.assertEqual(self.city["stockpile"]["arrows"], 70)
text = " ".join(event["report"]["lines"])
self.assertIn("used 30 arrows", text)
self.assertIn("broken in the fighting", text)
class FittingTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("fred", "artisan"))