diff --git a/faction_turns/propose_faction_turn.py b/faction_turns/propose_faction_turn.py index c47fae1..45b8b99 100644 --- a/faction_turns/propose_faction_turn.py +++ b/faction_turns/propose_faction_turn.py @@ -40,6 +40,7 @@ from faction_utils import ( roll_dice, roll_attribute_check, are_within_one_move, + write_faction, ) from wwn_assets_data import ( get_available_assets, @@ -279,7 +280,7 @@ def _build_hide_asset_options(faction_fm: dict, all_factions: list[dict], locati if tf.get("name") != faction_fm.get("name") ) if hostile_boi_in_loc: - return [] + return [] # <--- скрытие запрещено lines = [] for a in faction_fm.get("assets", []): @@ -363,7 +364,7 @@ def _build_create_asset_options(faction_fm: dict, location_wikilink: str) -> lis """ available = get_available_assets(faction_fm, location_wikilink) if not available: - return [" - *(нет доступных активов для покупки)*"] + return [] # <--- теперь возвращаем пустой список, а не заглушку lines = [] for a in available: @@ -478,19 +479,42 @@ def _format_faction_section( create_opts = _build_create_asset_options(fm, location_wikilink) sell_opts = _build_sell_options(fm) - # Expand Influence - treasure = fm.get("treasure", 0) - expand_str = ( - f" - Создать BoI в новой локации: 1 Treasure/HP нового BoI " - f"(текущий Treasure: {treasure}, максимальный BoI HP: {treasure})\n" - f" Потребует Cunning vs Cunning vs всех фракций с BoI в целевой локации" - ) - + # ----- ИСПРАВЛЕННАЯ ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ----- + # Возвращает пустую строку, если список опций пуст def _section(title: str, lines: list[str]) -> str: if not lines: - return f"\n**[{title}]** *(недоступно)*" + return "" # <-- теперь не выводится заголовок "недоступно" return f"\n**[{title}]**\n" + "\n".join(lines) + # ----- Блок Expand Influence (теперь только если доступен) ----- + treasure = fm.get("treasure", 0) + has_regular_asset_in_loc = any( + not a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm + for a in fm.get("assets", []) + ) + expand_section = "" + if has_regular_asset_in_loc: + expand_str = ( + f" - Создать BoI в новой локации: 1 Treasure/HP нового BoI " + f"(текущий Treasure: {treasure}, максимальный BoI HP: {treasure})\n" + f" Потребует Cunning vs Cunning vs всех фракций с любым активом в целевой локации" + ) + expand_section = f"\n**[EXPAND INFLUENCE]**\n{expand_str}" + + # Собираем все секции, кроме EXPAND INFLUENCE (он уже отдельно) + actions_sections = ( + _section("ATTACK", attack_opts) + + _section("CREATE ASSET", create_opts) + + _section("HIDE ASSET", hide_opts) + + _section("MOVE ASSET", move_opts) + + _section("REPAIR ASSET", repair_opts) + + expand_section + + _section("SELL ASSET", sell_opts) + ) + + if not actions_sections.strip(): + actions_sections = "\n*(нет доступных действий — фракция пропускает ход)*" + return ( f"### {idx}. {name} | Initiative: {initiative} | {state_label.upper()}\n" f"**Статы:** {status_str}" @@ -500,16 +524,87 @@ def _format_faction_section( f"\n**Доход этого хода:** {income_str}{upkeep_str}" f"\n\n**Активы в локации:**\n{assets_str}" f"\n\n#### Доступные действия:" - + _section("ATTACK", attack_opts) - + _section("CREATE ASSET", create_opts) - + _section("HIDE ASSET", hide_opts) - + _section("MOVE ASSET", move_opts) - + _section("REPAIR ASSET", repair_opts) - + f"\n**[EXPAND INFLUENCE]**\n{expand_str}" - + _section("SELL ASSET", sell_opts) + f"{actions_sections}" ) +def _ensure_startup_asset( + faction: dict, + factions_folder: str, + default_location: str = "[[Уотердип]]" +) -> bool: + """ + Проверяет, есть ли у фракции активы (включая BoI). Если нет, и фракция новая + (нет last_turn_date и last_action), то добавляет самый дешёвый актив tier 1 + в первую локацию из списка locations (или default_location) БЕСПЛАТНО. + """ + fm = faction.get("frontmatter", {}) + # Пропускаем побеждённые или мёртвые + if fm.get("state") == "defeated" or fm.get("hp", 0) <= 0: + return False + + # Если есть хоть один актив (включая BoI) — ничего не делаем + if fm.get("assets"): + return False + + # Если фракция уже действовала ранее — не даём халявный актив + if fm.get("last_turn_date") or fm.get("last_action"): + return False + + # Выбираем локацию + locations = fm.get("locations", []) + if not locations: + loc = default_location + fm.setdefault("locations", []).append(default_location) + else: + loc = locations[0] + + # Определяем наивысший атрибут (приоритет: Cunning > Force > Wealth) + attrs = { + "Cunning": fm.get("cunning", 0), + "Force": fm.get("force", 0), + "Wealth": fm.get("wealth", 0), + } + priority = {"Cunning": 0, "Force": 1, "Wealth": 2} + sorted_attrs = sorted(attrs.items(), key=lambda x: (-x[1], priority[x[0]])) + best_attr = sorted_attrs[0][0] + + from wwn_assets_data import get_assets_by_attribute, ALL_ASSETS + candidates = [a for a in get_assets_by_attribute(best_attr) if a["tier_required"] == 1] + if not candidates: + candidates = [a for a in ALL_ASSETS if a["tier_required"] == 1] + if not candidates: + return False + + best_asset = min(candidates, key=lambda a: a["cost"]) + + # Создаём актив бесплатно (не тратим Treasure) + new_asset = { + "name": best_asset["name"], + "is_base_of_influence": False, + "attribute": best_asset["attribute"], + "tier_required": best_asset["tier_required"], + "cost": best_asset["cost"], + "hp": best_asset["hp_max"], + "hp_max": best_asset["hp_max"], + "location": loc, + "stealthed": False, # по умолчанию активы не скрыты, даже если имеют Subtle + "subtle": False, + } + fm.setdefault("assets", []).append(new_asset) + # Treasure не изменяется + + # Сохраняем изменения в файл + file_path = faction.get("path") + if file_path: + body = faction.get("body", "") + success = write_faction(file_path, fm, body) + if success: + faction["frontmatter"] = fm + return True + return False + + # --------------------------------------------------------------------------- # Главная функция # --------------------------------------------------------------------------- @@ -602,6 +697,9 @@ def propose_faction_turn( faction["_income"] = income faction["_upkeep"] = upkeep + # Добавляем бесплатный стартовый актив, если надо + _ensure_startup_asset(faction, factions_folder, location_wikilink) + # ── Сборка вывода ──────────────────────────────────────────────── header = ( f"# Ход фракций: {location_wikilink} | {game_date}\n\n" diff --git a/faction_turns/resolve_faction_turn.py b/faction_turns/resolve_faction_turn.py index 80f7215..fe5e2d1 100644 --- a/faction_turns/resolve_faction_turn.py +++ b/faction_turns/resolve_faction_turn.py @@ -346,7 +346,7 @@ def _make_event_content( def _resolve_attack( action: dict, faction_fm: dict, - all_factions_data: dict, # faction_file → (fm, body, path) + all_factions_data: dict, factions_folder: str, ) -> dict: """ @@ -355,8 +355,6 @@ def _resolve_attack( Бросает 1d10+attr vs 1d10+attr с учётом faction tags. Применяет урон к target_asset или BoI. При ударе по BoI — также наносит урон HP фракции. - - Возвращает словарь с результатами для отчёта. """ attacking_asset_name = action.get("attacking_asset", "") target_faction_file = action.get("target_faction", "") @@ -377,6 +375,21 @@ def _resolve_attack( result["error"] = f"Актив '{attacking_asset_name}' не найден в таблицах WWN" return result + # ----- Проверка: атакующий актив должен находиться в той же локации, что и цель ----- + own_asset_ref = _find_asset_in_faction(faction_fm, attacking_asset_name) + if not own_asset_ref: + result["error"] = f"Актив '{attacking_asset_name}' не найден у атакующей фракции" + return result + + own_loc = normalize_wikilink(own_asset_ref.get("location", "")) + target_loc = normalize_wikilink(location) # цель должна быть в этой локации + if own_loc != target_loc: + result["error"] = ( + f"Атакующий актив находится в {own_loc}, а цель указана в {target_loc}. " + "Атака возможна только в одной локации." + ) + return result + atk_attr = own_asset_def.get("attack_attribute", "Force") atk_vs = own_asset_def.get("attack_vs", "Force") atk_dmg_notation = own_asset_def.get("attack_damage", "1d6") @@ -594,7 +607,12 @@ def _resolve_create_asset( return result -def _resolve_hide_asset(action: dict, faction_fm: dict) -> dict: +def _resolve_hide_asset( + action: dict, + faction_fm: dict, + all_factions_data: dict, + own_faction_file: str, +) -> dict: """ Выполняет действие Hide Asset по правилам WWN. @@ -602,6 +620,7 @@ def _resolve_hide_asset(action: dict, faction_fm: dict) -> dict: нельзя скрывать в локации с вражеским BoI. """ asset_name = action.get("asset_name", "") + location = action.get("location", "") result = {"action": "Hide Asset", "asset": asset_name} if faction_fm.get("cunning", 0) < 3: @@ -617,6 +636,23 @@ def _resolve_hide_asset(action: dict, faction_fm: dict) -> dict: result["error"] = f"Актив '{asset_name}' не найден" return result + # ----- Проверка на вражеский BoI в той же локации ----- + loc_norm = normalize_wikilink(location) + hostile_boi = any( + normalize_wikilink(a.get("location", "")) == loc_norm + and a.get("is_base_of_influence") + for tf_file, (tf_fm, _, _) in all_factions_data.items() + if tf_file != own_faction_file + for a in tf_fm.get("assets", []) + ) + if hostile_boi: + result["error"] = ( + f"В локации {location} присутствует вражеский BoI — скрытие невозможно " + "(правило стр. 326)." + ) + return result + + # Применяем скрытие asset["stealthed"] = True faction_fm["treasure"] = faction_fm.get("treasure", 0) - 2 result["treasure_spent"] = 2 @@ -654,24 +690,32 @@ def _resolve_move_asset(action: dict, faction_fm: dict) -> dict: return result -def _resolve_repair_asset(action: dict, faction_fm: dict) -> dict: +def _resolve_repair_asset( + action: dict, + faction_fm: dict, + repair_application_number: int, # номер применения ремонта для данного целевого объекта (актива или faction) +) -> dict: """ Выполняет действие Repair Asset по правилам WWN. repair_asset = "faction" → ремонтирует HP самой фракции. repair_asset = имя актива → ремонтирует актив. + + Параметр repair_application_number — номер применения ремонта к этому объекту в текущем ходу + (1 – первое применение, 2 – второе и т.д.). + Стоимость = номер применения (1 Treasure за первое, 2 за второе, ...). """ repair_target = action.get("repair_asset", "") result = {"action": "Repair Asset", "target": repair_target} treasure = faction_fm.get("treasure", 0) + cost = repair_application_number # стоимость равна номеру применения if repair_target == "faction": - if treasure < 1: - result["error"] = "Недостаточно Treasure для ремонта фракции" + if treasure < cost: + result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})" return result repair_info = calc_faction_repair_heal(faction_fm) - cost = repair_info["cost"] heal = repair_info["heal"] old_hp = faction_fm.get("hp", 0) hp_max = faction_fm.get("hp_max", 1) @@ -686,15 +730,15 @@ def _resolve_repair_asset(action: dict, faction_fm: dict) -> dict: result["error"] = f"Актив '{repair_target}' не найден" return result - attr = asset.get("attribute", "Force") - repair_info = calc_repair_heal(faction_fm, attr, 1) - cost = repair_info["cost"] - heal = repair_info["heal"] - if treasure < cost: result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})" return result + attr = asset.get("attribute", "Force") + # heal вычисляется по формуле из WWN: ceil(attr_value / 2) + attr_value = faction_fm.get(attr.lower(), 0) + heal = math.ceil(attr_value / 2) + old_hp = asset.get("hp", 0) hp_max = asset.get("hp_max", 1) asset["hp"] = min(hp_max, old_hp + heal) @@ -716,7 +760,7 @@ def _resolve_expand_influence( Выполняет действие Expand Influence по правилам WWN. Создаёт новый BoI в expand_location за expand_hp Treasure. - Затем выполняет Cunning vs Cunning против каждой фракции с BoI в локации. + Затем выполняет Cunning vs Cunning против каждой фракции с любым активом в локации. При проигрыше — враг получает право на немедленную атаку (помечается в отчёте). """ expand_loc = action.get("expand_location", action.get("location", "")) @@ -727,32 +771,28 @@ def _resolve_expand_influence( "boi_hp": desired_hp, } - - # Получаем текущий список (если его нет, создаем пустой) - if "locations" not in faction_fm: - faction_fm["locations"] = [] - - # Проверяем, нет ли уже этой локации в списке (нормализованно) loc_norm = normalize_wikilink(expand_loc) - existing_locs_norm = [normalize_wikilink(l) for l in faction_fm["locations"]] - - if loc_norm not in existing_locs_norm: - # Добавляем именно в том виде, в котором пришло (например, "[[Neverwinter]]") - faction_fm["locations"].append(expand_loc) + # ----- Проверка: у создающей фракции должен быть хотя бы один актив в локации (не BoI) ----- + has_asset = any( + not a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm + for a in faction_fm.get("assets", []) + ) + if not has_asset: + result["error"] = ( + f"Нет активов (кроме BoI) в локации {expand_loc} для создания нового BoI. " + "Требуется хотя бы один обычный актив." + ) + return result + # ----- Проверка Treasure ----- cost = desired_hp # 1 Treasure per HP treasure = faction_fm.get("treasure", 0) - if treasure < cost: - # Если денег не хватило, удаляем локацию обратно (откат) - if expand_loc in faction_fm["locations"]: - faction_fm["locations"].remove(expand_loc) result["error"] = f"Недостаточно Treasure (нужно {cost}, есть {treasure})" return result - # Создаём BoI - loc_norm = normalize_wikilink(expand_loc) + # ----- Создаём BoI ----- boi_name = f"Base of Influence — {expand_loc.strip('[]')}" new_boi = { "name": boi_name, @@ -770,17 +810,28 @@ def _resolve_expand_influence( result["treasure_after"] = faction_fm["treasure"] result["boi_created"] = boi_name - # Cunning checks против враждебных фракций в локации + # ----- Добавляем локацию в список regions (если ещё нет) ----- + if "locations" not in faction_fm: + faction_fm["locations"] = [] + existing_locs_norm = [normalize_wikilink(l) for l in faction_fm["locations"]] + if loc_norm not in existing_locs_norm: + faction_fm["locations"].append(expand_loc) + + # ----- Cunning checks против ВСЕХ фракций, у которых есть ЛЮБОЙ актив в локации ----- own_cunning = faction_fm.get("cunning", 0) contesting_checks = [] for tf_file, (tf_fm, _tf_body, _tf_path) in all_factions_data.items(): - # Проверяем есть ли у них BoI в expand_loc - tf_has_boi = any( - a.get("is_base_of_influence") and normalize_wikilink(a.get("location", "")) == loc_norm + # Пропускаем себя + if tf_file == action.get("faction_file", ""): + continue + + # Проверяем, есть ли у этой фракции хотя бы один актив (любой) в локации + tf_has_asset = any( + normalize_wikilink(a.get("location", "")) == loc_norm for a in tf_fm.get("assets", []) ) - if not tf_has_boi: + if not tf_has_asset: continue tf_cunning = tf_fm.get("cunning", 0) @@ -952,6 +1003,9 @@ def resolve_faction_turn( action_results: list[dict] = [] event_files_created: list[str] = [] + # ---- В начале цикла обработки действий инициализируем счётчики ремонта ---- + repair_counters = {} # ключ: (faction_file, target_name) -> количество применений в этом ходу + for action in actions: faction_file = action.get("faction_file", "") action_type = action.get("action_type", "") @@ -968,24 +1022,29 @@ def resolve_faction_turn( fm, body, path = all_factions_data[faction_file] result = {"faction": faction_file} - if action_type == "Attack": + # ----- Для Repair Asset обновляем счётчик ----- + if action_type == "Repair Asset": + repair_target = action.get("repair_asset", "") + key = (faction_file, repair_target) + repair_counters[key] = repair_counters.get(key, 0) + 1 + repair_application_number = repair_counters[key] + result.update(_resolve_repair_asset(action, fm, repair_application_number)) + # ----- Остальные действия без изменений (кроме Hide Asset и Attack, которые теперь требуют дополнительные параметры) ----- + elif action_type == "Attack": result.update(_resolve_attack(action, fm, all_factions_data, factions_folder)) elif action_type == "Create Asset": result.update(_resolve_create_asset(action, fm, location)) elif action_type == "Hide Asset": - result.update(_resolve_hide_asset(action, fm)) - + result.update(_resolve_hide_asset(action, fm, all_factions_data, faction_file)) + elif action_type == "Move Asset": result.update(_resolve_move_asset(action, fm)) - elif action_type == "Repair Asset": - result.update(_resolve_repair_asset(action, fm)) - elif action_type == "Expand Influence": result.update(_resolve_expand_influence(action, fm, all_factions_data)) - + elif action_type == "Sell Asset": result.update(_resolve_sell_asset(action, fm))