Combat rework: damage types, races with variants, armour weights, tailoring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 14:16:12 +01:00
parent a35bcd6512
commit ba7556de4d
7 changed files with 715 additions and 254 deletions

View file

@ -10,42 +10,56 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
## What's in it ## What's in it
- **Skills:** mining, woodcutting, farming, hunting, fishing, smithing, - **Skills:** mining, woodcutting, farming, hunting, fishing, smithing,
construction (with masonry), cooking, leatherworking, herblore, combat, construction (with masonry), cooking, leatherworking, tailoring, herblore,
prayer and reconnaissance. Smiths also make hardware for builders, one kind combat, prayer and reconnaissance. Smiths also make hardware for builders,
per metal (copper rivets to rune locks), which fittings and buildings need. one kind per metal (copper rivets to rune locks), which fittings and
buildings need.
- **Combat:** every fight is played out swing by swing: you and the enemy - **Combat:** every fight is played out swing by swing: you and the enemy
each have HP, a swing timer, accuracy and a max hit, and swings hit, miss each have HP, a swing timer, accuracy and a max hit, and swings hit, miss
or crit (power from level and gear aims and hits; armour dodges and soaks). or crit. Every blow has a damage type (slashing, blunt, piercing, magic);
Below half health you eat from your bank; out of food you retreat, losing each enemy is vulnerable to some (+25%) and resists others (-25%), shown
nothing but the fight. HP recovers 25% an hour. The Combat page replays as tinted icons. Armour stops physical blows, ward stops magic. Below half
the fight live. Fights are split into the Frontier (bandits, sabotage, health you eat from your bank; out of food you retreat, losing nothing but
drilling, the city's troubles) and expeditions: Greenwood to start, then the fight. HP recovers 25% an hour. The Combat page replays the fight
one area per tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, live. Fights are split into the Frontier (bandits, sabotage, drilling, the
Dragon's Spine), each with a golem, a troll, a spirit and a flyer that drop city's troubles) and expeditions: Greenwood to start, then one area per
rare trophies (3%, 6% from bosses) for stronger weapons, and a boss (the tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's
dragons at the top). Each kill is followed by a 2-second pause. Spine), each with four races and a boss (the dragons at the top). Every
kill is one of the race's variants (a brute, a skirmisher, a caster, now
and then a rare elite), so each encounter mixes damage types: spirit races
lean on magic, golems and trolls on blows. Races drop rare trophies (3%,
6% from bosses) for stronger weapons and the area's armour reagents (10%,
far more from dragons). Each kill is followed by a 2-second pause.
- **Weapons:** four styles, one per trade, and anyone can use any of them. - **Weapons:** four styles, one per trade, and anyone can use any of them.
Sword and shield (2.4s swings, the shield blocks 15% of hits; swords from Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits;
Smithing, shields from Construction), warhammer (Smithing; slow, heavy, swords from Smithing, shields from Construction), warhammer (blunt,
twice the crits), staff (Herblore; accurate, ignores armour) and bow Smithing; slow, heavy, twice the crits), staff (magic, Herblore;
(Leatherworking; fast, strikes first). The last three take both hands and accurate) and bow (piercing, Leatherworking; fast, strikes first). The
carry a shield's power. Your trade's weapon does 15% more damage. Golems last three take both hands and carry a shield's power. Your trade's weapon
break under hammers, trolls fall to swords, spirits only fear staves, and does 15% more damage. Past copper, each weapon needs its tier's trophies.
flyers fall to bows. Past copper, each weapon needs its tier's trophies. - **Armour:** three weights. Heavy plate (Smithing) is strong against blows
and weak to magic, light cloth (Tailoring) the other way round, medium
leather (Leatherworking) a bit of both. Every trade gathers its own base
material at every tier (ore, hunted hides, farmed fibres), and past copper
each piece also needs reagents from that tier's area: plating, lacing or
thread.
- **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril, - **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril,
adamant, rune at levels 1–75) of ore, trees (up to enchanted) and hides (deer, adamant, rune at levels 1–75) of ore, trees (up to enchanted), hides (deer,
boar and bear from hunting; green, blue and red dragonhide from fighting boar, bear, elk, sabrecat, mammoth) and fibres (flax, cotton, silk,
dragons). Every metal tier has a sword, shield, helmet, greaves, cuirass and moonpetal, starbloom, spiritvine). Every metal tier has a sword, shield,
five tools; every leather tier a cap, trousers and jerkin. Higher bars need helmet, greaves, cuirass and five tools; every leather tier a cap,
coal; each metal past copper needs a Smithy level (rune needs level 5, so a trousers and jerkin; every cloth tier a hood, leggings and robe. Higher
city). Gear needs a combat level to wear, tools a skill level to use. bars need coal; each metal past copper needs a Smithy level (rune needs
level 5, so a city). Gear needs a combat level to wear, tools a skill
level to use.
- **Classes are trades,** from gathering to finished goods, so each player has - **Classes are trades,** from gathering to finished goods, so each player has
a quiet gathering side and a busier crafting side: miner (mining, smithing), a quiet gathering side and a busier crafting side: miner (mining, smithing),
builder (woodcutting, construction), homesteader (farming, cooking, builder (woodcutting, construction), homesteader (farming, cooking,
herblore) and ranger (hunting, fishing, leatherworking, cooking). A trade's herblore, tailoring) and ranger (hunting, fishing, leatherworking,
skills are 20% faster, 1 in 10 of its crafts make double, and each has one cooking). A trade's skills are 20% faster, 1 in 10 of its crafts make
bonus of its own. Homesteaders farm differently: two plots, either one crop double, and each has one bonus of its own. Homesteaders farm differently:
30% faster or two crops at once sharing a 20% faster pace. Every class can train every skill, scout and drill. two plots, either one crop 30% faster or two crops at once sharing a 20%
faster pace. Every class can train every skill, scout and drill.
Building costs draw on all four trades: thatch, flax for rope, hide glue, Building costs draw on all four trades: thatch, flax for rope, hide glue,
leather and provisions as well as wood, stone and metal. leather and provisions as well as wood, stone and metal.
- **Bank:** 12 slots of 1,000 each to start; more than a stack of one item - **Bank:** 12 slots of 1,000 each to start; more than a stack of one item

3
app.py
View file

@ -98,6 +98,8 @@ def player_view(world: dict, player: dict, now: float) -> dict:
view["max_hp"] = game.max_hp(player) view["max_hp"] = game.max_hp(player)
view["armour"] = game.armour(player) view["armour"] = game.armour(player)
view["reduction"] = round(game.damage_reduction(player), 3) view["reduction"] = round(game.damage_reduction(player), 3)
view["ward"] = game.magic_ward(player)
view["ward_reduction"] = round(game.reduction(game.magic_ward(player)), 3)
view["edible"] = game.edible(player) view["edible"] = game.edible(player)
view["blessing"] = game.blessing(player) view["blessing"] = game.blessing(player)
view["fight_odds"] = {a["id"]: game.fight_odds(player, a) for a in game.ACTIONS if "enemy" in a} view["fight_odds"] = {a["id"]: game.fight_odds(player, a) for a in game.ACTIONS if "enemy" in a}
@ -170,6 +172,7 @@ def roster(world: dict, now: float) -> list[dict]:
"xp": player["xp"], "equip": player["equip"], "joined": player["joined"], "xp": player["xp"], "equip": player["equip"], "joined": player["joined"],
"hp": int(player["hp"]), "max_hp": game.max_hp(player), "power": game.combat_power(player), "hp": int(player["hp"]), "max_hp": game.max_hp(player), "power": game.combat_power(player),
"armour": game.armour(player), "reduction": round(game.damage_reduction(player), 3), "armour": game.armour(player), "reduction": round(game.damage_reduction(player), 3),
"ward": game.magic_ward(player), "ward_reduction": round(game.reduction(game.magic_ward(player)), 3),
"favour": player["favour"], "favour": player["favour"],
"id": player["id"], "name": player["name"], "cls": player["cls"], "id": player["id"], "name": player["name"], "cls": player["cls"],
"total": sum(game.skill_level(player, skill) for skill in game.SKILL_IDS), "total": sum(game.skill_level(player, skill) for skill in game.SKILL_IDS),

679
game.py
View file

