diff --git a/README.md b/README.md index cb7f6c0..1ccf66c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/game.py b/game.py index 6f6e6ce..8c883bb 100644 --- a/game.py +++ b/game.py @@ -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 --------- diff --git a/static/app.js b/static/app.js index cdc089f..069bae3 100644 --- a/static/app.js +++ b/static/app.js @@ -861,7 +861,7 @@ function raidPrep(ev) { }).join(""); return `

Council requests

${reqs} -

Arrows, swords and shields in the stockpile arm the defenders. Food keeps them fighting.

+

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.

Defence

${Object.entries(d.parts).map(([k, v]) => ``).join("")} ${d.starving ? `` : ""}
${names[k]}${fmt(v)}
Hungry (Ă—0.8)
diff --git a/tests/test_game.py b/tests/test_game.py index 13222b7..4566852 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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"))