Dungeon loot: patterns, masterworks, relics, uniques

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 13:48:16 +01:00
parent 60b446acfd
commit 8afcba79fc
8 changed files with 358 additions and 18 deletions

162
game.py
View file

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