Weapon styles: warhammer, sword and shield, staff, bow; trophies in recipes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 23:38:34 +01:00
parent 19ccab4ac3
commit 4cb6ff5ec0
6 changed files with 244 additions and 24 deletions

155
game.py
View file

@ -186,6 +186,22 @@ PLANKS = [("plank", "Plank"), ("oak_plank", "Oak plank"), ("willow_plank", "Will
for (_log, _name, _icon, _value, _action_id, _verb), (_plank, _plank_name) in zip(TREES, PLANKS):
ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value}
ITEMS[_plank] = {"name": _plank_name, "icon": "🪚", "value": _value * 2 + 1}
# Weapons: four styles, one per trade. The sword pairs with a shield; the
# warhammer, staff and bow take both hands, so they carry the shield's power too.
SWORD_POWER = next(piece[5] for piece in PIECES if piece[0] == "sword")
SHIELD_POWER = next(piece[5] for piece in PIECES if piece[0] == "shield")
TWO_HANDED_POWER = [sword + shield for sword, shield in zip(SWORD_POWER, SHIELD_POWER)]
WOODS = ["wooden", "oak", "willow", "maple", "yew", "magic"]
STAVES = ["Wooden staff", "Oak staff", "Willow staff", "Maple staff", "Yew staff", "Enchanted staff"]
BOWS = ["Shortbow", "Oak bow", "Willow bow", "Maple bow", "Yew bow", "Enchanted bow"]
for _tier, _metal in enumerate(METALS):
ITEMS[f"{_metal[0]}_sword"]["style"] = "sword"
_weapon = {"slot": "weapon", "power": TWO_HANDED_POWER[_tier], "tier": _tier, "two_handed": True,
"req": ["combat", EQUIP_LEVELS[_tier]], "value": round(5 * _metal[5] * 1.2)}
ITEMS[f"{_metal[0]}_warhammer"] = {"name": f"{_metal[1]} warhammer", "icon": "🔨", "style": "hammer", **_weapon}
ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"),
"icon": "🪄", "style": "staff", **_weapon}
ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "🏹", "style": "bow", **_weapon}
for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS):
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
@ -314,18 +330,21 @@ CLASSES = {
"bonus": "Mining and smithing 20% faster",
"masterwork": "Masterwork: 1 in 10 smithing crafts make double",
"signature": "Prospector: 1 in 10 mining trips bring back double",
"combat": "Warhammer: +15% damage with one. Slow, heavy, crushes golems",
},
"builder": {
"name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction"], "craft": ["construction"],
"bonus": "Woodcutting and construction (masonry included) 20% faster",
"masterwork": "Masterwork: 1 in 10 construction crafts make double",
"signature": "Foreman: +1 labour every time you work a building site",
"combat": "Sword and shield: +15% damage with a sword. The shield blocks, and trolls fall to blades",
},
"homesteader": {
"name": "Homesteader", "icon": "🌾", "fast": ["cooking", "herblore"], "craft": ["cooking", "herblore"],
"bonus": "Cooking and herblore 20% faster. Two plots: farm one crop 30% faster, or two crops at once",
"masterwork": "Masterwork: 1 in 10 cooking and herblore crafts make double",
"signature": "Steady hand: potions you drink last 25% longer",
"combat": "Staff: +15% damage with one. Rarely misses, ignores armour, and spirits fear it",
},
"ranger": {
"name": "Ranger", "icon": "🏹", "fast": ["hunting", "fishing", "leatherworking", "cooking"],
@ -333,6 +352,7 @@ CLASSES = {
"bonus": "Hunting, fishing, leatherworking and cooking 20% faster",
"masterwork": "Masterwork: 1 in 10 leatherworking and cooking crafts make double",
"signature": "Pathfinder: your scouting gathers 50% more reconnaissance",
"combat": "Bow: +15% damage with one. Fast, strikes first, and brings down flyers",
},
}
# Classes from before the trades, as a starting point until their player re-picks.
@ -369,9 +389,16 @@ def _tiered_actions() -> list[dict]:
bars * piece_xp[tier], {f"{key}_bar": bars}, {hardware: made}, group="Hardware",
city=city))
for piece, _slot, noun, bars, offset, _power, _armour in PIECES:
if piece == "shield":
continue # builders make shields now, in Construction
inputs = {f"{key}_bar": bars, **({trophy("troll", tier): tier} if piece == "sword" and tier else {})}
actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset,
25 + 5 * bars + 2 * tier, bars * piece_xp[tier], {f"{key}_bar": bars},
25 + 5 * bars + 2 * tier, bars * piece_xp[tier], inputs,
{f"{key}_{piece}": 1}, group=name, city=city))
actions.append(_action(f"smith_{key}_warhammer", "smithing", f"{name} warhammer", TIER_LEVELS[tier] + 6,
45 + 2 * tier, 4 * piece_xp[tier],
{f"{key}_bar": 3, PLANKS[tier][0]: 1, **({trophy("golem", tier): 2 + tier} if tier else {})},
{f"{key}_warhammer": 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,
@ -406,10 +433,54 @@ def _tiered_actions() -> list[dict]:
CRIT_CHANCE = 0.05
CRIT_MULT = 1.5
RESPAWN = 3 # seconds before the next enemy turns up
PLAYER_INTERVAL = 2.4 # seconds between your swings
MAX_FIGHT = 600 # a fight that drags on longer is broken off
FLEE_BELOW = 0.2 # out of food and under this share of HP: get out
# How each kind of weapon fights. Two-handed weapons carry the shield's power themselves.
STYLES = {
"sword": {"name": "sword", "interval": 2.4, "hit": 1.0, "accuracy": 1.0, "crit": 0.05},
"hammer": {"name": "warhammer", "interval": 3.6, "hit": 1.6, "accuracy": 0.95, "crit": 0.10},
"staff": {"name": "staff", "interval": 3.0, "hit": 1.15, "accuracy": 1.25, "crit": 0.05, "pierce": True},
"bow": {"name": "bow", "interval": 1.8, "hit": 0.75, "accuracy": 1.05, "crit": 0.05, "first": True},
}
CLASS_STYLE = {"miner": "hammer", "builder": "sword", "homesteader": "staff", "ranger": "bow"}
CLASS_STYLE_BONUS = 1.15 # damage with your own trade's weapon
BLOCK_CHANCE = 0.15 # a shield turns aside this share of hits
# Damage each style does to each kind of monster (1 when not listed).
WEAKNESS = {
"golem": {"hammer": 1.5, "bow": 0.5},
"troll": {"sword": 1.5},
"spirit": {"hammer": 0.5, "sword": 0.5, "bow": 0.5, "staff": 1.5},
"flyer": {"hammer": 0.5, "sword": 0.5, "bow": 1.5},
}
def trophy(kind: str, tier: int) -> str:
"""The trophy a tier's monster of this kind drops (tier 1 is the Old Watchtower)."""
return _slug(AREA_MONSTERS[kind][2][tier - 1])
def _weapon_actions() -> list[dict]:
"""Shields (Construction), staves (Herblore) and bows (Leatherworking); trophies past the first tier."""
actions = []
for tier, (key, name, *_rest) in enumerate(METALS):
plank, leather, herb = PLANKS[tier][0], LEATHERS[tier][1][0], HERBS[tier][0]
extra = lambda kind, count: {trophy(kind, tier): count} if tier else {}
actions.append(_action(f"make_{key}_shield", "construction", f"{name} shield", TIER_LEVELS[tier] + 5,
40 + 2 * tier, [10, 22, 36, 52, 70, 92][tier],
{plank: 3, leather: 2, f"{key}_bar": 1, **extra("troll", 1 + tier)},
{f"{key}_shield": 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("spirit", 2 + tier)}, {f"{WOODS[tier]}_staff": 1},
group="Staves"))
actions.append(_action(f"lw_{WOODS[tier]}_bow", "leatherworking", BOWS[tier], LEATHERS[tier][4] + 4,
40 + 2 * tier, [12, 26, 42, 60, 80, 105][tier],
{plank: 2, leather: 2, **extra("flyer", 2 + tier)}, {f"{WOODS[tier]}_bow": 1},
group="Bows"))
return actions
# kind: HP, seconds between swings, accuracy, evasion, max hit (multipliers), damage it shrugs off
MONSTER_KINDS = {
"human": (1.0, 2.6, 1.0, 1.0, 1.0, 0.0),
@ -581,9 +652,9 @@ BUILDINGS = {
"barracks": {
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
"base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80, "copper_rivets": 60},
"advanced": {"iron_sword": 20, "hard_leather": 40},
"late": {"steel_sword": 12, "oak_plank": 30, "hide_glue": 40},
"final": {"mithril_sword": 8, "willow_plank": 25, "bear_leather": 40},
"advanced": {"iron_bar": 60, "hard_leather": 40},
"late": {"steel_bar": 40, "oak_plank": 30, "hide_glue": 40},
"final": {"mithril_bar": 25, "willow_plank": 25, "bear_leather": 40},
"effect": "Trains townsfolk as militia: more defence per citizen",
},
"smithy": {
@ -671,7 +742,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 13
WORLD_VERSION = 14
def new_world(now: float, speed: float = 1.0) -> dict:
@ -792,6 +863,14 @@ def migrate(world: dict, now: float | None = None) -> dict:
_fold_masonry(world)
_return_overfunding(world)
world["version"] = 13
if world["version"] < 14:
# Shields moved from Smithing to Construction: anyone smithing them stops.
for player in world["players"].values():
action = player["action"]
if action and (action["id"] not in ACTION_BY_ID or action.get("resume", "") and action["resume"] not in ACTION_BY_ID):
stop(player, "Shields are made in Construction now")
_return_overfunding(world)
world["version"] = 14
return world
@ -1073,10 +1152,23 @@ def regen(world: dict, player: dict, now: float, boost: float = 1) -> None:
player["hp_at"] = now
def fighter(player: dict, blessed: dict, brew: dict) -> dict:
"""Your side of a fight: power from level and gear (plus prayers and potions) aims and hits; armour dodges and soaks."""
def weapon_style(player: dict) -> str:
weapon = player["equip"].get("weapon")
return ITEMS[weapon].get("style", "sword") if weapon else "sword"
def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) -> dict:
"""Your side of a fight: power from level and gear (plus prayers and potions) aims and hits, shaped by
the weapon's style, your trade and the enemy's weakness; armour dodges and soaks, a shield blocks."""
style_id = weapon_style(player)
style = STYLES[style_id]
power = combat_power(player) + blessed["power"] + brew.get("power", 0)
return {"interval": PLAYER_INTERVAL, "accuracy": 10 + 2 * power, "max_hit": max(2, round(2 + power / 3)),
damage = style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1)
weakness = WEAKNESS.get(enemy["kind"], {}).get(style_id, 1) if enemy else 1
return {"style": style_id, "interval": style["interval"], "accuracy": (10 + 2 * power) * style["accuracy"],
"max_hit": max(2, round((2 + power / 3) * damage * weakness)), "crit": style["crit"],
"pierce": style.get("pierce", False), "first": style.get("first", False), "weakness": weakness,
"block": BLOCK_CHANCE if player["equip"].get("shield") else 0,
"evasion": 10 + 2 * (skill_level(player, "combat") + armour(player) // 2),
"taken": (1 - damage_reduction(player)) * blessed["taken"] * (1 - brew.get("reduce", 0))}
@ -1085,50 +1177,59 @@ def hit_chance(accuracy: float, evasion: float) -> float:
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
def _average_hit(max_hit: float) -> float:
return (1 - CRIT_CHANCE) * (max_hit + 1) / 2 + CRIT_CHANCE * max_hit * CRIT_MULT
def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float:
return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT
def fight_odds(player: dict, action: dict) -> dict:
"""What a fight against this enemy looks like on average, for the cards."""
me, foe = fighter(player, blessing(player), potion_effect(player, "combat")), action["enemy"]
foe = action["enemy"]
me = fighter(player, blessing(player), potion_effect(player, "combat"), foe)
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
dps = hit * _average_hit(me["max_hit"]) * (1 - foe["reduction"]) / me["interval"]
shrug = 0 if me["pierce"] else foe["reduction"]
dps = hit * _average_hit(me["max_hit"], me["crit"]) * (1 - shrug) / me["interval"]
kill = foe["hp"] / dps
hurt = their_hit * _average_hit(foe["max_hit"]) * me["taken"] / foe["interval"] * kill
return {"hit": round(hit, 2), "their_hit": round(their_hit, 2), "max_hit": me["max_hit"],
"their_max": max(1, round(foe["max_hit"] * me["taken"])), "kill_time": round(kill, 1), "damage": round(hurt, 1)}
hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * me["taken"] / foe["interval"] * kill
return {"hit": round(hit, 2), "their_hit": round(their_hit, 2), "max_hit": me["max_hit"], "style": me["style"],
"weakness": me["weakness"], "their_max": max(1, round(foe["max_hit"] * me["taken"])),
"kill_time": round(kill, 1), "damage": round(hurt, 1)}
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
"""Play out one fight swing by swing. Nothing changes until it's over: the result is applied then."""
foe, blessed, brew = action["enemy"], blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew)
me = fighter(player, blessed, brew, foe)
shrug = 0 if me["pierce"] else foe["reduction"]
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
top, hp, foe_hp = max_hp(player), player["hp"], foe["hp"]
pantry = {item: player["bank"][item] for item in edible(player)}
eaten, events, hurt = {}, [], 0
wind = blessed["wind"] and not (player["action"] or {}).get("wind_used")
pace = speed(world)
mine, theirs, result, t = me["interval"], foe["interval"], None, 0.0
# A bow looses its first arrow before the enemy closes in.
mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0
while result is None:
t = min(mine, theirs)
if t > MAX_FIGHT:
result = "fled"
break
crit = rng.random() < CRIT_CHANCE
if mine <= theirs:
mine += me["interval"]
crit = rng.random() < me["crit"]
landed = rng.random() < hit
dealt = max(1, round((me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))
* (1 - foe["reduction"]))) if landed else 0
* (1 - shrug))) if landed else 0
foe_hp = max(0, foe_hp - dealt)
events.append([round(t / pace, 2), "you", ("crit" if crit else "hit") if landed else "miss", dealt, round(hp), foe_hp])
if not foe_hp:
result = "won"
continue
theirs += foe["interval"]
crit = rng.random() < CRIT_CHANCE
landed = rng.random() < their_hit
if landed and rng.random() < me["block"]:
events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(hp)), foe_hp])
continue
dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * me["taken"])) if landed else 0
hp -= dealt
hurt += dealt
@ -1151,7 +1252,7 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random)
result = "fled"
return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]),
"hp": hp, "result": result, "end": round(t / pace, 2), "duration": (t + RESPAWN) / pace,
"interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events,
"interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"],
"eaten": eaten, "damage": hurt, "pp": blessed["cost"],
"wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")}
@ -1887,6 +1988,9 @@ def _wear_lines(broken: dict, arrows: int) -> list[str]:
def equip_problem(player: dict, item: str) -> str | None:
"""Why the player can't wear or use this yet, or None."""
weapon = player["equip"].get("weapon")
if ITEMS[item].get("slot") == "shield" and weapon and ITEMS[weapon].get("two_handed"):
return f"Your {ITEMS[weapon]['name'].lower()} takes both hands"
req = ITEMS[item].get("req")
if req and skill_level(player, req[0]) < req[1]:
name = next(s["name"] for s in SKILLS if s["id"] == req[0])
@ -2463,6 +2567,10 @@ FITTINGS = [
("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "red_dragon_leather": 5, "adamant_chain": 6},
"Everyone recovers HP twice as fast", "fireplace", "#a77be8"),
]
# Builders' shields, homesteaders' staves and rangers' bows, after each skill's other tabs.
for _weapon in _weapon_actions():
ACTIONS.append(_weapon)
ACTION_BY_ID[_weapon["id"]] = _weapon
FITTING_BY_ID = {}
for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS:
FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect}
@ -3038,6 +3146,7 @@ PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "vi
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
"hunting knife": "bowie-knife", "hammer": "claw-hammer"}
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"}
HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"]
GLYPHS = {
"stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"),
@ -3109,6 +3218,9 @@ def _assign_glyphs() -> None:
ITEMS[ore[0]].update(glyph="ore", tint=tint)
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"{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():
ITEMS[f"{key}_{piece}"].update(glyph=glyph, tint=tint)
for tool, glyph in TOOL_GLYPHS.items():
@ -3176,5 +3288,6 @@ def content() -> dict:
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED,
"styles": STYLES, "weakness": WEAKNESS, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,
}