From 8afcba79fcd01288e3337156ea67ad1a0e3e5c94 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 24 Oct 2026 13:48:16 +0100 Subject: [PATCH] Dungeon loot: patterns, masterworks, relics, uniques Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++ app.py | 6 +- combat.py | 60 +++++++++++++++-- game.py | 162 +++++++++++++++++++++++++++++++++++++++++++-- static/app.js | 37 ++++++++--- static/icons.svg | 6 ++ static/styles.css | 2 + tests/test_game.py | 98 +++++++++++++++++++++++++++ 8 files changed, 358 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7129836..4eee2f1 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,11 @@ Beta. The core game is in place; changes from here are mostly balance and tuning before the first pull. A wipe repeats the pull, three on one pull end the run, and the run waits while anyone looks away. Bosses give everyone their own loot; clearing pays gold, a trophy and reagents. +- **Dungeon loot:** dungeons don't hand out finished gear. Every boss drops the + dungeon's relic and now and then a pattern: a crafter learns it once, then + reforges one of the tier's pieces with relics into a masterwork (a third + stronger, named for the dungeon). Each last boss also has a unique, worn as a + ring, amulet or trinket, that changes one or more abilities. - **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 diff --git a/app.py b/app.py index f1fc3e4..0947fdd 100644 --- a/app.py +++ b/app.py @@ -117,7 +117,9 @@ def player_view(world: dict, player: dict, now: float) -> dict: me = game.fighter(player, game.blessing(player), game.potion_effect(player, "combat")) view["fighter"] = {"max_hit": me["max_hit"], "accuracy": round(me["accuracy"]), "evasion": me["evasion"], "crit": me["crit"], "interval": me["interval"], "block": me["block"], "damage": me["damage"], - "role": me["role"], "abilities": me["abilities"], "style": me["style"]} + "role": me["role"], "abilities": me["abilities"], "style": me["style"], "mods": me["mods"], + "boosts": {ab: [game.ITEMS[i]["name"] for i in player["equip"].values() if i and ab in game.ITEMS[i].get("mods", {})] + for ab in me["mods"]}} view["health_potion"] = game.health_potion(player) view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]} view["rent_cost"] = game.rent_cost(player) @@ -363,6 +365,8 @@ class Handler(BaseHTTPRequestHandler): return game.press(world, player, str(body.get("id")), now, str(target) if target else None) if path == "/api/target": return game.set_fight_target(world, player, str(body.get("id")), now) + if path == "/api/learn": + return game.learn_pattern(player, str(body.get("item"))) if path == "/api/pull": return game.pull_now(world, player, now) if path == "/api/manual": diff --git a/combat.py b/combat.py index 581a75b..6da3f6e 100644 --- a/combat.py +++ b/combat.py @@ -145,6 +145,45 @@ def known_abilities(style: str, cls: str, level: int, shield: bool) -> list[str] return sorted(kit, key=PRIORITY.index) +def modded(ab: dict, mod: dict | None) -> dict: + """An ability as something you wear changes it: `cd` and `cast` multiply, `c` multiplies every + effect's size, `dur` adds seconds to every effect that lasts, `cost` replaces the arrows it takes, + and `extra` adds effects.""" + if not mod: + return ab + ab = {**ab, "effects": [dict(eff) for eff in ab["effects"]]} + for key in ("cd", "cast"): + if key in mod and ab.get(key): + ab[key] = ab[key] * mod[key] + if "cost" in mod: + ab["cost"] = mod["cost"] + for eff in ab["effects"]: + if "c" in eff and "c" in mod: + eff["c"] *= mod["c"] + if "dur" in eff and "dur" in mod: + eff["dur"] += mod["dur"] + ab["effects"] += [dict(eff) for eff in mod.get("extra", [])] + return ab + + +def combine_mods(mods: list[dict]) -> dict: + """Several things changing the same abilities: multipliers multiply, durations add, extras pile up.""" + out = {} + for mod in mods: + for ab_id, change in mod.items(): + have = out.setdefault(ab_id, {}) + for key, value in change.items(): + if key in ("cd", "cast", "c"): + have[key] = have.get(key, 1) * value + elif key == "dur": + have[key] = have.get(key, 0) + value + elif key == "extra": + have[key] = have.get(key, []) + value + else: + have[key] = value + return out + + def role(style: str, shield: bool) -> str: return "tank" if style == "sword" and shield else "healer" if style == "staff" else "damage" @@ -225,6 +264,7 @@ def _member_unit(member: dict) -> dict: "damage": me["damage"], "interval": me["interval"], "accuracy": me["accuracy"], "evasion": me["evasion"], "crit": me["crit"], "block": me["block"], "taken": me["taken"], "base": me["base"], "abilities": list(me["abilities"]), "cds": {}, "busy": 0.0, "cast": None, "auras": [], + "kit": {a: modded(ABILITIES[a], me.get("mods", {}).get(a)) for a in me["abilities"]}, "next": member["at"] + (0.5 if me["first"] else me["interval"]), "stun": 0.0, "target": None, "mend": member["mend"], "wind": member["wind"], "wind_used": False, "potion": member.get("potion") or 0, "drank": False, "arrows": member.get("arrows", 0), "shot": 0, @@ -428,7 +468,7 @@ def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None: watching = t < unit["watch"] queued = unit["queued"] if queued: - ab = ABILITIES[queued["ab"]] + ab = unit["kit"][queued["ab"]] why = _ready(unit, ab, t, manual=True) if why is None: unit["queued"] = None @@ -440,7 +480,7 @@ def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None: elif why == "arrows" or t - queued["at"] > QUEUE: unit["queued"] = None for ab_id in unit["abilities"]: - ab = ABILITIES[ab_id] + ab = unit["kit"][ab_id] if (watching and ab_id in unit["manual"]) or _ready(unit, ab, t, manual=False): continue target = _rule(state, unit, ab, t) @@ -491,7 +531,7 @@ def _use(state: dict, unit: dict, ab: dict, target, t: float, rng: random.Random def _finish_cast(state: dict, unit: dict, t: float, rng: random.Random) -> None: cast, unit["cast"] = unit["cast"], None if unit["side"] == "party": - _apply(state, unit, ABILITIES[cast["ab"]], cast["target"], t, rng) + _apply(state, unit, unit["kit"][cast["ab"]], cast["target"], t, rng) else: _enemy_effect(state, unit, cast["ab"], cast["target"], t, rng) @@ -536,13 +576,17 @@ def _apply(state: dict, unit: dict, ab: dict, target_id, t: float, rng: random.R "amount": eff["c"] * _avg(unit, target), "every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6}) elif kind == "heal": - _heal(state, unit, target, eff["c"] * _avg(unit) * spread(), t, ab["id"]) + _heal(state, unit, _second(state, target) if eff.get("second") else target, + eff["c"] * _avg(unit) * spread(), t, ab["id"]) elif kind == "hot": for ally in _party(state): _add_aura(ally, {"id": ab["id"], "kind": "hot", "by": unit["id"], "amount": eff["c"] * _avg(unit), "every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6}) elif kind == "shield": - _add_aura(target, {"id": ab["id"], "kind": "shield", "by": unit["id"], "amount": eff["c"] * _avg(unit), + holder = _second(state, target) if eff.get("second") else target + if not holder: + continue + _add_aura(holder, {"id": ab["id"], "kind": "shield", "by": unit["id"], "amount": eff["c"] * _avg(unit), "until": t + eff["dur"]}) _heal_threat(state, unit, eff["c"] * _avg(unit)) elif kind == "aura": @@ -580,6 +624,12 @@ def _apply(state: dict, unit: dict, ab: dict, target_id, t: float, rng: random.R enemy["next"] += eff["by"] +def _second(state: dict, first: dict | None) -> dict | None: + """The most hurt of your side after `first`: for heals and wards that reach a second ally.""" + others = [u for u in _party(state) if u is not first] + return min(others, key=_share) if others else None + + def _stop_cast(state: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None: cast = enemy.get("cast") if not cast or (not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]): diff --git a/game.py b/game.py index 66b61b9..089a343 100644 --- a/game.py +++ b/game.py @@ -1304,6 +1304,110 @@ ACTIONS = [ {log: 1}, special="tend", tend=fire, tier=tier, group=spec["name"]) for fire, spec in FIRES.items() for tier, log in enumerate(FIREWOOD)], ] + +# --- dungeon loot -------------------------------------------------------------- +# Dungeons don't hand out finished gear: crafters still make it. Every boss in a dungeon drops its +# relic, and now and then a pattern: a crafter who learns it can reforge one of the tier's weapons +# or body pieces into a masterwork with relics. The final boss also has one unique, worn in a +# jewellery slot, that changes an ability. +MASTERWORK = 1.3 # a masterwork's power or armour and ward against the piece it's reforged from +PATTERN_ODDS = (0.15, 0.35) # each player's chance at one of the dungeon's patterns, from a mini-boss and the final boss +RELIC_ODDS = (0.5, 1.0) # ...and at its relic (two from the final boss) +UNIQUE_ODDS = 0.08 # each player's chance at the final boss's unique +MASTERWORK_RELICS = 3 + +# Per dungeon: what its masterworks are called, its relic's name, and its unique. +DUNGEON_LOOT = { + "dungeon_goblin_warren": ("Kingsguard", "Warren relic", "one_eyes_fang"), + "dungeon_watchtower": ("Ogre-forged", "Watchtower relic", "ogres_knuckle"), + "dungeon_barrow_crypts": ("Barrow-blessed", "Barrow relic", "barrow_kings_pendant"), + "dungeon_bandit_stronghold": ("Warlord's", "Stronghold relic", "warlords_whistle"), + "dungeon_deep_forge": ("Deepforged", "Deep Forge relic", "forgeheart"), + "dungeon_frozen_hold": ("Rimeforged", "Frozen Hold relic", "rimeglass_lens"), + "dungeon_dragons_lair": ("Dragonforged", "Dragon's Lair relic", "wyrmscale_quiver"), +} +# Uniques: (name, slot, glyph, tint, what it does, the ability changes). +UNIQUES = { + "one_eyes_fang": ("One-Eye's fang", "amulet", "fangs", "#e0e0d0", "Shield Bash also taunts for 3s.", + {"shield_bash": {"extra": [{"t": "taunt", "dur": 3}]}}), + "ogres_knuckle": ("Ogre's knuckle", "ring", "fist", "#d1a46a", "Crushing Blow winds up in 1s and stuns for 1s.", + {"crushing_blow": {"cast": 2 / 3, "extra": [{"t": "stun", "dur": 1}]}}), + "barrow_kings_pendant": ("Barrow king's pendant", "amulet", "gem-pendant", "#8fb8f0", + "Mend also heals the next most hurt ally for half.", + {"mend": {"extra": [{"t": "heal", "c": 1.0, "second": True}]}}), + "warlords_whistle": ("Warlord's whistle", "trinket", "whistle", "#a08060", + "Shield Bash, Concuss and Pinning Shot come back 30% sooner.", + {"shield_bash": {"cd": 0.7}, "concuss": {"cd": 0.7}, "pinning_shot": {"cd": 0.7}}), + "forgeheart": ("Forgeheart", "amulet", "heart-tower", "#ff8a3a", "Barricade lasts 12s and comes back 25% sooner.", + {"barricade": {"dur": 4, "cd": 0.75}}), + "rimeglass_lens": ("Rimeglass lens", "trinket", "crystal-ball", "#8fd0e0", + "Find the Seam and Mark Prey last twice as long; Ward also shields a second ally.", + {"find_the_seam": {"dur": 8}, "mark_prey": {"dur": 15}, + "ward": {"extra": [{"t": "shield", "c": 1.0, "dur": 20, "second": True}]}}), + "wyrmscale_quiver": ("Wyrmscale quiver", "trinket", "quiver", "#e05a5a", + "Volley takes no arrows and hits 40% harder; Aimed Shot aims 25% faster.", + {"volley": {"cost": 0, "c": 1.4}, "aimed_shot": {"cast": 0.75}}), +} +# The pieces every dungeon's patterns reforge, by what they're made of at each tier. +MASTERWORK_PIECES = ["sword", "warhammer", "staff", "bow", "heavy", "medium", "light"] +MASTERWORK_NOUNS = {"sword": "sword", "warhammer": "warhammer", "staff": "staff", "bow": "bow", + "heavy": "plate", "medium": "jerkin", "light": "robe"} +MASTERWORK_TINT = "#f0c040" + + +def _tier_piece(kind: str, tier: int) -> str: + """The tier's weapon of a style, or its body piece of an armour weight.""" + level = ["combat", TIER_LEVELS[tier]] + for item, spec in ITEMS.items(): + if spec.get("req") != level: + continue + if kind in ("heavy", "medium", "light") and spec.get("weight") == kind and spec.get("slot") == "body": + return item + if spec.get("style") == {"warhammer": "hammer"}.get(kind, kind): + return item + raise KeyError((kind, tier)) + + +def _dungeon_loot() -> None: + """Relics, patterns, masterworks and uniques, as items; the masterwork recipes, as actions; and each + dungeon boss's share of them, as its extra drops.""" + made = {output: action for action in ACTIONS for output in action["outputs"]} + for key, (prefix, relic_name, unique) in DUNGEON_LOOT.items(): + spec = DUNGEONS[key] + tier = spec["tier"] + relic = _slug(relic_name) + ITEMS[relic] = {"name": relic_name, "icon": "🏺", "value": 40 + 40 * tier, "relic": key} + patterns = [] + for kind in MASTERWORK_PIECES: + base = _tier_piece(kind, tier) + item, recipe = f"{_slug(prefix)}_{kind}", made[base] + pattern = f"pattern_{item}" + name = f"{prefix} {MASTERWORK_NOUNS[kind]}" + stats = {stat: max(ITEMS[base][stat] + 1, round(ITEMS[base][stat] * MASTERWORK)) + for stat in ("power", "armour", "ward") if ITEMS[base].get(stat)} + ITEMS[item] = {**{k: v for k, v in ITEMS[base].items() if k not in ("glyph", "tint", "plural")}, + **stats, "name": name, "value": ITEMS[base]["value"] * 3 + ITEMS[relic]["value"] * MASTERWORK_RELICS, + "req": ["combat", spec["level"]], "masterwork": key, "base": base} + ITEMS[pattern] = {"name": f"Pattern: {name}", "icon": "📜", "value": 100 + 50 * tier, "pattern": item, + "skill": recipe["skill"], "level": recipe["level"] + 8} + reforge = _action(f"reforge_{item}", recipe["skill"], name, recipe["level"] + 8, round(recipe["time"] * 1.5), + recipe["xp"] * 3, {base: 1, relic: MASTERWORK_RELICS}, {item: 1}, group="Masterwork", + pattern=pattern) + ACTIONS.append(reforge) + ACTION_BY_ID[reforge["id"]] = reforge + patterns.append(pattern) + name, slot, _glyph, _tint, text, mods = UNIQUES[unique] + ITEMS[unique] = {"name": name, "icon": "💠", "value": 500 + 250 * tier, "slot": slot, "unique": text, "mods": mods, + "req": ["combat", spec["level"]]} + ACTION_BY_ID[key]["loot"] = {"relic": relic, "patterns": patterns, "unique": unique, + "odds": {"pattern": PATTERN_ODDS, "unique": UNIQUE_ODDS}} + bosses = [foe for _name, pack in spec["pulls"] for foe in pack if foe["boss"]] + for foe in bosses: + final = foe is bosses[-1] + odds = PATTERN_ODDS[final] / len(patterns) + foe["extra_drops"] = [(relic, 1 + final, RELIC_ODDS[final]), *[(p, 1, odds) for p in patterns], + *([(unique, 1, UNIQUE_ODDS)] if final else [])] + ACTION_BY_ID = {action["id"]: action for action in ACTIONS} for _id, _gem in GEM_ROCKS.items(): ACTION_BY_ID[_id]["finds"] = [(_gem, 1, GEM_ODDS)] @@ -1418,7 +1522,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 29 +WORLD_VERSION = 30 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1653,6 +1757,11 @@ def migrate(world: dict, now: float | None = None) -> dict: for key, value in (("monuments", {}), ("built", []), ("carving", None)): world["city"].setdefault(key, value) world["version"] = 29 + if world["version"] < 30: + # Masterwork patterns, learned by crafters. + for player in world["players"].values(): + player.setdefault("patterns", []) + world["version"] = 30 return world @@ -1909,7 +2018,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict: "hp": 44, "hp_at": now, "action": None, "stopped": None, "last_active": now, "overflow": "city", - "perks": {"bank_slot": 0}, "manual": [], + "perks": {"bank_slot": 0}, "manual": [], "patterns": [], "rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK), "garrison": False, "donated": 0, @@ -2076,7 +2185,8 @@ def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) lands = affinity(enemy, style["damage"]) if enemy else 1 taken = blessed["taken"] * (1 - brew.get("reduce", 0)) shield = bool(player["equip"].get("shield")) - return {"style": style_id, "damage": style["damage"], "role": combat.role(style_id, shield), + mods = combat.combine_mods([ITEMS[item]["mods"] for item in player["equip"].values() if item and ITEMS[item].get("mods")]) + return {"style": style_id, "damage": style["damage"], "role": combat.role(style_id, shield), "mods": mods, "abilities": combat.known_abilities(style_id, player["cls"], skill_level(player, "combat"), shield), "interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2), "accuracy": (10 + 2 * power) * style["accuracy"], @@ -2174,7 +2284,7 @@ def fight_member(world: dict, player: dict, at: float = 0.0) -> dict: "fighter": me, "eaten": {}, "potion": ITEMS[potion]["drink"] if potion else 0, "potion_item": potion, "arrows": player["bank"].get("arrows", 0), "mend": blessed["mend"], "wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"], - "manual": [a for a in player.get("manual", []) if a in me["abilities"]], "inputs": [], + "manual": [a for a in player.get("manual", []) if a in me["abilities"]], "inputs": [], "mods": me["mods"], "watch": at + combat.WATCH * speed(world)} @@ -2288,6 +2398,24 @@ def _input(world: dict, player: dict, now: float, kind: str, *args) -> str | Non return None +def learn_pattern(player: dict, item: str) -> str | None: + """Read a pattern: from then on you can reforge its masterwork. The pattern's used up.""" + spec = ITEMS.get(item, {}) + if not spec.get("pattern"): + return "That isn't a pattern" + if not player["bank"].get(item): + return "You don't have it" + known = player.setdefault("patterns", []) + if item in known: + return "You know it already" + if skill_level(player, spec["skill"]) < spec["level"]: + name = next(s["name"] for s in SKILLS if s["id"] == spec["skill"]) + return f"Needs {name.lower()} level {spec['level']}" + bank_take(player, item, 1) + known.append(item) + return None + + def press(world: dict, player: dict, ability: str, now: float, target: str | None = None) -> str | None: """Use one of your manual abilities: as soon as it's ready, if that's soon enough.""" if ability not in combat.ABILITIES: @@ -2650,6 +2778,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: """Why the action can't run right now, or None.""" if skill_level(player, action["skill"]) < action["level"]: return f"Needs level {action['level']}" + if action.get("pattern") and action["pattern"] not in player.get("patterns", []): + return "Learn its pattern first" for key, level in action.get("city", {}).items(): if building_level(world, key) < level: return f"Needs {BUILDINGS[key]['name']} level {level}" @@ -2993,6 +3123,9 @@ def pack_loot(action: dict, fight: dict, rng: random.Random) -> tuple[int, dict] if f"e{i}" not in kills: continue more, found = roll_loot(ACTION_BY_ID[foe["loot"]] if foe.get("loot") else action, rng, foe["share"]) + for item, qty, odds in foe.get("extra_drops", []): + if rng.random() < odds: + found[item] = found.get(item, 0) + qty gold += more for item, qty in found.items(): drops[item] = drops.get(item, 0) + qty @@ -4621,6 +4754,7 @@ for _weapon in _weapon_actions(): for _jewel in [*_jewel_actions(), *_storage_actions()]: ACTIONS.append(_jewel) ACTION_BY_ID[_jewel["id"]] = _jewel +_dungeon_loot() # masterworks are reforged from all of the above FITTING_BY_ID = {} for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS: FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect} @@ -5774,6 +5908,16 @@ def _assign_glyphs() -> None: prayer["glyph"], prayer["tint"] = PRAYER_GLYPHS[prayer["id"]] for key, ability in [*combat.ABILITIES.items(), *combat.ENEMY_ABILITIES.items()]: ability["glyph"], ability["tint"] = ABILITY_GLYPHS[key] + # Dungeon loot: masterworks look like what they're reforged from, in gold. + for item, spec in ITEMS.items(): + if spec.get("masterwork"): + spec.update(glyph=ITEMS[spec["base"]]["glyph"], tint=MASTERWORK_TINT) + elif spec.get("pattern"): + spec.update(glyph="scroll-quill", tint=MASTERWORK_TINT) + elif spec.get("relic"): + spec.update(glyph="relic-blade", tint="#c8b070") + for unique, (_name, _slot, glyph, tint, *_rest) in UNIQUES.items(): + ITEMS[unique].update(glyph=glyph, tint=tint) for action in ACTIONS: if action["id"] in ACTION_GLYPHS: action["glyph"], action["tint"] = ACTION_GLYPHS[action["id"]] @@ -5811,6 +5955,16 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() # What changed, shown on the Patch notes page. Newest first; every shipped change adds a # line here (new / balance / fix), and a new release a new entry on top. PATCH_NOTES = [ + {"date": "2026-10-24", "title": "Dungeon loot", "notes": [ + ("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, " + "everyone rolls their own). A crafter learns it once, then reforges the tier's piece plus 3 relics into a " + "masterwork: about 30% more power or armour, wearable at the dungeon's level. Sword, warhammer, staff, bow, " + "plate, jerkin and robe in every dungeon."), + ("new", "Relics: 50% from a mini-boss, 2 from every last boss."), + ("new", "One unique per dungeon from its last boss (8% each), worn as a ring, amulet or trinket, that changes an " + "ability: One-Eye's fang, Ogre's knuckle, Barrow king's pendant, Warlord's whistle, Forgeheart, Rimeglass " + "lens, Wyrmscale quiver. Abilities it changes get a ✦."), + ]}, {"date": "2026-10-24", "title": "A dungeon in every area", "notes": [ ("new", "Six more dungeons for 2–4, seven pulls each with two mini-bosses and a final boss: Goblin Warren " "(Greenwood, combat 8, best with 3–4), Barrow Crypts (Sunken Barrows, 35), Bandit Stronghold (Frontier, 40), " diff --git a/static/app.js b/static/app.js index 635b977..fa820e1 100644 --- a/static/app.js +++ b/static/app.js @@ -888,7 +888,7 @@ function variantRows(e, o) { const fit = o.variants[i].affinity; return `
${v.attack.map((k) => dmgIcon(k, "attack")).join("")} - ${esc(v.name)}${v.archetype === "elite" ? ` · rare` : ""} + ${esc(v.name)}${v.archetype === "elite" && !e.boss ? ` · rare` : ""} ${fmt(v.hp)} HP ${resistIcons(v)}
`; @@ -924,7 +924,7 @@ function combatFrame() { const traits = (v) => `${v.attack.map((k) => dmgIcon(k, "attack")).join("")} ${v.vulnerable.map((k) => `${dmgIcon(k, "vuln")}`).join("")}${v.resists.map((k) => `${dmgIcon(k, "resist")}`).join("")}`; foe = `
${encounter?.icon || ""}${esc(f.enemy)}
-
${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" ? ` · rare` : ""}`, true, +
${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" && !v.boss ? ` · rare` : ""}`, true, ` ${traits(v)}`)).join("")}
    `; } @@ -943,9 +943,11 @@ function abilityBar(me) { const tile = (id, on) => { const ab = C.abilities[id], manual = on && me.manual.includes(id); const why = on ? "" : ab.needs === "shield" && !shield && me.levels.combat >= ab.level ? "needs a shield" : `combat ${ab.level}`; - const cd = ab.cd ? ` · ${manual ? ab.cd : ab.cd * C.auto_cd}s cooldown` : ""; - const tip = `${ab.name}: ${ab.tip}${cd}${why ? ` · ${why}` : ""}`; - return ` + const base = (ab.cd || 0) * (me.fighter.mods?.[id]?.cd ?? 1); + const cd = base ? ` · ${+(manual ? base : base * C.auto_cd).toFixed(1)}s cooldown` : ""; + const boosts = (me.fighter.boosts?.[id] || []).map((n) => ` · ✦ ${n}`).join(""); + const tip = `${ab.name}: ${ab.tip}${cd}${boosts}${why ? ` · ${why}` : ""}`; + return ` ${on ? ``}
    `; } // What an item does once made: healing, defence, tool speed, and who can use it. @@ -1227,6 +1239,12 @@ function actionCard(a) { extra = `${e.boss || own ? `
    ${e.boss ? `Boss ` : ""}${own}
    ` : ""}${variantRows(e, o)}${fightingHere(a, why)}`; if (a.gold) io += `${COIN} ${a.gold[0]}–${a.gold[1]}`; if (a.drops) io += a.drops.map(chance).join(""); + if (a.loot) { + const l = a.loot, pct = (x) => `${Math.round(x * 100)}%`; + io += `${item(l.relic).icon}` + + `${item(l.patterns[0]).icon} ${l.patterns.length}` + + `${item(l.unique).icon} ${pct(l.odds.unique)}`; + } if (a.special === "sabotage") extra += `
    -1% raid strength per win (max 30%)
    `; } if (a.special === "drill") { @@ -1359,6 +1377,8 @@ function itemPanel(id) { ${it.heal ? `
    Heals ${it.heal} resting between fights.
    ${S.me.food === id ? `Your fighting food` : ``}
    ` : ""} ${it.drink ? `
    +${it.drink} HP in a fight, drunk once a fight under ${Math.round(C.potion_below * 100)}% HP.
    ` : ""} + ${it.pattern ? patternRow(id, it) : ""} + ${it.relic ? `
    Reforges masterworks with a pattern from ${esc(C.actions.find((a) => a.id === it.relic).name)}.
    ` : ""} ${it.food ? `
    Feeds ${it.food} townsfolk for 2 days.
    ` : ""} ${C.city_uses[id].length ? `
    Donate to the city · +${it.value} favour each
    ${qtyBtns("data-donate")}
    @@ -2280,6 +2300,7 @@ document.addEventListener("click", (e) => { } if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) }); if ("pull" in d) return act("/api/pull", {}); + if (d.learn) return act("/api/learn", { item: d.learn }); if (d.aim) return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim }); if (d.start) return act("/api/action", { id: d.start }); if (d.once) return act("/api/action", { id: d.once, once: true }); diff --git a/static/icons.svg b/static/icons.svg index 820950f..83d6141 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -83,6 +83,7 @@ + @@ -93,6 +94,7 @@ + @@ -122,6 +124,7 @@ + @@ -182,6 +185,7 @@ + @@ -199,6 +203,7 @@ + @@ -265,6 +270,7 @@ + diff --git a/static/styles.css b/static/styles.css index 29211c0..09cbe53 100644 --- a/static/styles.css +++ b/static/styles.css @@ -585,6 +585,8 @@ button.sheet-skill:hover { background: var(--panel-2); } .fighter.aimable { cursor: pointer; border-radius: 8px; } .fighter.aimed { outline: 2px solid var(--gold); outline-offset: 4px; } .ab-lv .gi { width: 10px; height: 10px; } +.ab.boosted .ab-press::after { content: "✦"; position: absolute; top: 1px; right: 3px; font-size: 10px; color: var(--gold); } +.unique-text { color: var(--gold); margin-top: 4px; } .ab.locked { opacity: .35; filter: grayscale(1); } .ab.fired .ab-press { box-shadow: 0 0 0 2px var(--gold); } .ab.spent { opacity: .35; } diff --git a/tests/test_game.py b/tests/test_game.py index 6b88e48..5c5375b 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -944,6 +944,104 @@ class DungeonTest(unittest.TestCase): self.assertGreaterEqual(joined[0][0], yell[0]) +class LootTest(unittest.TestCase): + def smith(self, level=30): + world = world_with(("sam", "miner")) + p = world["players"]["sam"] + p["xp"]["smithing"] = game.XP_TABLE[level] + return world, p + + def test_every_dungeon_has_relic_patterns_and_a_unique(self): + for key in game.DUNGEONS: + loot = game.ACTION_BY_ID[key]["loot"] + self.assertEqual(len(loot["patterns"]), 7) + self.assertTrue(game.ITEMS[loot["unique"]]["mods"]) + for pattern in loot["patterns"]: + made = game.ITEMS[game.ITEMS[pattern]["pattern"]] + self.assertEqual(made["req"], ["combat", game.DUNGEONS[key]["level"]]) + base = game.ITEMS[made["base"]] + for stat in ("power", "armour"): + if base.get(stat): + self.assertGreater(made[stat], base[stat]) + + def test_bosses_drop_relics_and_patterns(self): + ogre = game.DUNGEONS["dungeon_watchtower"]["pulls"][7][1] + fight = {"pack": [dict(ogre[0])], "kills": ["e0"]} + found = {} + for seed in range(200): + for item, qty in game.pack_loot(game.ACTION_BY_ID["dungeon_watchtower"], fight, random.Random(seed))[1].items(): + found[item] = found.get(item, 0) + qty + self.assertEqual(found["watchtower_relic"], 400) # two, every time + patterns = sum(qty for item, qty in found.items() if item.startswith("pattern_")) + self.assertTrue(40 < patterns < 100) # about 35% + self.assertGreater(found.get("ogres_knuckle", 0), 0) + + def test_learn_a_pattern_and_reforge(self): + world, p = self.smith() + reforge = game.ACTION_BY_ID["reforge_ogre_forged_warhammer"] + p["bank"] = {"iron_warhammer": 1, "watchtower_relic": 3} + self.assertEqual(game.can_start(world, p, reforge), "Learn its pattern first") + self.assertEqual(game.learn_pattern(p, "pattern_ogre_forged_warhammer"), "You don't have it") + p["bank"]["pattern_ogre_forged_warhammer"] = 1 + p["xp"]["smithing"] = game.XP_TABLE[20] + self.assertEqual(game.learn_pattern(p, "pattern_ogre_forged_warhammer"), "Needs smithing level 29") + p["xp"]["smithing"] = game.XP_TABLE[30] + self.assertIsNone(game.learn_pattern(p, "pattern_ogre_forged_warhammer")) + self.assertNotIn("pattern_ogre_forged_warhammer", p["bank"]) + self.assertEqual(game.learn_pattern(p, "pattern_ogre_forged_warhammer"), "You don't have it") + world["city"]["buildings"]["smithy"]["level"] = 5 + self.assertIsNone(game.start_action(world, p, "reforge_ogre_forged_warhammer", T0, once=True)) + game.process_player(world, p, T0 + 3600, random.Random(1)) + self.assertEqual(p["bank"].get("ogre_forged_warhammer"), 1) + self.assertNotIn("watchtower_relic", p["bank"]) + + def ranger(self, *uniques): + world = world_with(("ash", "ranger")) + p = world["players"]["ash"] + p["xp"]["combat"] = game.XP_TABLE[85] + p["equip"].update(weapon="magic_bow", helmet="mammoth_leather_cap", body="mammoth_leather_body", + legs="mammoth_leather_legs") + for unique in uniques: + p["equip"][game.ITEMS[unique]["slot"]] = unique + p["hp"] = game.max_hp(p) + p["bank"] = {"arrows": 1000} + return world, p + + def test_a_unique_changes_an_ability(self): + world, p = self.ranger() + self.assertEqual(game.fighter(p, game.NO_BLESSING, {})["mods"], {}) + plain = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4)) + world, p = self.ranger("wyrmscale_quiver") + mods = game.fighter(p, game.NO_BLESSING, {})["mods"] + self.assertEqual(mods["volley"], {"cost": 0, "c": 1.4}) + quivered = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4)) + self.assertGreater(game.member_of(plain, "ash")["shot"], 0) + self.assertEqual(game.member_of(quivered, "ash")["shot"], 0) # no arrows + + def test_a_pendant_mends_a_second_ally(self): + world = world_with(("kay", "homesteader"), ("wil", "builder"), ("bo", "miner")) + players = list(world["players"].values()) + for p in players: + p["xp"]["combat"] = game.XP_TABLE[40] + p["hp"] = game.max_hp(p) + kay = world["players"]["kay"] + kay["equip"].update(weapon="willow_staff", amulet="barrow_kings_pendant") + ogre = game.ACTION_BY_ID["fight_ruins"] + heals = 0 + for seed in range(5): + fight = game.draw_fight(world, players, ogre, random.Random(seed)) + mends = [e for e in fight["events"] if e[1] == "kay" and e[3] == "heal" and e[6] == "mend"] + heals += sum(1 for a, b in zip(mends, mends[1:]) if a[0] == b[0] and a[2] != b[2]) + self.assertGreater(heals, 0) # one cast, two people healed + + def test_old_worlds_get_patterns(self): + world, p = self.smith() + del p["patterns"] + world["version"] = 29 + game.migrate(world, T0) + self.assertEqual(p["patterns"], []) + + class NewSkillsTest(unittest.TestCase): def test_tools_speed_up_gathering(self): world = world_with(("pia", "commoner"))