Herblore: herbs, potions with charges, one per skill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
278c5f4283
commit
090b202188
5 changed files with 245 additions and 19 deletions
|
|
@ -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
6
app.py
|
|
@ -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"
|
||||||
|
|
|
||||||
140
game.py
140
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[_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,6 +916,10 @@ 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:
|
||||||
|
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():
|
for item, qty in action["inputs"].items():
|
||||||
bank_take(player, item, qty)
|
bank_take(player, item, qty)
|
||||||
xp = action["xp"]
|
xp = action["xp"]
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue