From 002e990dc45f25e68c75222ec89fd656ef032e36 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 28 Oct 2026 10:18:15 +0000 Subject: [PATCH] Guard kit: halberds, pavises, gambesons, salves; Raid readiness Co-Authored-By: Claude Opus 5.5 --- game.py | 154 +++++++++++++++++++++++++++++++++------------ static/app.js | 17 ++--- static/icons.svg | 4 ++ tests/test_game.py | 46 ++++++++++++++ 4 files changed, 175 insertions(+), 46 deletions(-) diff --git a/game.py b/game.py index c853e29..035354b 100644 --- a/game.py +++ b/game.py @@ -211,6 +211,19 @@ LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "🧢"), ("legs", "legs", "trou # 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 +# The other trades' guard kit works the same way: the trade's own material only, three times what +# the piece it stands in for takes, for that piece's XP. Each shares that piece's slot at 75% of its +# stat, so trophy gear stays the best kit. Salves have a slot of their own and are used up. +KIT_SHARE = 0.75 +GUARD_HALBERD_BARS = 6 # a sword is 2 +GUARD_PAVISE_PLANKS = 9 # a shield is 3 +GUARD_GAMBESON_CLOTH = 12 # like the jerkin +FIELD_SALVE_HERBS = 6 # a staff is 3 +SALVE_DEFENCE = 0.125 # per point of mend, per guard +SALVE_CAP = 50 +# Kit slot: (stat, defence per point per guard) +KIT_DEFENCE = {"weapon": ("power", 0.35), "shield": ("armour", 0.5), "jerkin": ("armour", JERKIN_DEFENCE), + "salve": ("mend", SALVE_DEFENCE)} # 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 CLOTHS = [ @@ -381,7 +394,7 @@ for _tier, (_raw, _leather, _prefix, _label) in enumerate(LEATHERS): if _noun == "trousers": 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, + "name": f"{_label} guard jerkin", "plural": f"{_label} guard jerkins", "icon": "🦺", "kit": "jerkin", "tier": _tier, "armour": _armour_stats("medium", "body", _tier)["armour"], "value": round(GUARD_JERKIN_LEATHER * _leather[2] * 1.3), } @@ -483,6 +496,24 @@ for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS: "value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"], "potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}} +# Guard kit from the other trades (values are set from the recipe further down). +_sword, _shield = (next(piece for piece in PIECES if piece[0] == name) for name in ("sword", "shield")) +for _tier, (_key, _name, *_rest) in enumerate(METALS): + ITEMS[f"{_key}_halberd"] = {"name": f"{_name} halberd", "plural": f"{_name} halberds", "icon": "🪓", "kit": "weapon", "tier": _tier, + "power": round(_sword[5][_tier] * KIT_SHARE), "value": 0} + _wood = WOODS[_tier] + ITEMS[f"{_wood}_pavise"] = {"name": f"{WOOD_NAMES[_tier]} pavise", "plural": f"{WOOD_NAMES[_tier]} pavises", "icon": "🛡️", + "kit": "shield", "tier": _tier, "armour": round(_shield[6][_tier] * KIT_SHARE), + "value": 0} +for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS): + ITEMS[f"{_prefix}_gambeson"] = { + "name": f"{_label} gambeson", "plural": f"{_label} gambesons", "icon": "🦺", "kit": "jerkin", "tier": _tier, "value": 0, + "armour": round(ITEMS[f"{LEATHERS[_tier][2]}_guard_jerkin"]["armour"] * KIT_SHARE), + } +for _tier, (_id, _name, *_rest) in enumerate(HERBS): + ITEMS[f"{_id}_salve"] = {"name": f"{_name.removesuffix(' leaf')} salve", "plural": f"{_name.removesuffix(' leaf')} salves", "icon": "🩹", "kit": "salve", + "tier": _tier, "mend": _tier + 1, "value": 0} + # Health potions: the one heal you take into a fight, drunk once a fight when you're low. # Each tier is the tier's herb and its cooked fish. HEALTH_HEAL = [20, 40, 65, 90, 115, 140] @@ -600,6 +631,9 @@ def _tiered_actions() -> list[dict]: 45 + 2 * tier, 4 * piece_xp[tier], {f"{key}_bar": 3, PLANKS[tier][0]: 1, **({trophy("hammer", tier): 2 + tier} if tier else {})}, {f"{key}_warhammer": 1}, group=name, city=city)) + actions.append(_action(f"smith_{key}_halberd", "smithing", f"{name} halberd", TIER_LEVELS[tier] + 7, + 25 + 5 * GUARD_HALBERD_BARS + 2 * tier, 2 * piece_xp[tier], + {f"{key}_bar": GUARD_HALBERD_BARS}, {f"{key}_halberd": 1}, group=name, city=city)) for tool, _icon in TOOLS.values(): tool_id = f"{key}_{tool.replace(' ', '_')}" actions.append(_action(f"smith_{tool_id}", "smithing", f"{name} {tool}", TIER_LEVELS[tier] + 3, @@ -633,6 +667,9 @@ def _tiered_actions() -> list[dict]: 25 + 5 * used + 2 * tier, used * piece_xp[tier], {cloth[0]: used, **({reagent("light", tier): REAGENT_COUNTS[slot]} if tier else {})}, {f"{prefix}_{suffix}": 1}, group=label)) + actions.append(_action(f"sew_{prefix}_gambeson", "tailoring", f"{label} gambeson", TIER_LEVELS[tier] + 9, + 25 + 5 * GUARD_GAMBESON_CLOTH + 2 * tier, 4 * piece_xp[tier], + {cloth[0]: GUARD_GAMBESON_CLOTH}, {f"{prefix}_gambeson": 1}, group=label)) for raw, cooked, level, fish_id, verb in FISH: tier = TIER_LEVELS.index(level) if level in TIER_LEVELS else 3 actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier], @@ -892,10 +929,17 @@ def _weapon_actions() -> list[dict]: 40 + 2 * tier, [10, 22, 36, 52, 70, 92][tier], {plank: 3, leather: 2, f"{key}_bar": 1, **extra("sword", 1 + tier)}, {f"{key}_shield": 1}, group="Shields")) + actions.append(_action(f"make_{WOODS[tier]}_pavise", "construction", ITEMS[f"{WOODS[tier]}_pavise"]["name"], + TIER_LEVELS[tier] + 5, 40 + 3 * GUARD_PAVISE_PLANKS + 2 * tier, + [10, 22, 36, 52, 70, 92][tier], {plank: GUARD_PAVISE_PLANKS}, + {f"{WOODS[tier]}_pavise": 1}, group="Shields")) actions.append(_action(f"brew_{WOODS[tier]}_staff", "herblore", STAVES[tier], TIER_LEVELS[tier] + 5, 45 + 2 * tier, [12, 26, 42, 60, 80, 105][tier], {plank: 2, herb: 3, **extra("staff", 2 + tier)}, {f"{WOODS[tier]}_staff": 1}, group="Staves")) + actions.append(_action(f"brew_{herb}_salve", "herblore", ITEMS[f"{herb}_salve"]["name"], TIER_LEVELS[tier] + 5, + 35 + 2 * tier, [12, 26, 42, 60, 80, 105][tier], {herb: FIELD_SALVE_HERBS}, + {f"{herb}_salve": 1}, group="Salves")) actions.append(_action(f"lw_{WOODS[tier]}_bow", "leatherworking", BOWS[tier], TIER_LEVELS[tier] + 4, 40 + 2 * tier, [12, 26, 42, 60, 80, 105][tier], {plank: 2, leather: 2, **extra("bow", 2 + tier)}, {f"{WOODS[tier]}_bow": 1}, @@ -4282,6 +4326,11 @@ def best_kit(stock: dict, slot: str, stat: str, people: int) -> float: return total +def kit_defence(stock: dict, slot: str, people: int) -> float: + stat, rate = KIT_DEFENCE[slot] + return round(rate * best_kit(stock, slot, stat, people), 1) + + def kit_count(stock: dict, slot: str) -> int: return sum(qty for item, qty in stock.items() if kit_slot(item) == slot) @@ -4293,21 +4342,37 @@ def wear_share(ratio: float) -> float: return 0.1 + 0.2 * max(0.0, 2 - ratio) +def issued_kit(world: dict, slot: str) -> list[tuple[str, int]]: + """The `slot` items handed to the guards on duty, best first.""" + stock = world["city"]["stockpile"] + stat = KIT_DEFENCE[slot][0] + people = guards_on_duty(world) + issued = [] + for item in sorted((i for i in stock if kit_slot(i) == slot), key=lambda i: -ITEMS[i].get(stat, 0)): + if not people: + break + take = min(people, stock[item]) + issued.append((item, take)) + people -= take + return issued + + +def use_salves(world: dict) -> dict: + """Every salve handed to a guard is used up in the fight.""" + used = dict(issued_kit(world, "salve")) + for item, qty in used.items(): + take_stock_qty(world["city"]["stockpile"], item, qty) + return used + + def wear_kit(world: dict, share: float, rng: random.Random) -> dict: - """Break `share` of the swords, shields and jerkins handed to defenders, worst first. Spares are untouched.""" + """Break `share` of the weapons, shields and body armour handed to defenders, worst first. Spares are untouched.""" stock = world["city"]["stockpile"] broken = {} - for slot, stat in (("weapon", "power"), ("shield", "armour"), ("jerkin", "armour")): - people = guards_on_duty(world) - issued = [] - if not people: + for slot in ("weapon", "shield", "jerkin"): + issued = issued_kit(world, slot) + if not issued: continue - 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]) - 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) @@ -4318,10 +4383,12 @@ def wear_kit(world: dict, share: float, rng: random.Random) -> dict: return broken -def _wear_lines(broken: dict, arrows: int) -> list[str]: +def _wear_lines(broken: dict, arrows: int, salves: dict) -> list[str]: lines = [] if arrows: lines.append(f"The defenders used {amount('arrows', arrows)}.") + if salves: + lines.append(f"The wounded used {_listing(salves)}.") if broken: lines.append(f"{upper_first(_listing(broken))} {'was' if sum(broken.values()) == 1 else 'were'} broken in the fighting.") return lines @@ -4361,9 +4428,10 @@ def defence(world: dict, guards: int | None = None) -> dict: "townsfolk": 10, "walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"), "militia": round(militia * armed / posts, 1) if posts else 0, - "weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1), - "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1), - "jerkins": round(JERKIN_DEFENCE * best_kit(stock, "jerkin", "armour", armed), 1), + "weapons": kit_defence(stock, "weapon", armed), + "shields": kit_defence(stock, "shield", armed), + "jerkins": kit_defence(stock, "jerkin", armed), + "salves": min(SALVE_CAP, kit_defence(stock, "salve", armed)), "arrows": min(100, stock.get("arrows", 0) // 10), "drilled": round(drilled(world), 1), "rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid @@ -4679,7 +4747,7 @@ def event_view(world: dict, event: dict, now: float) -> dict: if level >= 3: view["strength"] = round(strength) view["trait"] = event["trait"] - view["requests"] = council_requests(world, event) + view["requests"] = raid_readiness(world, event) if gates: view["headline"] = "The dragon is overhead!" elif event["kind"] == "raid": @@ -4700,7 +4768,7 @@ def event_view(world: dict, event: dict, now: float) -> dict: view["trait"] = event["trait"] view["camp"] = True view["sabotage"] = round(event["sabotage"] * 100) - view["requests"] = council_requests(world, event) + view["requests"] = raid_readiness(world, event) if gates: view["headline"] = "Raiders at the gates!" elif event["kind"] == "merchant": @@ -4710,32 +4778,31 @@ def event_view(world: dict, event: dict, now: float) -> dict: return view -def council_requests(world: dict, event: dict) -> list[dict]: +def raid_readiness(world: dict, event: dict) -> list[dict]: city = world["city"] stock = city["stockpile"] strength = event["strength"] armed = guard_posts(world) + kit = min(armed, math.ceil(strength / 10)) + # item shown, how many, label, what counts: a kit slot, "food" (counted in meals) or just the item wants = [ - ("arrows", min(1000, 50 * math.ceil(strength * 4 / 50))), - ("copper_sword", min(armed, max(5, math.ceil(strength / 6)))), - ("bread", math.ceil(city["population"] * 1.5)), + ("arrows", min(1000, 50 * math.ceil(strength * 4 / 50)), ITEMS["arrows"]["name"], None), + ("copper_sword", min(armed, max(5, math.ceil(strength / 6))), "Weapons (any)", "weapon"), + ("bread", math.ceil(city["population"] * 1.5), "Food (any)", "food"), ] if building_level(world, "smithy") >= 1: - wants.append(("iron_shield", min(armed, math.ceil(strength / 10)))) + wants.append(("iron_shield", kit, "Shields (any)", "shield")) + wants += [("leather_guard_jerkin", kit, "Body armour (any)", "jerkin"), + ("guam_leaf_salve", kit, "Salves (any)", "salve")] requests = [] - for item, qty in wants: - have = stock.get(item, 0) - if item in ("copper_sword", "iron_shield"): - have = kit_count(stock, ITEMS[item]["slot"]) - if item == "bread": + for item, qty, label, counts in wants: + if counts == "food": have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER) - label = {"copper_sword": "Swords (any)", "iron_shield": "Shields (any)", "bread": "Food (any)"}[item] \ - if item in ("copper_sword", "iron_shield", "bread") else ITEMS[item]["name"] + else: + have = kit_count(stock, counts) if counts else stock.get(item, 0) request = {"item": item, "label": label, "want": qty, "have": have} - if item in ("copper_sword", "iron_shield"): - request["any"] = ITEMS[item]["slot"] # any item in this slot counts - if item == "bread": - request["any"] = "food" # counted in meals + if counts: + request["any"] = counts requests.append(request) return requests @@ -4790,10 +4857,11 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N city["treasury"] += loot city["repelled"] += 1 broken = wear_kit(world, wear_share(ratio), rng) + salves = use_salves(world) 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)) + lines.extend(_wear_lines(broken, arrows, salves)) for entry in garrison: player = world["players"][entry["id"]] player["favour"] += 25 @@ -4804,6 +4872,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N else: severity = min(1.0, 1 - ratio) broken = wear_kit(world, wear_share(ratio), rng) + salves = use_salves(world) 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) @@ -4821,7 +4890,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N 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)) + lines.extend(_wear_lines(broken, arrows, salves)) if gold or taken: stolen = ", ".join(amount(item, qty) for item, qty in taken.items()) loot = [f"{gold:,} gold"] if gold else [] @@ -4861,10 +4930,11 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> city["repelled"] += 1 city["treasury"] += round(attack) broken = wear_kit(world, min(0.5, wear_share(guard["total"] / attack) * 1.25), rng) + salves = use_salves(world) 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)) + lines.extend(_wear_lines(broken, arrows, salves)) for entry in garrison: player = world["players"][entry["id"]] player["favour"] += 40 @@ -4878,6 +4948,7 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> else: severity = min(1.0, 1 - guard["total"] / attack) broken = wear_kit(world, 0.5, rng) + salves = use_salves(world) 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) @@ -4887,7 +4958,7 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> 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)) + lines.extend(_wear_lines(broken, arrows, salves)) if burned: lines.append(f"It burned {_listing(burned)}.") if name: @@ -6211,6 +6282,8 @@ def _assign_glyphs() -> None: ITEMS[f"{key}_bar"].update(glyph="metal-bar", tint=tint) ITEMS[HARDWARE[tier][0]].update(glyph=HARDWARE_GLYPHS[tier], tint=tint) ITEMS[f"{key}_warhammer"].update(glyph=WEAPON_GLYPHS["hammer"], tint=tint) + ITEMS[f"{key}_halberd"].update(glyph="halberd", tint=tint) + ITEMS[f"{WOODS[tier]}_pavise"].update(glyph="bordered-shield", tint=WOOD_TINTS[tier]) ITEMS[f"{WOODS[tier]}_staff"].update(glyph=WEAPON_GLYPHS["staff"], tint=WOOD_TINTS[tier]) ITEMS[f"{WOODS[tier]}_bow"].update(glyph=WEAPON_GLYPHS["bow"], tint=WOOD_TINTS[tier]) for piece, glyph in PIECE_GLYPHS.items(): @@ -6236,8 +6309,10 @@ def _assign_glyphs() -> None: ITEMS[cloth[0]].update(glyph="rolled-cloth", tint=tint) for suffix, glyph in CLOTH_GLYPHS.items(): ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) + ITEMS[f"{prefix}_gambeson"].update(glyph="sleeveless-jacket", tint=tint) for herb, tint in HERB_TINTS.items(): ITEMS[herb].update(glyph="herbs-bundle", tint=tint) + ITEMS[f"{herb}_salve"].update(glyph="bandage-roll", tint=tint) for tier, (wood, tint) in enumerate(zip(WOODS, WOOD_TINTS)): for suffix, _noun, glyph, _kind in STATIONS.values(): ITEMS[f"{wood}_{suffix}"].update(glyph=glyph, tint=tint) @@ -6314,6 +6389,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() PATCH_NOTES = [ {"date": "2026-10-28", "title": "Queue", "notes": [ ("new", "Queue: ▲ on #1 swaps it with the current make."), + ("new", "Updated Guard kit, added recipes for crafting professions to contribute to defence of town without needing combat-related loot."), ]}, {"date": "2026-10-27", "title": "Prayer", "notes": [ ("new", "Gathering can be queued."), @@ -6454,7 +6530,7 @@ def content() -> dict: "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "city_uses": {item: city_uses(item) for item in ITEMS}, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, - "potion_charges": POTION_CHARGES, "jerkin_defence": JERKIN_DEFENCE, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW, + "potion_charges": POTION_CHARGES, "kit_defence": KIT_DEFENCE, "salve_cap": SALVE_CAP, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW, "order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "make_max": MAKE_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, "abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD, diff --git a/static/app.js b/static/app.js index 0acca65..2c6c05a 100644 --- a/static/app.js +++ b/static/app.js @@ -246,7 +246,7 @@ function replayBattle(id) { // 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. const SQUADS = [ - { id: "guards", parts: ["militia", "weapons", "shields", "jerkins"], name: "Guards", glyph: "swordman", dmg: "slash", every: 2 }, + { id: "guards", parts: ["militia", "weapons", "shields", "jerkins", "salves"], name: "Guards", glyph: "swordman", dmg: "slash", every: 2 }, { 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: "folk", parts: ["townsfolk", "drilled", "rallied"], name: "Townsfolk", glyph: "farmer", dmg: "blunt", every: 2.8 }, @@ -936,10 +936,10 @@ function cityNeed(id) { const why = [...(fund?.why || [])]; for (const r of S.upcoming?.requests || []) { const short = r.want - r.have; - const counts = r.any === "food" ? it.food : r.any ? it.slot === r.any : id === r.item; + const counts = r.any === "food" ? it.food : r.any ? (it.slot || it.kit) === r.any : id === r.item; if (short <= 0 || !counts) continue; qty += r.any === "food" ? Math.ceil(short / it.food) : short; - why.push(`Council: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`); + why.push(`Raid readiness: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`); } return qty ? { qty, why } : null; } @@ -1716,7 +1716,10 @@ function itemStats(id) { 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()})` : ""}`); } - if (it.kit) parts.push(`+${Math.round(it.armour * C.jerkin_defence * 10) / 10} city defence per paid guard`); + if (it.kit) { + const [stat, rate] = C.kit_defence[it.kit]; + parts.push(`+${Math.round(it[stat] * rate * 10) / 10} city defence per paid guard${it.kit === "salve" ? `, used up · max ${C.salve_cap}` : ""}`); + } 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.jewel && !["power", "ward"].includes(it.jewel)) parts.push(jewelText(it)); @@ -2284,7 +2287,7 @@ function favourSpends() { function raidPrep(ev) { 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", jerkins: "Jerkins the guards wear", 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: "Weapons the guards hold", shields: "Shields the guards hold", jerkins: "Body armour the guards wear", salves: "Salves", arrows: "Arrows", garrison: "Garrison" }; const full = d.posts * d.wage, owed = Math.max(0, full - d.wages); const reqs = (ev.requests || []).map((r) => { const mine = me.bank[r.item] || 0; @@ -2293,7 +2296,7 @@ function raidPrep(ev) {
`; }).join(""); return `
-

