Herblore: herbs, potions with charges, one per skill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 23:12:57 +01:00
parent 278c5f4283
commit 090b202188
5 changed files with 245 additions and 19 deletions

View file

@ -37,6 +37,11 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
fight while their points last, and praying trains Prayer too. A Temple adds 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 points per bone and lets players offer bones as devotion, which wards the
city in the next raid. 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.
- **Events:** raids are announced a day or two ahead. Intel (spymaster's - **Events:** raids are announced a day or two ahead. Intel (spymaster's
guild + scout reconnaissance) narrows down when, how strong, and finally guild + scout reconnaissance) narrows down when, how strong, and finally
where their camp is, so fighters can sabotage it. Defeat kills or drives where their camp is, so fighters can sabotage it. Defeat kills or drives

6
app.py
View file

@ -99,8 +99,8 @@ def player_view(world: dict, player: dict, now: float) -> dict:
view["armour"] = game.armour(player) view["armour"] = game.armour(player)
view["reduction"] = round(game.damage_reduction(player), 3) view["reduction"] = round(game.damage_reduction(player), 3)
view["edible"] = game.edible(player) view["edible"] = game.edible(player)
blessed = game.blessing(player) view["blessing"] = game.blessing(player)
view["blessing"] = blessed blessed = game.with_potion(view["blessing"], game.potion_effect(player, "combat"))
view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1) view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1)
for a in game.ACTIONS if a["skill"] == "combat"} for a in game.ACTIONS if a["skill"] == "combat"}
view["pp"] = int(player["pp"]) view["pp"] = int(player["pp"])
@ -328,6 +328,8 @@ class Handler(BaseHTTPRequestHandler):
return "There's no raid to stand guard for" return "There's no raid to stand guard for"
player["garrison"] = bool(body.get("join")) player["garrison"] = bool(body.get("join"))
return None return None
if path == "/api/potion":
return game.use_potion(player, str(body.get("item")))
if path == "/api/prayers": if path == "/api/prayers":
ids = body.get("ids") ids = body.get("ids")
return game.set_prayers(player, ids) if isinstance(ids, list) else "Bad request" return game.set_prayers(player, ids) if isinstance(ids, list) else "Bad request"

144
game.py
View file

