Guard kit: halberds, pavises, gambesons, salves; Raid readiness

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 10:18:15 +00:00
parent 3d93fabbfd
commit 002e990dc4
4 changed files with 175 additions and 46 deletions

154
game.py
View file

@ -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,