Council requests

${reqs} +

Raid readiness

${reqs}

Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.

Guards · ${d.guards} / ${d.posts} paid

Wage chest ${COIN} ${fmt(d.wages)} / ${fmt(full)} · ${d.wage} gold a guard per threat

@@ -2301,7 +2304,7 @@ function raidPrep(ev) {
-

Only paid guards stand, and only they get the armoury's swords, shields and jerkins. Paying earns favour.

+

Only paid guards stand, and only they get the armoury's kit. Paying earns favour.

Defence

${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => ``).join("")} ${d.starving ? `` : ""}
${names[k]}${fmt(v)}
Hungry (×0.8)
diff --git a/static/icons.svg b/static/icons.svg index 83d6141..bb89106 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -15,6 +15,7 @@ + @@ -27,6 +28,7 @@ + @@ -115,6 +117,7 @@ + @@ -219,6 +222,7 @@ + diff --git a/tests/test_game.py b/tests/test_game.py index 5408eac..5190269 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1533,6 +1533,42 @@ class TierTest(unittest.TestCase): recipe = game.ACTION_BY_ID["lw_elk_leather_guard_jerkin"] self.assertEqual(recipe["inputs"], {"elk_leather": 12}) + def test_guard_kit_shares_slots_below_trophy_gear(self): + world = world_with() + world["city"]["stockpile"] = {"rune_sword": 2, "mithril_halberd": 50, "maple_pavise": 4, + "moonweave_gambeson": 5, "tarromin_salve": 50} + world["city"]["wages"] = 10_000 + parts = game.defence(world)["parts"] + armed = int(world["city"]["population"] // 2) # 10 + self.assertEqual(parts["weapons"], round(0.35 * (2 * 35 + (armed - 2) * 15), 1)) + self.assertEqual(parts["shields"], 0.5 * 4 * 7) + self.assertEqual(parts["jerkins"], round(game.JERKIN_DEFENCE * 5 * 8, 1)) + self.assertEqual(parts["salves"], round(game.SALVE_DEFENCE * armed * 3, 1)) + self.assertLess(game.ITEMS["mithril_halberd"]["power"], game.ITEMS["mithril_sword"]["power"]) + for item in ("mithril_halberd", "maple_pavise", "moonweave_gambeson", "tarromin_salve"): + self.assertNotIn("slot", game.ITEMS[item]) # city kit, not player gear + self.assertEqual(game.ACTION_BY_ID["smith_mithril_halberd"]["inputs"], {"mithril_bar": 6}) + self.assertEqual(game.ACTION_BY_ID["make_maple_pavise"]["inputs"], {"maple_plank": 9}) + self.assertEqual(game.ACTION_BY_ID["brew_tarromin_salve"]["inputs"], {"tarromin": 6}) + + def test_salves_are_capped(self): + world = world_with() + world["city"]["stockpile"] = {"snapdragon_salve": 500} + world["city"]["buildings"]["barracks"]["level"] = 5 + world["city"]["population"] = 400.0 + world["city"]["wages"] = 1_000_000 + self.assertEqual(game.defence(world)["parts"]["salves"], game.SALVE_CAP) + + def test_readiness_counts_guard_kit(self): + world = world_with() + world["city"]["stockpile"] = {"steel_halberd": 3, "oak_pavise": 2, "linen_gambeson": 4, "guam_leaf_salve": 1} + world["city"]["buildings"]["smithy"]["level"] = 1 + have = {r["label"]: r["have"] for r in game.raid_readiness(world, {"strength": 200})} + self.assertEqual(have["Weapons (any)"], 3) + self.assertEqual(have["Shields (any)"], 2) + self.assertEqual(have["Body armour (any)"], 4) + self.assertEqual(have["Salves (any)"], 1) + def test_higher_building_levels_need_higher_tiers(self): self.assertNotIn("steel_bar", game.build_cost("walls", 2)) self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 60) @@ -2075,6 +2111,16 @@ class WearTest(unittest.TestCase): 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_issued_salves_are_used_up(self): + self.city["stockpile"] = {"guam_leaf_salve": 30, "arrows": 100} + self.city["buildings"]["barracks"]["level"] = 1 + self.city["wages"] = 10_000 + event = {"strength": 1, "sabotage": 0, "group": "the Red Hand"} + game._resolve_raid(self.world, event, T0, random.Random(1)) + # 20 guards each use one; the 10 spares are kept. + self.assertEqual(self.city["stockpile"]["guam_leaf_salve"], 10) + self.assertIn("The wounded used 20 guam salves.", event["report"]["lines"]) + def test_a_won_raid_breaks_some_kit_and_reports_it(self): self.city["stockpile"] = {"copper_sword": 20, "arrows": 100} self.city["wages"] = 10_000