Bosses: own loot each, open at area level +5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-22 14:08:41 +01:00
parent 988968f3b3
commit 2579be2475
3 changed files with 41 additions and 14 deletions

34
game.py
View file

@ -689,9 +689,9 @@ def monster(name: str, archetype: str, level: int, attack, vulnerable=(), resist
def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: int = 0, drops=(),
boss: bool = False, **extra) -> dict:
boss: bool = False, req: int | None = None, **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."""
An elite is a couple of levels above the rest. `req` is the combat level to start, if not the enemy's own."""
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)
@ -699,13 +699,14 @@ def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: i
enemy = {"name": name, "level": level, "boss": boss, "variants": variants}
# Gold per kill is a range around the encounter's worth.
purse = [max(1, round(gold * 0.7)), max(2, round(gold * 1.3))] if gold else None
return _action(id, "combat", extra.pop("label", None) or name, level, RESPAWN, xp, gold=purse,
return _action(id, "combat", extra.pop("label", None) or name, req or level, RESPAWN, xp, gold=purse,
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:
"""One big enemy: everyone who brings it down gets their own loot."""
return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)],
boss=extra.pop("is_boss", True), **extra)
boss=extra.pop("is_boss", True), personal_loot=True, **extra)
# Rosters: (variant, archetype, attacks with, vulnerable to, resists).
@ -940,24 +941,25 @@ COMBAT_ACTIONS = [
_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(),
# Area bosses open at the area's level + 5, halfway through it: hard alone, comfortable together.
_boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"),
("blunt", "pierce"), gold=25,
("blunt", "pierce"), gold=25, req=20,
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", 41, "boss", ("slash", "magic"), ("blunt", "magic"),
("pierce", "slash"), gold=40,
("pierce", "slash"), gold=40, req=35,
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", 56, "dragon", ("slash", "magic"), ("pierce", "magic"),
("slash", "blunt"), is_boss=False, gold=80,
("slash", "blunt"), is_boss=False, gold=80, req=50,
drops=[("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(3, 0.6)]),
_boss("fight_blue_dragon", "Blue dragon", "Frostpeak", 71, "dragon", ("slash", "magic"), ("blunt", "pierce"),
("magic", "slash"), is_boss=False, gold=110,
("magic", "slash"), is_boss=False, gold=110, req=65,
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", 85, "dragon", ("slash", "magic"), ("slash", "magic"),
("pierce", "blunt"), is_boss=False, gold=150,
("pierce", "blunt"), is_boss=False, gold=150, req=80,
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", 95, "dragon", ("slash", "magic"), ("blunt", "magic"),
("slash", "pierce"), gold=220,
("slash", "pierce"), gold=220, req=90,
drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]),
]
@ -2598,7 +2600,8 @@ def share_loot(loot: tuple[int, dict], uids: list, rng: random.Random) -> dict:
# --- fighting together ------------------------------------------------------
# Anyone can tag into someone else's fight: from then on they're a party on that
# encounter, every enemy fought by all of them at once. Everyone gets the kill's full
# XP; its gold is split evenly and each drop goes to someone at random. The party
# XP; its gold is split evenly and each drop goes to someone at random, except on
# bosses and dragons, where everyone rolls their own loot as if alone. The party
# lasts until all but one have left (stopped, retreated, or been away too long), and
# the last one carries on alone.
@ -2737,7 +2740,10 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
player["action"]["start"] = party["start"]
results = {m["id"]: m["result"] for m in fight["members"]}
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
shares = share_loot(roll_loot(action, rng), winners, rng) if winners else {}
if action.get("personal_loot"):
shares = {uid: roll_loot(action, rng) for uid in winners}
else:
shares = share_loot(roll_loot(action, rng), winners, rng) if winners else {}
for player in members:
if results.get(player["id"], "left") == "left":
continue
@ -4741,6 +4747,10 @@ 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-22", "title": "Bosses", "notes": [
("new", "Bosses and dragons: everyone in the fight gets their own loot."),
("balance", "Level requirements for area boss reduced to be area level +5."),
]},
{"date": "2026-10-21", "title": "Beta", "notes": [
("new", "Beta: completely meaningless milestone reached; every class now makes a decent amount of valuable contributions to the guild."),
("new", "Patch notes menu."),

View file

@ -1045,7 +1045,8 @@ function actionCard(a) {
let extra = "";
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy;
extra = `${e.boss ? `<div><span class="tag warn">Boss</span></div>` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
const own = a.personal_loot ? `<span class="tag good" title="Everyone in the fight gets their own loot">👥 Own loot</span>` : "";
extra = `${e.boss || own ? `<div>${e.boss ? `<span class="tag warn">Boss</span> ` : ""}${own}</div>` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
if (a.gold) io += `<span class="item-chip" title="Gold: ${a.gold[0]}–${a.gold[1]} a kill">${COIN} ${a.gold[0]}–${a.gold[1]}</span>`;
if (a.drops) io += a.drops.map(chance).join("");
if (a.special === "sabotage") extra += `<div class="small muted">-3% raid strength per win (max 30%)</div>`;

View file

@ -2154,11 +2154,27 @@ class PartyTest(unittest.TestCase):
self.assertEqual(game.join_fight(world, kay, "kay", T0), "No one to join")
wil["xp"]["combat"] = game.XP_TABLE[60]
game.start_action(world, wil, "fight_ruins", T0)
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Needs level 26")
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Needs level 20")
game.start_action(world, wil, "fight_scouts", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.assertEqual(game.join_fight(world, wil, "kay", T0), "Already fighting together")
def test_bosses_give_everyone_their_own_loot(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):
p["equip"].update(weapon="steel_sword", helmet="steel_helmet", body="steel_body", legs="steel_legs")
p["bank"] = {"cooked_salmon": 5000}
game.start_action(world, wil, "fight_ruins", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 3600, random.Random(6))
wins = wil["away"]["wins"]
self.assertGreater(wins, 10)
low, high = game.ACTION_BY_ID["fight_ruins"]["gold"]
for p in (wil, kay):
self.assertTrue(wins * low <= p["away"]["gold"] <= wins * high) # a whole purse each
bones = p["away"]["items"].get("big_bones", 0) + p["away"]["overflow"].get("big_bones", 0)
self.assertEqual(bones, wins) # the sure drop, for each of them
def test_loot_is_shared_out(self):
shares = game.share_loot((7, {"bones": 1, "arrows": 5}), ["a", "b"], random.Random(1))
self.assertEqual(sorted(gold for gold, _ in shares.values()), [3, 4])