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

View file

@ -32,6 +32,11 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
pays taxes, and grows up to the housing cap; it promotes the settlement pays taxes, and grows up to the housing cap; it promotes the settlement
(hamlet, village at 40, town at 90, city at 150), which raises building (hamlet, village at 40, town at 90, city at 150), which raises building
level caps and gives everyone bank slots. level caps and gives everyone bank slots.
- **Prayer:** enemies drop bones; burying them gives prayer points and
Prayer XP. Up to two prayers (eight unlock from level 1 to 60) boost each
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.
- **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

11
app.py
View file

@ -99,7 +99,11 @@ 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)
view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a), 1) for a in game.ACTIONS if a["skill"] == "combat"} blessed = game.blessing(player)
view["blessing"] = blessed
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"])
view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}
view["slots"] = game.bank_slots(world, player) view["slots"] = game.bank_slots(world, player)
view["stack"] = game.stack_size(world) view["stack"] = game.stack_size(world)
@ -134,6 +138,8 @@ def city_view(world: dict, now: float) -> dict:
**game.population_view(world), **game.population_view(world),
"buildings": buildings, "buildings": buildings,
"queue": city["queue"], "queue": city["queue"],
"devotion": round(city.get("devotion", 0), 1),
"ward": game.ward(world),
"treasury": int(city["treasury"]), "treasury": int(city["treasury"]),
"stockpile": city["stockpile"], "stockpile": city["stockpile"],
"construction": city["construction"], "construction": city["construction"],
@ -322,6 +328,9 @@ 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/prayers":
ids = body.get("ids")
return game.set_prayers(player, ids) if isinstance(ids, list) else "Bad request"
if path == "/api/food": if path == "/api/food":
food = body.get("item") food = body.get("item")
if food is not None and "heal" not in game.ITEMS.get(str(food), {}): if food is not None and "heal" not in game.ITEMS.get(str(food), {}):

167
game.py
View file

