diff --git a/README.md b/README.md index 04ca9f4..5a9a398 100644 --- a/README.md +++ b/README.md @@ -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 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. - **Events:** raids are announced a day or two ahead. Intel (spymaster's guild + scout reconnaissance) narrows down when, how strong, and finally where their camp is, so fighters can sabotage it. Defeat kills or drives diff --git a/app.py b/app.py index 95a1967..36eb88f 100644 --- a/app.py +++ b/app.py @@ -99,8 +99,8 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["armour"] = game.armour(player) view["reduction"] = round(game.damage_reduction(player), 3) view["edible"] = game.edible(player) - blessed = game.blessing(player) - view["blessing"] = blessed + view["blessing"] = game.blessing(player) + blessed = game.with_potion(view["blessing"], game.potion_effect(player, "combat")) view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1) for a in game.ACTIONS if a["skill"] == "combat"} view["pp"] = int(player["pp"]) @@ -328,6 +328,8 @@ class Handler(BaseHTTPRequestHandler): return "There's no raid to stand guard for" player["garrison"] = bool(body.get("join")) return None + if path == "/api/potion": + return game.use_potion(player, str(body.get("item"))) if path == "/api/prayers": ids = body.get("ids") return game.set_prayers(player, ids) if isinstance(ids, list) else "Bad request" diff --git a/game.py b/game.py index b4b341b..13a57fd 100644 --- a/game.py +++ b/game.py @@ -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[_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. 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] @@ -190,13 +249,14 @@ SKILLS = [ {"id": "masonry", "name": "Masonry", "icon": "๐งฑ", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "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": "prayer", "name": "Prayer", "icon": "๐", "group": "Combat"}, {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐ญ", "group": "Signature", "class": "scout"}, ] SKILL_IDS = [skill["id"] for skill in SKILLS] 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 CLASSES = { @@ -281,6 +341,11 @@ ACTIONS = [ _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_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_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}), @@ -398,7 +463,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 5 +WORLD_VERSION = 6 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"].setdefault("devotion", 0) 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 @@ -555,7 +625,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict: "bank": {}, "gold": 0, "favour": 0, "equip": {slot: None for slot in EQUIP_SLOTS}, "food": None, # preferred food in fights; None eats the cheapest first - "pp": 0, "prayers": [], + "pp": 0, "prayers": [], "potions": {}, "hp": 44, "hp_at": now, "action": None, "stopped": None, "last_active": now, "overflow": "city", @@ -764,9 +834,50 @@ def action_time(world: dict, player: dict, action: dict) -> float: seconds *= 1 - ITEMS[tool]["speed"] if action["skill"] == "smithing": seconds *= 1 - 0.08 * building_level(world, "smithy") + seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0) 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: for event in world["events"]: 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: - for item, qty in action["inputs"].items(): - bank_take(player, item, qty) + brew = potion_effect(player, action["skill"]) + 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"] lucky = ( (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()} if lucky: 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": 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"] gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary) regen(world, player, now, blessed["regen"]) + blessed = with_potion(blessed, brew) auto_eat(player, summary) chance = 0.5 + (combat_power(player) + blessed["power"] - action["difficulty"]) * 0.04 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) - player["hp"] -= hurt + player["hp"] = min(max_hp(player), player["hp"] - hurt + brew.get("heal", 0)) summary["damage"] += hurt auto_eat(player, summary) outing = player["action"] @@ -863,12 +983,14 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R event = upcoming_event(world) if event: event["recon"] += action["recon"] + bonus = 1 + brew.get("bones", 0) if action.get("special") == "bury": - player["pp"] += bone_points(world, action) - summary["pp_gained"] += bone_points(world, action) + player["pp"] += bone_points(world, action) * bonus + summary["pp_gained"] += bone_points(world, action) * bonus if action.get("special") == "offer": 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(): 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: return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0, "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: @@ -1579,5 +1702,6 @@ def content() -> dict: "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "city_uses": {item: city_uses(item) for item in ITEMS}, "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, } diff --git a/static/app.js b/static/app.js index 57d78b8..8e2599b 100644 --- a/static/app.js +++ b/static/app.js @@ -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`); for (const [skill, level] of sum.levels) toast(`๐ ${skillOf(skill).name} level ${level}!`, "level"); 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.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()}`); @@ -321,6 +323,7 @@ function skillPage(id) { `; if (id === "combat") html += combatPanel(); if (id === "prayer") html += prayerPanel(); + html += potionPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `