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:
parent
f2e4ea39de
commit
366a195c6d
4 changed files with 91 additions and 10 deletions
|
|
@ -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
|
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.
|
off people, loots the stockpile and treasury, and may damage a building.
|
||||||
Damaged buildings stop working until repaired but keep their level.
|
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.
|
Now and then a dragon comes instead of bandits.
|
||||||
- **Happenings:** three more tracks run on their own cooldowns. Minor threats
|
- **Happenings:** three more tracks run on their own cooldowns. Minor threats
|
||||||
(wolves, a mine collapse, goblin thieves…) slow or block a skill until
|
(wolves, a mine collapse, goblin thieves…) slow or block a skill until
|
||||||
|
|
|
||||||
65
game.py
65
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)
|
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:
|
def equip_problem(player: dict, item: str) -> str | None:
|
||||||
"""Why the player can't wear or use this yet, or None."""
|
"""Why the player can't wear or use this yet, or None."""
|
||||||
req = ITEMS[item].get("req")
|
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)
|
loot = round(attack * 2)
|
||||||
city["treasury"] += loot
|
city["treasury"] += loot
|
||||||
city["repelled"] += 1
|
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"
|
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.append(f"The raiders fled and left {loot} gold worth of plunder behind.")
|
||||||
|
lines.extend(_wear_lines(broken, arrows))
|
||||||
for entry in garrison:
|
for entry in garrison:
|
||||||
player = world["players"][entry["id"]]
|
player = world["players"][entry["id"]]
|
||||||
player["favour"] += 25
|
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")
|
log(world, now, "raid", f"{upper_first(event["group"])} were driven off")
|
||||||
else:
|
else:
|
||||||
severity = min(1.0, 1 - ratio)
|
severity = min(1.0, 1 - ratio)
|
||||||
|
broken = wear_kit(world, wear_share(ratio), rng)
|
||||||
shelter = 1 - 0.08 * building_level(world, "walls")
|
shelter = 1 - 0.08 * building_level(world, "walls")
|
||||||
lost_people = int(city["population"] * (0.1 + 0.3 * severity))
|
lost_people = int(city["population"] * (0.1 + 0.3 * severity))
|
||||||
city["population"] = max(5.0, city["population"] - lost_people)
|
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]
|
del stock[item]
|
||||||
gold = int(city["treasury"] * share)
|
gold = int(city["treasury"] * share)
|
||||||
city["treasury"] -= gold
|
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"
|
report["title"] = f"Defeat. {upper_first(event["group"])} broke into the city"
|
||||||
lines.append(f"{lost_people} townsfolk were killed or fled.")
|
lines.append(f"{lost_people} townsfolk were killed or fled.")
|
||||||
|
lines.extend(_wear_lines(broken, arrows))
|
||||||
if gold or taken:
|
if gold or taken:
|
||||||
stolen = ", ".join(amount(item, qty) for item, qty in taken.items())
|
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 "."))
|
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:
|
if guard["total"] >= attack:
|
||||||
city["repelled"] += 1
|
city["repelled"] += 1
|
||||||
city["treasury"] += round(attack)
|
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"
|
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.append(f"It fled wounded, dropping a hoard worth {round(attack):,} gold.")
|
||||||
|
lines.extend(_wear_lines(broken, arrows))
|
||||||
for entry in garrison:
|
for entry in garrison:
|
||||||
player = world["players"][entry["id"]]
|
player = world["players"][entry["id"]]
|
||||||
player["favour"] += 40
|
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"])
|
log(world, now, "raid", report["title"])
|
||||||
else:
|
else:
|
||||||
severity = min(1.0, 1 - guard["total"] / attack)
|
severity = min(1.0, 1 - guard["total"] / attack)
|
||||||
|
broken = wear_kit(world, 0.5, rng)
|
||||||
lost = int(city["population"] * (0.05 + 0.15 * severity))
|
lost = int(city["population"] * (0.05 + 0.15 * severity))
|
||||||
city["population"] = max(5.0, city["population"] - lost)
|
city["population"] = max(5.0, city["population"] - lost)
|
||||||
share = min(0.8, 0.2 + 0.4 * severity)
|
share = min(0.8, 0.2 + 0.4 * severity)
|
||||||
burned = _take_share(stock, WOOD, share * (1 - 0.25 * building_level(world, "lumber_shed")))
|
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"))))
|
burned.update(_take_share(stock, _food_items(stock), share * (1 - 0.25 * building_level(world, "granary"))))
|
||||||
name = _damage_building(world, now, rng)
|
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"
|
report["title"] = f"{upper_first(event['group'])} set the city ablaze"
|
||||||
lines.append(f"{lost} townsfolk were lost in the flames.")
|
lines.append(f"{lost} townsfolk were lost in the flames.")
|
||||||
|
lines.extend(_wear_lines(broken, arrows))
|
||||||
if burned:
|
if burned:
|
||||||
lines.append(f"It burned {_listing(burned)}.")
|
lines.append(f"It burned {_listing(burned)}.")
|
||||||
if name:
|
if name:
|
||||||
|
|
@ -1865,11 +1912,11 @@ def _work_problem(world: dict, player: dict, action: dict, now: float) -> None:
|
||||||
_solve(world, problem, now, player["name"])
|
_solve(world, problem, now, player["name"])
|
||||||
|
|
||||||
|
|
||||||
def _use_up(stock: dict, item: str, share: float) -> None:
|
def _use_up(stock: dict, item: str, share: float) -> int:
|
||||||
if stock.get(item):
|
used = int(stock.get(item, 0) * share)
|
||||||
stock[item] -= int(stock[item] * share)
|
if used:
|
||||||
if not stock[item]:
|
take_stock_qty(stock, item, used)
|
||||||
del stock[item]
|
return used
|
||||||
|
|
||||||
|
|
||||||
# --- happenings: weather, seasons, hardships, fortunes, minor threats ---------
|
# --- happenings: weather, seasons, hardships, fortunes, minor threats ---------
|
||||||
|
|
|
||||||
|
|
@ -861,7 +861,7 @@ function raidPrep(ev) {
|
||||||
}).join("");
|
}).join("");
|
||||||
return `<div class="cols">
|
return `<div class="cols">
|
||||||
<div class="panel"><h2>Council requests</h2>${reqs}
|
<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>
|
<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("")}
|
<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>
|
${d.starving ? `<tr><td class="bad">Hungry (×0.8)</td><td></td></tr>` : ""}</table>
|
||||||
|
|
|
||||||
|
|
@ -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):
|
class FittingTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.world = world_with(("fred", "artisan"))
|
self.world = world_with(("fred", "artisan"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue