Prayer: bones, prayer points, combat prayers, temple offerings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 23:09:17 +01:00
parent 568e61799d
commit 278c5f4283
6 changed files with 296 additions and 25 deletions

167
game.py
View file

@ -61,6 +61,9 @@ ITEMS = {
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
"bones": {"name": "Bones", "icon": "🦴", "value": 1},
"big_bones": {"name": "Big bones", "icon": "🦴", "value": 4},
"dragon_bones": {"name": "Dragon bones", "icon": "🦴", "value": 15},
"studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 6,
"req": ["combat", 10]},
}
@ -188,6 +191,7 @@ SKILLS = [
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
{"id": "leatherworking", "name": "Leatherworking", "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]
@ -295,23 +299,32 @@ ACTIONS = [
_action("cook_stew", "cooking", "Stew", 15, 35, 16,
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}),
_action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2,
drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5]]),
drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5], ["bones", 1, 1.0]]),
_action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10,
drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4]]),
drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4], ["bones", 1, 1.0]]),
_action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, damage=14, gold=25,
drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03]]),
drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03], ["big_bones", 1, 0.8]]),
_action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60,
drops=[["steel_bar", 1, 0.4], ["coal", 3, 0.5], ["iron_shield", 1, 0.04]]),
drops=[["steel_bar", 1, 0.4], ["coal", 3, 0.5], ["iron_shield", 1, 0.04], ["big_bones", 1, 1.0]]),
_action("fight_green_dragon", "combat", "Green dragon", 50, 120, 135, difficulty=90, damage=30, gold=80,
drops=[["green_dragonhide", 1, 0.9], ["mithril_ore", 2, 0.3]]),
drops=[["green_dragonhide", 1, 0.9], ["mithril_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]),
_action("fight_blue_dragon", "combat", "Blue dragon", 62, 130, 175, difficulty=115, damage=40, gold=110,
drops=[["blue_dragonhide", 1, 0.9], ["adamantite_ore", 2, 0.3]]),
drops=[["blue_dragonhide", 1, 0.9], ["adamantite_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]),
_action("fight_red_dragon", "combat", "Red dragon", 75, 140, 220, difficulty=140, damage=52, gold=150,
drops=[["red_dragonhide", 1, 0.9], ["runite_ore", 1, 0.25]]),
drops=[["red_dragonhide", 1, 0.9], ["runite_ore", 1, 0.25], ["dragon_bones", 1, 1.0]]),
_action("fight_black_dragon", "combat", "Black dragon", 85, 150, 280, difficulty=165, damage=65, gold=220,
drops=[["red_dragonhide", 2, 0.9], ["rune_bar", 1, 0.1]]),
drops=[["red_dragonhide", 2, 0.9], ["rune_bar", 1, 0.1], ["dragon_bones", 2, 1.0]]),
_action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10,
special="sabotage"),
_action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2),
_action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6),
_action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20),
_action("offer_bones", "prayer", "Offer bones at the temple", 1, 12, 7, {"bones": 1}, special="offer",
devotion=2, city={"temple": 1}),
_action("offer_big_bones", "prayer", "Offer big bones", 15, 12, 25, {"big_bones": 1}, special="offer",
devotion=6, city={"temple": 1}),
_action("offer_dragon_bones", "prayer", "Offer dragon bones", 40, 14, 110, {"dragon_bones": 1}, special="offer",
devotion=20, city={"temple": 2}),
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
]
@ -342,6 +355,12 @@ BUILDINGS = {
"late": {"steel_bar": 80}, "final": {"mithril_bar": 60},
"effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune",
},
"temple": {
"name": "Temple", "icon": "⛪", "max": 3, "gold": 300,
"base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20},
"late": {"willow_logs": 150},
"effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level",
},
"spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
@ -379,7 +398,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 4
WORLD_VERSION = 5
def new_world(now: float, speed: float = 1.0) -> dict:
@ -432,6 +451,14 @@ def migrate(world: dict, now: float | None = None) -> dict:
if world["version"] < 4:
_sell_useless_stock(world, now)
world["version"] = 4
if world["version"] < 5:
for player in world["players"].values():
player["xp"].setdefault("prayer", 0)
player.setdefault("pp", 0)
player.setdefault("prayers", [])
world["city"]["buildings"].setdefault("temple", {"level": 0, "damaged": False, "fund": {}})
world["city"].setdefault("devotion", 0)
world["version"] = 5
return world
@ -528,6 +555,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": [],
"hp": 44, "hp_at": now,
"action": None, "stopped": None,
"last_active": now, "overflow": "city",
@ -621,19 +649,90 @@ def max_hp(player: dict) -> int:
return 40 + 4 * skill_level(player, "combat")
def regen(world: dict, player: dict, now: float) -> None:
def regen(world: dict, player: dict, now: float, boost: float = 1) -> None:
"""Heal for the time since HP was last settled."""
if now > player["hp_at"]:
top = max_hp(player)
per_second = top * REGEN_PER_HOUR * speed(world) / HOUR
per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
player["hp_at"] = now
def fight_damage(player: dict, action: dict) -> float:
"""Average damage one fight deals: less when out-levelling the enemy or armoured."""
gap = combat_power(player) - action["difficulty"]
return action["damage"] * min(1.5, max(0.3, 1 - gap * 0.03)) * (1 - damage_reduction(player))
def fight_damage(player: dict, action: dict, blessing: dict | None = None) -> float:
"""Average damage one fight deals: less when out-levelling the enemy, armoured or praying."""
blessing = blessing or NO_BLESSING
gap = combat_power(player) + blessing["power"] - action["difficulty"]
scale = min(1.5, max(0.3, 1 - gap * 0.03))
return action["damage"] * scale * (1 - damage_reduction(player)) * blessing["taken"]
# --- prayer -------------------------------------------------------------------
# Melvor-style: bones become prayer points, active prayers cost points each
# fight and give boosts while they last, and praying in a fight trains Prayer.
PRAYERS = [
{"id": "thick_skin", "name": "Thick Skin", "icon": "🪨", "level": 1, "cost": 1, "reduce": 0.10},
{"id": "burst_of_strength", "name": "Burst of Strength", "icon": "💪", "level": 5, "cost": 1, "power": 5},
{"id": "rapid_heal", "name": "Rapid Heal", "icon": "💗", "level": 10, "cost": 1, "regen": 2},
{"id": "steel_skin", "name": "Steel Skin", "icon": "🛡️", "level": 20, "cost": 2, "reduce": 0.20},
{"id": "superhuman_strength", "name": "Superhuman Strength", "icon": "🦾", "level": 30, "cost": 2, "power": 12},
{"id": "second_wind", "name": "Second Wind", "icon": "🌬️", "level": 40, "cost": 1, "wind": True},
{"id": "protection", "name": "Protection", "icon": "✨", "level": 50, "cost": 3, "reduce": 0.35},
{"id": "piety", "name": "Piety", "icon": "😇", "level": 60, "cost": 4, "power": 20, "reduce": 0.20},
]
PRAYER_BY_ID = {prayer["id"]: prayer for prayer in PRAYERS}
MAX_PRAYERS = 2
PRAYER_XP_PER_POINT = 5
NO_BLESSING = {"cost": 0, "power": 0, "taken": 1.0, "regen": 1, "wind": False}
def describe_prayer(prayer: dict) -> str:
parts = []
if prayer.get("reduce"):
parts.append(f"-{round(prayer['reduce'] * 100)}% damage taken")
if prayer.get("power"):
parts.append(f"+{prayer['power']} power")
if prayer.get("regen"):
parts.append("HP recovers twice as fast while fighting")
if prayer.get("wind"):
parts.append("once per outing, a retreat becomes a second wind: back to half health")
return ", ".join(parts)
def blessing(player: dict) -> dict:
"""What the active prayers give this fight, if there are points to pay for them."""
active = [PRAYER_BY_ID[p] for p in player["prayers"] if p in PRAYER_BY_ID]
cost = sum(p["cost"] for p in active)
if not active or player["pp"] < cost:
return NO_BLESSING
taken = 1.0
for prayer in active:
taken *= 1 - prayer.get("reduce", 0)
return {"cost": cost, "power": sum(p.get("power", 0) for p in active), "taken": taken,
"regen": max([p.get("regen", 1) for p in active]), "wind": any(p.get("wind") for p in active)}
def set_prayers(player: dict, ids: list) -> str | None:
ids = list(dict.fromkeys(str(i) for i in ids))
if len(ids) > MAX_PRAYERS:
return f"At most {MAX_PRAYERS} prayers at once"
for prayer_id in ids:
prayer = PRAYER_BY_ID.get(prayer_id)
if not prayer:
return "No such prayer"
if skill_level(player, "prayer") < prayer["level"]:
return f"{prayer['name']} needs prayer level {prayer['level']}"
player["prayers"] = ids
return None
def bone_points(world: dict, action: dict) -> int:
return action["pp"] + building_level(world, "temple")
def ward(world: dict) -> float:
"""Defence the temple's devotion adds to the next raid."""
return round(min(world["city"].get("devotion", 0) / 4, 15 * building_level(world, "temple")), 1)
def edible(player: dict) -> list[str]:
@ -718,14 +817,26 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
summary["lucky"] += 1
if action["skill"] == "combat":
regen(world, player, now)
blessed = blessing(player)
if player["prayers"] and not blessed["cost"]:
summary["prayer_out"] = True
if blessed["cost"]:
player["pp"] -= blessed["cost"]
summary["pp_spent"] += blessed["cost"]
gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary)
regen(world, player, now, blessed["regen"])
auto_eat(player, summary)
chance = 0.5 + (combat_power(player) - 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))
hurt = fight_damage(player, action) * 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
summary["damage"] += hurt
auto_eat(player, summary)
outing = player["action"]
if player["hp"] <= 0 and blessed["wind"] and outing and not outing.get("wind_used"):
outing["wind_used"] = True
player["hp"] = max_hp(player) / 2
summary["second_wind"] += 1
if player["hp"] <= 0:
# Out of food and out of luck: fall back with nothing but your life.
player["hp"] = 1
@ -752,6 +863,12 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
event = upcoming_event(world)
if event:
event["recon"] += action["recon"]
if action.get("special") == "bury":
player["pp"] += bone_points(world, action)
summary["pp_gained"] += bone_points(world, action)
if action.get("special") == "offer":
city = world["city"]
city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple"))
for item, qty in outputs.items():
kept = bank_add(world, player, item, qty, now, summary)
@ -777,7 +894,7 @@ 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}
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0}
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
@ -795,7 +912,14 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
break
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
reason = "Retreated: hurt and out of food"
if blessing(player)["wind"] and not current.get("wind_used"):
# Second Wind: the retreat that was coming turns into half health.
current["wind_used"] = True
player["hp"] = max_hp(player) / 2
summary["second_wind"] += 1
reason = None
else:
reason = "Retreated: hurt and out of food"
if reason:
stop(player, reason)
summary["stopped"] = reason
@ -1064,6 +1188,7 @@ def defence(world: dict) -> dict:
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
"arrows": min(100, stock.get("arrows", 0) // 10),
"rallied": city.get("rally", 0),
"ward": ward(world),
"garrison": 0,
}
garrison = []
@ -1356,6 +1481,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
city["devotion"] = 0
def _use_up(stock: dict, item: str, share: float) -> None:
@ -1452,5 +1578,6 @@ def content() -> dict:
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
"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,
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
}