""" wwn_mechanics.py — механика системы фракций WWN (Worlds Without Number). Содержит все расчётные формулы и таблицы из книги: - Расчёт hp_max фракции по атрибутам - Расчёт дохода за ход (Treasure income) - Таблица стоимости повышения атрибутов (XP) - Проверка лимитов активов - Расчёт апкипа за превышение лимитов - Логика faction goals (цели фракций) - Вспомогательные функции для propose и resolve тулов """ import math from typing import Optional from wwn_assets_data import MAGIC_LEVELS, get_asset # --------------------------------------------------------------------------- # Таблицы WWN (стр. 327) # --------------------------------------------------------------------------- # hp_value[attribute_rating] = HP contribution HP_VALUE: dict[int, int] = { 1: 1, 2: 2, 3: 4, 4: 6, 5: 9, 6: 12, 7: 16, 8: 20, } # XP cost to raise attribute FROM current level TO current+1 # Key = текущее значение атрибута, Value = стоимость повышения до следующего XP_COST_TO_RAISE: dict[int, int] = { 1: 2, 2: 4, 3: 6, 4: 9, 5: 12, 6: 16, 7: 20, } # --------------------------------------------------------------------------- # Расчёт HP фракции # --------------------------------------------------------------------------- def calc_hp_max(force: int, wealth: int, cunning: int) -> int: """ Считает максимальные HP фракции по таблице WWN (стр. 327). HP = hp_value(Force) + hp_value(Wealth) + hp_value(Cunning) Примеры: Force 2, Wealth 4, Cunning 5 → 2 + 6 + 9 = 17 Force 3, Wealth 3, Cunning 3 → 4 + 4 + 4 = 12 """ return ( HP_VALUE.get(force, 0) + HP_VALUE.get(wealth, 0) + HP_VALUE.get(cunning, 0) ) # --------------------------------------------------------------------------- # Расчёт дохода # --------------------------------------------------------------------------- def calc_income(wealth: int, force: int, cunning: int) -> int: """ Считает Treasure income за один faction turn (стр. 324 WWN). Income = ceil(Wealth/2 + (Force + Cunning)/4) Примеры: Wealth 4, Force 2, Cunning 5 → ceil(2 + 1.75) = 4 Wealth 2, Force 1, Cunning 1 → ceil(1 + 0.5) = 2 """ return math.ceil(wealth / 2 + (force + cunning) / 4) # --------------------------------------------------------------------------- # Апкип активов # --------------------------------------------------------------------------- def calc_asset_upkeep(faction_fm: dict) -> dict: """ Рассчитывает апкип фракции за превышение лимитов активов (стр. 325 WWN). Правило: фракция не может иметь больше Force/Wealth/Cunning активов чем её соответствующий атрибут. За каждый лишний актив платится 1 Treasure/ход. Также считает обязательный апкип для Free Company (1 Treasure) и Hired Legion (2 Treasure). Возвращает словарь: { "excess_cunning": int, количество лишних Cunning-активов "excess_force": int, "excess_wealth": int, "free_company_count": int, "hired_legion_count": int, "total_upkeep": int, суммарный Treasure к оплате "excess_assets": list[str] имена лишних активов (для уведомления LLM) } """ owned = faction_fm.get("assets", []) # Считаем активы по атрибутам (BoI не считается в лимит) attr_counts: dict[str, list[str]] = {"Cunning": [], "Force": [], "Wealth": []} free_company_count = 0 hired_legion_count = 0 for a in owned: if a.get("is_base_of_influence"): continue attr = a.get("attribute") if attr in attr_counts: attr_counts[attr].append(a["name"]) if a["name"] == "Free Company": free_company_count += 1 if a["name"] == "Hired Legion": hired_legion_count += 1 # Лимиты limits = { "Cunning": faction_fm.get("cunning", 0), "Force": faction_fm.get("force", 0), "Wealth": faction_fm.get("wealth", 0), } excess_assets: list[str] = [] excess: dict[str, int] = {} for attr, asset_names in attr_counts.items(): over = max(0, len(asset_names) - limits[attr]) excess[attr] = over if over > 0: # Лишние — последние в списке (произвольный порядок) excess_assets.extend(asset_names[-over:]) total_upkeep = ( excess["Cunning"] + excess["Force"] + excess["Wealth"] + free_company_count * 1 + hired_legion_count * 2 ) return { "excess_cunning": excess["Cunning"], "excess_force": excess["Force"], "excess_wealth": excess["Wealth"], "free_company_count": free_company_count, "hired_legion_count": hired_legion_count, "total_upkeep": total_upkeep, "excess_assets": excess_assets, } # --------------------------------------------------------------------------- # Repair Asset # --------------------------------------------------------------------------- def calc_repair_heal(faction_fm: dict, asset_attribute: str, application_number: int) -> dict: """ Считает параметры одного применения Repair Asset (стр. 326 WWN). Правило: 1-е применение на актив: cost=1 Treasure, heal=ceil(attr/2) 2-е применение на тот же актив: cost=2 Treasure, heal=ceil(attr/2) 3-е: cost=3 и т.д. asset_attribute — "Force" | "Wealth" | "Cunning" application_number — порядковый номер применения на этот актив за ход (1, 2, 3...) Возвращает {"cost": int, "heal": int} """ attr_value = faction_fm.get(asset_attribute.lower(), 0) heal = math.ceil(attr_value / 2) return {"cost": application_number, "heal": heal} def calc_faction_repair_heal(faction_fm: dict) -> dict: """ Считает параметры починки HP самой фракции (стр. 326 WWN). Правило: один раз за ход, 1 Treasure, лечит ceil((highest_attr + lowest_attr) / 2). Возвращает {"cost": 1, "heal": int, "highest_attr": str, "lowest_attr": str} """ attrs = { "force": faction_fm.get("force", 0), "wealth": faction_fm.get("wealth", 0), "cunning": faction_fm.get("cunning", 0), } highest = max(attrs, key=lambda k: attrs[k]) lowest = min(attrs, key=lambda k: attrs[k]) heal = math.ceil((attrs[highest] + attrs[lowest]) / 2) return { "cost": 1, "heal": heal, "highest_attr": highest, "lowest_attr": lowest, } # --------------------------------------------------------------------------- # Expand Influence # --------------------------------------------------------------------------- def calc_expand_influence_cost(desired_hp: int) -> int: """ Считает стоимость создания нового Base of Influence (стр. 326 WWN). Правило: 1 Treasure за каждый HP нового BoI. desired_hp — желаемый hp_max нового BoI. """ return desired_hp # --------------------------------------------------------------------------- # Faction Goals и XP # --------------------------------------------------------------------------- def calc_xp_cost_to_raise(current_value: int) -> Optional[int]: """ Возвращает стоимость в XP для повышения атрибута с current_value до current_value+1. Возвращает None если атрибут уже на максимуме (8). Таблица из WWN стр. 327: 1→2: 2 XP 2→3: 4 XP 3→4: 6 XP 4→5: 9 XP 5→6: 12 XP 6→7: 16 XP 7→8: 20 XP """ if current_value >= 8: return None return XP_COST_TO_RAISE.get(current_value) def get_available_upgrades(faction_fm: dict) -> list[dict]: """ Возвращает список атрибутов которые фракция может повысить прямо сейчас. Каждый элемент: { "attribute": "cunning", "current_value": 5, "next_value": 6, "xp_cost": 12, "current_xp": 15, "can_afford": True } """ current_xp = faction_fm.get("xp", 0) result = [] for attr in ("force", "wealth", "cunning"): current = faction_fm.get(attr, 0) cost = calc_xp_cost_to_raise(current) if cost is None: continue result.append({ "attribute": attr, "current_value": current, "next_value": current + 1, "xp_cost": cost, "current_xp": current_xp, "can_afford": current_xp >= cost, }) return result # --------------------------------------------------------------------------- # Faction Tags — extra die flags # --------------------------------------------------------------------------- def get_attack_extra_die_flags( attacker_fm: dict, defender_fm: dict, attack_attribute: str, asset_magic_required: str = "None", ) -> tuple[bool, bool]: """ Определяет флаги extra die для attribute check на основе faction tags (стр. 325 WWN). WWN-правила: Machiavellian → extra die для всех Cunning checks (Cunning должен быть высшим) Martial → extra die для всех Force checks Rich → extra die для всех Wealth checks Antimagical (у защитника) → атаки с Medium+ Magic: атакующий берёт ХУДШИЙ из 2d10 Zealot (у атакующего) → при провале может перебросить, но получает counterattack Возвращает (attacker_extra_die: bool, defender_extra_die: bool). Antimagical кодируется как defender_extra_die=True И отдельно помечается флагом antimagical_penalty в вызывающем коде. """ def has_tag(fm: dict, tag: str) -> bool: return tag in fm.get("faction_tags", []) attacker_extra = False defender_extra = False if attack_attribute == "Cunning": if has_tag(attacker_fm, "Machiavellian"): attacker_extra = True if has_tag(defender_fm, "Machiavellian"): defender_extra = True elif attack_attribute == "Force": if has_tag(attacker_fm, "Martial"): attacker_extra = True if has_tag(defender_fm, "Martial"): defender_extra = True elif attack_attribute == "Wealth": if has_tag(attacker_fm, "Rich"): attacker_extra = True if has_tag(defender_fm, "Rich"): defender_extra = True # Antimagical: если у защитника есть тег и атака требует Medium+ magic, # атакующий бросает с ХУДШИМ результатом (моделируется как penalty, не bonus) # Возвращаем как отдельный флаг в расширенной версии return attacker_extra, defender_extra def has_antimagical_penalty(defender_fm: dict, asset_magic_required: str) -> bool: """ Проверяет применяется ли штраф Antimagical тега (стр. 325 WWN). Antimagical: Assets that require Medium or higher Magic roll all attribute checks twice against this faction during an Attack and take the worst roll. """ if "Antimagical" not in defender_fm.get("faction_tags", []): return False return MAGIC_LEVELS.get(asset_magic_required, 0) >= MAGIC_LEVELS["Medium"] # --------------------------------------------------------------------------- # Massive tag # --------------------------------------------------------------------------- def check_massive_auto_win( attacker_fm: dict, defender_fm: dict, attribute: str, ) -> Optional[bool]: """ Проверяет автоматическую победу по тегу Massive (стр. 325 WWN). Massive: faction automatically wins attribute checks if its attribute is more than twice as big as the opposing side's, unless the other side is also Massive. Возвращает: True — атакующий автоматически побеждает False — защитник автоматически побеждает None — броски нужны (нет автопобеды) """ def has_massive(fm: dict) -> bool: return "Massive" in fm.get("faction_tags", []) a_val = attacker_fm.get(attribute.lower(), 0) d_val = defender_fm.get(attribute.lower(), 0) a_massive = has_massive(attacker_fm) d_massive = has_massive(defender_fm) if a_massive and not d_massive and a_val > d_val * 2: return True if d_massive and not a_massive and d_val > a_val * 2: return False return None # --------------------------------------------------------------------------- # Форматирование для вывода в Markdown # --------------------------------------------------------------------------- def format_faction_status(faction_fm: dict, faction_name: str) -> str: """ Форматирует статус фракции в одну строку для вывода в propose-промпте. Пример: "Force 2 | Wealth 4 | Cunning 5 | Magic: Medium | Treasure: 10 | HP: 17/17" """ locs_list = faction_fm.get("locations", []) locs_str = ", ".join(locs_list) if locs_list else "нет влияния" return ( f"Force {faction_fm.get('force', '?')} | " f"Wealth {faction_fm.get('wealth', '?')} | " f"Cunning {faction_fm.get('cunning', '?')} | " f"Magic: {faction_fm.get('magic', 'None')} | " f"Treasure: {faction_fm.get('treasure', 0)} | " f"HP: {faction_fm.get('hp', '?')}/{faction_fm.get('hp_max', '?')}" f"Locs: [{locs_str}]" ) def format_asset_line(asset: dict, is_base: bool = False) -> str: """ Форматирует одну строку описания актива для вывода в propose-промпте. Пример для обычного актива: " - Organization Moles [C5, HP 10/10, Stealthed ✓] C vs C / 2d6 dmg" Пример для BoI: " - BoI [[Waterdeep]] [HP 17/17]" """ if is_base: hp = asset.get("hp", "?") hp_max = asset.get("hp_max", "?") loc = asset.get("location", "?") return f" - **BoI** {loc} [HP {hp}/{hp_max}]" name = asset.get("name", "?") attr = asset.get("attribute", "?")[0] # C/F/W tier = asset.get("tier_required", "?") hp = asset.get("hp", "?") hp_max = asset.get("hp_max", "?") stealthed = " 🥷Stealthed" if asset.get("stealthed") else "" loc = asset.get("location", "") loc_str = f" @ {loc}" if loc else "" # Атака atk_attr = asset.get("attack_attribute", "") atk_vs = asset.get("attack_vs", "") atk_dmg = asset.get("attack_damage", "") if atk_attr and atk_vs and atk_dmg: atk_str = f" | {atk_attr[0]} vs {atk_vs[0]} / {atk_dmg} dmg" else: atk_str = " | no attack" ctr = asset.get("counterattack", "") ctr_str = f" | ctr {ctr}" if ctr else "" return ( f" - **{name}** [{attr}{tier}, HP {hp}/{hp_max}{stealthed}]" f"{atk_str}{ctr_str}{loc_str}" ) # --------------------------------------------------------------------------- # Тест # --------------------------------------------------------------------------- if __name__ == "__main__": print("=== Тест wwn_mechanics.py ===\n") print("--- calc_hp_max ---") cases = [(2, 4, 5), (3, 3, 3), (7, 6, 5), (1, 1, 1), (8, 8, 8)] for f, w, c in cases: hp = calc_hp_max(f, w, c) print(f" Force {f}, Wealth {w}, Cunning {c} → HP max {hp}") print("\n--- calc_income ---") for w, f, c in [(4, 2, 5), (2, 1, 1), (8, 7, 6)]: inc = calc_income(w, f, c) print(f" Wealth {w}, Force {f}, Cunning {c} → Income {inc}") print("\n--- calc_xp_cost_to_raise ---") for v in range(1, 9): cost = calc_xp_cost_to_raise(v) print(f" Raise from {v} → {v+1}: {cost} XP") print("\n--- calc_repair_heal ---") test_fm = {"force": 4, "wealth": 3, "cunning": 5} for attr, app in [("Force", 1), ("Force", 2), ("Cunning", 1)]: r = calc_repair_heal(test_fm, attr, app) print(f" Repair {attr} (application #{app}): cost={r['cost']} Treasure, heal={r['heal']} HP") print("\n--- calc_asset_upkeep ---") fm_over = { "force": 2, "wealth": 3, "cunning": 2, "assets": [ {"name": "Infantry", "attribute": "Force"}, {"name": "Cavalry", "attribute": "Force"}, {"name": "Knights", "attribute": "Force"}, # лишний {"name": "Front Merchant", "attribute": "Wealth"}, {"name": "Farmers", "attribute": "Wealth"}, {"name": "Free Company", "attribute": "Wealth"}, {"name": "Smugglers", "attribute": "Cunning"}, {"name": "Spymaster", "attribute": "Cunning"}, {"name": "Blackmail", "attribute": "Cunning"}, # лишний ] } upkeep = calc_asset_upkeep(fm_over) print(f" Excess: {upkeep}") print("\n=== Готово ===")