Prayer: bones, prayer points, combat prayers, temple offerings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
568e61799d
commit
278c5f4283
6 changed files with 296 additions and 25 deletions
|
|
@ -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
|
||||
(hamlet, village at 40, town at 90, city at 150), which raises building
|
||||
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
|
||||
guild + scout reconnaissance) narrows down when, how strong, and finally
|
||||
where their camp is, so fighters can sabotage it. Defeat kills or drives
|
||||
|
|
|
|||
11
app.py
11
app.py
|
|
@ -99,7 +99,11 @@ 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)
|
||||
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["slots"] = game.bank_slots(world, player)
|
||||
view["stack"] = game.stack_size(world)
|
||||
|
|
@ -134,6 +138,8 @@ def city_view(world: dict, now: float) -> dict:
|
|||
**game.population_view(world),
|
||||
"buildings": buildings,
|
||||
"queue": city["queue"],
|
||||
"devotion": round(city.get("devotion", 0), 1),
|
||||
"ward": game.ward(world),
|
||||
"treasury": int(city["treasury"]),
|
||||
"stockpile": city["stockpile"],
|
||||
"construction": city["construction"],
|
||||
|
|
@ -322,6 +328,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return "There's no raid to stand guard for"
|
||||
player["garrison"] = bool(body.get("join"))
|
||||
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":
|
||||
food = body.get("item")
|
||||
if food is not None and "heal" not in game.ITEMS.get(str(food), {}):
|
||||
|
|
|
|||
165
game.py
165
game.py
|
|
@ -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,6 +912,13 @@ 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"):
|
||||
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)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
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()}`);
|
||||
}
|
||||
|
||||
|
|
@ -318,6 +320,7 @@ function skillPage(id) {
|
|||
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
|
||||
</div></div>`;
|
||||
if (id === "combat") html += combatPanel();
|
||||
if (id === "prayer") html += prayerPanel();
|
||||
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>`;
|
||||
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")));
|
||||
}
|
||||
|
||||
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) {
|
||||
const tool = S.me.equip[`tool_${skill}`];
|
||||
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="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>
|
||||
<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.
|
||||
${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>
|
||||
|
|
@ -405,7 +437,8 @@ function actionCard(a) {
|
|||
const time = me.times[a.id];
|
||||
let io = "";
|
||||
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("");
|
||||
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 === "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);
|
||||
if (made.length) extra += `<div class="small makes">${made.join(" · ")}</div>`;
|
||||
return `
|
||||
|
|
@ -657,7 +693,7 @@ function favourSpends() {
|
|||
|
||||
function raidPrep(ev) {
|
||||
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 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>
|
||||
|
|
@ -758,6 +794,7 @@ function characterSheet(uid) {
|
|||
<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">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">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>` : ""}
|
||||
|
|
@ -861,6 +898,11 @@ document.addEventListener("click", (e) => {
|
|||
if (d.perk) return act("/api/perk", { perk: d.perk });
|
||||
if (d.favour) return act("/api/favour", { kind: d.favour });
|
||||
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.class) { pickedClass = d.class; render(); return; }
|
||||
if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; }
|
||||
|
|
|
|||
|
|
@ -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.dim:not(.on) { opacity: .5; }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -409,12 +409,12 @@ class CombatTest(unittest.TestCase):
|
|||
|
||||
def test_fights_hurt_and_food_heals(self):
|
||||
world, p = self.fighter()
|
||||
p["bank"] = {"bread": 50}
|
||||
p["bank"] = {"bread": 100}
|
||||
game.start_action(world, p, "fight_scouts", T0)
|
||||
summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4))
|
||||
self.assertGreater(summary["damage"], 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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue