diff --git a/README.md b/README.md index 2a951dc..04ca9f4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.py b/app.py index af2972a..95a1967 100644 --- a/app.py +++ b/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), {}): diff --git a/game.py b/game.py index aa5a373..b4b341b 100644 --- a/game.py +++ b/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,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, } diff --git a/static/app.js b/static/app.js index 16ecacb..57d78b8 100644 --- a/static/app.js +++ b/static/app.js @@ -137,6 +137,8 @@ function showSummary(sum) { if (sold.length) toast(`๐ฆ Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`); for (const [skill, level] of sum.levels) toast(`๐ ${skillOf(skill).name} level ${level}!`, "level"); if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); + 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 ? `${cls.icon} ${cls.name} bonus` : ""} `; if (id === "combat") html += combatPanel(); + if (id === "prayer") html += prayerPanel(); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
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.
+Devotion ${fmt(city.devotion)} ยท divine ward +${city.ward} defence for the next raid (up to +${15 * temple} at temple level ${temple}).
+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.
` + : `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.
`} +๐ ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} ยท ${fmt(me.pp)} points${me.blessing.cost ? "" : ` (not enough points: prayers are off)`}` : `No prayers active.`} +
Below half health you eat from your bank automatically. ${me.edible.length ? "" : `You have no food in your bank.`} Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.
@@ -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("") + `โ`; + io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("") + + (Object.keys(a.outputs).length ? `โ` : ""); } 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 += `