@ -176,6 +176,65 @@ for _raw, _cooked, _level, _action_id, _verb in FISH:
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]} ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]}
# --- herblore -------------------------------------------------------------------
# Herbs from Farming, brewed with a secondary into potions with charges. One
# potion per skill at a time, as in Melvor; each action in that skill uses a
# charge, and an empty potion is replaced from the bank if there's another.
# id, name, icon, farming level, seconds, xp, value
HERBS = [
("guam_leaf", "Guam leaf", "🍀", 5, 45, 12, 3),
("marrentill", "Marrentill", "🌼", 15, 50, 20, 5),
("tarromin", "Tarromin", "🌺", 25, 55, 30, 8),
("harralander", "Harralander", "🌻", 35, 60, 42, 12),
("ranarr", "Ranarr", "🪻", 45, 65, 55, 18),
("snapdragon", "Snapdragon", "🌸", 60, 70, 75, 28),
]
POTION_CHARGES = 50
# id, name, herblore level, herb, secondary, skill, effect
POTIONS = [
("trackers_tincture", "Tracker's tincture", 3, "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}),
]
def describe_potion(skill: str, effect: dict) -> str:
name = "combat" if skill == "combat" else skill
if "speed" in effect:
return f"{name.title()} {round(effect['speed'] * 100)}% faster"
if "double" in effect:
return f"{name.title()}: {round(effect['double'] * 100)}% chance of double yield"
if "keep" in effect:
return f"{name.title()}: {round(effect['keep'] * 100)}% chance to keep your materials"
if "bones" in effect:
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"
for _id, _name, _icon, _level, _time, _xp, _value in HERBS:
ITEMS[_id] = {"name": _name, "icon": _icon, "value": _value}
for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS:
ITEMS[_id] = {"name": _name, "icon": "⚗️" if _skill == "combat" else "🧪",
"value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"],
"potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}}
# What the city eats first: plain food before the good stuff. # What the city eats first: plain food before the good stuff.
FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"])) FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"]))
EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS] EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS]
@ -190,13 +249,14 @@ SKILLS = [
{"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"}, {"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"},
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"}, {"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"}, {"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"}, {"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Signature", "class": "scout"}, {"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Signature", "class": "scout"},
] ]
SKILL_IDS = [skill["id"] for skill in SKILLS] SKILL_IDS = [skill["id"] for skill in SKILLS]
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"} GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
CRAFTING = {"smithing", "masonry", "cooking", "leatherworking"} CRAFTING = {"smithing", "masonry", "cooking", "leatherworking", "herblore"}
SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to
CLASSES = { CLASSES = {
@ -281,6 +341,11 @@ ACTIONS = [
_action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}), _action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}),
_action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}), _action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}),
_action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}), _action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}),
*[_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")
for potion, name, level, herb, second, skill, _effect in POTIONS],
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}), _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}), _action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
_action("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}), _action("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}),
@ -398,7 +463,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 5 WORLD_VERSION = 6
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
@ -459,6 +524,11 @@ def migrate(world: dict, now: float | None = None) -> dict:
world["city"]["buildings"].setdefault("temple", {"level": 0, "damaged": False, "fund": {}}) world["city"]["buildings"].setdefault("temple", {"level": 0, "damaged": False, "fund": {}})
world["city"].setdefault("devotion", 0) world["city"].setdefault("devotion", 0)
world["version"] = 5 world["version"] = 5
if world["version"] < 6:
for player in world["players"].values():
player["xp"].setdefault("herblore", 0)
player.setdefault("potions", {})
world["version"] = 6
return world return world
@ -555,7 +625,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"bank": {}, "gold": 0, "favour": 0, "bank": {}, "gold": 0, "favour": 0,
"equip": {slot: None for slot in EQUIP_SLOTS}, "equip": {slot: None for slot in EQUIP_SLOTS},
"food": None, # preferred food in fights; None eats the cheapest first "food": None, # preferred food in fights; None eats the cheapest first
"pp": 0, "prayers": [], "pp": 0, "prayers": [], "potions": {},
"hp": 44, "hp_at": now, "hp": 44, "hp_at": now,
"action": None, "stopped": None, "action": None, "stopped": None,
"last_active": now, "overflow": "city", "last_active": now, "overflow": "city",
@ -764,9 +834,50 @@ def action_time(world: dict, player: dict, action: dict) -> float:
seconds *= 1 - ITEMS[tool]["speed"] seconds *= 1 - ITEMS[tool]["speed"]
if action["skill"] == "smithing": if action["skill"] == "smithing":
seconds *= 1 - 0.08 * building_level(world, "smithy") seconds *= 1 - 0.08 * building_level(world, "smithy")
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
return seconds / speed(world) return seconds / speed(world)
def with_potion(blessed: dict, brew: dict) -> dict:
"""A fight's prayer boosts with a combat potion's added on top."""
return {**blessed, "power": blessed["power"] + brew.get("power", 0),
"taken": blessed["taken"] * (1 - brew.get("reduce", 0))}
def potion_effect(player: dict, skill: str) -> dict:
active = player.get("potions", {}).get(skill)
return ITEMS[active["item"]]["potion"] if active else {}
def use_potion(player: dict, item: str) -> str | None:
"""Drink a potion from the bank into its skill's slot (topping up the same potion)."""
spec = ITEMS.get(item, {}).get("potion")
if not spec:
return "That isn't a potion"
if player["bank"].get(item, 0) < 1:
return "You don't have one"
bank_take(player, item, 1)
current = player["potions"].get(spec["skill"])
charges = current["charges"] if current and current["item"] == item else 0
player["potions"][spec["skill"]] = {"item": item, "charges": charges + POTION_CHARGES}
return None
def _use_charge(player: dict, skill: str, summary: dict) -> None:
active = player["potions"].get(skill)
if not active:
return
active["charges"] -= 1
if active["charges"] > 0:
return
del player["potions"][skill]
if player["bank"].get(active["item"], 0):
use_potion(player, active["item"])
summary["potions_drunk"][active["item"]] = summary["potions_drunk"].get(active["item"], 0) + 1
else:
summary["potion_out"].append(active["item"])
def upcoming_raid(world: dict) -> dict | None: def upcoming_raid(world: dict) -> dict | None:
for event in world["events"]: for event in world["events"]:
if event["status"] == "upcoming" and event["kind"] == "raid": if event["status"] == "upcoming" and event["kind"] == "raid":
@ -805,8 +916,12 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None: def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None:
for item, qty in action["inputs"].items(): brew = potion_effect(player, action["skill"])
bank_take(player, item, qty) if action["inputs"] and rng.random() < brew.get("keep", 0):
summary["kept"] += 1
else:
for item, qty in action["inputs"].items():
bank_take(player, item, qty)
xp = action["xp"] xp = action["xp"]
lucky = ( lucky = (
(player["cls"] == "artisan" and action["skill"] in CRAFTING) (player["cls"] == "artisan" and action["skill"] in CRAFTING)
@ -815,6 +930,10 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()} outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()}
if lucky: if lucky:
summary["lucky"] += 1 summary["lucky"] += 1
if brew.get("double") and rng.random() < brew["double"]:
for item, qty in action["outputs"].items():
outputs[item] += qty
summary["lucky"] += 1
if action["skill"] == "combat": if action["skill"] == "combat":
blessed = blessing(player) blessed = blessing(player)
@ -825,11 +944,12 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
summary["pp_spent"] += blessed["cost"] summary["pp_spent"] += blessed["cost"]
gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary) gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary)
regen(world, player, now, blessed["regen"]) regen(world, player, now, blessed["regen"])
blessed = with_potion(blessed, brew)
auto_eat(player, summary) auto_eat(player, summary)
chance = 0.5 + (combat_power(player) + blessed["power"] - action["difficulty"]) * 0.04 chance = 0.5 + (combat_power(player) + blessed["power"] - action["difficulty"]) * 0.04
won = rng.random() < min(0.97, max(0.15, chance)) won = rng.random() < min(0.97, max(0.15, chance))
hurt = fight_damage(player, action, blessed) * rng.uniform(0.6, 1.4) * (1 if won else 1.3) hurt = fight_damage(player, action, blessed) * rng.uniform(0.6, 1.4) * (1 if won else 1.3)
player["hp"] -= hurt player["hp"] = min(max_hp(player), player["hp"] - hurt + brew.get("heal", 0))
summary["damage"] += hurt summary["damage"] += hurt
auto_eat(player, summary) auto_eat(player, summary)
outing = player["action"] outing = player["action"]
@ -863,12 +983,14 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
event = upcoming_event(world) event = upcoming_event(world)
if event: if event:
event["recon"] += action["recon"] event["recon"] += action["recon"]
bonus = 1 + brew.get("bones", 0)
if action.get("special") == "bury": if action.get("special") == "bury":
player["pp"] += bone_points(world, action) player["pp"] += bone_points(world, action) * bonus
summary["pp_gained"] += bone_points(world, action) summary["pp_gained"] += bone_points(world, action) * bonus
if action.get("special") == "offer": if action.get("special") == "offer":
city = world["city"] city = world["city"]
city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple")) city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple")) * bonus
_use_charge(player, action["skill"], summary)
for item, qty in outputs.items(): for item, qty in outputs.items():
kept = bank_add(world, player, item, qty, now, summary) kept = bank_add(world, player, item, qty, now, summary)
@ -894,7 +1016,8 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict: def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0, return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None, "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0} "sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
"kept": 0, "potions_drunk": {}, "potion_out": []}
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict: def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
@ -1579,5 +1702,6 @@ def content() -> dict:
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
"city_uses": {item: city_uses(item) for item in ITEMS}, "city_uses": {item: city_uses(item) for item in ITEMS},
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES,
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR, "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
} }