@ -64,8 +64,8 @@ ITEMS = {
"bones": {"name": "Bones", "icon": "🦴", "value": 1}, "bones": {"name": "Bones", "icon": "🦴", "value": 1},
"big_bones": {"name": "Big bones", "icon": "🦴", "value": 4}, "big_bones": {"name": "Big bones", "icon": "🦴", "value": 4},
"dragon_bones": {"name": "Dragon bones", "icon": "🦴", "value": 15}, "dragon_bones": {"name": "Dragon bones", "icon": "🦴", "value": 15},
"studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 6, "studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 5, "ward": 5,
"req": ["combat", 10]}, "weight": "medium", "req": ["combat", 10]},
# Building materials from every trade: thatch and flax from the fields, rope # Building materials from every trade: thatch and flax from the fields, rope
# twisted from flax, glue boiled from hides, and provisions to feed the builders. # twisted from flax, glue boiled from hides, and provisions to feed the builders.
"thatch": {"name": "Thatch", "icon": "🌾", "value": 2}, "thatch": {"name": "Thatch", "icon": "🌾", "value": 2},
@ -100,6 +100,23 @@ PIECES = [
("legs", "legs", "greaves", 4, 9, None, [3, 6, 9, 12, 16, 21]), ("legs", "legs", "greaves", 4, 9, None, [3, 6, 9, 12, 16, 21]),
("body", "body", "cuirass", 5, 11, None, [4, 8, 12, 16, 22, 28]), ("body", "body", "cuirass", 5, 11, None, [4, 8, 12, 16, 22, 28]),
] ]
# Armour weights: a piece's rating, split into armour (against physical blows) and
# ward (against magic). Heavy plate from Smithing, medium leather from
# Leatherworking, light cloth from Tailoring; each adds up to about the same.
WEIGHTS = {
"heavy": {"name": "Heavy", "armour": 1.0, "ward": 0.2},
"medium": {"name": "Medium", "armour": 0.6, "ward": 0.6},
"light": {"name": "Light", "armour": 0.2, "ward": 1.0},
}
ARMOUR_RATING = {slot: rating for piece, slot, _noun, _bars, _offset, _power, rating in PIECES if piece != "shield" and rating}
def _armour_stats(weight: str, slot: str, tier: int) -> dict:
rating = ARMOUR_RATING[slot][tier]
return {"armour": round(rating * WEIGHTS[weight]["armour"]), "ward": round(rating * WEIGHTS[weight]["ward"]),
"weight": weight}
PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"} PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"}
PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs": "🦿", "body": "🦾"} PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs": "🦿", "body": "🦾"}
COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"} COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"}
@ -118,22 +135,34 @@ TREES = [
("magic_logs", "Enchanted logs", "🔮", 55, "cut_magic", "Cut enchanted trees"), ("magic_logs", "Enchanted logs", "🔮", 55, "cut_magic", "Cut enchanted trees"),
] ]
# raw hide (id, name, icon, value), leather (id, name, value), armour id prefix, armour name prefix, # Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value),
# leatherworking level, armour by piece # armour id prefix, armour name prefix, leatherworking level
LEATHERS = [ LEATHERS = [
(("hide", "Hide", "🟫", 3), ("leather", "Leather", 5), "leather", "Leather", 1, (1, 2, 3)), (("hide", "Hide", "🟫", 3), ("leather", "Leather", 5), "leather", "Leather", 1),
(("boar_hide", "Boar hide", "🐗", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20, (2, 4, 6)), (("boar_hide", "Boar hide", "🐗", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20),
(("bear_pelt", "Bear pelt", "🐻", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35, (4, 6, 9)), (("bear_pelt", "Bear pelt", "🐻", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35),
(("green_dragonhide", "Green dragonhide", "🐉", 35), ("green_dragon_leather", "Green dragon leather", 50), (("elk_hide", "Elk hide", "🦌", 35), ("elk_leather", "Elk leather", 50), "elk_leather", "Elk leather", 50),
"green_dhide", "Green dragonhide", 50, (6, 9, 13)), (("sabrecat_pelt", "Sabrecat pelt", "🐆", 60), ("sabrecat_leather", "Sabrecat leather", 85),
(("blue_dragonhide", "Blue dragonhide", "🐲", 60), ("blue_dragon_leather", "Blue dragon leather", 85), "sabrecat_leather", "Sabrecat leather", 65),
"blue_dhide", "Blue dragonhide", 65, (8, 12, 17)), (("mammoth_hide", "Mammoth hide", "🦣", 100), ("mammoth_leather", "Mammoth leather", 140),
(("red_dragonhide", "Red dragonhide", "🔥", 100), ("red_dragon_leather", "Red dragon leather", 140), "mammoth_leather", "Mammoth leather", 80),
"red_dhide", "Red dragonhide", 80, (10, 15, 21)),
] ]
# 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, "🦺")]
# 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 = [
(("flax", "Flax", "🌿", 2), 1, 35, 11, ("linen", "Linen", 5), "linen", "Linen"),
(("cotton", "Cotton", "☁️", 4), 15, 45, 20, ("cotton_cloth", "Cotton cloth", 12), "cotton", "Cotton"),
(("silk_cocoons", "Silk cocoons", "🐛", 8), 30, 55, 32, ("silk", "Silk", 24), "silk", "Silk"),
(("moonpetal", "Moonpetal", "🌙", 15), 45, 60, 45, ("moonweave", "Moonweave", 45), "moonweave", "Moonweave"),
(("starbloom", "Starbloom", "⭐", 28), 60, 70, 60, ("starweave", "Starweave", 80), "starweave", "Starweave"),
(("spiritvine", "Spiritvine", "🌀", 50), 75, 80, 80, ("spiritweave", "Spiritweave", 135), "spiritweave", "Spiritweave"),
]
FIBRE_VERBS = {"silk_cocoons": "Raise silkworms"}
CLOTH_PIECES = [("hood", "helmet", "hood", 2, 2, "🧢"), ("legs", "legs", "leggings", 3, 7, "👖"),
("body", "body", "robe", 4, 11, "👘")]
# raw (id, name, icon, value), cooked (id, name, icon, value, heal, food), fishing level, fish action, verb # raw (id, name, icon, value), cooked (id, name, icon, value, heal, food), fishing level, fish action, verb
FISH = [ FISH = [
@ -159,8 +188,10 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in
"req": ["combat", EQUIP_LEVELS[_tier]]} "req": ["combat", EQUIP_LEVELS[_tier]]}
if _power: if _power:
_item["power"] = _power[_tier] _item["power"] = _power[_tier]
if _armour: if _armour and _piece == "shield":
_item["armour"] = _armour[_tier] _item["armour"] = _armour[_tier]
elif _armour:
_item.update(_armour_stats("heavy", _slot, _tier))
ITEMS[f"{_key}_{_piece}"] = {k: v for k, v in _item.items() if v is not None} ITEMS[f"{_key}_{_piece}"] = {k: v for k, v in _item.items() if v is not None}
for _skill, (_tool, _icon) in TOOLS.items(): for _skill, (_tool, _icon) in TOOLS.items():
ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = { ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = {
@ -202,16 +233,26 @@ for _tier, _metal in enumerate(METALS):
ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"), ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"),
"icon": "🪄", "style": "staff", **_weapon} "icon": "🪄", "style": "staff", **_weapon}
ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "🏹", "style": "bow", **_weapon} ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "🏹", "style": "bow", **_weapon}
for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS): for _tier, (_raw, _leather, _prefix, _label, _level) in enumerate(LEATHERS):
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]} ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
for (_suffix, _slot, _noun, _used, _offset, _icon), _points in zip(LEATHER_PIECES, _armour): for _suffix, _slot, _noun, _used, _offset, _icon in LEATHER_PIECES:
ITEMS[f"{_prefix}_{_suffix}"] = { ITEMS[f"{_prefix}_{_suffix}"] = {
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3), "name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3),
"slot": _slot, "armour": _points, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], "slot": _slot, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], **_armour_stats("medium", _slot, _tier),
} }
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"]
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[_cloth[0]] = {"name": _cloth[1], "icon": "🧶", "value": _cloth[2]}
for _suffix, _slot, _noun, _used, _offset, _icon in CLOTH_PIECES:
ITEMS[f"{_prefix}_{_suffix}"] = {
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _cloth[2] * 1.3),
"slot": _slot, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], **_armour_stats("light", _slot, _tier),
}
if _noun == "leggings":
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"]
for _raw, _cooked, _level, _action_id, _verb in FISH: for _raw, _cooked, _level, _action_id, _verb in FISH:
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]} ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]}
@ -245,12 +286,14 @@ POTION_LINES = [
("fishing", "Fishing potion", "fishing", "speed", SPEEDS, [fish[0][0] for fish in FISH]), ("fishing", "Fishing potion", "fishing", "speed", SPEEDS, [fish[0][0] for fish in FISH]),
("farming", "Farming potion", "farming", "speed", SPEEDS, ("farming", "Farming potion", "farming", "speed", SPEEDS,
["wheat", "vegetables", "guam_leaf", "marrentill", "tarromin", "harralander"]), ["wheat", "vegetables", "guam_leaf", "marrentill", "tarromin", "harralander"]),
("hunting", "Hunting potion", "hunting", "speed", SPEEDS[:4], ["feathers", "raw_meat", "boar_hide", "bear_pelt"]), ("hunting", "Hunting potion", "hunting", "speed", SPEEDS,
["feathers", "raw_meat", "boar_hide", "bear_pelt", "elk_hide", "sabrecat_pelt"]),
("construction", "Construction potion", "construction", "speed", SPEEDS, [plank for plank, _name in PLANKS]), ("construction", "Construction potion", "construction", "speed", SPEEDS, [plank for plank, _name in PLANKS]),
("smithing", "Smithing potion", "smithing", "keep", SPEEDS, [f"{metal[0]}_bar" for metal in METALS]), ("smithing", "Smithing potion", "smithing", "keep", SPEEDS, [f"{metal[0]}_bar" for metal in METALS]),
("cooking", "Cooking potion", "cooking", "keep", SPEEDS, [fish[1][0] for fish in FISH]), ("cooking", "Cooking potion", "cooking", "keep", SPEEDS, [fish[1][0] for fish in FISH]),
("leatherworking", "Leatherworking potion", "leatherworking", "keep", SPEEDS, ("leatherworking", "Leatherworking potion", "leatherworking", "keep", SPEEDS,
[leather[1][0] for leather in LEATHERS]), [leather[1][0] for leather in LEATHERS]),
("tailoring", "Tailoring potion", "tailoring", "keep", SPEEDS, [cloth[4][0] for cloth in CLOTHS]),
("prayer", "Prayer potion", "prayer", "bones", [0.25, 0.5, 0.75], ["bones", "big_bones", "dragon_bones"]), ("prayer", "Prayer potion", "prayer", "bones", [0.25, 0.5, 0.75], ["bones", "big_bones", "dragon_bones"]),
("strength", "Strength potion", "combat", "power", [4, 7, 11, 15, 20, 26], [leather[0][0] for leather in LEATHERS]), ("strength", "Strength potion", "combat", "power", [4, 7, 11, 15, 20, 26], [leather[0][0] for leather in LEATHERS]),
("defence", "Defence potion", "combat", "reduce", [0.10, 0.14, 0.18, 0.22, 0.26, 0.30], ("defence", "Defence potion", "combat", "reduce", [0.10, 0.14, 0.18, 0.22, 0.26, 0.30],
@ -312,6 +355,7 @@ SKILLS = [
{"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"}, {"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"},
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"}, {"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
{"id": "tailoring", "name": "Tailoring", "icon": "🧵", "group": "Crafting"},
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"}, {"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"}, {"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"}, {"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
@ -319,7 +363,7 @@ SKILLS = [
] ]
SKILL_IDS = [skill["id"] for skill in SKILLS] SKILL_IDS = [skill["id"] for skill in SKILLS]
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"} GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
CRAFTING = {"smithing", "construction", "cooking", "leatherworking", "herblore"} CRAFTING = {"smithing", "construction", "cooking", "leatherworking", "herblore", "tailoring"}
# A class is a whole trade, from gathering to the finished goods: its skills are # A class is a whole trade, from gathering to the finished goods: its skills are
# 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own. # 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own.
@ -340,9 +384,10 @@ CLASSES = {
"combat": "Sword: +15% damage", "combat": "Sword: +15% damage",
}, },
"homesteader": { "homesteader": {
"name": "Homesteader", "icon": "🌾", "fast": ["cooking", "herblore"], "craft": ["cooking", "herblore"], "name": "Homesteader", "icon": "🌾", "fast": ["cooking", "herblore", "tailoring"],
"bonus": "Cooking and herblore 20% faster, two farm plots", "craft": ["cooking", "herblore", "tailoring"],
"masterwork": "Masterwork: 10% double cooking and herblore crafts", "bonus": "Cooking, herblore and tailoring 20% faster, two farm plots",
"masterwork": "Masterwork: 10% double cooking, herblore and tailoring crafts",
"signature": "Steady hand: potions last 25% longer", "signature": "Steady hand: potions last 25% longer",
"combat": "Staff: +15% damage", "combat": "Staff: +15% damage",
}, },
@ -391,13 +436,16 @@ def _tiered_actions() -> list[dict]:
for piece, _slot, noun, bars, offset, _power, _armour in PIECES: for piece, _slot, noun, bars, offset, _power, _armour in PIECES:
if piece == "shield": if piece == "shield":
continue # builders make shields now, in Construction continue # builders make shields now, in Construction
inputs = {f"{key}_bar": bars, **({trophy("troll", tier): tier} if piece == "sword" and tier else {})} inputs = {f"{key}_bar": bars}
if tier:
inputs.update({trophy("sword", tier): tier} if piece == "sword" else
{reagent("heavy", tier): REAGENT_COUNTS[_slot]})
actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset, actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset,
25 + 5 * bars + 2 * tier, bars * piece_xp[tier], inputs, 25 + 5 * bars + 2 * tier, bars * piece_xp[tier], inputs,
{f"{key}_{piece}": 1}, group=name, city=city)) {f"{key}_{piece}": 1}, group=name, city=city))
actions.append(_action(f"smith_{key}_warhammer", "smithing", f"{name} warhammer", TIER_LEVELS[tier] + 6, actions.append(_action(f"smith_{key}_warhammer", "smithing", f"{name} warhammer", TIER_LEVELS[tier] + 6,
45 + 2 * tier, 4 * piece_xp[tier], 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}_bar": 3, PLANKS[tier][0]: 1, **({trophy("hammer", tier): 2 + tier} if tier else {})},
{f"{key}_warhammer": 1}, group=name, city=city)) {f"{key}_warhammer": 1}, group=name, city=city))
for tool, _icon in TOOLS.values(): for tool, _icon in TOOLS.values():
tool_id = f"{key}_{tool.replace(' ', '_')}" tool_id = f"{key}_{tool.replace(' ', '_')}"
@ -407,14 +455,26 @@ def _tiered_actions() -> list[dict]:
for tier, (log, _name, _icon, _value, action_id, verb) in enumerate(TREES): for tier, (log, _name, _icon, _value, action_id, verb) in enumerate(TREES):
actions.append(_action(action_id, "woodcutting", verb, TIER_LEVELS[tier], [20, 35, 40, 50, 60, 75][tier], actions.append(_action(action_id, "woodcutting", verb, TIER_LEVELS[tier], [20, 35, 40, 50, 60, 75][tier],
[5, 15, 24, 36, 52, 75][tier], outputs={log: 1})) [5, 15, 24, 36, 52, 75][tier], outputs={log: 1}))
for tier, (raw, leather, prefix, label, level, _armour) in enumerate(LEATHERS): for tier, (raw, leather, prefix, label, level) in enumerate(LEATHERS):
actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking", actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking",
f"Tan {leather[1].lower()}", level, 25 + 3 * tier, bar_xp[tier], f"Tan {leather[1].lower()}", level, 25 + 3 * tier, bar_xp[tier],
{raw[0]: 1}, {leather[0]: 1}, group="Tanning")) {raw[0]: 1}, {leather[0]: 1}, group="Tanning"))
for suffix, _slot, noun, used, offset, _icon in LEATHER_PIECES: for suffix, slot, noun, used, offset, _icon in LEATHER_PIECES:
actions.append(_action(f"lw_{suffix}" if tier == 0 else f"lw_{prefix}_{suffix}", "leatherworking", actions.append(_action(f"lw_{suffix}" if tier == 0 else f"lw_{prefix}_{suffix}", "leatherworking",
f"{label} {noun}", level + offset, 25 + 5 * used + 2 * tier, used * piece_xp[tier], f"{label} {noun}", level + offset, 25 + 5 * used + 2 * tier, used * piece_xp[tier],
{leather[0]: used}, {f"{prefix}_{suffix}": 1}, group=label)) {leather[0]: used, **({reagent("medium", tier): REAGENT_COUNTS[slot]} if tier else {})},
{f"{prefix}_{suffix}": 1}, group=label))
for tier, (fibre, level, seconds, fibre_xp, cloth, prefix, label) in enumerate(CLOTHS):
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()}"),
level, seconds, fibre_xp, outputs={fibre[0]: 2}, group="Fibres"))
actions.append(_action(f"weave_{cloth[0]}", "tailoring", f"Weave {cloth[1].lower()}", TIER_LEVELS[tier],
25 + 3 * tier, bar_xp[tier], {fibre[0]: 2}, {cloth[0]: 1}, group="Weaving"))
for suffix, slot, noun, used, offset, _icon in CLOTH_PIECES:
actions.append(_action(f"sew_{prefix}_{suffix}", "tailoring", f"{label} {noun}", TIER_LEVELS[tier] + offset,
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))
for raw, cooked, level, fish_id, verb in FISH: for raw, cooked, level, fish_id, verb in FISH:
tier = TIER_LEVELS.index(level) if level in TIER_LEVELS else 3 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], actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier],
@ -426,38 +486,224 @@ def _tiered_actions() -> list[dict]:
# --- combat ----------------------------------------------------------------- # --- combat -----------------------------------------------------------------
# A fight is played out swing by swing: you and the enemy each have HP, a swing # A fight is played out swing by swing: you and the enemy each have HP, a swing
# timer, accuracy and a max hit, and every swing hits, misses or crits. Combat # timer, accuracy and a max hit, and every swing hits, misses or crits. Every
# is split into the Frontier (bandits and the city's troubles) and expeditions: # blow has a damage type: weapons deal one, and each enemy is vulnerable to some
# one area per tier, each with a golem, a troll, a spirit and a flyer that drop # types and resists others. Armour stops physical blows, ward stops magic.
# the trophies higher weapons need, and a boss. # Combat is split into the Frontier (bandits and the city's troubles) and
# expeditions: one area per tier, each with four races (each feeding one weapon
# line with its trophy, and every armour line with the area's reagents) and a boss.
CRIT_CHANCE = 0.05 CRIT_CHANCE = 0.05
CRIT_MULT = 1.5 CRIT_MULT = 1.5
RESPAWN = 2 # seconds after a kill before the next enemy turns up RESPAWN = 2 # seconds after a kill before the next enemy turns up
MAX_FIGHT = 600 # a fight that drags on longer is broken off 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 FLEE_BELOW = 0.2 # out of food and under this share of HP: get out
DAMAGE_TYPES = {
"slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"},
"blunt": {"name": "Blunt", "glyph": "hammer-drop", "tint": "#d1a46a"},
"pierce": {"name": "Piercing", "glyph": "arrowhead", "tint": "#5fae8a"},
"magic": {"name": "Magic", "glyph": "magic-swirl", "tint": "#a77be8"},
}
VULNERABLE = 1.25 # damage an enemy takes from a type it's vulnerable to
RESISTANT = 0.75 # ...and from one it resists
# How each kind of weapon fights. Two-handed weapons carry the shield's power themselves. # How each kind of weapon fights. Two-handed weapons carry the shield's power themselves.
STYLES = { STYLES = {
"sword": {"name": "sword", "interval": 2.4, "hit": 1.0, "accuracy": 1.0, "crit": 0.05}, "sword": {"name": "sword", "damage": "slash", "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}, "hammer": {"name": "warhammer", "damage": "blunt", "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}, "staff": {"name": "staff", "damage": "magic", "interval": 3.0, "hit": 1.2, "accuracy": 1.25, "crit": 0.05},
"bow": {"name": "bow", "interval": 1.8, "hit": 0.75, "accuracy": 1.05, "crit": 0.05, "first": True}, "bow": {"name": "bow", "damage": "pierce", "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 = {"miner": "hammer", "builder": "sword", "homesteader": "staff", "ranger": "bow"}
CLASS_STYLE_BONUS = 1.15 # damage with your own trade's weapon CLASS_STYLE_BONUS = 1.15 # damage with your own trade's weapon
BLOCK_CHANCE = 0.15 # a shield turns aside this share of hits 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 = { # Each armour piece past copper needs reagents from its tier's expedition area.
"golem": {"hammer": 1.5, "bow": 0.5}, REAGENT_COUNTS = {"helmet": 1, "legs": 2, "body": 3}
"troll": {"sword": 1.5}, REAGENT_NOUNS = {"heavy": "plating", "medium": "lacing", "light": "thread"}
"spirit": {"hammer": 0.5, "sword": 0.5, "bow": 0.5, "staff": 1.5}, REAGENT_ODDS = 0.10 # each of the area's three reagents, from any of its races
"flyer": {"hammer": 0.5, "sword": 0.5, "bow": 1.5}, TROPHY_ODDS = 0.03 # rare: higher-tier weapons are hunted for
BOSS_TROPHY_ODDS = 0.06 # each of the four lines, so a boss is worth the longer fight
def _slug(name: str) -> str:
return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_")
# Archetypes: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up.
ARCHETYPES = {
"brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4),
"skirmisher": (0.85, 2.2, 1.1, 1.25, 0.85, 4),
"caster": (0.85, 2.6, 1.15, 1.0, 1.25, 3),
"elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1),
"boss": (1.4, 3.0, 1.0, 1.0, 1.35, 1),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 1),
} }
def trophy(kind: str, tier: int) -> str: def _types(value) -> list:
"""The trophy a tier's monster of this kind drops (tier 1 is the Old Watchtower).""" return [value] if isinstance(value, str) else list(value)
return _slug(AREA_MONSTERS[kind][2][tier - 1])
def monster(name: str, archetype: str, level: int, attack, vulnerable=(), resists=(), boss: bool = False) -> dict:
hp, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype]
hp = round((20 + 6 * level) * hp * (2.5 if boss else 1))
return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight,
"hp": hp, "interval": interval, "xp": round(hp * 0.4 + level * 1.5),
"accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion),
"max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))),
"attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)}
def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: int = 0, drops=(),
boss: bool = False, **extra) -> dict:
"""A combat action: each kill is one of the roster's variants, picked by how often each turns up.
An elite is a couple of levels above the rest."""
variants = [monster(vname, archetype, level + (2 if archetype == "elite" else 0), attack, vulnerable, resists, boss)
for vname, archetype, attack, vulnerable, resists in roster]
total = sum(v["weight"] for v in variants)
xp = round(sum(v["xp"] * v["weight"] for v in variants) / total)
enemy = {"name": name, "level": level, "boss": boss, "variants": variants}
return _action(id, "combat", extra.pop("label", None) or name, level, RESPAWN, xp, gold=gold,
drops=[list(drop) for drop in drops], enemy=enemy, group=group, **extra)
def _boss(id: str, name: str, group: str, level: int, archetype: str, attack, vulnerable, resists, **extra) -> dict:
return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)],
boss=extra.pop("is_boss", True), **extra)
# Rosters: (variant, archetype, attacks with, vulnerable to, resists).
BANDITS = [("Bandit brute", "brute", "blunt", "slash", "blunt"),
("Bandit archer", "skirmisher", "pierce", "blunt", "pierce"),
("Hedge mage", "caster", "magic", "pierce", "magic")]
WOLVES = [("Grey wolf", "skirmisher", "slash", "pierce", "blunt"),
("Timber wolf", "brute", "slash", "slash", "pierce"),
("Wolf alpha", "elite", "slash", "blunt", "slash")]
GOBLINS = [("Goblin brute", "brute", "blunt", "blunt", "slash"),
("Goblin rogue", "skirmisher", "pierce", "magic", "blunt"),
("Goblin warlock", "caster", "magic", "slash", "magic")]
# Expedition areas, tiers II to VI: name, level, the reagent prefix, and four races.
# Each race: (name, trophy, weapon line its trophy goes into, level offset, roster).
AREAS = [
("Old Watchtower", 10, "Watchtower", [
("Clay golems", "Clay core", "hammer", 0, [
("Clay brute", "brute", "blunt", "blunt", "pierce"),
("Mud shaper", "caster", "magic", "pierce", "magic"),
("Clay colossus", "elite", "blunt", "magic", "blunt")]),
("Cave trolls", "Cave troll hide", "sword", 2, [
("Troll bruiser", "brute", "blunt", "slash", "blunt"),
("Troll rockhurler", "skirmisher", "pierce", "blunt", "pierce"),
("Troll hexer", "caster", "magic", "pierce", "slash")]),
("Restless dead", "Grave dust", "staff", 1, [
("Skeleton warrior", "brute", "slash", "blunt", "pierce"),
("Wisp", "skirmisher", "magic", "magic", "slash"),
("Necromancer", "caster", "magic", "slash", "blunt")]),
("Harpies", "Harpy sinew", "bow", 0, [
("Harpy slasher", "skirmisher", "slash", "pierce", "blunt"),
("Harpy windcaster", "caster", "magic", ("slash", "pierce"), "magic"),
("Harpy matriarch", "elite", "slash", "magic", "pierce")]),
]),
("Sunken Barrows", 20, "Barrow", [
("Stone guardians", "Stone core", "hammer", 0, [
("Stone guardian", "brute", "blunt", "blunt", "slash"),
("Barrow sentry", "skirmisher", "pierce", "magic", "pierce"),
("Runebound guardian", "elite", "magic", "slash", "magic")]),
("Lizardfolk", "Lizard scale", "sword", 2, [
("Lizard warrior", "brute", "slash", "blunt", "slash"),
("Lizard spearman", "skirmisher", "pierce", "slash", "pierce"),
("Lizard shaman", "caster", "magic", "pierce", "magic")]),
("Bog wraiths", "Wraith essence", "staff", 1, [
("Bog wraith", "caster", "magic", "magic", "slash"),
("Drowned knight", "brute", "slash", "blunt", "pierce"),
("Marsh wisp", "skirmisher", "magic", "pierce", "blunt")]),
("Giant spiders", "Spider silk", "bow", 0, [
("Spider hunter", "skirmisher", "pierce", "slash", "pierce"),
("Venom weaver", "caster", "magic", "blunt", "magic"),
("Brood mother", "elite", "pierce", "magic", "blunt")]),
]),
("Iron Hills", 30, "Ironhill", [
("Iron golems", "Iron core", "hammer", 0, [
("Iron golem", "brute", "blunt", "blunt", "slash"),
("Forge golem", "caster", "magic", "pierce", "magic"),
("Iron colossus", "elite", "blunt", "magic", "pierce")]),
("Orcs", "Orc tusk", "sword", 2, [
("Orc berserker", "brute", "slash", "pierce", "blunt"),
("Orc archer", "skirmisher", "pierce", "slash", "pierce"),
("Orc shaman", "caster", "magic", "blunt", "magic")]),
("Mine ghosts", "Ghost essence", "staff", 1, [
("Lost miner", "brute", "blunt", "magic", "slash"),
("Lantern ghost", "caster", "magic", "blunt", "magic"),
("Shaft howler", "skirmisher", "magic", "pierce", "blunt")]),
("Wyverns", "Wyvern sinew", "bow", 0, [
("Wyvern", "brute", "slash", "pierce", "slash"),
("Wyvern hatchling", "skirmisher", "pierce", "blunt", "magic"),
("Venomspitter", "caster", "magic", "slash", "pierce")]),
]),
("Frostpeak", 40, "Frostpeak", [
("Ice elementals", "Frozen core", "hammer", 0, [
("Ice brute", "brute", "blunt", "blunt", "pierce"),
("Frost wisp", "skirmisher", "magic", "slash", "magic"),
("Glacier colossus", "elite", "blunt", "magic", "slash")]),
("Frost trolls", "Frost troll hide", "sword", 2, [
("Frost troll", "brute", "blunt", "slash", "blunt"),
("Troll icehurler", "skirmisher", "pierce", "magic", "pierce"),
("Troll rimecaller", "caster", "magic", "blunt", "magic")]),
("Banshees", "Banshee essence", "staff", 1, [
("Banshee", "caster", "magic", "magic", "slash"),
("Wailing knight", "brute", "slash", "blunt", "pierce"),
("Keening shade", "skirmisher", "magic", "pierce", "blunt")]),
("Griffins", "Griffin sinew", "bow", 0, [
("Griffin", "brute", "slash", "pierce", "blunt"),
("Griffin rider", "skirmisher", "pierce", "slash", "pierce"),
("Storm griffin", "caster", "magic", "blunt", "magic")]),
]),
("Dragon's Spine", 50, "Dragonspine", [
("Magma constructs", "Magma core", "hammer", 0, [
("Magma brute", "brute", "blunt", "blunt", "slash"),
("Cinder golem", "skirmisher", "pierce", "magic", "pierce"),
("Fire binder", "caster", "magic", "pierce", "magic")]),
("Dragonkin", "Dragonkin scale", "sword", 2, [
("Drakeguard", "brute", "slash", "blunt", "slash"),
("Drake lancer", "skirmisher", "pierce", "magic", "pierce"),
("Flame priest", "caster", "magic", "slash", "magic")]),
("Shades", "Shade essence", "staff", 1, [
("Shade", "caster", "magic", "magic", "pierce"),
("Shadow blade", "skirmisher", "slash", "blunt", "slash"),
("Void knight", "elite", "magic", "pierce", "blunt")]),
("Rocs", "Roc sinew", "bow", 0, [
("Roc", "brute", "slash", "pierce", "blunt"),
("Roc hatchling", "skirmisher", "pierce", "slash", "pierce"),
("Thunder roc", "caster", "magic", "blunt", "magic")]),
]),
]
AREA_BONES = ["bones", "big_bones", "big_bones", "dragon_bones", "dragon_bones"]
TROPHIES = {} # id: (name, weapon line, tier)
REAGENTS = {} # id: (weight, tier)
for _tier, (_area, _level, _prefix, _races) in enumerate(AREAS, start=1):
for _race, _trophy, _line, _offset, _roster in _races:
TROPHIES[_slug(_trophy)] = (_trophy, _line, _tier)
ITEMS[_slug(_trophy)] = {"name": _trophy, "icon": "🏆", "value": 15 * _tier, "trophy": _line, "tier": _tier}
for _weight, _noun in REAGENT_NOUNS.items():
_id = f"{_slug(_prefix)}_{_noun}"
REAGENTS[_id] = (_weight, _tier)
ITEMS[_id] = {"name": f"{_prefix} {_noun}", "icon": "✨", "value": 12 * _tier, "reagent": _weight, "tier": _tier}
def trophy(line: str, tier: int) -> str:
"""The trophy for a weapon line from a tier's area (tier 1 is the Old Watchtower)."""
return next(item for item, (_name, kind, t) in TROPHIES.items() if kind == line and t == tier)
def reagent(weight: str, tier: int) -> str:
"""The reagent an armour weight needs at a tier, from that tier's area."""
return next(item for item, (kind, t) in REAGENTS.items() if kind == weight and t == tier)
def _area_drops(tier: int, odds: float = REAGENT_ODDS) -> list[tuple]:
return [(reagent(weight, tier), 1, odds) for weight in WEIGHTS]
def _weapon_actions() -> list[dict]: def _weapon_actions() -> list[dict]:
@ -465,118 +711,69 @@ def _weapon_actions() -> list[dict]:
actions = [] actions = []
for tier, (key, name, *_rest) in enumerate(METALS): for tier, (key, name, *_rest) in enumerate(METALS):
plank, leather, herb = PLANKS[tier][0], LEATHERS[tier][1][0], HERBS[tier][0] plank, leather, herb = PLANKS[tier][0], LEATHERS[tier][1][0], HERBS[tier][0]
extra = lambda kind, count: {trophy(kind, tier): count} if tier else {} extra = lambda line, count: {trophy(line, tier): count} if tier else {}
actions.append(_action(f"make_{key}_shield", "construction", f"{name} shield", TIER_LEVELS[tier] + 5, 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], 40 + 2 * tier, [10, 22, 36, 52, 70, 92][tier],
{plank: 3, leather: 2, f"{key}_bar": 1, **extra("troll", 1 + tier)}, {plank: 3, leather: 2, f"{key}_bar": 1, **extra("sword", 1 + tier)},
{f"{key}_shield": 1}, group="Shields")) {f"{key}_shield": 1}, group="Shields"))
actions.append(_action(f"brew_{WOODS[tier]}_staff", "herblore", STAVES[tier], TIER_LEVELS[tier] + 5, 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], 45 + 2 * tier, [12, 26, 42, 60, 80, 105][tier],
{plank: 2, herb: 3, **extra("spirit", 2 + tier)}, {f"{WOODS[tier]}_staff": 1}, {plank: 2, herb: 3, **extra("staff", 2 + tier)}, {f"{WOODS[tier]}_staff": 1},
group="Staves")) group="Staves"))
actions.append(_action(f"lw_{WOODS[tier]}_bow", "leatherworking", BOWS[tier], LEATHERS[tier][4] + 4, 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], 40 + 2 * tier, [12, 26, 42, 60, 80, 105][tier],
{plank: 2, leather: 2, **extra("flyer", 2 + tier)}, {f"{WOODS[tier]}_bow": 1}, {plank: 2, leather: 2, **extra("bow", 2 + tier)}, {f"{WOODS[tier]}_bow": 1},
group="Bows")) group="Bows"))
return actions 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),
"beast": (0.9, 2.2, 1.0, 1.0, 0.9, 0.0),
"golem": (1.5, 3.2, 1.0, 0.7, 1.25, 0.2),
"troll": (1.4, 3.0, 0.9, 1.0, 1.4, 0.0),
"spirit": (0.8, 2.4, 1.0, 1.35, 0.9, 0.0),
"flyer": (0.8, 2.0, 1.0, 1.2, 0.8, 0.0),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 0.15),
}
def monster(name: str, kind: str, level: int, boss: bool = False) -> dict:
hp, interval, accuracy, evasion, hit, shrug = MONSTER_KINDS[kind]
return {"name": name, "kind": kind, "level": level, "boss": boss,
"hp": round((20 + 6 * level) * hp * (2.5 if boss else 1)), "interval": interval,
"accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion),
"max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))), "reduction": shrug}
def _fight(id: str, enemy: dict, group: str, gold: int = 0, drops=(), name: str | None = None, **extra) -> dict:
"""A combat action: XP per kill, needs the enemy's level in combat."""
xp = round(enemy["hp"] * 0.4 + enemy["level"] * 1.5)
return _action(id, "combat", name or enemy["name"], enemy["level"], RESPAWN, xp, gold=gold,
drops=[list(drop) for drop in drops], enemy=enemy, group=group, **extra)
# Expedition areas, tiers II to VI: name, level, and the trophy each kind of monster drops there.
AREAS = ["Old Watchtower", "Sunken Barrows", "Iron Hills", "Frostpeak", "Dragon's Spine"]
AREA_LEVELS = [10, 20, 30, 40, 50]
AREA_MONSTERS = { # kind: (level offset, monsters, trophies)
"golem": (0, ["Clay golem", "Stone golem", "Iron golem", "Obsidian golem", "Runic golem"],
["Clay core", "Stone core", "Iron core", "Obsidian core", "Runic core"]),
"troll": (2, ["Cave troll", "Hill troll", "Rock troll", "Frost troll", "Elder troll"],
["Cave troll hide", "Hill troll hide", "Rock troll hide", "Frost troll hide", "Elder troll hide"]),
"spirit": (1, ["Will-o'-wisp", "Bog wraith", "Mine ghost", "Banshee", "Shade"],
["Wisp essence", "Wraith essence", "Ghost essence", "Banshee essence", "Shade essence"]),
"flyer": (0, ["Giant bat", "Harpy", "Wyvern", "Griffin", "Roc"],
["Bat sinew", "Harpy sinew", "Wyvern sinew", "Griffin sinew", "Roc sinew"]),
}
TROPHY_ODDS = 0.03 # rare: higher-tier weapons are hunted for
BOSS_TROPHY_ODDS = 0.06 # each of the four kinds, so a boss is worth the longer fight
AREA_BONES = ["bones", "big_bones", "big_bones", "dragon_bones", "dragon_bones"]
def _slug(name: str) -> str:
return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_")
TROPHIES = {} # id: (name, kind, tier)
for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items():
for _tier, _trophy in enumerate(_trophies, start=1):
TROPHIES[_slug(_trophy)] = (_trophy, _kind, _tier)
ITEMS[_slug(_trophy)] = {"name": _trophy, "icon": "🏆", "value": 15 * _tier, "trophy": _kind, "tier": _tier}
def _area_fights() -> list[dict]: def _area_fights() -> list[dict]:
fights = [] fights = []
for index, (area, level) in enumerate(zip(AREAS, AREA_LEVELS)): for tier, (area, level, _prefix, races) in enumerate(AREAS, start=1):
for kind, (offset, monsters, trophies) in AREA_MONSTERS.items(): for race, trophy_name, _line, offset, roster in races:
enemy = monster(monsters[index], kind, level + offset) fights.append(_encounter(f"fight_{_slug(race)}", race, area, level + offset, roster, gold=level + offset,
fights.append(_fight(f"fight_{_slug(monsters[index])}", enemy, area, gold=level + offset, drops=[(AREA_BONES[tier - 1], 1, 1.0), (_slug(trophy_name), 1, TROPHY_ODDS),
drops=[(AREA_BONES[index], 1, 1.0), (_slug(trophies[index]), 1, TROPHY_ODDS)])) *_area_drops(tier)]))
return fights return fights
def _boss_trophies(tier: int) -> list[tuple]: def _boss_drops(tier: int, reagents: float = 0.3) -> list[tuple]:
return [(_slug(trophies[tier - 1]), 1, BOSS_TROPHY_ODDS) for _offset, _monsters, trophies in AREA_MONSTERS.values()] return [*[(trophy(line, tier), 1, BOSS_TROPHY_ODDS) for line in STYLES], *_area_drops(tier, reagents)]
COMBAT_ACTIONS = [ COMBAT_ACTIONS = [
# The Frontier: the city's own troubles. # The Frontier: the city's own troubles.
_fight("fight_scouts", monster("Bandit scouts", "human", 10), "Frontier", gold=10, _encounter("fight_scouts", "Bandit scouts", "Frontier", 10, BANDITS, gold=10,
drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]), drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]),
_fight("sabotage", monster("Camp guards", "human", 12), "Frontier", name="Sabotage the bandit camp", _encounter("sabotage", "Camp guards", "Frontier", 12, BANDITS, label="Sabotage the bandit camp", special="sabotage"),
special="sabotage"), _boss("fight_stronghold", "Bandit warlord", "Frontier", 40, "boss", ("slash", "magic"), ("blunt", "magic"),
_fight("fight_stronghold", monster("Bandit stronghold", "human", 40, boss=True), "Frontier", gold=60, ("slash", "pierce"), label="Bandit stronghold", gold=60,
drops=[("steel_bar", 1, 0.4), ("coal", 3, 0.5), ("iron_shield", 1, 0.04), ("big_bones", 1, 1.0)]), drops=[("steel_bar", 1, 0.4), ("coal", 3, 0.5), ("iron_shield", 1, 0.04), ("big_bones", 1, 1.0)]),
# Greenwood: where everyone starts. # Greenwood: where everyone starts.
_fight("fight_wolves", monster("Wolves", "beast", 1), "Greenwood", gold=2, name="Wolves in the woods", _encounter("fight_wolves", "Wolves", "Greenwood", 1, WOLVES, gold=2, label="Wolves in the woods",
drops=[("raw_meat", 1, 0.6), ("hide", 1, 0.5), ("bones", 1, 1.0)]), drops=[("raw_meat", 1, 0.6), ("hide", 1, 0.5), ("bones", 1, 1.0)]),
_fight("fight_goblins", monster("Cave goblins", "human", 5), "Greenwood", gold=4, _encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4,
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
*_area_fights(), *_area_fights(),
_fight("fight_ruins", monster("Watchtower ogre", "troll", 18, boss=True), "Old Watchtower", gold=25, _boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 18, "boss", ("blunt", "magic"), ("slash", "magic"),
drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_trophies(1)]), ("blunt", "pierce"), gold=25,
_fight("fight_barrow_king", monster("Barrow king", "spirit", 28, boss=True), "Sunken Barrows", gold=40, drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]),
drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_trophies(2)]), _boss("fight_barrow_king", "Barrow king", "Sunken Barrows", 28, "boss", ("slash", "magic"), ("blunt", "magic"),
_fight("fight_green_dragon", monster("Green dragon", "dragon", 38), "Iron Hills", gold=80, ("pierce", "slash"), gold=40,
drops=[("green_dragonhide", 1, 0.9), ("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(3)]), drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_drops(2)]),
_fight("fight_blue_dragon", monster("Blue dragon", "dragon", 48), "Frostpeak", gold=110, # Dragons: the prize hunt, dropping their area's reagents far more often than anything else there.
drops=[("blue_dragonhide", 1, 0.9), ("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(4)]), _boss("fight_green_dragon", "Green dragon", "Iron Hills", 38, "dragon", ("slash", "magic"), ("pierce", "magic"),
_fight("fight_red_dragon", monster("Red dragon", "dragon", 60), "Dragon's Spine", gold=150, ("slash", "blunt"), is_boss=False, gold=80,
drops=[("red_dragonhide", 1, 0.9), ("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_trophies(5)]), drops=[("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(3, 0.6)]),
_fight("fight_black_dragon", monster("Black dragon", "dragon", 75, boss=True), "Dragon's Spine", gold=220, _boss("fight_blue_dragon", "Blue dragon", "Frostpeak", 48, "dragon", ("slash", "magic"), ("blunt", "pierce"),
drops=[("red_dragonhide", 2, 0.9), ("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_trophies(5)]), ("magic", "slash"), is_boss=False, gold=110,
drops=[("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(4, 0.6)]),
_boss("fight_red_dragon", "Red dragon", "Dragon's Spine", 60, "dragon", ("slash", "magic"), ("slash", "magic"),
("pierce", "blunt"), is_boss=False, gold=150,
drops=[("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_drops(5, 0.6)]),
_boss("fight_black_dragon", "Black dragon", "Dragon's Spine", 75, "dragon", ("slash", "magic"), ("blunt", "magic"),
("slash", "pierce"), gold=220,
drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]),
] ]
ACTIONS = [ ACTIONS = [
@ -593,6 +790,9 @@ ACTIONS = [
_action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}), _action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
_action("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}), _action("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}),
_action("hunt_bear", "hunting", "Hunt bears", 40, 60, 48, outputs={"raw_meat": 3, "bear_pelt": 1}), _action("hunt_bear", "hunting", "Hunt bears", 40, 60, 48, outputs={"raw_meat": 3, "bear_pelt": 1}),
_action("hunt_elk", "hunting", "Hunt elk", 50, 70, 64, outputs={"raw_meat": 3, "elk_hide": 1}),
_action("hunt_sabrecat", "hunting", "Track sabrecats", 65, 80, 84, outputs={"raw_meat": 3, "sabrecat_pelt": 1}),
_action("hunt_mammoth", "hunting", "Hunt mammoths", 80, 90, 108, outputs={"raw_meat": 5, "mammoth_hide": 1}),
*_tiered_actions(), *_tiered_actions(),
_action("smith_arrows", "smithing", "Arrows", 5, 30, 10, _action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Copper"), {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Copper"),
@ -601,7 +801,7 @@ ACTIONS = [
_action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"), _action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"),
_action("twist_rope", "construction", "Twist rope", 5, 30, 10, {"flax": 3}, {"rope": 1}, group="Supplies"), _action("twist_rope", "construction", "Twist rope", 5, 30, 10, {"flax": 3}, {"rope": 1}, group="Supplies"),
_action("grow_thatch", "farming", "Cut thatch reeds", 3, 30, 7, outputs={"thatch": 3}, group="Materials"), _action("grow_thatch", "farming", "Cut thatch reeds", 3, 30, 7, outputs={"thatch": 3}, group="Materials"),
_action("grow_flax", "farming", "Grow flax", 10, 35, 11, outputs={"flax": 3}, group="Materials"), _action("grow_flax", "farming", "Grow flax", 1, 35, 11, outputs={"flax": 3}, group="Fibres"),
_action("boil_glue", "leatherworking", "Boil hide glue", 5, 30, 10, {"hide": 1, "logs": 1}, {"hide_glue": 2}, _action("boil_glue", "leatherworking", "Boil hide glue", 5, 30, 10, {"hide": 1, "logs": 1}, {"hide_glue": 2},
group="Tanning"), group="Tanning"),
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier, *[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
@ -743,7 +943,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 15 WORLD_VERSION = 16
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
@ -878,9 +1078,67 @@ def migrate(world: dict, now: float | None = None) -> dict:
for player in world["players"].values(): for player in world["players"].values():
player["seen_threat"] = latest["id"] if latest else 0 player["seen_threat"] = latest["id"] if latest else 0
world["version"] = 15 world["version"] = 15
if world["version"] < 16:
_migrate_combat_rework(world)
world["version"] = 16
return world return world
# The combat rework: the old area monsters became races, their trophies carry over by
# weapon line and tier, dragonhide became the dragons' reagents, and the leathers past
# bear are hunted now.
RENAMED_ITEMS = {
"bat_sinew": "harpy_sinew", "harpy_sinew": "spider_silk", "wisp_essence": "grave_dust",
"hill_troll_hide": "lizard_scale", "rock_troll_hide": "orc_tusk", "elder_troll_hide": "dragonkin_scale",
"obsidian_core": "frozen_core", "runic_core": "magma_core",
"green_dragonhide": "ironhill_lacing", "blue_dragonhide": "frostpeak_lacing", "red_dragonhide": "dragonspine_lacing",
"green_dragon_leather": "elk_leather", "blue_dragon_leather": "sabrecat_leather", "red_dragon_leather": "mammoth_leather",
**{f"{old}_{suffix}": f"{new}_{suffix}" for old, new in (("green_dhide", "elk_leather"), ("blue_dhide", "sabrecat_leather"),
("red_dhide", "mammoth_leather")) for suffix in ("cap", "legs", "body")},
}
RENAMED_ACTIONS = {
**{f"fight_{_slug(old)}": f"fight_{_slug(new)}" for old, new in (
("Clay golem", "Clay golems"), ("Cave troll", "Cave trolls"), ("Will-o'-wisp", "Restless dead"),
("Giant bat", "Harpies"), ("Stone golem", "Stone guardians"), ("Hill troll", "Lizardfolk"),
("Bog wraith", "Bog wraiths"), ("Harpy", "Giant spiders"), ("Iron golem", "Iron golems"), ("Rock troll", "Orcs"),
("Mine ghost", "Mine ghosts"), ("Wyvern", "Wyverns"), ("Obsidian golem", "Ice elementals"),
("Frost troll", "Frost trolls"), ("Banshee", "Banshees"), ("Griffin", "Griffins"),
("Runic golem", "Magma constructs"), ("Elder troll", "Dragonkin"), ("Shade", "Shades"), ("Roc", "Rocs"))},
**{f"tan_{old}_dragon_leather": f"tan_{new}_leather" for old, new in
(("green", "elk"), ("blue", "sabrecat"), ("red", "mammoth"))},
**{f"lw_{old}_{suffix}": f"lw_{new}_{suffix}" for old, new in (("green_dhide", "elk_leather"),
("blue_dhide", "sabrecat_leather"), ("red_dhide", "mammoth_leather")) for suffix in ("cap", "legs", "body")},
}
def _renamed(stock: dict) -> dict:
out = {}
for item, qty in stock.items():
new = RENAMED_ITEMS.get(item, item)
out[new] = out.get(new, 0) + qty
return out
def _migrate_combat_rework(world: dict) -> None:
city = world["city"]
city["stockpile"] = _renamed(city["stockpile"])
for building in city["buildings"].values():
building["fund"] = _renamed(building["fund"])
for player in world["players"].values():
player["xp"].setdefault("tailoring", 0)
player["bank"] = _renamed(player["bank"])
for slot, item in list(player["equip"].items()):
if item:
player["equip"][slot] = RENAMED_ITEMS.get(item, item)
action = player["action"]
if action:
for key in ("id", "resume"):
if key in action:
action[key] = RENAMED_ACTIONS.get(action[key], action[key])
if action["id"] not in ACTION_BY_ID or action.get("resume", "") and action["resume"] not in ACTION_BY_ID:
stop(player, "Combat has changed: pick your fight again")
def last_threat(world: dict) -> dict | None: def last_threat(world: dict) -> dict | None:
"""The most recent raid or dragon attack that has played out.""" """The most recent raid or dragon attack that has played out."""
return next((e for e in reversed(world["events"]) if e["status"] == "done" and e["kind"] in THREATS return next((e for e in reversed(world["events"]) if e["status"] == "done" and e["kind"] in THREATS
@ -1143,16 +1401,30 @@ def bank_take(player: dict, item: str, qty: int) -> None:
def armour(player: dict) -> int: def armour(player: dict) -> int:
"""Protection from physical blows: slashing, blunt and piercing."""
return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item) return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item)
def damage_reduction(player: dict) -> float: def magic_ward(player: dict) -> int:
points = armour(player) """Protection from magic."""
return sum(ITEMS[item].get("ward", 0) for item in player["equip"].values() if item)
def reduction(points: float) -> float:
return points / (points + 25) return points / (points + 25)
def damage_reduction(player: dict) -> float:
return reduction(armour(player))
def defence_bonus(player: dict) -> int:
"""What all your armour adds to power and evasion, whatever its weight."""
return round((armour(player) + magic_ward(player)) * 0.4)
def combat_power(player: dict) -> int: def combat_power(player: dict) -> int:
power = skill_level(player, "combat") + armour(player) // 2 power = skill_level(player, "combat") + defence_bonus(player)
for item in player["equip"].values(): for item in player["equip"].values():
if item: if item:
power += ITEMS[item].get("power", 0) power += ITEMS[item].get("power", 0)
@ -1178,20 +1450,32 @@ def weapon_style(player: dict) -> str:
return ITEMS[weapon].get("style", "sword") if weapon else "sword" return ITEMS[weapon].get("style", "sword") if weapon else "sword"
def affinity(enemy: dict, damage: str) -> float:
"""How hard a damage type lands on this enemy."""
return VULNERABLE if damage in enemy["vulnerable"] else RESISTANT if damage in enemy["resists"] else 1
def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) -> dict: 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 """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.""" the weapon's style, your trade and how the enemy takes its damage type. Armour soaks physical blows,
ward soaks magic, both help you dodge, and a shield blocks."""
style_id = weapon_style(player) style_id = weapon_style(player)
style = STYLES[style_id] style = STYLES[style_id]
power = combat_power(player) + blessed["power"] + brew.get("power", 0) power = combat_power(player) + blessed["power"] + brew.get("power", 0)
damage = style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1) 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 lands = affinity(enemy, style["damage"]) if enemy else 1
return {"style": style_id, "interval": style["interval"], "accuracy": (10 + 2 * power) * style["accuracy"], taken = blessed["taken"] * (1 - brew.get("reduce", 0))
"max_hit": max(2, round((2 + power / 3) * damage * weakness)), "crit": style["crit"], return {"style": style_id, "damage": style["damage"], "interval": style["interval"],
"pierce": style.get("pierce", False), "first": style.get("first", False), "weakness": weakness, "accuracy": (10 + 2 * power) * style["accuracy"],
"max_hit": max(2, round((2 + power / 3) * damage * lands)), "crit": style["crit"],
"first": style.get("first", False), "affinity": lands,
"block": BLOCK_CHANCE if player["equip"].get("shield") else 0, "block": BLOCK_CHANCE if player["equip"].get("shield") else 0,
"evasion": 10 + 2 * (skill_level(player, "combat") + armour(player) // 2), "evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)),
"taken": (1 - damage_reduction(player)) * blessed["taken"] * (1 - brew.get("reduce", 0))} "taken": {"physical": (1 - damage_reduction(player)) * taken, "magic": (1 - reduction(magic_ward(player))) * taken}}
def _taken(me: dict, damage: str) -> float:
return me["taken"]["magic" if damage == "magic" else "physical"]
def hit_chance(accuracy: float, evasion: float) -> float: def hit_chance(accuracy: float, evasion: float) -> float:
@ -1202,26 +1486,38 @@ def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float:
return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT
def fight_odds(player: dict, action: dict) -> dict: def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict) -> dict:
"""What a fight against this enemy looks like on average, for the cards.""" me = fighter(player, blessed, brew, foe)
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"]) hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
shrug = 0 if me["pierce"] else foe["reduction"] kill = foe["hp"] / (hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"])
dps = hit * _average_hit(me["max_hit"], me["crit"]) * (1 - shrug) / me["interval"] taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"])
kill = foe["hp"] / dps hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"] * kill
hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * me["taken"] / foe["interval"] * kill return {"hit": hit, "their_hit": their_hit, "max_hit": me["max_hit"], "affinity": me["affinity"],
return {"hit": round(hit, 2), "their_hit": round(their_hit, 2), "max_hit": me["max_hit"], "style": me["style"], "their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))),
"weakness": me["weakness"], "their_max": max(1, round(foe["max_hit"] * me["taken"])), "kill_time": kill, "damage": hurt}
"kill_time": round(kill, 1), "damage": round(hurt, 1)}
def fight_odds(player: dict, action: dict) -> dict:
"""What a fight in this encounter looks like on average, for the cards, and against each variant."""
blessed, brew = blessing(player), potion_effect(player, "combat")
variants = action["enemy"]["variants"]
rows = [_variant_odds(player, foe, blessed, brew) for foe in variants]
total = sum(foe["weight"] for foe in variants)
mean = lambda key: sum(row[key] * foe["weight"] for row, foe in zip(rows, variants)) / total
return {"hit": round(mean("hit"), 2), "their_hit": round(mean("their_hit"), 2), "style": weapon_style(player),
"max_hit": max(row["max_hit"] for row in rows), "their_max": max(row["their_max"] for row in rows),
"kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1),
"variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1),
"damage": round(row["damage"], 1)} for row in rows]}
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict: def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
"""Play out one fight swing by swing, both swing timers starting empty. Nothing changes """Play out one fight swing by swing, both swing timers starting empty. Nothing changes
until it's over: the result is applied then.""" until it's over: the result is applied then."""
foe, blessed, brew = action["enemy"], blessing(player), potion_effect(player, "combat") variants = action["enemy"]["variants"]
foe = rng.choices(variants, [v["weight"] for v in variants])[0]
blessed, brew = blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew, foe) 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"]) 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"] top, hp, foe_hp = max_hp(player), player["hp"], foe["hp"]
pantry = {item: player["bank"][item] for item in edible(player)} pantry = {item: player["bank"][item] for item in edible(player)}
@ -1230,6 +1526,7 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random)
pace = speed(world) pace = speed(world)
# A bow looses its first arrow before the enemy closes in. # 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 mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0
swings = 0 # an enemy with more than one attack takes turns with them
while result is None: while result is None:
t = min(mine, theirs) t = min(mine, theirs)
if t > MAX_FIGHT: if t > MAX_FIGHT:
@ -1239,23 +1536,27 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random)
mine += me["interval"] mine += me["interval"]
crit = rng.random() < me["crit"] crit = rng.random() < me["crit"]
landed = rng.random() < hit landed = rng.random() < hit
dealt = max(1, round((me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"])) dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0
* (1 - shrug))) if landed else 0
foe_hp = max(0, foe_hp - dealt) 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]) events.append([round(t / pace, 2), "you", ("crit" if crit else "hit") if landed else "miss", dealt, round(hp),
foe_hp, me["damage"]])
if not foe_hp: if not foe_hp:
result = "won" result = "won"
continue continue
theirs += foe["interval"] theirs += foe["interval"]
kind = foe["attack"][swings % len(foe["attack"])]
swings += 1
crit = rng.random() < CRIT_CHANCE crit = rng.random() < CRIT_CHANCE
landed = rng.random() < their_hit landed = rng.random() < their_hit
if landed and rng.random() < me["block"]: if landed and rng.random() < me["block"]:
events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(hp)), foe_hp]) events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(hp)), foe_hp])
continue 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 dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * _taken(me, kind))) \
if landed else 0
hp -= dealt hp -= dealt
hurt += dealt hurt += dealt
events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt, max(0, round(hp)), foe_hp]) events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt, max(0, round(hp)),
foe_hp, kind])
while hp < top * EAT_BELOW and any(pantry.values()): while hp < top * EAT_BELOW and any(pantry.values()):
food = next(item for item, left in pantry.items() if left) food = next(item for item, left in pantry.items() if left)
pantry[food] -= 1 pantry[food] -= 1
@ -1272,7 +1573,8 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random)
hp, result = 1, "lost" hp, result = 1, "lost"
elif starving: elif starving:
result = "fled" result = "fled"
return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), "xp": foe["xp"],
"foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")},
"hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace, "hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace,
"interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"], "interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"],
"eaten": eaten, "damage": hurt, "pp": blessed["cost"], "eaten": eaten, "damage": hurt, "pp": blessed["cost"],
@ -1536,6 +1838,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
outing = player["action"] outing = player["action"]
fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng) fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng)
outing.pop("carry", None) # from a short-lived version that ran the swing timer on outing.pop("carry", None) # from a short-lived version that ran the swing timer on
xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met
if player["prayers"] and not fight["pp"]: if player["prayers"] and not fight["pp"]:
summary["prayer_out"] = True summary["prayer_out"] = True
paid = min(player["pp"], fight["pp"]) paid = min(player["pp"], fight["pp"])
@ -2453,9 +2756,10 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
player["favour"] += 40 player["favour"] += 40
gain_xp(world, player, "combat", round(attack * 6), now) gain_xp(world, player, "combat", round(attack * 6), now)
bank_add(world, player, "dragon_bones", 2, now, empty_summary()) bank_add(world, player, "dragon_bones", 2, now, empty_summary())
bank_add(world, player, "green_dragonhide", 1, now, empty_summary()) for weight in WEIGHTS:
bank_add(world, player, reagent(weight, 3), 1, now, empty_summary())
if garrison: if garrison:
lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and a hide.") lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.")
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)
@ -2556,9 +2860,9 @@ PROBLEMS = {
} }
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars") MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars")
PROBLEM_ACTIONS = [ PROBLEM_ACTIONS = [
_fight("clear_wolves", monster("Wolf pack", "beast", 4), "Frontier", name="Drive off the wolves", _encounter("clear_wolves", "Wolf pack", "Frontier", 4, WOLVES, label="Drive off the wolves",
special="problem", problem="wolves"), special="problem", problem="wolves"),
_fight("catch_goblins", monster("Goblin thieves", "human", 5), "Frontier", name="Catch the goblin thieves", _encounter("catch_goblins", "Goblin thieves", "Frontier", 5, GOBLINS, label="Catch the goblin thieves",
special="problem", problem="goblins"), special="problem", problem="goblins"),
_action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"), _action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"),
_action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"), _action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"),
@ -2600,7 +2904,7 @@ FITTINGS = [
"+1 prayer point per bone, ward +15", "star-altar", "#6f9a4a"), "+1 prayer point per bone, ward +15", "star-altar", "#6f9a4a"),
("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_lock": 6}, ("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_lock": 6},
"+500 stack size", "open-treasure-chest", "#a77be8"), "+500 stack size", "open-treasure-chest", "#a77be8"),
("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "red_dragon_leather": 5, "adamant_chain": 6}, ("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "mammoth_leather": 5, "adamant_chain": 6},
"HP recovers twice as fast", "fireplace", "#a77be8"), "HP recovers twice as fast", "fireplace", "#a77be8"),
] ]
# Builders' shields, homesteaders' staves and rangers' bows, after each skill's other tabs. # Builders' shields, homesteaders' staves and rangers' bows, after each skill's other tabs.
@ -3182,6 +3486,11 @@ PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "vi
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole", TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
"hunting knife": "bowie-knife", "hammer": "claw-hammer"} "hunting knife": "bowie-knife", "hammer": "claw-hammer"}
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"} LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"}
FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower",
"starbloom": "spiral-bloom", "spiritvine": "vine-flower"}
CLOTH_TINTS = ["#d8d0b8", "#f0f0f0", "#e8c8e0", "#a8c8f0", "#f0e080", "#9be0c8"]
REAGENT_GLYPHS = {"heavy": "chain-mail", "medium": "scale-mail", "light": "yarn"}
WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"} WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"}
HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"] HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"]
GLYPHS = { GLYPHS = {
@ -3206,7 +3515,7 @@ SKILL_GLYPHS = {
"construction": ("hammer-nails", "#d1a46a"), "construction": ("hammer-nails", "#d1a46a"),
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"), "cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"), "herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
"reconnaissance": ("spyglass", "#6f8fec"), "reconnaissance": ("spyglass", "#6f8fec"), "tailoring": ("sewing-machine", "#c8a0e0"),
} }
CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"), CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"),
"homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")} "homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")}
@ -3225,24 +3534,31 @@ ACTION_GLYPHS = {
"labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"), "labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"),
"catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"), "catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"),
"catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"), "catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"),
"hunt_elk": ("deer", "#a07a50"), "hunt_sabrecat": ("saber-toothed-cat-head", "#e0a050"),
"hunt_mammoth": ("mammoth", "#8a6a50"),
} }
PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("biceps", "#e06a6a"), PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("biceps", "#e06a6a"),
"rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"), "rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"),
"superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"), "superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"),
"protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")} "protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")}
TROPHY_GLYPHS = {"golem": "crystal-growth", "troll": "animal-hide", "spirit": "magic-swirl", "flyer": "sewing-string"} TROPHY_GLYPHS = {"hammer": "crystal-growth", "sword": "animal-hide", "staff": "spiral-bloom", "bow": "sewing-string"}
TIER_TINTS = ["#9aa1ab", "#d4dae2", "#6f8fec", "#4cb878", "#3fd0e0"] # trophies of areas II to VI TIER_TINTS = ["#9aa1ab", "#d4dae2", "#6f8fec", "#4cb878", "#3fd0e0"] # trophies of areas II to VI
MONSTER_GLYPHS = {"golem": ["rock-golem"] * 5, "troll": ["troll"] * 5, RACE_GLYPHS = {
"spirit": ["fairy", "spectre", "ghost", "spectre", "ghost"], "Clay golems": "rock-golem", "Cave trolls": "troll", "Restless dead": "skeleton", "Harpies": "harpy",
"flyer": ["bat", "harpy", "wyvern", "griffin-symbol", "eagle-head"]} "Stone guardians": "golem-head", "Lizardfolk": "lizardman", "Bog wraiths": "spectre", "Giant spiders": "spider-face",
"Iron golems": "robot-golem", "Orcs": "orc-head", "Mine ghosts": "ghost", "Wyverns": "wyvern",
"Ice elementals": "ice-golem", "Frost trolls": "troll", "Banshees": "screaming", "Griffins": "griffin-symbol",
"Magma constructs": "lava", "Dragonkin": "dragon-spiral", "Shades": "ghost-ally", "Rocs": "eagle-head",
}
ACTION_GLYPHS.update({ ACTION_GLYPHS.update({
"fight_goblins": ("goblin-head", "#8fbf6a"), "fight_ruins": ("ogre", "#a08060"), "fight_goblins": ("goblin-head", "#8fbf6a"), "fight_ruins": ("ogre", "#a08060"),
"fight_barrow_king": ("crowned-skull", "#c8d0e0"), "drill_militia": ACTION_GLYPHS["drill_militia"], "fight_barrow_king": ("crowned-skull", "#c8d0e0"), "drill_militia": ACTION_GLYPHS["drill_militia"],
}) })
for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items(): for _index, (_area, _level, _prefix, _races) in enumerate(AREAS):
for _index, _name in enumerate(_monsters): for _race, *_rest in _races:
ACTION_GLYPHS[f"fight_{_slug(_name)}"] = (MONSTER_GLYPHS[_kind][_index], TIER_TINTS[_index]) ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index])
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"] UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield",
*(kind["glyph"] for kind in DAMAGE_TYPES.values())]
def _assign_glyphs() -> None: def _assign_glyphs() -> None:
@ -3269,8 +3585,16 @@ 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)
for trophy, (_name, kind, tier) in TROPHIES.items(): for trophy, (_name, line, tier) in TROPHIES.items():
ITEMS[trophy].update(glyph=TROPHY_GLYPHS[kind], tint=TIER_TINTS[tier - 1]) ITEMS[trophy].update(glyph=TROPHY_GLYPHS[line], tint=TIER_TINTS[tier - 1])
for item, (weight, tier) in REAGENTS.items():
ITEMS[item].update(glyph=REAGENT_GLYPHS[weight], tint=TIER_TINTS[tier - 1])
for (fibre, _level, _time, _xp, cloth, prefix, _label), tint in zip(CLOTHS, CLOTH_TINTS):
if fibre[0] in FIBRE_GLYPHS:
ITEMS[fibre[0]].update(glyph=FIBRE_GLYPHS[fibre[0]], tint=tint)
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)
for herb, tint in HERB_TINTS.items(): for herb, tint in HERB_TINTS.items():
ITEMS[herb].update(glyph="herbs-bundle", tint=tint) ITEMS[herb].update(glyph="herbs-bundle", tint=tint)
flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion", flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion",
@ -3324,6 +3648,7 @@ def content() -> dict:
"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, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED,
"styles": STYLES, "weakness": WEAKNESS, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, "styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
"resistant": RESISTANT, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR, "regen_per_hour": REGEN_PER_HOUR,
} }

