diff --git a/README.md b/README.md index e1f996e..cc7e2a2 100644 --- a/README.md +++ b/README.md @@ -10,42 +10,56 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. ## What's in it - **Skills:** mining, woodcutting, farming, hunting, fishing, smithing, - construction (with masonry), cooking, leatherworking, herblore, combat, - prayer and reconnaissance. Smiths also make hardware for builders, one kind - per metal (copper rivets to rune locks), which fittings and buildings need. + construction (with masonry), cooking, leatherworking, tailoring, herblore, + combat, prayer and reconnaissance. Smiths also make hardware for builders, + 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 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). - Below half health you eat from your bank; out of food you retreat, losing - nothing but the fight. HP recovers 25% an hour. The Combat page replays - the fight live. Fights are split into the Frontier (bandits, sabotage, - drilling, the city's troubles) and expeditions: Greenwood to start, then - one area per tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, - Dragon's Spine), each with a golem, a troll, a spirit and a flyer that drop - rare trophies (3%, 6% from bosses) for stronger weapons, and a boss (the - dragons at the top). Each kill is followed by a 2-second pause. + or crit. Every blow has a damage type (slashing, blunt, piercing, magic); + each enemy is vulnerable to some (+25%) and resists others (-25%), shown + as tinted icons. Armour stops physical blows, ward stops magic. Below half + health you eat from your bank; out of food you retreat, losing nothing but + the fight. HP recovers 25% an hour. The Combat page replays the fight + live. Fights are split into the Frontier (bandits, sabotage, drilling, the + city's troubles) and expeditions: Greenwood to start, then one area per + tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's + 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. - Sword and shield (2.4s swings, the shield blocks 15% of hits; swords from - Smithing, shields from Construction), warhammer (Smithing; slow, heavy, - twice the crits), staff (Herblore; accurate, ignores armour) and bow - (Leatherworking; fast, strikes first). The last three take both hands and - carry a shield's power. Your trade's weapon does 15% more damage. Golems - break under hammers, trolls fall to swords, spirits only fear staves, and - flyers fall to bows. Past copper, each weapon needs its tier's trophies. + Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits; + swords from Smithing, shields from Construction), warhammer (blunt, + Smithing; slow, heavy, twice the crits), staff (magic, Herblore; + accurate) and bow (piercing, Leatherworking; fast, strikes first). The + last three take both hands and carry a shield's power. Your trade's weapon + does 15% more damage. 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, - adamant, rune at levels 1โ75) of ore, trees (up to enchanted) and hides (deer, - boar and bear from hunting; green, blue and red dragonhide from fighting - dragons). Every metal tier has a sword, shield, helmet, greaves, cuirass and - five tools; every leather tier a cap, trousers and jerkin. Higher 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. + adamant, rune at levels 1โ75) of ore, trees (up to enchanted), hides (deer, + boar, bear, elk, sabrecat, mammoth) and fibres (flax, cotton, silk, + moonpetal, starbloom, spiritvine). Every metal tier has a sword, shield, + helmet, greaves, cuirass and five tools; every leather tier a cap, + trousers and jerkin; every cloth tier a hood, leggings and robe. Higher + 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 a quiet gathering side and a busier crafting side: miner (mining, smithing), builder (woodcutting, construction), homesteader (farming, cooking, - herblore) and ranger (hunting, fishing, leatherworking, cooking). A trade's - skills are 20% faster, 1 in 10 of its crafts make double, and each has one - bonus of its own. Homesteaders farm differently: 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. + herblore, tailoring) and ranger (hunting, fishing, leatherworking, + cooking). A trade's skills are 20% faster, 1 in 10 of its crafts make + double, and each has one bonus of its own. Homesteaders farm differently: + 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, 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 diff --git a/app.py b/app.py index 6ea5c31..02217bd 100644 --- a/app.py +++ b/app.py @@ -98,6 +98,8 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["max_hp"] = game.max_hp(player) view["armour"] = game.armour(player) 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["blessing"] = game.blessing(player) 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"], "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), + "ward": game.magic_ward(player), "ward_reduction": round(game.reduction(game.magic_ward(player)), 3), "favour": player["favour"], "id": player["id"], "name": player["name"], "cls": player["cls"], "total": sum(game.skill_level(player, skill) for skill in game.SKILL_IDS), diff --git a/game.py b/game.py index afe1c43..7c86ea7 100644 --- a/game.py +++ b/game.py @@ -64,8 +64,8 @@ ITEMS = { "bones": {"name": "Bones", "icon": "๐ฆด", "value": 1}, "big_bones": {"name": "Big bones", "icon": "๐ฆด", "value": 4}, "dragon_bones": {"name": "Dragon bones", "icon": "๐ฆด", "value": 15}, - "studded_body": {"name": "Studded jerkin", "icon": "๐งฅ", "value": 40, "slot": "body", "armour": 6, - "req": ["combat", 10]}, + "studded_body": {"name": "Studded jerkin", "icon": "๐งฅ", "value": 40, "slot": "body", "armour": 5, "ward": 5, + "weight": "medium", "req": ["combat", 10]}, # 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. "thatch": {"name": "Thatch", "icon": "๐พ", "value": 2}, @@ -100,6 +100,23 @@ PIECES = [ ("legs", "legs", "greaves", 4, 9, None, [3, 6, 9, 12, 16, 21]), ("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"} PIECE_ICONS = {"sword": "โ๏ธ", "shield": "๐ก๏ธ", "helmet": "๐ช", "legs": "๐ฆฟ", "body": "๐ฆพ"} COPPER_ICONS = {"sword": "๐ก๏ธ", "helmet": "โ๏ธ", "legs": "๐ฆต", "body": "๐ฅ"} @@ -118,22 +135,34 @@ 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, -# leatherworking level, armour by piece +# Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value), +# armour id prefix, armour name prefix, leatherworking level LEATHERS = [ - (("hide", "Hide", "๐ซ", 3), ("leather", "Leather", 5), "leather", "Leather", 1, (1, 2, 3)), - (("boar_hide", "Boar hide", "๐", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20, (2, 4, 6)), - (("bear_pelt", "Bear pelt", "๐ป", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35, (4, 6, 9)), - (("green_dragonhide", "Green dragonhide", "๐", 35), ("green_dragon_leather", "Green dragon leather", 50), - "green_dhide", "Green dragonhide", 50, (6, 9, 13)), - (("blue_dragonhide", "Blue dragonhide", "๐ฒ", 60), ("blue_dragon_leather", "Blue dragon leather", 85), - "blue_dhide", "Blue dragonhide", 65, (8, 12, 17)), - (("red_dragonhide", "Red dragonhide", "๐ฅ", 100), ("red_dragon_leather", "Red dragon leather", 140), - "red_dhide", "Red dragonhide", 80, (10, 15, 21)), + (("hide", "Hide", "๐ซ", 3), ("leather", "Leather", 5), "leather", "Leather", 1), + (("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), + (("elk_hide", "Elk hide", "๐ฆ", 35), ("elk_leather", "Elk leather", 50), "elk_leather", "Elk leather", 50), + (("sabrecat_pelt", "Sabrecat pelt", "๐", 60), ("sabrecat_leather", "Sabrecat leather", 85), + "sabrecat_leather", "Sabrecat leather", 65), + (("mammoth_hide", "Mammoth hide", "๐ฆฃ", 100), ("mammoth_leather", "Mammoth leather", 140), + "mammoth_leather", "Mammoth leather", 80), ] # piece suffix, slot, noun, leather used, level above the tier, icon LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "๐งข"), ("legs", "legs", "trousers", 3, 7, "๐"), ("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 FISH = [ @@ -159,8 +188,10 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in "req": ["combat", EQUIP_LEVELS[_tier]]} if _power: _item["power"] = _power[_tier] - if _armour: + if _armour and _piece == "shield": _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} for _skill, (_tool, _icon) in TOOLS.items(): 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"), "icon": "๐ช", "style": "staff", **_weapon} ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "๐น", "style": "bow", **_weapon} -for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS): +for _tier, (_raw, _leather, _prefix, _label, _level) in enumerate(LEATHERS): ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_leather[0]] = {"name": _leather[1], "icon": "๐ชข", "value": _leather[2]} - 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}"] = { "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": 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: 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]} @@ -245,12 +286,14 @@ POTION_LINES = [ ("fishing", "Fishing potion", "fishing", "speed", SPEEDS, [fish[0][0] for fish in FISH]), ("farming", "Farming potion", "farming", "speed", SPEEDS, ["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]), ("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]), ("leatherworking", "Leatherworking potion", "leatherworking", "keep", SPEEDS, [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"]), ("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], @@ -312,6 +355,7 @@ SKILLS = [ {"id": "construction", "name": "Construction", "icon": "๐๏ธ", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "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": "combat", "name": "Combat", "icon": "โ๏ธ", "group": "Combat"}, {"id": "prayer", "name": "Prayer", "icon": "๐", "group": "Combat"}, @@ -319,7 +363,7 @@ SKILLS = [ ] SKILL_IDS = [skill["id"] for skill in SKILLS] 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 # 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", }, "homesteader": { - "name": "Homesteader", "icon": "๐พ", "fast": ["cooking", "herblore"], "craft": ["cooking", "herblore"], - "bonus": "Cooking and herblore 20% faster, two farm plots", - "masterwork": "Masterwork: 10% double cooking and herblore crafts", + "name": "Homesteader", "icon": "๐พ", "fast": ["cooking", "herblore", "tailoring"], + "craft": ["cooking", "herblore", "tailoring"], + "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", "combat": "Staff: +15% damage", }, @@ -391,13 +436,16 @@ def _tiered_actions() -> list[dict]: for piece, _slot, noun, bars, offset, _power, _armour in PIECES: if piece == "shield": continue # builders make shields now, in Construction - inputs = {f"{key}_bar": bars, **({trophy("troll", tier): tier} if piece == "sword" and tier else {})} + 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, 25 + 5 * bars + 2 * tier, bars * piece_xp[tier], inputs, {f"{key}_{piece}": 1}, group=name, city=city)) actions.append(_action(f"smith_{key}_warhammer", "smithing", f"{name} warhammer", TIER_LEVELS[tier] + 6, 45 + 2 * tier, 4 * piece_xp[tier], - {f"{key}_bar": 3, PLANKS[tier][0]: 1, **({trophy("golem", tier): 2 + tier} if tier else {})}, + {f"{key}_bar": 3, PLANKS[tier][0]: 1, **({trophy("hammer", tier): 2 + tier} if tier else {})}, {f"{key}_warhammer": 1}, group=name, city=city)) for tool, _icon in TOOLS.values(): tool_id = f"{key}_{tool.replace(' ', '_')}" @@ -407,14 +455,26 @@ def _tiered_actions() -> list[dict]: 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], [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", f"Tan {leather[1].lower()}", level, 25 + 3 * tier, bar_xp[tier], {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", 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: 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], @@ -426,38 +486,224 @@ def _tiered_actions() -> list[dict]: # --- combat ----------------------------------------------------------------- # 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 -# is split into the Frontier (bandits and the city's troubles) and expeditions: -# one area per tier, each with a golem, a troll, a spirit and a flyer that drop -# the trophies higher weapons need, and a boss. +# timer, accuracy and a max hit, and every swing hits, misses or crits. Every +# blow has a damage type: weapons deal one, and each enemy is vulnerable to some +# types and resists others. Armour stops physical blows, ward stops magic. +# 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_MULT = 1.5 RESPAWN = 2 # seconds after a kill before the next enemy turns up 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 +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. STYLES = { - "sword": {"name": "sword", "interval": 2.4, "hit": 1.0, "accuracy": 1.0, "crit": 0.05}, - "hammer": {"name": "warhammer", "interval": 3.6, "hit": 1.6, "accuracy": 0.95, "crit": 0.10}, - "staff": {"name": "staff", "interval": 3.0, "hit": 1.15, "accuracy": 1.25, "crit": 0.05, "pierce": True}, - "bow": {"name": "bow", "interval": 1.8, "hit": 0.75, "accuracy": 1.05, "crit": 0.05, "first": True}, + "sword": {"name": "sword", "damage": "slash", "interval": 2.4, "hit": 1.0, "accuracy": 1.0, "crit": 0.05}, + "hammer": {"name": "warhammer", "damage": "blunt", "interval": 3.6, "hit": 1.6, "accuracy": 0.95, "crit": 0.10}, + "staff": {"name": "staff", "damage": "magic", "interval": 3.0, "hit": 1.2, "accuracy": 1.25, "crit": 0.05}, + "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_BONUS = 1.15 # damage with your own trade's weapon BLOCK_CHANCE = 0.15 # a shield turns aside this share of hits -# Damage each style does to each kind of monster (1 when not listed). -WEAKNESS = { - "golem": {"hammer": 1.5, "bow": 0.5}, - "troll": {"sword": 1.5}, - "spirit": {"hammer": 0.5, "sword": 0.5, "bow": 0.5, "staff": 1.5}, - "flyer": {"hammer": 0.5, "sword": 0.5, "bow": 1.5}, + +# Each armour piece past copper needs reagents from its tier's expedition area. +REAGENT_COUNTS = {"helmet": 1, "legs": 2, "body": 3} +REAGENT_NOUNS = {"heavy": "plating", "medium": "lacing", "light": "thread"} +REAGENT_ODDS = 0.10 # each of the area's three reagents, from any of its races +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: - """The trophy a tier's monster of this kind drops (tier 1 is the Old Watchtower).""" - return _slug(AREA_MONSTERS[kind][2][tier - 1]) +def _types(value) -> list: + return [value] if isinstance(value, str) else list(value) + + +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]: @@ -465,118 +711,69 @@ def _weapon_actions() -> list[dict]: actions = [] for tier, (key, name, *_rest) in enumerate(METALS): plank, leather, herb = PLANKS[tier][0], LEATHERS[tier][1][0], HERBS[tier][0] - extra = lambda kind, count: {trophy(kind, tier): count} if tier else {} + 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, 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")) actions.append(_action(f"brew_{WOODS[tier]}_staff", "herblore", STAVES[tier], TIER_LEVELS[tier] + 5, 45 + 2 * tier, [12, 26, 42, 60, 80, 105][tier], - {plank: 2, herb: 3, **extra("spirit", 2 + tier)}, {f"{WOODS[tier]}_staff": 1}, + {plank: 2, herb: 3, **extra("staff", 2 + tier)}, {f"{WOODS[tier]}_staff": 1}, group="Staves")) actions.append(_action(f"lw_{WOODS[tier]}_bow", "leatherworking", BOWS[tier], LEATHERS[tier][4] + 4, 40 + 2 * tier, [12, 26, 42, 60, 80, 105][tier], - {plank: 2, leather: 2, **extra("flyer", 2 + tier)}, {f"{WOODS[tier]}_bow": 1}, + {plank: 2, leather: 2, **extra("bow", 2 + tier)}, {f"{WOODS[tier]}_bow": 1}, group="Bows")) return actions -# kind: HP, seconds between swings, accuracy, evasion, max hit (multipliers), damage it shrugs off -MONSTER_KINDS = { - "human": (1.0, 2.6, 1.0, 1.0, 1.0, 0.0), - "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]: fights = [] - for index, (area, level) in enumerate(zip(AREAS, AREA_LEVELS)): - for kind, (offset, monsters, trophies) in AREA_MONSTERS.items(): - enemy = monster(monsters[index], kind, level + offset) - fights.append(_fight(f"fight_{_slug(monsters[index])}", enemy, area, gold=level + offset, - drops=[(AREA_BONES[index], 1, 1.0), (_slug(trophies[index]), 1, TROPHY_ODDS)])) + for tier, (area, level, _prefix, races) in enumerate(AREAS, start=1): + for race, trophy_name, _line, offset, roster in races: + fights.append(_encounter(f"fight_{_slug(race)}", race, area, level + offset, roster, gold=level + offset, + drops=[(AREA_BONES[tier - 1], 1, 1.0), (_slug(trophy_name), 1, TROPHY_ODDS), + *_area_drops(tier)])) return fights -def _boss_trophies(tier: int) -> list[tuple]: - return [(_slug(trophies[tier - 1]), 1, BOSS_TROPHY_ODDS) for _offset, _monsters, trophies in AREA_MONSTERS.values()] +def _boss_drops(tier: int, reagents: float = 0.3) -> list[tuple]: + return [*[(trophy(line, tier), 1, BOSS_TROPHY_ODDS) for line in STYLES], *_area_drops(tier, reagents)] COMBAT_ACTIONS = [ # The Frontier: the city's own troubles. - _fight("fight_scouts", monster("Bandit scouts", "human", 10), "Frontier", gold=10, - 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", - special="sabotage"), - _fight("fight_stronghold", monster("Bandit stronghold", "human", 40, boss=True), "Frontier", gold=60, - drops=[("steel_bar", 1, 0.4), ("coal", 3, 0.5), ("iron_shield", 1, 0.04), ("big_bones", 1, 1.0)]), + _encounter("fight_scouts", "Bandit scouts", "Frontier", 10, BANDITS, gold=10, + drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]), + _encounter("sabotage", "Camp guards", "Frontier", 12, BANDITS, label="Sabotage the bandit camp", special="sabotage"), + _boss("fight_stronghold", "Bandit warlord", "Frontier", 40, "boss", ("slash", "magic"), ("blunt", "magic"), + ("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)]), # Greenwood: where everyone starts. - _fight("fight_wolves", monster("Wolves", "beast", 1), "Greenwood", gold=2, name="Wolves in the woods", - 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, - drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), + _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)]), + _encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4, + drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), *_area_fights(), - _fight("fight_ruins", monster("Watchtower ogre", "troll", 18, boss=True), "Old Watchtower", gold=25, - drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_trophies(1)]), - _fight("fight_barrow_king", monster("Barrow king", "spirit", 28, boss=True), "Sunken Barrows", gold=40, - drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_trophies(2)]), - _fight("fight_green_dragon", monster("Green dragon", "dragon", 38), "Iron Hills", gold=80, - drops=[("green_dragonhide", 1, 0.9), ("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(3)]), - _fight("fight_blue_dragon", monster("Blue dragon", "dragon", 48), "Frostpeak", gold=110, - drops=[("blue_dragonhide", 1, 0.9), ("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(4)]), - _fight("fight_red_dragon", monster("Red dragon", "dragon", 60), "Dragon's Spine", gold=150, - drops=[("red_dragonhide", 1, 0.9), ("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_trophies(5)]), - _fight("fight_black_dragon", monster("Black dragon", "dragon", 75, boss=True), "Dragon's Spine", gold=220, - drops=[("red_dragonhide", 2, 0.9), ("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_trophies(5)]), + _boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 18, "boss", ("blunt", "magic"), ("slash", "magic"), + ("blunt", "pierce"), gold=25, + drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]), + _boss("fight_barrow_king", "Barrow king", "Sunken Barrows", 28, "boss", ("slash", "magic"), ("blunt", "magic"), + ("pierce", "slash"), gold=40, + drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_drops(2)]), + # Dragons: the prize hunt, dropping their area's reagents far more often than anything else there. + _boss("fight_green_dragon", "Green dragon", "Iron Hills", 38, "dragon", ("slash", "magic"), ("pierce", "magic"), + ("slash", "blunt"), is_boss=False, gold=80, + drops=[("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(3, 0.6)]), + _boss("fight_blue_dragon", "Blue dragon", "Frostpeak", 48, "dragon", ("slash", "magic"), ("blunt", "pierce"), + ("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 = [ @@ -593,6 +790,9 @@ ACTIONS = [ _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_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(), _action("smith_arrows", "smithing", "Arrows", 5, 30, 10, {"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("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_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}, group="Tanning"), *[_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_VERSION = 15 +WORLD_VERSION = 16 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(): player["seen_threat"] = latest["id"] if latest else 0 world["version"] = 15 + if world["version"] < 16: + _migrate_combat_rework(world) + world["version"] = 16 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: """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 @@ -1143,16 +1401,30 @@ def bank_take(player: dict, item: str, qty: int) -> None: 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) -def damage_reduction(player: dict) -> float: - points = armour(player) +def magic_ward(player: dict) -> int: + """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) +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: - power = skill_level(player, "combat") + armour(player) // 2 + power = skill_level(player, "combat") + defence_bonus(player) for item in player["equip"].values(): if item: 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" +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: """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 = STYLES[style_id] 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) - weakness = WEAKNESS.get(enemy["kind"], {}).get(style_id, 1) if enemy else 1 - return {"style": style_id, "interval": style["interval"], "accuracy": (10 + 2 * power) * style["accuracy"], - "max_hit": max(2, round((2 + power / 3) * damage * weakness)), "crit": style["crit"], - "pierce": style.get("pierce", False), "first": style.get("first", False), "weakness": weakness, + lands = affinity(enemy, style["damage"]) if enemy else 1 + taken = blessed["taken"] * (1 - brew.get("reduce", 0)) + return {"style": style_id, "damage": style["damage"], "interval": style["interval"], + "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, - "evasion": 10 + 2 * (skill_level(player, "combat") + armour(player) // 2), - "taken": (1 - damage_reduction(player)) * blessed["taken"] * (1 - brew.get("reduce", 0))} + "evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)), + "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: @@ -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 -def fight_odds(player: dict, action: dict) -> dict: - """What a fight against this enemy looks like on average, for the cards.""" - foe = action["enemy"] - me = fighter(player, blessing(player), potion_effect(player, "combat"), foe) +def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict) -> dict: + me = fighter(player, blessed, brew, foe) hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) - shrug = 0 if me["pierce"] else foe["reduction"] - dps = hit * _average_hit(me["max_hit"], me["crit"]) * (1 - shrug) / me["interval"] - kill = foe["hp"] / dps - hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * me["taken"] / foe["interval"] * kill - return {"hit": round(hit, 2), "their_hit": round(their_hit, 2), "max_hit": me["max_hit"], "style": me["style"], - "weakness": me["weakness"], "their_max": max(1, round(foe["max_hit"] * me["taken"])), - "kill_time": round(kill, 1), "damage": round(hurt, 1)} + kill = foe["hp"] / (hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"]) + taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"]) + hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"] * kill + return {"hit": hit, "their_hit": their_hit, "max_hit": me["max_hit"], "affinity": me["affinity"], + "their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))), + "kill_time": kill, "damage": hurt} + + +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: """Play out one fight swing by swing, both swing timers starting empty. Nothing changes 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) - shrug = 0 if me["pierce"] else foe["reduction"] hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) top, hp, foe_hp = max_hp(player), player["hp"], foe["hp"] pantry = {item: player["bank"][item] for item in edible(player)} @@ -1230,6 +1526,7 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) pace = speed(world) # 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 + swings = 0 # an enemy with more than one attack takes turns with them while result is None: t = min(mine, theirs) if t > MAX_FIGHT: @@ -1239,23 +1536,27 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) mine += me["interval"] crit = rng.random() < me["crit"] landed = rng.random() < hit - dealt = max(1, round((me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"])) - * (1 - shrug))) if landed else 0 + dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0 foe_hp = max(0, foe_hp - dealt) - events.append([round(t / pace, 2), "you", ("crit" if crit else "hit") if landed else "miss", dealt, round(hp), foe_hp]) + 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: result = "won" continue theirs += foe["interval"] + kind = foe["attack"][swings % len(foe["attack"])] + swings += 1 crit = rng.random() < CRIT_CHANCE landed = rng.random() < their_hit if landed and rng.random() < me["block"]: events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(hp)), foe_hp]) continue - dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * me["taken"])) if landed else 0 + 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 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()): food = next(item for item, left in pantry.items() if left) pantry[food] -= 1 @@ -1272,7 +1573,8 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) hp, result = 1, "lost" elif starving: 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, "interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"], "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"] 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 + xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met if player["prayers"] and not fight["pp"]: summary["prayer_out"] = True 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 gain_xp(world, player, "combat", round(attack * 6), now) 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: - 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"]) else: severity = min(1.0, 1 - guard["total"] / attack) @@ -2556,10 +2860,10 @@ PROBLEMS = { } MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars") PROBLEM_ACTIONS = [ - _fight("clear_wolves", monster("Wolf pack", "beast", 4), "Frontier", name="Drive off the wolves", - special="problem", problem="wolves"), - _fight("catch_goblins", monster("Goblin thieves", "human", 5), "Frontier", name="Catch the goblin thieves", - special="problem", problem="goblins"), + _encounter("clear_wolves", "Wolf pack", "Frontier", 4, WOLVES, label="Drive off the wolves", + special="problem", problem="wolves"), + _encounter("catch_goblins", "Goblin thieves", "Frontier", 5, GOBLINS, label="Catch the goblin thieves", + special="problem", problem="goblins"), _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("catch_rats", "hunting", "Catch the rats", 1, 30, 8, special="problem", problem="rats"), @@ -2600,7 +2904,7 @@ FITTINGS = [ "+1 prayer point per bone, ward +15", "star-altar", "#6f9a4a"), ("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_lock": 6}, "+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"), ] # 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", "hunting knife": "bowie-knife", "hammer": "claw-hammer"} 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"} HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"] GLYPHS = { @@ -3206,7 +3515,7 @@ SKILL_GLYPHS = { "construction": ("hammer-nails", "#d1a46a"), "cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"), "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"), "homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")} @@ -3225,24 +3534,31 @@ ACTION_GLYPHS = { "labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"), "catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"), "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"), "rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"), "superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"), "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 -MONSTER_GLYPHS = {"golem": ["rock-golem"] * 5, "troll": ["troll"] * 5, - "spirit": ["fairy", "spectre", "ghost", "spectre", "ghost"], - "flyer": ["bat", "harpy", "wyvern", "griffin-symbol", "eagle-head"]} +RACE_GLYPHS = { + "Clay golems": "rock-golem", "Cave trolls": "troll", "Restless dead": "skeleton", "Harpies": "harpy", + "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({ "fight_goblins": ("goblin-head", "#8fbf6a"), "fight_ruins": ("ogre", "#a08060"), "fight_barrow_king": ("crowned-skull", "#c8d0e0"), "drill_militia": ACTION_GLYPHS["drill_militia"], }) -for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items(): - for _index, _name in enumerate(_monsters): - ACTION_GLYPHS[f"fight_{_slug(_name)}"] = (MONSTER_GLYPHS[_kind][_index], TIER_TINTS[_index]) -UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"] +for _index, (_area, _level, _prefix, _races) in enumerate(AREAS): + for _race, *_rest in _races: + ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index]) +UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", + *(kind["glyph"] for kind in DAMAGE_TYPES.values())] def _assign_glyphs() -> None: @@ -3269,8 +3585,16 @@ def _assign_glyphs() -> None: ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint) for suffix, glyph in LEATHER_GLYPHS.items(): ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) - for trophy, (_name, kind, tier) in TROPHIES.items(): - ITEMS[trophy].update(glyph=TROPHY_GLYPHS[kind], tint=TIER_TINTS[tier - 1]) + for trophy, (_name, line, tier) in TROPHIES.items(): + 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(): ITEMS[herb].update(glyph="herbs-bundle", tint=tint) 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, "potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, - "styles": STYLES, "weakness": WEAKNESS, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, + "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, } diff --git a/static/app.js b/static/app.js index c10a840..cbbf0a0 100644 --- a/static/app.js +++ b/static/app.js @@ -439,7 +439,7 @@ function skillPage(id) { const open = (g) => actions.some((a) => a.group === g && !isLocked(a)); let current = tabs[id] || remember(`tab.${id}`); // 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]; const bar = groups.map((g) => ``).join(""); // Ungrouped actions (the building site) stay above the tabs. @@ -522,40 +522,57 @@ function toolPanel(skill) { : `No tool.`}`; } -// What a monster is weak to and shrugs off, and how your weapon fares against it. -function weaknessLine(kind, odds) { - const table = C.weakness[kind]; - if (!table) return ""; - const names = (pick) => Object.entries(table).filter(([, m]) => pick(m)).map(([s]) => `${C.styles[s].name}s`).join(", "); - const weak = names((m) => m > 1), tough = names((m) => m < 1); - const yours = odds.weakness > 1 ? ` ยท your ${C.styles[odds.style].name} hits it hard` - : odds.weakness < 1 ? ` ยท your ${C.styles[odds.style].name} does half damage` : ""; - return `
${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${traits[styleId]}. + return `
${dmgIcon(st.damage)} ${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${C.damage_types[st.damage].name.toLowerCase()}, ${traits[styleId]}. ${own === styleId ? `Trade weapon: +15% damage.` : `Trade weapon: ${esc(C.styles[own].name)} (+15%).`}
`; } function combatPanel() { 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 id = me.equip[name]; return `Power ${me.power} ยท armour ${me.armour} (-${Math.round(me.reduction * 100)}% damage)
+Power ${me.power} ยท armour ${me.armour} (-${Math.round(me.reduction * 100)}% physical) ยท ward ${me.ward} (-${Math.round(me.ward_reduction * 100)}% magic)
${styleNote(me)}