diff --git a/game.py b/game.py index 2f18796..f8b8fbe 100644 --- a/game.py +++ b/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,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."), diff --git a/static/app.js b/static/app.js index 3f4a7b0..d7c885b 100644 --- a/static/app.js +++ b/static/app.js @@ -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 ? `
Boss
` : ""}${variantRows(e, o)}${fightingHere(a, why)}`; + const own = a.personal_loot ? `👥 Own loot` : ""; + 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.special === "sabotage") extra += `
-3% raid strength per win (max 30%)
`; diff --git a/tests/test_game.py b/tests/test_game.py index d637e2f..9a49126 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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])