dnd5-scripts/faction_turns/wwn_mechanics.py
2026-07-06 01:48:00 +03:00

499 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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=== Готово ===")