536 lines
20 KiB
Python
536 lines
20 KiB
Python
"""
|
||
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}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# WWN Goals — XP rewards
|
||
# ---------------------------------------------------------------------------
|
||
|
||
WWN_GOAL_XP_REWARD = {
|
||
"Blood the Enemy": 2,
|
||
"Eliminate Target": 1,
|
||
"Expand Influence": 1,
|
||
"Peaceable Kingdom": 1,
|
||
"Wealth of Kingdoms": 2,
|
||
# Для динамических целей используем функции
|
||
}
|
||
|
||
def get_xp_reward_for_goal(goal_type: str, faction_fm: dict = None) -> int:
|
||
"""
|
||
Возвращает XP-награду за выполнение цели.
|
||
Для целей с динамической наградой (Destroy the Foe, Root Out the Enemy и т.п.)
|
||
вычисляет по формулам.
|
||
"""
|
||
if goal_type in ("Destroy the Foe", "Root Out the Enemy", "Sphere Dominance", "Invincible Valor"):
|
||
if faction_fm is None:
|
||
return 0
|
||
# Пример для Destroy the Foe: 2 + среднее атрибутов (округление вверх?)
|
||
if goal_type == "Destroy the Foe":
|
||
avg = (faction_fm.get("force", 0) + faction_fm.get("wealth", 0) + faction_fm.get("cunning", 0)) / 3
|
||
return 2 + round(avg)
|
||
elif goal_type == "Root Out the Enemy":
|
||
avg = (faction_fm.get("force", 0) + faction_fm.get("wealth", 0) + faction_fm.get("cunning", 0)) / 3
|
||
return max(1, round(avg / 2))
|
||
elif goal_type == "Sphere Dominance":
|
||
# За каждые 2 уничтоженных - 1 XP, но базово 1?
|
||
return 1 # Упрощённо
|
||
elif goal_type == "Invincible Valor":
|
||
return 2
|
||
return WWN_GOAL_XP_REWARD.get(goal_type, 0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Тест
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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=== Готово ===") |