View file

@ -439,7 +439,7 @@ function skillPage(id) {
const open = (g) => actions.some((a) => a.group === g && !isLocked(a)); const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
let current = tabs[id] || remember(`tab.${id}`); let current = tabs[id] || remember(`tab.${id}`);
// Tier ladders open on the highest tier you can work; categories open on the first. // Tier ladders open on the highest tier you can work; categories open on the first.
const ladder = ["smithing", "leatherworking", "herblore", "combat"].includes(id); const ladder = ["smithing", "leatherworking", "tailoring", "herblore", "combat"].includes(id);
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0]; if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join(""); const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
// Ungrouped actions (the building site) stay above the tabs. // Ungrouped actions (the building site) stay above the tabs.
@ -522,40 +522,57 @@ function toolPanel(skill) {
: `<span class="muted">No tool.</span>`}</div>`; : `<span class="muted">No tool.</span>`}</div>`;
} }
// What a monster is weak to and shrugs off, and how your weapon fares against it. // A damage type's icon: what an enemy attacks with, or (tinted) what it's vulnerable to or resists.
function weaknessLine(kind, odds) { function dmgIcon(type, how = "") {
const table = C.weakness[kind]; const t = C.damage_types[type], name = t.name.toLowerCase();
if (!table) return ""; const tip = how === "vuln" ? `Vulnerable to ${name} (+${Math.round((C.vulnerable - 1) * 100)}%)`
const names = (pick) => Object.entries(table).filter(([, m]) => pick(m)).map(([s]) => `${C.styles[s].name}s`).join(", "); : how === "resist" ? `Resists ${name} (-${Math.round((1 - C.resistant) * 100)}%)`
const weak = names((m) => m > 1), tough = names((m) => m < 1); : how === "attack" ? `Attacks with ${name}` : `${t.name} damage`;
const yours = odds.weakness > 1 ? ` · <b class="good">your ${C.styles[odds.style].name} hits it hard</b>` return `<span class="dmg ${how}" title="${esc(tip)}">${glyph(t.glyph, t.tint)}</span>`;
: odds.weakness < 1 ? ` · <b class="bad">your ${C.styles[odds.style].name} does half damage</b>` : ""; }
return `<div class="small muted">${weak ? `Weak to ${weak}` : ""}${weak && tough ? " · " : ""}${tough ? `shrugs off ${tough}` : ""}${yours}</div>`;
function resistIcons(v) {
return v.vulnerable.map((k) => dmgIcon(k, "vuln")).join("") + v.resists.map((k) => dmgIcon(k, "resist")).join("");
}
// Who turns up in an encounter: each variant, what it attacks with, and how it takes each damage type.
// A variant your weapon is good against is green, one that resists it red.
function variantRows(e, o) {
return `<div class="variants">${e.variants.map((v, i) => {
const fit = o.variants[i].affinity;
return `<div class="variant ${fit > 1 ? "good" : fit < 1 ? "bad" : ""}">
<span class="v-attack">${v.attack.map((k) => dmgIcon(k, "attack")).join("")}</span>
<span class="v-name">${esc(v.name)}${v.archetype === "elite" ? ` <span class="muted">· rare</span>` : ""}</span>
<span class="v-hp muted">${fmt(v.hp)} HP</span>
<span class="v-res">${resistIcons(v)}</span>
</div>`;
}).join("")}</div>`;
} }
// The fight in progress, replayed from the server's swing-by-swing record. // The fight in progress, replayed from the server's swing-by-swing record.
function fightPanel() { function fightPanel() {
const f = S.me.action?.fight; const f = S.me.action?.fight;
if (!f) return ""; if (!f) return "";
const side = (who, name) => `<div class="fighter"> const side = (who, name, foe) => `<div class="fighter">
<div class="fighter-name"><b>${esc(name)}</b><span class="small muted" data-f="${who}-hp"></span></div> <div class="fighter-name"><b>${esc(name)}</b>${foe ? `<span class="v-res">${resistIcons(foe)}</span>` : ""}<span class="small muted" data-f="${who}-hp"></span></div>
<div class="bar hp ${who}"><span data-f="${who}-bar"></span></div> <div class="bar hp ${who}"><span data-f="${who}-bar"></span></div>
<div class="bar swing"><span data-f="${who}-swing"></span></div> <div class="bar swing"><span data-f="${who}-swing"></span></div>
</div>`; </div>`;
return `<div class="panel fight" data-fight="${S.me.action.start}"> return `<div class="panel fight" data-fight="${S.me.action.start}">
<div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy)}</div> <div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy, f.foe)}</div>
<ul class="fight-log" data-f="log"></ul> <ul class="fight-log" data-f="log"></ul>
</div>`; </div>`;
} }
function fightLine(e, enemy) { function fightLine(e, enemy) {
const [, who, kind, amount, , , food] = e; const [, who, kind, amount, , , food] = e;
const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : "";
const foe = esc(enemy); const foe = esc(enemy);
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`; if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`; if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`;
if (kind === "block") return `<li class="good">You block ${foe}'s blow</li>`; if (kind === "block") return `<li class="good">You block ${foe}'s blow</li>`;
if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b></li>`; if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b>${type}</li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b></li>`; return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b>${type}</li>`;
} }
function animateFight(now) { function animateFight(now) {
@ -605,16 +622,16 @@ function styleNote(me) {
const own = C.class_style[me.cls], traits = { const own = C.class_style[me.cls], traits = {
sword: `${st.interval}s${me.equip.shield ? `, shield blocks ${Math.round(C.block_chance * 100)}%` : ""}`, sword: `${st.interval}s${me.equip.shield ? `, shield blocks ${Math.round(C.block_chance * 100)}%` : ""}`,
hammer: `${st.interval}s, double crits`, hammer: `${st.interval}s, double crits`,
staff: `${st.interval}s, accurate, ignores armour`, staff: `${st.interval}s, accurate`,
bow: `${st.interval}s, shoots first`, bow: `${st.interval}s, shoots first`,
}; };
return `<p class="small">${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${traits[styleId]}. return `<p class="small">${dmgIcon(st.damage)} ${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${C.damage_types[st.damage].name.toLowerCase()}, ${traits[styleId]}.
${own === styleId ? `<b class="good">Trade weapon: +15% damage.</b>` : `Trade weapon: ${esc(C.styles[own].name)} (+15%).`}</p>`; ${own === styleId ? `<b class="good">Trade weapon: +15% damage.</b>` : `Trade weapon: ${esc(C.styles[own].name)} (+15%).`}</p>`;
} }
function combatPanel() { function combatPanel() {
const me = S.me; const me = S.me;
const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", "); const stat = (id) => gearStats(item(id));
const slot = (name, label) => { const slot = (name, label) => {
const id = me.equip[name]; const id = me.equip[name];
return `<div class="gear-slot">${id return `<div class="gear-slot">${id
@ -636,11 +653,17 @@ function combatPanel() {
</div> </div>
<div class="panel"><h2>Equipment</h2> <div class="panel"><h2>Equipment</h2>
<div class="gear">${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}</div> <div class="gear">${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}</div>
<p class="small muted">Power ${me.power} · armour ${me.armour} (-${Math.round(me.reduction * 100)}% damage)</p> <p class="small muted">Power ${me.power} · armour ${me.armour} (-${Math.round(me.reduction * 100)}% physical) · ward ${me.ward} (-${Math.round(me.ward_reduction * 100)}% magic)</p>
${styleNote(me)} ${styleNote(me)}
</div></div>`; </div></div>`;
} }
// Power, armour and ward, and the armour's weight.
function gearStats(it) {
const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`, it.ward && `+${it.ward} ward`];
return stats.filter(Boolean).join(", ") + (it.weight ? ` (${C.weights[it.weight].name.toLowerCase()})` : "");
}
// What an item does once made: healing, defence, tool speed, and who can use it. // What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats(id) { function itemStats(id) {
const it = item(id); const it = item(id);
@ -652,7 +675,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) parts.push(`+${it.armour} armour`); if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 }));
if (it.reagent) parts.push(`Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`);
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`); if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`); if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`); if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
@ -685,10 +709,10 @@ function actionCard(a) {
let extra = ""; let extra = "";
if (a.enemy) { if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`; const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`;
extra = `<div class="small">${HEART} ${fmt(e.hp)} HP · hits up to <b>${o.their_max}</b> every ${e.interval}s${e.boss ? ` · <span class="tag warn">Boss</span>` : ""}</div> extra = `${variantRows(e, o)}
<div class="small">Hits up to <b>${o.their_max}</b>${e.boss ? ` · <span class="tag warn">Boss</span>` : ""}</div>
<div class="small">You hit <b>${pct(o.hit)}</b> (max ${o.max_hit}) · it hits <b>${pct(o.their_hit)}</b></div> <div class="small">You hit <b>${pct(o.hit)}</b> (max ${o.max_hit}) · it hits <b>${pct(o.their_hit)}</b></div>
<div class="small">~<b>${dur(o.kill_time)}</b> a kill · <b>${Math.round(o.damage)}</b> damage taken${a.gold ? ` · ${a.gold} gold` : ""}</div> <div class="small">~<b>${dur(o.kill_time)}</b> a kill · <b>${Math.round(o.damage)}</b> damage taken${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
${weaknessLine(e.kind, o)}`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`).join(""); if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`).join("");
if (a.special === "sabotage") extra += `<div class="small muted">-3% raid strength per win (max 30%)</div>`; if (a.special === "sabotage") extra += `<div class="small muted">-3% raid strength per win (max 30%)</div>`;
} }
@ -811,7 +835,7 @@ function equipRow(id, it) {
function equipLabel(it) { function equipLabel(it) {
if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`; if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`;
const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", "); const stats = gearStats(it);
return `Equip ${it.slot === "body" ? "body armour" : it.slot === "legs" ? "leg armour" : it.slot} (${stats})`; return `Equip ${it.slot === "body" ? "body armour" : it.slot === "legs" ? "leg armour" : it.slot} (${stats})`;
} }
@ -1147,7 +1171,7 @@ function characterSheet(uid) {
const id = p.equip[slot]; const id = p.equip[slot];
const it = id && item(id); const it = id && item(id);
const stat = !it ? "" : it.tool ? `${Math.round(it.speed * 100)}% faster` const stat = !it ? "" : it.tool ? `${Math.round(it.speed * 100)}% faster`
: [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", "); : gearStats(it);
return `<div class="sheet-slot ${id ? "" : "empty"}"> return `<div class="sheet-slot ${id ? "" : "empty"}">
<div class="k">${label}</div> <div class="k">${label}</div>
${id ? `<div class="sheet-item"><span class="ico">${it.icon}</span><span><b>${esc(it.name)}</b><span class="muted small">${stat}</span></span> ${id ? `<div class="sheet-item"><span class="ico">${it.icon}</span><span><b>${esc(it.name)}</b><span class="muted small">${stat}</span></span>
@ -1183,7 +1207,8 @@ function characterSheet(uid) {
<div class="stat"><div class="k">Health</div><div class="v">${fmt(p.hp)} <small>/ ${p.max_hp}</small></div><div class="bar hp" style="margin-top:6px"><span style="width:${p.hp / p.max_hp * 100}%"></span></div></div> <div class="stat"><div class="k">Health</div><div class="v">${fmt(p.hp)} <small>/ ${p.max_hp}</small></div><div class="bar hp" style="margin-top:6px"><span style="width:${p.hp / p.max_hp * 100}%"></span></div></div>
<div class="stat"><div class="k">Combat power</div><div class="v">${p.power}</div><div class="note">${guard} in the garrison</div></div> <div class="stat"><div class="k">Combat power</div><div class="v">${p.power}</div><div class="note">${guard} in the garrison</div></div>
${mine ? `<div class="stat"><div class="k">Prayer points</div><div class="v">${fmt(p.pp)}</div><div class="note">${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}</div></div>` : ""} ${mine ? `<div class="stat"><div class="k">Prayer points</div><div class="v">${fmt(p.pp)}</div><div class="note">${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}</div></div>` : ""}
<div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less damage</div></div> <div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less physical</div></div>
<div class="stat"><div class="k">Ward</div><div class="v">${p.ward}</div><div class="note">${Math.round(p.ward_reduction * 100)}% less magic</div></div>
<div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div> <div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div>
${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${p.slots_used} / ${p.slots} bank slots</div></div>` : ""} ${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${p.slots_used} / ${p.slots} bank slots</div></div>` : ""}
</div> </div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Before After
Before After

View file

@ -443,3 +443,16 @@ button.sheet-skill:hover { background: var(--panel-2); }
.battle-result { font-weight: 700; font-size: 17px; margin: 6px 0 8px; } .battle-result { font-weight: 700; font-size: 17px; margin: 6px 0 8px; }
.battle-result.win { color: var(--good); } .battle-result.win { color: var(--good); }
.battle-result.loss { color: var(--bad); } .battle-result.loss { color: var(--bad); }
/* Damage types and encounter rosters */
.dmg { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; vertical-align: -4px; }
.dmg .gi { width: 13px; height: 13px; }
.dmg.vuln { background: rgba(95, 174, 90, .22); box-shadow: inset 0 0 0 1px rgba(95, 174, 90, .6); }
.dmg.resist { background: rgba(226, 100, 92, .2); box-shadow: inset 0 0 0 1px rgba(226, 100, 92, .55); }
.variants { display: grid; gap: 3px; margin: 2px 0 4px; font-size: 13px; }
.variant { display: grid; grid-template-columns: auto 1fr auto auto; gap: 6px; align-items: center; }
.variant.good .v-name { color: var(--good); }
.variant.bad .v-name { color: var(--bad); }
.v-attack, .v-res { display: inline-flex; gap: 2px; }
.v-hp { font-size: 12px; }
.fighter-name .v-res { margin-left: 6px; }

View file

@ -730,9 +730,13 @@ class TierTest(unittest.TestCase):
self.assertIn(f"{key}_{piece}", game.ITEMS) self.assertIn(f"{key}_{piece}", game.ITEMS)
self.assertIn(f"smith_{key}_{piece}", game.ACTION_BY_ID) self.assertIn(f"smith_{key}_{piece}", game.ACTION_BY_ID)
self.assertIn(f"make_{key}_shield", game.ACTION_BY_ID) # builders make shields self.assertIn(f"make_{key}_shield", game.ACTION_BY_ID) # builders make shields
for *_, prefix, _label, _level, _armour in game.LEATHERS: for *_, prefix, _label, _level in game.LEATHERS:
for piece in ("cap", "legs", "body"): for piece in ("cap", "legs", "body"):
self.assertIn(f"{prefix}_{piece}", game.ITEMS) self.assertIn(f"{prefix}_{piece}", game.ITEMS)
for *_, prefix, _label in game.CLOTHS:
for piece in ("hood", "legs", "body"):
self.assertIn(f"{prefix}_{piece}", game.ITEMS)
self.assertIn(f"sew_{prefix}_{piece}", game.ACTION_BY_ID)
self.assertEqual(game.ITEMS["rune_body"]["plural"], "Rune cuirasses") self.assertEqual(game.ITEMS["rune_body"]["plural"], "Rune cuirasses")
def test_gear_needs_levels_to_wear(self): def test_gear_needs_levels_to_wear(self):
@ -758,7 +762,7 @@ class TierTest(unittest.TestCase):
self.assertEqual(game.build_cost("walls", 5)["mithril_brackets"], 60) self.assertEqual(game.build_cost("walls", 5)["mithril_brackets"], 60)
self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180) self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180)
def test_dragons_drop_dragonhide(self): def test_dragons_drop_their_areas_reagents(self):
world = world_with(("wil", "commoner")) world = world_with(("wil", "commoner"))
p = world["players"]["wil"] p = world["players"]["wil"]
p["xp"]["combat"] = game.XP_TABLE[80] p["xp"]["combat"] = game.XP_TABLE[80]
@ -767,7 +771,7 @@ class TierTest(unittest.TestCase):
p["bank"] = {"cooked_swordfish": 200} p["bank"] = {"cooked_swordfish": 200}
game.start_action(world, p, "fight_green_dragon", T0) game.start_action(world, p, "fight_green_dragon", T0)
game.process_player(world, p, T0 + 3600 * 2, random.Random(3)) game.process_player(world, p, T0 + 3600 * 2, random.Random(3))
self.assertGreater(p["bank"].get("green_dragonhide", 0), 30) self.assertGreater(sum(p["bank"].get(game.reagent(weight, 3), 0) for weight in game.WEIGHTS), 30)
@ -1345,28 +1349,30 @@ class WeaponStyleTest(unittest.TestCase):
p["equip"].update(weapon=weapon, shield=shield, body="iron_body", helmet="iron_helmet", legs="iron_legs") p["equip"].update(weapon=weapon, shield=shield, body="iron_body", helmet="iron_helmet", legs="iron_legs")
return world, p return world, p
def test_each_trade_fights_its_own_monster_best(self): def test_no_trade_is_funnelled_into_one_race(self):
# Every race mixes its weaknesses across its variants, so no weapon has one easy target.
styles = {"miner": "iron_warhammer", "builder": "iron_sword", "homesteader": "oak_staff", "ranger": "oak_bow"} styles = {"miner": "iron_warhammer", "builder": "iron_sword", "homesteader": "oak_staff", "ranger": "oak_bow"}
prey = {"miner": "fight_clay_golem", "builder": "fight_cave_troll", "homesteader": "fight_will_o_wisp", races = ["fight_clay_golems", "fight_cave_trolls", "fight_restless_dead", "fight_harpies"]
"ranger": "fight_giant_bat"}
for cls, weapon in styles.items(): for cls, weapon in styles.items():
_world, p = self.armed(cls, weapon, "iron_shield" if cls == "builder" else None) _world, p = self.armed(cls, weapon, "iron_shield" if cls == "builder" else None)
times = {target: game.fight_odds(p, game.ACTION_BY_ID[target])["kill_time"] for target in prey.values()} times = [game.fight_odds(p, game.ACTION_BY_ID[race])["kill_time"] for race in races]
self.assertEqual(min(times, key=times.get), prey[cls], cls) self.assertLess(max(times) / min(times), 1.45, cls)
def test_style_traits(self): def test_style_traits(self):
_w, swordsman = self.armed("builder", "iron_sword", "iron_shield") _w, swordsman = self.armed("builder", "iron_sword", "iron_shield")
_w, smith = self.armed("miner", "iron_warhammer") _w, smith = self.armed("miner", "iron_warhammer")
_w, archer = self.armed("ranger", "oak_bow") _w, archer = self.armed("ranger", "oak_bow")
spec = lambda p: game.fighter(p, game.NO_BLESSING, {}, game.ACTION_BY_ID["fight_clay_golem"]["enemy"]) brute = game.ACTION_BY_ID["fight_clay_golems"]["enemy"]["variants"][0] # vulnerable to blunt, resists piercing
spec = lambda p: game.fighter(p, game.NO_BLESSING, {}, brute)
self.assertEqual(spec(swordsman)["block"], game.BLOCK_CHANCE) self.assertEqual(spec(swordsman)["block"], game.BLOCK_CHANCE)
self.assertEqual(spec(smith)["crit"], 0.10) self.assertEqual(spec(smith)["crit"], 0.10)
self.assertEqual(spec(smith)["weakness"], 1.5) self.assertEqual(spec(smith)["affinity"], game.VULNERABLE)
self.assertEqual(spec(archer)["weakness"], 0.5) self.assertEqual(spec(archer)["affinity"], game.RESISTANT)
self.assertEqual(spec(swordsman)["affinity"], 1)
_w, other = self.armed("builder", "iron_warhammer") _w, other = self.armed("builder", "iron_warhammer")
self.assertGreater(spec(smith)["max_hit"], spec(other)["max_hit"]) # the trade's own weapon hits harder self.assertGreater(spec(smith)["max_hit"], spec(other)["max_hit"]) # the trade's own weapon hits harder
world, archer = self.armed("ranger", "oak_bow") world, archer = self.armed("ranger", "oak_bow")
fight = game.simulate_fight(world, archer, game.ACTION_BY_ID["fight_giant_bat"], random.Random(1)) fight = game.simulate_fight(world, archer, game.ACTION_BY_ID["fight_harpies"], random.Random(1))
self.assertEqual(fight["events"][0][1], "you") # the bow strikes first self.assertEqual(fight["events"][0][1], "you") # the bow strikes first
def test_two_handed_weapons_leave_no_room_for_a_shield(self): def test_two_handed_weapons_leave_no_room_for_a_shield(self):
@ -1377,13 +1383,40 @@ class WeaponStyleTest(unittest.TestCase):
def test_weapons_past_copper_need_trophies(self): def test_weapons_past_copper_need_trophies(self):
for action_id, trophy in (("smith_iron_warhammer", "clay_core"), ("smith_iron_sword", "cave_troll_hide"), for action_id, trophy in (("smith_iron_warhammer", "clay_core"), ("smith_iron_sword", "cave_troll_hide"),
("make_iron_shield", "cave_troll_hide"), ("brew_oak_staff", "wisp_essence"), ("make_iron_shield", "cave_troll_hide"), ("brew_oak_staff", "grave_dust"),
("lw_oak_bow", "bat_sinew"), ("smith_rune_warhammer", "runic_core")): ("lw_oak_bow", "harpy_sinew"), ("smith_rune_warhammer", "magma_core")):
self.assertIn(trophy, game.ACTION_BY_ID[action_id]["inputs"], action_id) self.assertIn(trophy, game.ACTION_BY_ID[action_id]["inputs"], action_id)
for action_id in ("smith_copper_warhammer", "smith_copper_sword", "make_copper_shield", for action_id in ("smith_copper_warhammer", "smith_copper_sword", "make_copper_shield",
"brew_wooden_staff", "lw_wooden_bow"): "brew_wooden_staff", "lw_wooden_bow"):
self.assertFalse(any(item in game.TROPHIES for item in game.ACTION_BY_ID[action_id]["inputs"]), action_id) self.assertFalse(any(item in game.TROPHIES for item in game.ACTION_BY_ID[action_id]["inputs"]), action_id)
def test_armour_past_copper_needs_its_areas_reagent(self):
self.assertEqual(game.ACTION_BY_ID["smith_iron_body"]["inputs"], {"iron_bar": 5, "watchtower_plating": 3})
self.assertEqual(game.ACTION_BY_ID["lw_hard_leather_cap"]["inputs"], {"hard_leather": 2, "watchtower_lacing": 1})
self.assertEqual(game.ACTION_BY_ID["sew_moonweave_legs"]["inputs"], {"moonweave": 3, "ironhill_thread": 2})
for action_id in ("smith_copper_body", "lw_cap", "sew_linen_body"):
self.assertFalse(any(item in game.REAGENTS for item in game.ACTION_BY_ID[action_id]["inputs"]), action_id)
harpies = game.ACTION_BY_ID["fight_harpies"]["drops"]
self.assertIn(["watchtower_thread", 1, game.REAGENT_ODDS], harpies)
def test_heavy_stops_blows_light_stops_magic(self):
_w, plate = self.armed("miner", "iron_warhammer")
_w, cloth = self.armed("miner", "iron_warhammer")
cloth["equip"].update(helmet="cotton_hood", body="cotton_body", legs="cotton_legs")
self.assertGreater(game.armour(plate), game.armour(cloth))
self.assertGreater(game.magic_ward(cloth), game.magic_ward(plate))
shaper = next(v for v in game.ACTION_BY_ID["fight_clay_golems"]["enemy"]["variants"] if v["name"] == "Mud shaper")
brute = game.ACTION_BY_ID["fight_clay_golems"]["enemy"]["variants"][0]
hurt = lambda p, foe: game._variant_odds(p, foe, game.NO_BLESSING, {})["damage"]
self.assertLess(hurt(cloth, shaper), hurt(plate, shaper))
self.assertLess(hurt(plate, brute), hurt(cloth, brute))
def test_each_kill_is_one_of_the_variants(self):
world, p = self.armed("miner", "iron_warhammer", level=30)
seen = {game.simulate_fight(world, p, game.ACTION_BY_ID["fight_goblins"], random.Random(seed))["enemy"]
for seed in range(60)}
self.assertEqual(seen, {"Goblin brute", "Goblin rogue", "Goblin warlock"})
def test_shield_smiths_stop_after_the_move(self): def test_shield_smiths_stop_after_the_move(self):
world = world_with(("kay", "miner")) world = world_with(("kay", "miner"))
world["version"] = 13 world["version"] = 13
@ -1463,3 +1496,24 @@ class IconTest(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
class CombatReworkMigrationTest(unittest.TestCase):
def test_old_monsters_trophies_and_dragonhide_carry_over(self):
world = world_with(("kay", "ranger"))
world["version"] = 15
p = world["players"]["kay"]
del p["xp"]["tailoring"]
p["bank"] = {"bat_sinew": 3, "harpy_sinew": 2, "green_dragonhide": 4, "red_dragon_leather": 1}
p["equip"]["body"] = "blue_dhide_body"
p["action"] = {"id": "fight_giant_bat", "start": T0}
world["players"]["kay"] = p
game.migrate(world, T0)
self.assertEqual(p["bank"], {"harpy_sinew": 3, "spider_silk": 2, "ironhill_lacing": 4, "mammoth_leather": 1})
self.assertEqual(p["equip"]["body"], "sabrecat_leather_body")
self.assertEqual(p["action"]["id"], "fight_harpies")
self.assertEqual(p["xp"]["tailoring"], 0)
def test_homesteaders_tailor(self):
self.assertIn("tailoring", game.CLASSES["homesteader"]["fast"])
self.assertIn("tailoring", game.CLASSES["homesteader"]["craft"])