diff --git a/README.md b/README.md
index 0ef47b1..6ef6752 100644
--- a/README.md
+++ b/README.md
@@ -44,11 +44,14 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
fight while their points last, and praying trains Prayer too. A Temple adds
points per bone and lets players offer bones as devotion, which wards the
city in the next raid.
-- **Herblore:** farmers grow six herbs (guam to snapdragon); herblore brews
- them with a secondary into potions of 50 uses. One potion per skill at a
- time: faster gathering, a chance to keep materials or double ore, more
- prayer from bones, or combat power, armour and healing. An empty potion is
- replaced from the bank automatically.
+- **Herblore:** farmers grow six herbs (guam to snapdragon), one per tier.
+ Every potion is a ladder of up to six tiers (I–VI): the tier's herb plus
+ the matching rung of the skill's own resources (copper ore → runite ore,
+ logs → enchanted logs, copper bar → rune bar…), at Herblore 1, 15, 30, 45,
+ 60 and 75. Gathering and Construction potions are 10–35% faster, crafting
+ potions a 10–35% chance to keep materials, Prayer more points from bones,
+ and combat has Strength and Defence ladders. Potions last 50 uses, one per
+ skill at a time; an empty one is replaced from the bank automatically.
- **Events:** raids are announced a day or two ahead. Reconnaissance from
scouts, informants and the spymaster's guild narrows down when, how strong,
and finally where their camp is, so fighters can sabotage it. Fighters can
diff --git a/game.py b/game.py
index 7e5aefb..bdeb2f7 100644
--- a/game.py
+++ b/game.py
@@ -186,7 +186,7 @@ for _raw, _cooked, _level, _action_id, _verb in FISH:
# id, name, icon, farming level, seconds, xp, value
HERBS = [
- ("guam_leaf", "Guam leaf", "🍀", 5, 45, 12, 3),
+ ("guam_leaf", "Guam leaf", "🍀", 1, 45, 12, 3),
("marrentill", "Marrentill", "🌼", 15, 50, 20, 5),
("tarromin", "Tarromin", "🌺", 25, 55, 30, 8),
("harralander", "Harralander", "🌻", 35, 60, 42, 12),
@@ -194,24 +194,50 @@ HERBS = [
("snapdragon", "Snapdragon", "🌸", 60, 70, 75, 28),
]
POTION_CHARGES = 50
-# id, name, herblore level, herb, secondary, skill, effect
-POTIONS = [
- ("trackers_tincture", "Tracker's tincture", 1, "guam_leaf", "feathers", "hunting", {"speed": 0.2}),
- ("lumberjacks_tonic", "Lumberjack's tonic", 6, "guam_leaf", "logs", "woodcutting", {"speed": 0.2}),
- ("strength_potion", "Strength potion", 10, "guam_leaf", "bones", "combat", {"power": 6}),
- ("anglers_brew", "Angler's brew", 15, "marrentill", "raw_shrimp", "fishing", {"speed": 0.2}),
- ("prospectors_draught", "Prospector's draught", 18, "marrentill", "copper_ore", "mining", {"double": 0.15}),
- ("growth_potion", "Growth potion", 20, "marrentill", "wheat", "farming", {"speed": 0.2}),
- ("defence_potion", "Defence potion", 25, "tarromin", "iron_ore", "combat", {"reduce": 0.15}),
- ("chefs_spice", "Chef's spice", 28, "tarromin", "vegetables", "cooking", {"keep": 0.15}),
- ("bonedust_tonic", "Bonedust tonic", 30, "tarromin", "big_bones", "prayer", {"bones": 0.5}),
- ("smiths_focus", "Smith's focus", 35, "harralander", "coal", "smithing", {"keep": 0.15}),
- ("masons_patience", "Mason's patience", 38, "harralander", "cut_stone", "masonry", {"keep": 0.15}),
- ("tanners_oil", "Tanner's oil", 40, "harralander", "raw_meat", "leatherworking", {"keep": 0.15}),
- ("regeneration_potion", "Regeneration potion", 45, "ranarr", "cooked_trout", "combat", {"heal": 4}),
- ("super_strength", "Super strength", 50, "ranarr", "dragon_bones", "combat", {"power": 14}),
- ("super_defence", "Super defence", 60, "snapdragon", "blue_dragonhide", "combat", {"reduce": 0.30}),
+# Potions come in tiers like everything else: tier n is brewed from the nth herb
+# and the nth rung of the skill's own ladder, needs the tier's Herblore level
+# (none for tier I), and is stronger the higher it goes. Ladders stop where the
+# skill runs out of higher things to do.
+ROMAN = ["I", "II", "III", "IV", "V", "VI"]
+SPEEDS = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35]
+_ores = ["copper_ore", "iron_ore", "coal", "mithril_ore", "adamantite_ore", "runite_ore"]
+# id, name, skill, effect, strength by tier, secondary by tier
+POTION_LINES = [
+ ("mining", "Mining potion", "mining", "speed", SPEEDS, _ores),
+ ("woodcutting", "Woodcutting potion", "woodcutting", "speed", SPEEDS, [tree[0] for tree in TREES]),
+ ("fishing", "Fishing potion", "fishing", "speed", SPEEDS, [fish[0][0] for fish in FISH]),
+ ("farming", "Farming potion", "farming", "speed", SPEEDS,
+ ["wheat", "vegetables", "guam_leaf", "marrentill", "tarromin", "harralander"]),
+ ("hunting", "Hunting potion", "hunting", "speed", SPEEDS[:4], ["feathers", "raw_meat", "boar_hide", "bear_pelt"]),
+ ("construction", "Construction potion", "construction", "speed", SPEEDS, [plank for plank, _name in PLANKS]),
+ ("smithing", "Smithing potion", "smithing", "keep", SPEEDS, [f"{metal[0]}_bar" for metal in METALS]),
+ ("cooking", "Cooking potion", "cooking", "keep", SPEEDS, [fish[1][0] for fish in FISH]),
+ ("leatherworking", "Leatherworking potion", "leatherworking", "keep", SPEEDS,
+ [leather[1][0] for leather in LEATHERS]),
+ ("masonry", "Masonry potion", "masonry", "keep", SPEEDS[:3], ["stone", "bricks", "cut_stone"]),
+ ("prayer", "Prayer potion", "prayer", "bones", [0.25, 0.5, 0.75], ["bones", "big_bones", "dragon_bones"]),
+ ("strength", "Strength potion", "combat", "power", [4, 7, 11, 15, 20, 26], [leather[0][0] for leather in LEATHERS]),
+ ("defence", "Defence potion", "combat", "reduce", [0.10, 0.14, 0.18, 0.22, 0.26, 0.30],
+ [f"{metal[0]}_shield" for metal in METALS]),
]
+# id, name, herblore level, herb, secondary, skill, effect
+POTIONS = []
+POTION_TIER = {}
+for _line, _name, _skill, _key, _strengths, _seconds in POTION_LINES:
+ for _tier, (_strength, _second) in enumerate(zip(_strengths, _seconds)):
+ _id = f"{_line}_potion_{_tier + 1}"
+ POTIONS.append((_id, f"{_name} {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0], _second, _skill,
+ {_key: _strength}))
+ POTION_TIER[_id] = _tier
+# Potions from before the tiers, and what they became.
+OLD_POTIONS = {
+ "trackers_tincture": "hunting_potion_3", "lumberjacks_tonic": "woodcutting_potion_3",
+ "strength_potion": "strength_potion_2", "anglers_brew": "fishing_potion_3",
+ "prospectors_draught": "mining_potion_2", "growth_potion": "farming_potion_3",
+ "defence_potion": "defence_potion_2", "chefs_spice": "cooking_potion_2", "bonedust_tonic": "prayer_potion_2",
+ "smiths_focus": "smithing_potion_2", "masons_patience": "masonry_potion_2", "tanners_oil": "leatherworking_potion_2",
+ "regeneration_potion": "defence_potion_3", "super_strength": "strength_potion_4", "super_defence": "defence_potion_5",
+}
def describe_potion(skill: str, effect: dict) -> str:
@@ -226,9 +252,7 @@ def describe_potion(skill: str, effect: dict) -> str:
return f"+{round(effect['bones'] * 100)}% prayer points and devotion from bones"
if "power" in effect:
return f"+{effect['power']} combat power"
- if "reduce" in effect:
- return f"-{round(effect['reduce'] * 100)}% damage taken in fights"
- return f"Heals {effect['heal']} HP every fight"
+ return f"-{round(effect['reduce'] * 100)}% damage taken in fights"
for _id, _name, _icon, _level, _time, _xp, _value in HERBS:
@@ -348,7 +372,7 @@ ACTIONS = [
*[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1})
for herb, name, _icon, level, seconds, xp, _value in HERBS],
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
- {herb: 1, second: 1}, {potion: 1}, group="Combat potions" if skill == "combat" else "Skill potions")
+ {herb: 1, second: 1}, {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}")
for potion, name, level, herb, second, skill, _effect in POTIONS],
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
_action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
@@ -499,7 +523,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
-WORLD_VERSION = 10
+WORLD_VERSION = 11
def new_world(now: float, speed: float = 1.0) -> dict:
@@ -606,9 +630,32 @@ def migrate(world: dict, now: float | None = None) -> dict:
for building in world["city"]["buildings"].values():
building.setdefault("fittings", [])
world["version"] = 10
+ if world["version"] < 11:
+ _migrate_potions(world)
+ world["version"] = 11
return world
+def _migrate_potions(world: dict) -> None:
+ """Potions from before the tiers become their tiered equivalents, uses and all."""
+ def renamed(stock: dict) -> None:
+ for old, new in OLD_POTIONS.items():
+ if old in stock:
+ stock[new] = stock.get(new, 0) + stock.pop(old)
+ renamed(world["city"]["stockpile"])
+ for player in world["players"].values():
+ renamed(player["bank"])
+ for active in player["potions"].values(): # regeneration was combat too, so slots still match
+ if active["item"] in OLD_POTIONS:
+ active["item"] = OLD_POTIONS[active["item"]]
+ action = player["action"]
+ if action:
+ for key in ("id", "resume"):
+ old = (action.get(key) or "").removeprefix("brew_")
+ if old in OLD_POTIONS:
+ action[key] = f"brew_{OLD_POTIONS[old]}"
+
+
def _sell_useless_stock(world: dict, now: float) -> None:
"""The council sells what the city can't use, into the treasury."""
city = world["city"]
@@ -2742,11 +2789,11 @@ def _assign_glyphs() -> None:
ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint)
for herb, tint in HERB_TINTS.items():
ITEMS[herb].update(glyph="herbs-bundle", tint=tint)
- for potion, *_rest, skill, _effect in POTIONS:
- if skill == "combat":
- ITEMS[potion].update(glyph="potion-ball", tint="#e05a5a")
- else:
- ITEMS[potion].update(glyph="round-bottom-flask", tint=SKILL_GLYPHS[skill][1])
+ flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion",
+ "bubbling-flask", "bubbling-flask"]
+ for potion, *_rest, skill, effect in POTIONS:
+ tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1]
+ ITEMS[potion].update(glyph=flasks[POTION_TIER[potion]], tint=tint)
for skill in SKILLS:
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
for key, spec in CLASSES.items():
diff --git a/static/icons.svg b/static/icons.svg
index 0e9b35b..042873b 100644
--- a/static/icons.svg
+++ b/static/icons.svg
@@ -23,6 +23,7 @@
+
@@ -72,7 +73,6 @@
-
@@ -87,6 +87,7 @@
+
diff --git a/tests/test_game.py b/tests/test_game.py
index 64a09c1..5244e5f 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -820,44 +820,52 @@ class HerbloreTest(unittest.TestCase):
def test_herbs_to_potion(self):
world, p = self.world, self.p
- p["xp"]["farming"] = game.XP_TABLE[5]
game.start_action(world, p, "grow_guam_leaf", T0)
game.process_player(world, p, T0 + 450, random.Random(1))
self.assertEqual(p["bank"]["guam_leaf"], 10)
- p["xp"]["herblore"] = game.XP_TABLE[6]
p["bank"]["logs"] = 3
- game.start_action(world, p, "brew_lumberjacks_tonic", T0 + 450)
+ self.assertIsNone(game.start_action(world, p, "brew_woodcutting_potion_1", T0 + 450)) # tier I needs no Herblore
game.process_player(world, p, T0 + 1000, random.Random(1))
- self.assertEqual(p["bank"]["lumberjacks_tonic"], 3)
+ self.assertEqual(p["bank"]["woodcutting_potion_1"], 3)
+ self.assertEqual(game.start_action(world, p, "brew_woodcutting_potion_2", T0 + 1000), "Needs level 15")
+
+ def test_potion_tiers_climb_the_ladders(self):
+ mining = [potion for potion in game.POTIONS if potion[0].startswith("mining_potion")]
+ self.assertEqual([(herb, second) for _id, _name, _level, herb, second, *_rest in mining[:2]],
+ [("guam_leaf", "copper_ore"), ("marrentill", "iron_ore")])
+ self.assertEqual([p[2] for p in mining], game.TIER_LEVELS)
+ self.assertEqual([p[6]["speed"] for p in mining], game.SPEEDS)
+ self.assertEqual(game.ITEMS["mining_potion_6"]["name"], "Mining potion VI")
+ self.assertFalse(any("heal" in p[6] or "double" in p[6] for p in game.POTIONS))
def test_skill_potion_speeds_up_and_uses_charges(self):
world, p = self.world, self.p
plain = game.action_time(world, p, game.ACTION_BY_ID["cut_logs"])
- p["bank"] = {"lumberjacks_tonic": 1}
- self.assertIsNone(game.use_potion(p, "lumberjacks_tonic"))
- self.assertEqual(p["potions"]["woodcutting"], {"item": "lumberjacks_tonic", "charges": game.POTION_CHARGES})
+ p["bank"] = {"woodcutting_potion_3": 1}
+ self.assertIsNone(game.use_potion(p, "woodcutting_potion_3"))
+ self.assertEqual(p["potions"]["woodcutting"], {"item": "woodcutting_potion_3", "charges": game.POTION_CHARGES})
self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["cut_logs"]), plain * 0.8)
self.assertEqual(game.action_time(world, p, game.ACTION_BY_ID["mine_stone"]), plain)
game.start_action(world, p, "cut_logs", T0)
summary = game.process_player(world, p, T0 + 16 * 60, random.Random(1))
- self.assertEqual(summary["potion_out"], ["lumberjacks_tonic"])
+ self.assertEqual(summary["potion_out"], ["woodcutting_potion_3"])
self.assertNotIn("woodcutting", p["potions"])
def test_empty_potion_is_replaced_from_the_bank(self):
world, p = self.world, self.p
- p["bank"] = {"trackers_tincture": 2}
- game.use_potion(p, "trackers_tincture")
+ p["bank"] = {"hunting_potion_1": 2}
+ game.use_potion(p, "hunting_potion_1")
p["potions"]["hunting"]["charges"] = 1
game.start_action(world, p, "hunt_birds", T0)
summary = game.process_player(world, p, T0 + 25, random.Random(1))
- self.assertEqual(summary["potions_drunk"], {"trackers_tincture": 1})
+ self.assertEqual(summary["potions_drunk"], {"hunting_potion_1": 1})
self.assertEqual(p["potions"]["hunting"]["charges"], game.POTION_CHARGES)
- self.assertNotIn("trackers_tincture", p["bank"])
+ self.assertNotIn("hunting_potion_1", p["bank"])
def test_keep_chance_saves_materials(self):
world, p = self.world, self.p
- p["bank"] = {"copper_ore": 100, "logs": 100, "smiths_focus": 1}
- game.use_potion(p, "smiths_focus")
+ p["bank"] = {"copper_ore": 100, "logs": 100, "smithing_potion_2": 1}
+ game.use_potion(p, "smithing_potion_2")
p["potions"]["smithing"]["charges"] = 1000
game.start_action(world, p, "smith_copper_bar", T0)
summary = game.process_player(world, p, T0 + 20 * 100, random.Random(2))
@@ -868,15 +876,30 @@ class HerbloreTest(unittest.TestCase):
world, p = self.world, self.p
p["xp"]["combat"] = game.XP_TABLE[20]
p["hp"] = game.max_hp(p)
- p["bank"] = {"defence_potion": 1, "bread": 500}
- game.use_potion(p, "defence_potion")
+ p["bank"] = {"defence_potion_6": 1, "bread": 500}
+ game.use_potion(p, "defence_potion_6")
game.start_action(world, p, "fight_scouts", T0)
scouts = game.ACTION_BY_ID["fight_scouts"]
brewed = game.with_potion(game.NO_BLESSING, game.potion_effect(p, "combat"))
- self.assertAlmostEqual(game.fight_damage(p, scouts, brewed), game.fight_damage(p, scouts) * 0.85)
+ self.assertAlmostEqual(game.fight_damage(p, scouts, brewed), game.fight_damage(p, scouts) * 0.70)
summary = game.process_player(world, p, T0 + 75 * 10, random.Random(1))
self.assertEqual(p["potions"]["combat"]["charges"], game.POTION_CHARGES - summary["done"])
+ def test_old_potions_become_their_tiers(self):
+ world, p = self.world, self.p
+ world["version"] = 10
+ p["bank"] = {"lumberjacks_tonic": 4, "woodcutting_potion_3": 1, "regeneration_potion": 2}
+ p["potions"] = {"mining": {"item": "prospectors_draught", "charges": 12}}
+ p["action"] = {"id": "brew_anglers_brew", "start": T0, "resume": "brew_smiths_focus"}
+ world["city"]["stockpile"]["super_strength"] = 1
+ game.migrate(world, T0)
+ self.assertEqual(p["bank"], {"woodcutting_potion_3": 5, "defence_potion_3": 2})
+ self.assertEqual(p["potions"]["mining"], {"item": "mining_potion_2", "charges": 12})
+ self.assertEqual((p["action"]["id"], p["action"]["resume"]), ("brew_fishing_potion_3", "brew_smithing_potion_2"))
+ self.assertEqual(world["city"]["stockpile"]["strength_potion_4"], 1)
+ for action in (p["action"]["id"], p["action"]["resume"]):
+ self.assertIn(action, game.ACTION_BY_ID)
+
def test_v5_worlds_get_herblore(self):
world = world_with(("fred", "pioneer"))
world["version"] = 5