Bosses: own loot each, open at area level +5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
988968f3b3
commit
2579be2475
3 changed files with 41 additions and 14 deletions
32
game.py
32
game.py
|
|
@ -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,6 +2740,9 @@ 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"]
|
||||
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":
|
||||
|
|
@ -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."),
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue