Guard jerkins: leather-only city kit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-27 17:40:41 +00:00
parent 4abf165857
commit 54f703341e
3 changed files with 56 additions and 12 deletions

37
game.py
View file

@ -206,6 +206,10 @@ LEATHERS = [
# piece suffix, slot, noun, leather used, level above the tier, icon # piece suffix, slot, noun, leather used, level above the tier, icon
LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "🧢"), ("legs", "legs", "trousers", 3, 7, "👖"), LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "🧢"), ("legs", "legs", "trousers", 3, 7, "👖"),
("body", "body", "jerkin", 4, 11, "🦺")] ("body", "body", "jerkin", 4, 11, "🦺")]
# Guard jerkins: city kit, not worn by players. Only the tier's leather, so a hunter can turn
# their own hides into them; every paid guard wears the best one in the stockpile.
GUARD_JERKIN_LEATHER = 12
JERKIN_DEFENCE = 0.2 # defence per point of armour, per guard
# Tailoring: a fibre from Farming, the cloth woven from it, and the light armour sewn from that. # Tailoring: a fibre from Farming, the cloth woven from it, and the light armour sewn from that.
# fibre (id, name, icon, value), farming level, seconds, xp, cloth (id, name, value), armour id prefix, armour name prefix # fibre (id, name, icon, value), farming level, seconds, xp, cloth (id, name, value), armour id prefix, armour name prefix
CLOTHS = [ CLOTHS = [
@ -375,6 +379,11 @@ for _tier, (_raw, _leather, _prefix, _label) in enumerate(LEATHERS):
} }
if _noun == "trousers": if _noun == "trousers":
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"]
ITEMS[f"{_prefix}_guard_jerkin"] = {
"name": f"{_label} guard jerkin", "icon": "🦺", "kit": "jerkin", "tier": _tier,
"armour": _armour_stats("medium", "body", _tier)["armour"],
"value": round(GUARD_JERKIN_LEATHER * _leather[2] * 1.3),
}
for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS): for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS):
ITEMS.setdefault(_fibre[0], {"name": _fibre[1], "icon": _fibre[2], "value": _fibre[3]}) ITEMS.setdefault(_fibre[0], {"name": _fibre[1], "icon": _fibre[2], "value": _fibre[3]})
ITEMS[_cloth[0]] = {"name": _cloth[1], "icon": "🧶", "value": _cloth[2]} ITEMS[_cloth[0]] = {"name": _cloth[1], "icon": "🧶", "value": _cloth[2]}
@ -608,6 +617,10 @@ def _tiered_actions() -> list[dict]:
25 + 5 * used + 2 * tier, used * piece_xp[tier], 25 + 5 * used + 2 * tier, used * piece_xp[tier],
{leather[0]: used, **({reagent("medium", tier): REAGENT_COUNTS[slot]} if tier else {})}, {leather[0]: used, **({reagent("medium", tier): REAGENT_COUNTS[slot]} if tier else {})},
{f"{prefix}_{suffix}": 1}, group=label)) {f"{prefix}_{suffix}": 1}, group=label))
actions.append(_action(f"lw_{prefix}_guard_jerkin", "leatherworking", f"{label} guard jerkin",
TIER_LEVELS[tier] + 9, 25 + 5 * GUARD_JERKIN_LEATHER + 2 * tier, 4 * piece_xp[tier],
{leather[0]: GUARD_JERKIN_LEATHER}, {f"{prefix}_guard_jerkin": 1},
group=label))
for tier, (fibre, level, seconds, fibre_xp, cloth, prefix, label) in enumerate(CLOTHS): for tier, (fibre, level, seconds, fibre_xp, cloth, prefix, label) in enumerate(CLOTHS):
if tier: # flax is grown with the other building materials if tier: # flax is grown with the other building materials
actions.append(_action(f"grow_{fibre[0]}", "farming", FIBRE_VERBS.get(fibre[0], f"Grow {fibre[1].lower()}"), actions.append(_action(f"grow_{fibre[0]}", "farming", FIBRE_VERBS.get(fibre[0], f"Grow {fibre[1].lower()}"),
@ -2087,7 +2100,7 @@ def city_uses(item: str) -> list[str]:
spec = ITEMS[item] spec = ITEMS[item]
if "food" in spec: if "food" in spec:
uses.append("the granary") uses.append("the granary")
if spec.get("slot") in ("weapon", "shield") or item == "arrows": if spec.get("slot") in ("weapon", "shield") or "kit" in spec or item == "arrows":
uses.append("the armoury") uses.append("the armoury")
if item == "water_barrel": if item == "water_barrel":
uses.append("the fire brigade") uses.append("the fire brigade")
@ -4184,10 +4197,15 @@ def population_view(world: dict) -> dict:
} }
def kit_slot(item: str) -> str | None:
"""Where a defender carries it: a gear slot, or city-only kit like guard jerkins."""
return ITEMS[item].get("slot") or ITEMS[item].get("kit")
def best_kit(stock: dict, slot: str, stat: str, people: int) -> float: def best_kit(stock: dict, slot: str, stat: str, people: int) -> float:
"""Total `stat` when the best `slot` items in the stockpile go to `people` defenders.""" """Total `stat` when the best `slot` items in the stockpile go to `people` defenders."""
total = 0 total = 0
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)): for item in sorted((i for i in stock if kit_slot(i) == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
take = min(people, stock[item]) take = min(people, stock[item])
total += take * ITEMS[item].get(stat, 0) total += take * ITEMS[item].get(stat, 0)
people -= take people -= take
@ -4197,26 +4215,26 @@ def best_kit(stock: dict, slot: str, stat: str, people: int) -> float:
def kit_count(stock: dict, slot: str) -> int: 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 kit_slot(item) == slot)
def wear_share(ratio: float) -> float: 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.""" """Share of the issued swords, shields and jerkins a fight breaks: 10% in an easy win, 30% in a close one, 40% in defeat."""
if ratio < 1: if ratio < 1:
return 0.4 return 0.4
return 0.1 + 0.2 * max(0.0, 2 - ratio) return 0.1 + 0.2 * max(0.0, 2 - ratio)
def wear_kit(world: dict, share: float, rng: random.Random) -> dict: 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.""" """Break `share` of the swords, shields and jerkins handed to defenders, worst first. Spares are untouched."""
stock = world["city"]["stockpile"] stock = world["city"]["stockpile"]
broken = {} broken = {}
for slot, stat in (("weapon", "power"), ("shield", "armour")): for slot, stat in (("weapon", "power"), ("shield", "armour"), ("jerkin", "armour")):
people = guards_on_duty(world) people = guards_on_duty(world)
issued = [] issued = []
if not people: if not people:
continue continue
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)): for item in sorted((i for i in stock if kit_slot(i) == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
take = min(people, stock[item]) take = min(people, stock[item])
issued.append((item, take)) issued.append((item, take))
people -= take people -= take
@ -4277,6 +4295,7 @@ def defence(world: dict, guards: int | None = None) -> dict:
"militia": round(militia * armed / posts, 1) if posts else 0, "militia": round(militia * armed / posts, 1) if posts else 0,
"weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1), "weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1),
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1), "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
"jerkins": round(JERKIN_DEFENCE * best_kit(stock, "jerkin", "armour", armed), 1),
"arrows": min(100, stock.get("arrows", 0) // 10), "arrows": min(100, stock.get("arrows", 0) // 10),
"drilled": round(drilled(world), 1), "drilled": round(drilled(world), 1),
"rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid "rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid
@ -6133,6 +6152,7 @@ def _assign_glyphs() -> None:
ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint) ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint)
for suffix, glyph in LEATHER_GLYPHS.items(): for suffix, glyph in LEATHER_GLYPHS.items():
ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint)
ITEMS[f"{prefix}_guard_jerkin"].update(glyph="leather-armor", tint=tint)
for trophy, (_name, line, tier) in TROPHIES.items(): for trophy, (_name, line, tier) in TROPHIES.items():
ITEMS[trophy].update(glyph=TROPHY_GLYPHS[line], tint=TIER_TINTS[tier - 1]) ITEMS[trophy].update(glyph=TROPHY_GLYPHS[line], tint=TIER_TINTS[tier - 1])
for item, (weight, tier) in REAGENTS.items(): for item, (weight, tier) in REAGENTS.items():
@ -6220,6 +6240,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources()
# line here (new / balance / fix), and a new release a new entry on top. # line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [ PATCH_NOTES = [
{"date": "2026-10-27", "title": "Prayer", "notes": [ {"date": "2026-10-27", "title": "Prayer", "notes": [
("new", "Added resource dumps for Leatherworking: Guard Jerkins"),
("fix", "+Q button: rounded corners."), ("fix", "+Q button: rounded corners."),
("fix", "Make box: number field sits in front of +Q."), ("fix", "Make box: number field sits in front of +Q."),
("new", "Crafting queue menu: tap ⏳ in the top bar to reorder, change counts, skip or clear."), ("new", "Crafting queue menu: tap ⏳ in the top bar to reorder, change counts, skip or clear."),
@ -6347,7 +6368,7 @@ def content() -> dict:
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
"city_uses": {item: city_uses(item) for item in ITEMS}, "city_uses": {item: city_uses(item) for item in ITEMS},
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW, "potion_charges": POTION_CHARGES, "jerkin_defence": JERKIN_DEFENCE, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW,
"order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "make_max": MAKE_MAX, "queue_max": QUEUE_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "make_max": MAKE_MAX, "queue_max": QUEUE_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, "eat_below": EAT_BELOW, "eat_choices": EAT_CHOICES, "equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, "eat_below": EAT_BELOW, "eat_choices": EAT_CHOICES,
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD, "abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD,

View file

@ -244,7 +244,7 @@ function replayBattle(id) {
// strong as its share of the total. Theirs: the raiders' strength split into bands, or the one dragon. // strong as its share of the total. Theirs: the raiders' strength split into bands, or the one dragon.
// `every` is how often each swings, in seconds of the replay; walls and the ram only take blows. // `every` is how often each swings, in seconds of the replay; walls and the ram only take blows.
const SQUADS = [ const SQUADS = [
{ id: "guards", parts: ["militia", "weapons", "shields"], name: "Guards", glyph: "swordman", dmg: "slash", every: 2 }, { id: "guards", parts: ["militia", "weapons", "shields", "jerkins"], name: "Guards", glyph: "swordman", dmg: "slash", every: 2 },
{ id: "archers", parts: ["arrows"], name: "Archers", glyph: "bow-arrow", dmg: "pierce", every: 1.8 }, { id: "archers", parts: ["arrows"], name: "Archers", glyph: "bow-arrow", dmg: "pierce", every: 1.8 },
{ id: "walls", parts: ["walls"], name: "Walls", glyph: "stone-wall", dmg: "blunt", still: true }, { id: "walls", parts: ["walls"], name: "Walls", glyph: "stone-wall", dmg: "blunt", still: true },
{ id: "folk", parts: ["townsfolk", "drilled", "rallied"], name: "Townsfolk", glyph: "farmer", dmg: "blunt", every: 2.8 }, { id: "folk", parts: ["townsfolk", "drilled", "rallied"], name: "Townsfolk", glyph: "farmer", dmg: "blunt", every: 2.8 },
@ -1689,7 +1689,8 @@ function itemStats(id) {
const st = C.styles[it.style], trade = Object.entries(C.class_style).find(([, s]) => s === it.style)?.[0]; const st = C.styles[it.style], trade = Object.entries(C.class_style).find(([, s]) => s === it.style)?.[0];
parts.push(`${it.two_handed ? "two-handed " : ""}${st.name}, ${st.interval}s${trade ? ` (${C.classes[trade].name.toLowerCase()})` : ""}`); parts.push(`${it.two_handed ? "two-handed " : ""}${st.name}, ${st.interval}s${trade ? ` (${C.classes[trade].name.toLowerCase()})` : ""}`);
} }
if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 })); if (it.kit) parts.push(`+${Math.round(it.armour * C.jerkin_defence * 10) / 10} city defence per paid guard`);
else if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 }));
if (it.reagent) parts.push(it.reagent === "jewel" ? "Reagent for jewellery" : `Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`); if (it.reagent) parts.push(it.reagent === "jewel" ? "Reagent for jewellery" : `Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`);
if (it.jewel && !["power", "ward"].includes(it.jewel)) parts.push(jewelText(it)); if (it.jewel && !["power", "ward"].includes(it.jewel)) parts.push(jewelText(it));
if (it.tool || it.station) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool || it.station).name.toLowerCase()}`); if (it.tool || it.station) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool || it.station).name.toLowerCase()}`);
@ -2250,7 +2251,7 @@ function favourSpends() {
function raidPrep(ev) { function raidPrep(ev) {
const city = S.city, me = S.me, d = city.defence; const city = S.city, me = S.me, d = city.defence;
const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", arrows: "Arrows", garrison: "Garrison" }; const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", jerkins: "Jerkins the guards wear", arrows: "Arrows", garrison: "Garrison" };
const full = d.posts * d.wage, owed = Math.max(0, full - d.wages); const full = d.posts * d.wage, owed = Math.max(0, full - d.wages);
const reqs = (ev.requests || []).map((r) => { const reqs = (ev.requests || []).map((r) => {
const mine = me.bank[r.item] || 0; const mine = me.bank[r.item] || 0;
@ -2267,7 +2268,7 @@ function raidPrep(ev) {
<div class="btns" style="margin-top:8px"> <div class="btns" style="margin-top:8px">
<button class="btn sm gold" data-wages="bank" ${!owed || !me.gold ? "disabled" : ""}>Pay ${COIN} ${fmt(Math.min(owed, me.gold))}</button> <button class="btn sm gold" data-wages="bank" ${!owed || !me.gold ? "disabled" : ""}>Pay ${COIN} ${fmt(Math.min(owed, me.gold))}</button>
<button class="btn sm" data-wages="city" ${!owed || city.treasury < 1 ? "disabled" : ""}>From the treasury (${fmt(city.treasury)})</button></div> <button class="btn sm" data-wages="city" ${!owed || city.treasury < 1 ? "disabled" : ""}>From the treasury (${fmt(city.treasury)})</button></div>
<p class="small muted">Only paid guards stand, and only they hold the armoury's swords and shields. Paying earns favour.</p> <p class="small muted">Only paid guards stand, and only they get the armoury's swords, shields and jerkins. Paying earns favour.</p>
<h2 style="margin-top:16px">Defence</h2> <h2 style="margin-top:16px">Defence</h2>
<table class="parts">${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => `<tr><td>${names[k]}</td><td>${fmt(v)}</td></tr>`).join("")} <table class="parts">${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).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>

View file

@ -1427,6 +1427,18 @@ class TierTest(unittest.TestCase):
self.assertEqual(parts["weapons"], round(0.35 * (2 * 35 + (armed - 2) * 4), 1)) self.assertEqual(parts["weapons"], round(0.35 * (2 * 35 + (armed - 2) * 4), 1))
self.assertEqual(parts["shields"], 0.5 * 3 * 4) self.assertEqual(parts["shields"], 0.5 * 3 * 4)
def test_guards_wear_the_best_jerkins(self):
world = world_with()
world["city"]["stockpile"] = {"leather_guard_jerkin": 50, "elk_leather_guard_jerkin": 3}
world["city"]["wages"] = 10_000
parts = game.defence(world)["parts"]
armed = int(world["city"]["population"] // 2) # 10
self.assertEqual(parts["jerkins"], round(game.JERKIN_DEFENCE * (3 * 10 + (armed - 3) * 2), 1))
self.assertEqual(game.city_uses("elk_leather_guard_jerkin"), ["the armoury"])
self.assertNotIn("slot", game.ITEMS["elk_leather_guard_jerkin"]) # city kit, not player gear
recipe = game.ACTION_BY_ID["lw_elk_leather_guard_jerkin"]
self.assertEqual(recipe["inputs"], {"elk_leather": 12})
def test_higher_building_levels_need_higher_tiers(self): def test_higher_building_levels_need_higher_tiers(self):
self.assertNotIn("steel_bar", game.build_cost("walls", 2)) self.assertNotIn("steel_bar", game.build_cost("walls", 2))
self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 60) self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 60)
@ -1947,6 +1959,16 @@ class WearTest(unittest.TestCase):
self.assertEqual(broken, {"copper_sword": 8}) self.assertEqual(broken, {"copper_sword": 8})
self.assertEqual(self.city["stockpile"], {"iron_sword": 10, "copper_sword": 22}) self.assertEqual(self.city["stockpile"], {"iron_sword": 10, "copper_sword": 22})
def test_issued_jerkins_break_too(self):
self.city["stockpile"] = {"bear_leather_guard_jerkin": 30}
self.city["buildings"]["barracks"]["level"] = 1
self.city["wages"] = 10_000
rng = random.Random()
rng.random = lambda: 0.0
# 20 defenders wear 20 jerkins; 8 break, the 10 spares are untouched.
self.assertEqual(game.wear_kit(self.world, 0.4, rng), {"bear_leather_guard_jerkin": 8})
self.assertEqual(self.city["stockpile"], {"bear_leather_guard_jerkin": 22})
def test_a_won_raid_breaks_some_kit_and_reports_it(self): def test_a_won_raid_breaks_some_kit_and_reports_it(self):
self.city["stockpile"] = {"copper_sword": 20, "arrows": 100} self.city["stockpile"] = {"copper_sword": 20, "arrows": 100}
self.city["wages"] = 10_000 self.city["wages"] = 10_000