View file

@ -137,6 +137,8 @@ function showSummary(sum) {
if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`); if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`);
for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level"); for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level");
if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad");
for (const id of sum.potion_out || []) toast(`🧪 Your ${item(id).name.toLowerCase()} ran out`, "bad");
for (const id of Object.keys(sum.potions_drunk || {})) toast(`🧪 Drank another ${item(id).name.toLowerCase()}`);
if (sum.prayer_out) toast("🙏 Out of prayer points: your prayers have gone quiet", "bad"); if (sum.prayer_out) toast("🙏 Out of prayer points: your prayers have gone quiet", "bad");
if (sum.second_wind) toast("🌬️ Second Wind! Back on your feet at half health", "level"); if (sum.second_wind) toast("🌬️ Second Wind! Back on your feet at half health", "level");
if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`); if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`);
@ -321,6 +323,7 @@ function skillPage(id) {
</div></div>`; </div></div>`;
if (id === "combat") html += combatPanel(); if (id === "combat") html += combatPanel();
if (id === "prayer") html += prayerPanel(); if (id === "prayer") html += prayerPanel();
html += potionPanel(id);
if (`tool_${id}` in me.equip) html += toolPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id);
if (id === "reconnaissance") html += `<div class="panel small muted">Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.</div>`; if (id === "reconnaissance") html += `<div class="panel small muted">Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.</div>`;
const actions = C.actions.filter((a) => a.skill === id); const actions = C.actions.filter((a) => a.skill === id);
@ -333,13 +336,10 @@ function skillPage(id) {
const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join(""); const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
return html + `<div class="tabs">${bar}</div><div class="grid">${actions.filter((a) => a.group === current).map(actionCard).join("")}</div>`; return html + `<div class="tabs">${bar}</div><div class="grid">${actions.filter((a) => a.group === current).map(actionCard).join("")}</div>`;
} }
let group = null, cards = ""; // A few groups: each under its own heading, in the order they first appear.
for (const a of actions) { let cards = actions.filter((a) => !a.group).map(actionCard).join("");
if (a.group && a.group !== group) { for (const g of groups) {
group = a.group; cards += `<div class="section-title grid-head">${esc(g)}</div>` + actions.filter((a) => a.group === g).map(actionCard).join("");
cards += `<div class="section-title grid-head">${esc(group)}</div>`;
}
cards += actionCard(a);
} }
return html + `<div class="grid">${cards}</div>`; return html + `<div class="grid">${cards}</div>`;
} }
@ -350,6 +350,22 @@ function isLocked(a) {
return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only"))); return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only")));
} }
// The skill's active potion, or the ones in the bank that could be drunk for it.
function potionPanel(skill) {
const me = S.me;
const kinds = Object.entries(C.items).filter(([, it]) => it.potion?.skill === skill);
if (!kinds.length) return "";
const active = me.potions[skill];
const owned = kinds.filter(([id]) => me.bank[id]);
const drink = owned.map(([id, it]) => `<button class="btn sm" data-potion="${id}" title="${esc(it.potion.text)}">${it.icon} ${esc(it.name)} (${fmt(me.bank[id])})</button>`).join("");
return `<div class="panel tool-panel">${active
? `${item(active.item).icon} <b>${esc(item(active.item).name)}</b> <span class="muted">${esc(item(active.item).potion.text)} · ${fmt(active.charges)} uses left${me.bank[active.item] ? `, ${fmt(me.bank[active.item])} more in the bank` : ""}</span>`
: `<span class="muted">No potion.</span>`}
${!active && drink ? `<span class="btns">Drink: ${drink}</span>` : ""}
${!active && !drink ? `<span class="small muted">Herblore can brew ${kinds.length === 1 ? "one" : "some"} for ${skill === "combat" ? "fighting" : esc(skillOf(skill).name.toLowerCase())}.</span>` : ""}
</div>`;
}
function prayerPanel() { function prayerPanel() {
const me = S.me, city = S.city, level = me.levels.prayer; const me = S.me, city = S.city, level = me.levels.prayer;
const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0); const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0);
@ -424,6 +440,7 @@ function itemStats(id) {
if (it.armour) parts.push(`+${it.armour} armour`); if (it.armour) parts.push(`+${it.armour} armour`);
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`); if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`); if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
return parts.join(", "); return parts.join(", ");
} }
@ -514,6 +531,9 @@ function itemPanel(id) {
<h3>${esc(it.name)}</h3> <h3>${esc(it.name)}</h3>
<div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div> <div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div>
${it.slot ? equipRow(id, it) : ""} ${it.slot ? equipRow(id, it) : ""}
${it.potion ? `<div class="row"><div class="small">${esc(it.potion.text)} for ${C.potion_charges} ${it.potion.skill === "combat" ? "fights" : "actions"}.</div>
<button class="btn gold" data-potion="${id}">Drink</button>
${S.me.potions[it.potion.skill] && S.me.potions[it.potion.skill].item !== id ? `<div class="small why">Replaces your ${esc(item(S.me.potions[it.potion.skill].item).name.toLowerCase())} (${S.me.potions[it.potion.skill].charges} uses left)</div>` : ""}</div>` : ""}
${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} in fights.</div> ${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} in fights.</div>
${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first in fights</button>`}</div>` : ""} ${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first in fights</button>`}</div>` : ""}
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for two days in the city granary.</div>` : ""} ${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for two days in the city granary.</div>` : ""}
@ -898,6 +918,7 @@ document.addEventListener("click", (e) => {
if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.favour) return act("/api/favour", { kind: d.favour }); if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food }); if (d.food) return act("/api/food", { item: d.food });
if (d.potion) return act("/api/potion", { item: d.potion });
if (d.pray) { if (d.pray) {
const on = S.me.prayers.includes(d.pray); const on = S.me.prayers.includes(d.pray);
if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Only ${C.max_prayers} prayers at once: turn one off first`, "bad"); if (!on && S.me.prayers.length >= C.max_prayers) return toast(`Only ${C.max_prayers} prayers at once: turn one off first`, "bad");

View file

@ -687,5 +687,79 @@ class PrayerTest(unittest.TestCase):
self.assertEqual(world["city"]["buildings"]["temple"]["level"], 0) self.assertEqual(world["city"]["buildings"]["temple"]["level"], 0)
class HerbloreTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("hal", "warrior"))
self.p = self.world["players"]["hal"]
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)
game.process_player(world, p, T0 + 1000, random.Random(1))
self.assertEqual(p["bank"]["lumberjacks_tonic"], 3)
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})
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.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["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(p["potions"]["hunting"]["charges"], game.POTION_CHARGES)
self.assertNotIn("trackers_tincture", 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["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))
self.assertGreater(summary["kept"], 5)
self.assertEqual(p["bank"]["copper_ore"], 100 - (100 - summary["kept"]))
def test_combat_potions_add_power_and_cut_damage(self):
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")
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)
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_v5_worlds_get_herblore(self):
world = world_with(("fred", "pioneer"))
world["version"] = 5
p = world["players"]["fred"]
del p["potions"], p["xp"]["herblore"]
game.migrate(world, T0)
self.assertEqual((p["potions"], p["xp"]["herblore"]), ({}, 0))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()