@ -61,6 +61,9 @@ ITEMS = {
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8}, "bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12}, "cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20}, "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, "studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 6,
"req": ["combat", 10]}, "req": ["combat", 10]},
} }
@ -188,6 +191,7 @@ SKILLS = [
{"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": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"}, {"id": "combat", "name": "Combat", "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]
@ -295,23 +299,32 @@ ACTIONS = [
_action("cook_stew", "cooking", "Stew", 15, 35, 16, _action("cook_stew", "cooking", "Stew", 15, 35, 16,
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), {"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, _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, _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, _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, _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, _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, _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, _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, _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, _action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10,
special="sabotage"), 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_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), _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}, "late": {"steel_bar": 80}, "final": {"mithril_bar": 60},
"effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune", "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": { "spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400, "name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, "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 ---------------------------------------------------------------
WORLD_VERSION = 4 WORLD_VERSION = 5
def new_world(now: float, speed: float = 1.0) -> dict: 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: if world["version"] < 4:
_sell_useless_stock(world, now) _sell_useless_stock(world, now)
world["version"] = 4 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 return world
@ -528,6 +555,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": [],
"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",
@ -621,19 +649,90 @@ def max_hp(player: dict) -> int:
return 40 + 4 * skill_level(player, "combat") 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.""" """Heal for the time since HP was last settled."""
if now > player["hp_at"]: if now > player["hp_at"]:
top = max_hp(player) 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"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
player["hp_at"] = now player["hp_at"] = now
def fight_damage(player: dict, action: dict) -> float: def fight_damage(player: dict, action: dict, blessing: dict | None = None) -> float:
"""Average damage one fight deals: less when out-levelling the enemy or armoured.""" """Average damage one fight deals: less when out-levelling the enemy, armoured or praying."""
gap = combat_power(player) - action["difficulty"] blessing = blessing or NO_BLESSING
return action["damage"] * min(1.5, max(0.3, 1 - gap * 0.03)) * (1 - damage_reduction(player)) 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]: 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 summary["lucky"] += 1
if action["skill"] == "combat": 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) 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)) 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 player["hp"] -= hurt
summary["damage"] += hurt summary["damage"] += hurt
auto_eat(player, summary) 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: if player["hp"] <= 0:
# Out of food and out of luck: fall back with nothing but your life. # Out of food and out of luck: fall back with nothing but your life.
player["hp"] = 1 player["hp"] = 1
@ -752,6 +863,12 @@ 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"]
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(): for item, qty in outputs.items():
kept = bank_add(world, player, item, qty, now, summary) 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: 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} "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: 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 break
reason = can_start(world, player, action) reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"): 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: if reason:
stop(player, reason) stop(player, reason)
summary["stopped"] = reason summary["stopped"] = reason
@ -1064,6 +1188,7 @@ def defence(world: dict) -> dict:
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1), "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
"arrows": min(100, stock.get("arrows", 0) // 10), "arrows": min(100, stock.get("arrows", 0) // 10),
"rallied": city.get("rally", 0), "rallied": city.get("rally", 0),
"ward": ward(world),
"garrison": 0, "garrison": 0,
} }
garrison = [] garrison = []
@ -1356,6 +1481,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
for player in world["players"].values(): for player in world["players"].values():
player["garrison"] = False player["garrison"] = False
city["rally"] = city["rallies"] = 0 city["rally"] = city["rallies"] = 0
city["devotion"] = 0
def _use_up(stock: dict, item: str, share: float) -> None: 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, "items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
"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,
"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");
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()}`); if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`);
} }
@ -318,6 +320,7 @@ function skillPage(id) {
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""} ${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
</div></div>`; </div></div>`;
if (id === "combat") html += combatPanel(); if (id === "combat") html += combatPanel();
if (id === "prayer") html += prayerPanel();
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);
@ -347,6 +350,33 @@ function isLocked(a) {
return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only"))); return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only")));
} }
function prayerPanel() {
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 cards = C.prayers.map((p) => {
const on = me.prayers.includes(p.id), locked = level < p.level;
const full = !on && me.prayers.length >= C.max_prayers;
return `<button class="prayer ${on ? "on" : ""} ${locked ? "locked" : ""}" data-pray="${p.id}" ${locked ? "disabled" : ""}
title="${locked ? `Needs prayer level ${p.level}` : full ? `Only ${C.max_prayers} at once: turn one off first` : ""}">
<span class="ico">${p.icon}</span>
<span class="grow"><b>${esc(p.name)}</b><span class="small muted">${esc(p.text)}</span></span>
<span class="small ${locked ? "why" : "muted"}">${locked ? `Lv ${p.level}` : `${p.cost} pt${p.cost > 1 ? "s" : ""}/fight`}</span>
</button>`;
}).join("");
const temple = city.buildings.temple.level;
return `<div class="cols">
<div class="panel"><h2>Prayers · ${me.prayers.length} / ${C.max_prayers} active</h2>
<div class="prayer-points">🙏 <b>${fmt(me.pp)}</b> prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}</div>
<div class="prayers">${cards}</div>
<p class="small muted">Active prayers work in every fight while you have the points to pay for them, and each point spent trains Prayer. Bury bones for more points.</p>
</div>
<div class="panel"><h2>Temple</h2>
${temple ? `<p>Devotion <b>${fmt(city.devotion)}</b> · divine ward <b>+${city.ward}</b> defence for the next raid (up to +${15 * temple} at temple level ${temple}).</p>
<p class="small muted">Offering bones builds devotion; it wards the city through the next raid, then starts again. The temple also adds ${temple} prayer point${temple > 1 ? "s" : ""} to every bone you bury.</p>`
: `<p class="muted">No temple yet. Build one on the Town square to offer bones for a divine ward in raids, and to get more prayer points from bones.</p>`}
</div></div>`;
}
function toolPanel(skill) { function toolPanel(skill) {
const tool = S.me.equip[`tool_${skill}`]; const tool = S.me.equip[`tool_${skill}`];
return `<div class="panel tool-panel">${tool return `<div class="panel tool-panel">${tool
@ -372,6 +402,8 @@ function combatPanel() {
<div class="bar thick hp"><span style="width:${hpPct}%"></span></div> <div class="bar thick hp"><span style="width:${hpPct}%"></span></div>
<div class="row" style="margin-top:12px"><label class="small muted">Food in fights <div class="row" style="margin-top:12px"><label class="small muted">Food in fights
<select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div> <select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div>
<p class="small">🙏 ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` <b class="warn">(not enough points: prayers are off)</b>`}` : `<span class="muted">No prayers active.</span>`}
<button class="linkish" data-nav="skill:prayer">Prayers</button></p>
<p class="small muted">Below half health you eat from your bank automatically. <p class="small muted">Below half health you eat from your bank automatically.
${me.edible.length ? "" : `<b class="warn">You have no food in your bank.</b>`} ${me.edible.length ? "" : `<b class="warn">You have no food in your bank.</b>`}
Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.</p> Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.</p>
@ -405,7 +437,8 @@ function actionCard(a) {
const time = me.times[a.id]; const time = me.times[a.id];
let io = ""; let io = "";
if (Object.keys(a.inputs).length) { if (Object.keys(a.inputs).length) {
io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("") + `<span class="arrow">→</span>`; io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
+ (Object.keys(a.outputs).length ? `<span class="arrow">→</span>` : "");
} }
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
let extra = ""; let extra = "";
@ -416,6 +449,9 @@ function actionCard(a) {
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`; if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
} }
if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`; if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`;
const temple = S.city.buildings.temple?.level || 0;
if (a.special === "bury") extra = `<div class="small makes">🙏 +${a.pp + temple} prayer points</div>`;
if (a.special === "offer") extra = `<div class="small makes">⛪ +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward</div>`;
const made = Object.keys(a.outputs).map(itemStats).filter(Boolean); const made = Object.keys(a.outputs).map(itemStats).filter(Boolean);
if (made.length) extra += `<div class="small makes">${made.join(" · ")}</div>`; if (made.length) extra += `<div class="small makes">${made.join(" · ")}</div>`;
return ` return `
@ -657,7 +693,7 @@ function favourSpends() {
function raidPrep(ev) { function raidPrep(ev) {
const city = S.city, me = S.me, d = city.defence; const city = S.city, me = S.me, d = city.defence;
const names = { rallied: "Rallied militia (favour)", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; const names = { ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" };
const reqs = (ev.requests || []).map((r) => { const reqs = (ev.requests || []).map((r) => {
const mine = me.bank[r.item] || 0; const mine = me.bank[r.item] || 0;
return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span> return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
@ -758,6 +794,7 @@ function characterSheet(uid) {
<div class="stats"> <div class="stats">
<div class="stat"><div class="k">Health</div><div class="v">${fmt(p.hp)} <small>/ ${p.max_hp}</small></div><div class="bar hp" style="margin-top:6px"><span style="width:${p.hp / p.max_hp * 100}%"></span></div></div> <div class="stat"><div class="k">Health</div><div class="v">${fmt(p.hp)} <small>/ ${p.max_hp}</small></div><div class="bar hp" style="margin-top:6px"><span style="width:${p.hp / p.max_hp * 100}%"></span></div></div>
<div class="stat"><div class="k">Combat power</div><div class="v">${p.power}</div><div class="note">${guard} in the garrison</div></div> <div class="stat"><div class="k">Combat power</div><div class="v">${p.power}</div><div class="note">${guard} in the garrison</div></div>
${mine ? `<div class="stat"><div class="k">Prayer points</div><div class="v">${fmt(p.pp)}</div><div class="note">${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}</div></div>` : ""}
<div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less damage</div></div> <div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less damage</div></div>
<div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div> <div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div>
${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${Object.keys(p.bank).length} / ${p.slots} bank slots</div></div>` : ""} ${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${Object.keys(p.bank).length} / ${p.slots} bank slots</div></div>` : ""}
@ -861,6 +898,11 @@ 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.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");
return act("/api/prayers", { ids: on ? S.me.prayers.filter((x) => x !== d.pray) : [...S.me.prayers, d.pray] });
}
if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.overflow) return act("/api/settings", { overflow: d.overflow });
if (d.class) { pickedClass = d.class; render(); return; } if (d.class) { pickedClass = d.class; render(); return; }
if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; } if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; }

View file

@ -379,3 +379,10 @@ button.sheet-skill:hover { background: var(--panel-2); }
.tab.on { background: var(--gold); border-color: var(--gold); color: var(--bg); } .tab.on { background: var(--gold); border-color: var(--gold); color: var(--bg); }
.tab.dim:not(.on) { opacity: .5; } .tab.dim:not(.on) { opacity: .5; }
.makes { color: var(--muted); } .makes { color: var(--muted); }
.prayers { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }
.prayer { display: flex; align-items: center; gap: 10px; text-align: left; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel-2); }
.prayer .ico { font-size: 22px; width: 28px; text-align: center; }
.prayer .grow { display: flex; flex-direction: column; }
.prayer.on { border-color: var(--favour); box-shadow: 0 0 0 1px var(--favour) inset; background: rgba(185, 140, 232, .12); }
.prayer.locked { opacity: .45; cursor: not-allowed; }
.prayer-points { font-size: 15px; }

View file

@ -409,12 +409,12 @@ class CombatTest(unittest.TestCase):
def test_fights_hurt_and_food_heals(self): def test_fights_hurt_and_food_heals(self):
world, p = self.fighter() world, p = self.fighter()
p["bank"] = {"bread": 50} p["bank"] = {"bread": 100}
game.start_action(world, p, "fight_scouts", T0) game.start_action(world, p, "fight_scouts", T0)
summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4)) summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4))
self.assertGreater(summary["damage"], 0) self.assertGreater(summary["damage"], 0)
self.assertGreater(summary["eaten"].get("bread", 0), 0) self.assertGreater(summary["eaten"].get("bread", 0), 0)
self.assertEqual(p["bank"]["bread"], 50 - summary["eaten"]["bread"]) self.assertEqual(p["bank"]["bread"], 100 - summary["eaten"]["bread"])
self.assertGreaterEqual(p["hp"], 1) self.assertGreaterEqual(p["hp"], 1)
def test_out_of_food_means_retreat_and_nothing_lost(self): def test_out_of_food_means_retreat_and_nothing_lost(self):
@ -606,5 +606,86 @@ class TierTest(unittest.TestCase):
self.assertGreater(p["bank"].get("green_dragonhide", 0), 30) self.assertGreater(p["bank"].get("green_dragonhide", 0), 30)
class PrayerTest(unittest.TestCase):
def priest(self, level=1, combat=10):
world = world_with(("pat", "warrior"))
p = world["players"]["pat"]
p["xp"]["prayer"] = game.XP_TABLE[level]
p["xp"]["combat"] = game.XP_TABLE[combat]
p["hp"] = game.max_hp(p)
return world, p
def test_burying_bones_gives_points_and_xp(self):
world, p = self.priest()
p["bank"] = {"bones": 5}
game.start_action(world, p, "bury_bones", T0)
game.process_player(world, p, T0 + 100, random.Random(1))
self.assertEqual(p["pp"], 10)
self.assertEqual(p["xp"]["prayer"], game.XP_TABLE[1] + 20)
world["city"]["buildings"]["temple"]["level"] = 2
self.assertEqual(game.bone_points(world, game.ACTION_BY_ID["bury_bones"]), 4)
def test_choosing_prayers(self):
world, p = self.priest(level=10)
self.assertEqual(game.set_prayers(p, ["steel_skin"]), "Steel Skin needs prayer level 20")
self.assertEqual(game.set_prayers(p, ["thick_skin", "burst_of_strength", "rapid_heal"]), "At most 2 prayers at once")
self.assertIsNone(game.set_prayers(p, ["thick_skin", "burst_of_strength"]))
def test_prayers_cost_points_cut_damage_and_train_prayer(self):
world, p = self.priest(level=10)
scouts = game.ACTION_BY_ID["fight_scouts"]
plain = game.fight_damage(p, scouts)
game.set_prayers(p, ["thick_skin", "burst_of_strength"])
p["pp"] = 4
blessed = game.blessing(p)
self.assertEqual(blessed["cost"], 2)
self.assertLess(game.fight_damage(p, scouts, blessed), plain * 0.9)
p["bank"] = {"bread": 200}
game.start_action(world, p, "fight_scouts", T0)
summary = game.process_player(world, p, T0 + 600, random.Random(1))
self.assertEqual(p["pp"], 0)
self.assertEqual(summary["pp_spent"], 4)
self.assertTrue(summary["prayer_out"])
self.assertEqual(p["xp"]["prayer"], game.XP_TABLE[10] + 4 * game.PRAYER_XP_PER_POINT)
def test_second_wind_saves_one_retreat(self):
world, p = self.priest(level=40, combat=10)
game.set_prayers(p, ["second_wind"])
p["pp"] = 1000
game.start_action(world, p, "fight_scouts", T0)
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
self.assertEqual(summary["second_wind"], 1)
self.assertTrue(summary["stopped"].startswith("Retreated"))
def test_offerings_ward_the_city_until_the_raid(self):
world, p = self.priest(level=15)
world["city"]["buildings"]["temple"]["level"] = 1
p["bank"] = {"big_bones": 10}
game.start_action(world, p, "offer_big_bones", T0)
game.process_player(world, p, T0 + 1000, random.Random(1))
self.assertEqual(world["city"]["devotion"], 10 * 6 * 1.25)
self.assertEqual(game.defence(world)["parts"]["ward"], min(75 / 4, 15))
world["next_event_at"] = T0
game.tick(world, T0 + 1000, random.Random(2))
game.tick(world, game.upcoming_raid(world)["fire_at"], random.Random(1))
self.assertEqual(world["city"]["devotion"], 0)
def test_offering_needs_a_temple(self):
world, p = self.priest()
p["bank"] = {"bones": 1}
self.assertEqual(game.start_action(world, p, "offer_bones", T0), "Needs Temple level 1")
def test_v4_worlds_get_prayer(self):
world = world_with(("fred", "pioneer"))
world["version"] = 4
p = world["players"]["fred"]
del p["pp"], p["prayers"], p["xp"]["prayer"]
del world["city"]["buildings"]["temple"]
game.migrate(world, T0)
self.assertEqual((p["pp"], p["prayers"], p["xp"]["prayer"]), (0, [], 0))
self.assertEqual(world["city"]["buildings"]["temple"]["level"], 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()