diff --git "a/demo_rules.py" "b/demo_rules.py" --- "a/demo_rules.py" +++ "b/demo_rules.py" @@ -1,1402 +1,1410 @@ -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass -from typing import Any - - -ACTION_TIME_COSTS: dict[str, int] = { - "MOVE": 30, - "ATTACK": 30, - "COMBAT": 30, - "CLAIM_REWARD": 10, - "REST": 30, - "OVERNIGHT_REST": 30, - "TALK": 10, - "SHOP_MENU": 10, - "SCENE_OPTIONS": 10, - "TRADE": 10, - "EQUIP": 10, - "USE_ITEM": 10, - "VIEW_MAP": 10, - "MAP": 10, - "QUEST": 10, -} - -MAX_OPTION_COUNT = 6 -DEFAULT_OPTION_COUNT = 3 -OVERNIGHT_REST_LOCATIONS = {"村庄旅店", "溪边营地"} -MAIN_QUEST_ID = "main_quest_01" -MAIN_QUEST_TROLL_ID = "main_quest_02" -FOREST_GOBLIN_DEFEATED_FLAG = "encounter::dark_forest_gate_goblin_defeated" -FOREST_TROLL_TRACKS_FOUND_FLAG = "clue::forest_troll_tracks_found" -FOREST_TROLL_DEFEATED_FLAG = "encounter::forest_troll_defeated" -FOREST_TROLL_INTRO_SEEN_FLAG = "scene::forest_troll_intro_seen" -FOREST_TROLL_HOARD_PENDING_FLAG = "reward::forest_troll_hoard_pending" -FOREST_TROLL_HOARD_CLAIMED_FLAG = "reward::forest_troll_hoard_claimed" -DEEP_FOREST_BARRIER_SEEN_FLAG = "clue::deep_forest_barrier_seen" -FOREST_CAUSE_OBJECTIVE = "调查怪物活动的原因" -REPORT_TO_CHIEF_OBJECTIVE = "与村长老伯对话汇报发现" -FOREST_TROLL_TRAVEL_OBJECTIVE = "前往森林深处" -FOREST_TROLL_BOSS_OBJECTIVE = "击败森林巨魔" -SIDE_QUEST_TRAVELER_ID = "side_quest_01" -SIDE_QUEST_FERRY_ID = "side_quest_02" -SIDE_QUEST_GUARDIAN_ID = "side_quest_03" -TRAVELER_RUMOR_HEARD_FLAG = "rumor::traveler_lead_heard" -MINE_RUMOR_HEARD_FLAG = "rumor::mine_ghost_heard" -FERRY_ROUTE_UNLOCKED_FLAG = "rumor::ferry_route_unlocked" -TRAVELER_ENCOUNTERED_FLAG = "scene::mysterious_traveler_encountered" -GUARDIAN_INTRO_SEEN_FLAG = "scene::elf_guardian_introduced" - -LOCATION_MAP_REQUIREMENTS: dict[str, str] = { - "村庄广场": "村庄地图", - "村庄铁匠铺": "村庄地图", - "村庄旅店": "村庄地图", - "村庄杂货铺": "村庄地图", - "村口小路": "村庄地图", - # 黑暗森林入口 可以用村庄地图到达;击败哥布林后获得黑暗森林地图 - "黑暗森林入口": "村庄地图", - # 溪边营地/森林深处 需要黑暗森林地图(森林入口战斗奖励) - "溪边营地": "黑暗森林地图", - "森林深处": "黑暗森林地图", - # 河边渡口 用村庄地图可到达;与老渔夫对话后获得山麓地图 - "河边渡口": "村庄地图", - # 以下地点需要山麓地图(老渔夫给予) - "废弃矿洞入口": "山麓地图", - "山麓盗贼营": "山麓地图", - "精灵遗迹": "山麓地图", - # 古塔废墟 从村口小路可直接发现,村庄地图即可 - "古塔废墟": "村庄地图", -} - -SHOP_LOCATION_TO_MERCHANT: dict[str, str] = { - "村庄铁匠铺": "铁匠格林", - "村庄旅店": "旅店老板娘莉娜", - "村庄杂货铺": "杂货商人阿尔", -} - -ARRIVAL_EVENT_CONFIG: dict[str, dict[str, str]] = { - "村庄铁匠铺": { - "event_key": "arrival::village_blacksmith", - "story_text": ( - "一间热气腾腾的铁匠铺,炉火正旺。墙上挂满了各式武器和护甲。\n" - "炉火映红了铁匠粗犷的脸庞,空气中弥漫着金属和碳的气味。" - ), - }, - "村庄旅店": { - "event_key": "arrival::village_inn", - "story_text": ( - "一家温馨的小旅店,空气中弥漫着烤肉和麦酒的香气。壁炉里的火焰跳动着。\n" - "壁炉噼啪作响,几位旅客正低声交谈,空气温暖而舒适。" - ), - }, - "村庄杂货铺": { - "event_key": "arrival::village_general_store", - "story_text": ( - "一家琳琅满目的杂货铺,从草药到绳索应有尽有。老板是个精明的商人。\n" - "货架上摆满了稀奇古怪的商品,柜台后的商人正用算盘噼里啪啦地算账。" - ), - }, - "黑暗森林入口": { - "event_key": "arrival::dark_forest_gate", - "story_text": ( - "黑暗森林入口的树冠压低了天色,落叶间散着新鲜爪痕和被拖行过的泥印。\n" - "一只拎着弯刀的哥布林正伏在断木后张望,时不时发出刺耳怪叫,像是在替林中的东西守门。" - ), - }, - "河边渡口": { - "event_key": "arrival::river_ferry", - "story_text": ( - "破旧的渡口被河水拍得吱呀作响,湿冷水汽贴着木桩往上爬。\n" - "披着蓑衣的老渔夫正盯着对岸,矿洞与山麓营地的路径都从这里分开。" - ), - }, - "山麓盗贼营": { - "event_key": "arrival::bandit_camp", - "story_text": ( - "山麓盗贼营的篝火还留着余温,翻倒的酒桶和散落的干粮说明有人刚撤离不久。\n" - "营帐阴影里有个盗贼斥候正贴着木桩窥探四周,显然不打算让外来者轻易通过。" - ), - }, - "古塔废墟": { - "event_key": "arrival::ancient_tower", - "story_text": ( - "半坍塌的古塔在风里发出低鸣,残破石阶和裂墙间还能看到新鲜抓痕。\n" - "塔门内飘着一团幽蓝冷火,游荡幽灵在尘灰间若隐若现,像是在警告你别再向前。" - ), - }, - "废弃矿洞入口": { - "event_key": "arrival::mine_entrance", - "story_text": ( - "矿洞入口被枯枝和碎石半堵着,腐朽的矿车轨道向黑暗深处延伸,铁锈和硫磺气味扑面而来。\n" - "一具骷髅兵从废弃矿车后方立起,锈迹斑斑的武器指向你——矿洞里的东西不欢迎活人。" - ), - }, - "溪边营地": { - "event_key": "arrival::creek_camp", - "story_text": ( - "森林中一处难得的开阔地带,清澈的溪水从旁流过,树冠间的光在水面打出零碎金片。\n" - "篝火余烬和被压平的草丛说明有人不久前在此扎营——这里适合短暂休息和搜寻遗留物资。" - ), - }, - "精灵遗迹": { - "event_key": "arrival::elf_ruins", - "story_text": ( - "石柱在藤蔓间若隐若现,精灵文字的刻痕随你的步伐明灭,像是感应到了来者的意图。\n" - "一个穿着褪色绿袍的消瘦身影从石柱阴影里转出,用警惕的眼神审视着你——遗迹有它的守护者。" - ), - }, - "森林深处": { - "event_key": "arrival::deep_forest", - "story_text": ( - "古树盘根错节,荧光苔藓将深处映成幽蓝,腐朽与魔力的气息混杂难辨。\n" - "远处传来低沉咆哮,树影间有成双眼睛移动——有什么东西已经感知到了你的闯入。" - ), - }, -} - - -@dataclass(slots=True) -class BattleSnapshot: - hp: int - attack: int - defense: int - stamina: int - hit_rate: float - dodge_rate: float - live_state_multiplier: float = 1.0 - - @property - def power(self) -> float: - return ( - (self.attack * 0.6 + self.defense * 0.3 + self.stamina * 0.1) - * self.live_state_multiplier - ) - - -BATTLE_ENCOUNTER_CONFIG: dict[tuple[str, str], dict[str, Any]] = { - ("黑暗森林入口", "哥布林"): { - "enemy_snapshot": BattleSnapshot( - hp=45, - attack=8, - defense=3, - stamina=28, - hit_rate=0.82, - dodge_rate=0.08, - live_state_multiplier=1.0, - ), - "defeated_flag": "encounter::dark_forest_gate_goblin_defeated", - "reward_items": ["黑暗森林地图"], - "quest_objectives": ["击败森林中的怪物"], - }, - ("山麓盗贼营", "盗贼斥候"): { - "enemy_snapshot": BattleSnapshot( - hp=52, - attack=9, - defense=4, - stamina=30, - hit_rate=0.84, - dodge_rate=0.12, - live_state_multiplier=1.0, - ), - "defeated_flag": "encounter::bandit_scout_defeated", - "reward_items": ["山麓地图"], - "quest_objectives": [], - }, - ("古塔废墟", "游荡幽灵"): { - "enemy_snapshot": BattleSnapshot( - hp=58, - attack=10, - defense=5, - stamina=32, - hit_rate=0.86, - dodge_rate=0.14, - live_state_multiplier=1.0, - ), - "defeated_flag": "encounter::ancient_tower_wraith_defeated", - "reward_items": ["古塔地图"], - "quest_objectives": [], - }, - ("废弃矿洞入口", "骷髅兵"): { - "enemy_snapshot": BattleSnapshot( - hp=38, - attack=7, - defense=4, - stamina=22, - hit_rate=0.75, - dodge_rate=0.05, - live_state_multiplier=1.0, - ), - "defeated_flag": "encounter::mine_skeleton_defeated", - "reward_items": ["骷髅碎骨"], - "quest_objectives": ["前往废弃矿洞调查"], - }, - ("黑暗森林入口", "野狼"): { - "enemy_snapshot": BattleSnapshot( - hp=58, - attack=11, - defense=5, - stamina=36, - hit_rate=0.82, - dodge_rate=0.14, - live_state_multiplier=1.0, - ), - "defeated_flag": "", - "reward_items": [], - "quest_objectives": [], - }, - ("森林深处", "森林巨魔"): { - "enemy_snapshot": BattleSnapshot( - hp=120, - attack=15, - defense=10, - stamina=130, - hit_rate=0.88, - dodge_rate=0.1, - live_state_multiplier=1.0, - ), - "defeated_flag": FOREST_TROLL_DEFEATED_FLAG, - "reward_items": [], - "quest_id": MAIN_QUEST_TROLL_ID, - "quest_objectives": [FOREST_TROLL_BOSS_OBJECTIVE], - }, -} - - -def action_time_cost_minutes(action_type: str) -> int: - return ACTION_TIME_COSTS.get(str(action_type or "").upper(), 10) - - -def resolve_battle( - player: BattleSnapshot, - enemy: BattleSnapshot, - *, - player_unarmed: bool = False, -) -> dict[str, Any]: - enemy_power = max(enemy.power, 1.0) - player_power = player.power - - if player_unarmed and player.attack <= enemy.defense: - player_power *= 0.5 - - ratio = player_power / enemy_power - if ratio < 0.6: - outcome = "forced_retreat" - elif ratio < 1.0: - outcome = "pyrrhic_win" - elif ratio < 1.5: - outcome = "normal_win" - else: - outcome = "dominant_win" - - return { - "player_power": round(player_power, 2), - "enemy_power": round(enemy_power, 2), - "ratio": round(ratio, 3), - "outcome": outcome, - } - - -def resolve_trade( - game_state, - *, - merchant_name: str, - item_name: str, - confirm: bool, -) -> dict[str, Any]: - npc = game_state.world.npcs.get(merchant_name) - if npc is None or not npc.can_trade: - return {"applied": False, "reason": "invalid_merchant"} - if npc.location != game_state.player.location: - return {"applied": False, "reason": "merchant_not_here"} - if item_name not in npc.shop_inventory: - return {"applied": False, "reason": "item_not_sold_here"} - - item_info = game_state.world.item_registry.get(item_name) - if item_info is None: - return {"applied": False, "reason": "unknown_item"} - - if not confirm: - return { - "applied": False, - "reason": "awaiting_confirmation", - "price": item_info.value, - } - - if game_state.player.gold < item_info.value: - return {"applied": False, "reason": "insufficient_gold", "price": item_info.value} - - game_state.player.gold -= item_info.value - game_state.player.inventory.append(item_name) - game_state.last_recent_gain = item_name - follow_up_actions = build_contextual_actions(game_state, recent_gain=item_name) - return { - "applied": True, - "reason": "purchased", - "price": item_info.value, - "item_name": item_name, - "follow_up_actions": follow_up_actions, - } - - -def get_battle_encounter(location_name: str, enemy_name: str) -> dict[str, Any] | None: - return BATTLE_ENCOUNTER_CONFIG.get((str(location_name), str(enemy_name))) - - -def _has_status(game_state, keyword: str) -> bool: - keyword = str(keyword or "") - return any(keyword in effect.name for effect in game_state.player.status_effects) - - -def _has_map(game_state) -> bool: - owned_items = set(game_state.player.inventory) | { - str(item) - for item in game_state.player.equipment.values() - if item - } - return any("地图" in item for item in owned_items) - - -def _has_named_map(game_state, map_name: str) -> bool: - owned_items = set(game_state.player.inventory) | { - str(item) - for item in game_state.player.equipment.values() - if item - } - return str(map_name) in owned_items - - -def _is_accessible_destination(game_state, destination: str) -> bool: - target_location = game_state.world.locations.get(destination) - if target_location is None: - return False - if target_location.is_accessible: - return True - required_item = str(target_location.required_item or "") - owned_items = set(game_state.player.inventory) | { - str(item) - for item in game_state.player.equipment.values() - if item - } - return bool(required_item) and required_item in owned_items - - -def _is_route_visible(game_state, destination: str) -> bool: - if destination == "河边渡口": - return bool(game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG)) - return True - - -def _is_forest_troll_hunt_active(game_state) -> bool: - quest = game_state.world.quests.get(MAIN_QUEST_TROLL_ID) - if quest is None or quest.status != "active": - return False - return not bool(game_state.world.global_flags.get(FOREST_TROLL_DEFEATED_FLAG)) - - -def _is_main_quest_report_pending(game_state) -> bool: - quest = game_state.world.quests.get(MAIN_QUEST_ID) - if quest is None or quest.status != "active": - return False - if not game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG): - return False - return not bool(quest.objectives.get(REPORT_TO_CHIEF_OBJECTIVE)) - - -def _find_encounter_location(enemy_name: str) -> str | None: - enemy_name = str(enemy_name or "") - if not enemy_name: - return None - for (location_name, encounter_enemy), _config in BATTLE_ENCOUNTER_CONFIG.items(): - if str(encounter_enemy) == enemy_name: - return str(location_name) - return None - - -def _move_action( - target: str, - *, - priority: int, - text: str | None = None, - preserve_text: bool = False, -) -> dict[str, Any]: - action = _make_action( - action_type="MOVE", - target=target, - text=text or f"前往{target}", - priority=priority, - ) - if preserve_text: - action["preserve_text"] = True - return action - - -def _make_action( - *, - action_type: str, - text: str, - target: Any = None, - priority: int = 50, -) -> dict[str, Any]: - return { - "id": 0, - "text": text, - "action_type": action_type, - "target": target, - "priority": priority, - } - - -def _dedupe_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]: - deduped: list[dict[str, Any]] = [] - seen: set[tuple[str, str]] = set() - for action in sorted( - actions, - key=lambda item: int(item.get("priority", 0) or 0), - reverse=True, - ): - normalized = dict(action) - normalized.setdefault("priority", 0) - key = ( - str(normalized.get("action_type")), - str(normalized.get("target")), - ) - if key in seen: - continue - seen.add(key) - normalized["id"] = len(deduped) + 1 - deduped.append(normalized) - return deduped - - -def _first_incomplete_objective(game_state): - active_quests = [ - quest - for quest in game_state.world.quests.values() - if quest.status == "active" - ] - active_quests.sort(key=lambda quest: (quest.quest_type != "main", quest.quest_id)) - for quest in active_quests: - for objective, completed in quest.objectives.items(): - if not completed: - return quest, objective - return None, None - - -def _extract_dialogue_target(objective: str) -> str | None: - suffixes = ("对话", "交谈", "确认情报", "了解情况") - text = str(objective or "") - if not text.startswith("与"): - return None - candidate = text[1:] - for separator in ("对话", "交谈"): - if separator in candidate: - candidate = candidate.split(separator, 1)[0] - break - else: - for suffix in suffixes: - if candidate.endswith(suffix): - candidate = candidate[: -len(suffix)] - break - candidate = candidate.strip() - return candidate or None - - -def _extract_location_target(objective: str) -> str | None: - text = str(objective or "") - for prefix in ("前往",): - if text.startswith(prefix): - candidate = text[len(prefix):] - for suffix in ("调查", "探索", "查看", "侦察"): - if candidate.endswith(suffix): - candidate = candidate[: -len(suffix)] - break - return candidate or None - return None - - -def _find_next_step(game_state, destination: str) -> str | None: - if destination == game_state.player.location: - return destination - visited = {game_state.player.location} - queue: deque[tuple[str, list[str]]] = deque([(game_state.player.location, [])]) - while queue: - current, path = queue.popleft() - current_loc = game_state.world.locations.get(current) - if current_loc is None: - continue - for neighbor in current_loc.connected_to: - if neighbor in visited: - continue - visited.add(neighbor) - new_path = path + [neighbor] - if neighbor == destination: - return new_path[0] - queue.append((neighbor, new_path)) - return None - - -def build_village_chief_follow_up_actions(game_state) -> list[dict[str, Any]]: - blacksmith_text = "前往村庄铁匠铺准备武器" - path_text = None - if _is_forest_troll_hunt_active(game_state): - blacksmith_text = "前往村庄铁匠铺准备武器和防具" - path_text = "沿村口小路赶赴森林深处" - return _dedupe_actions( - [ - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=120, - ), - _move_action( - "森林深处" if path_text else "村口小路", - priority=116, - text=path_text, - preserve_text=bool(path_text), - ), - _move_action( - "村庄杂货铺", - priority=112, - text="前往村庄杂货铺准备火把", - preserve_text=True, - ), - _move_action( - "村庄铁匠铺", - priority=108, - text=blacksmith_text, - preserve_text=True, - ), - ] - ) - - -def build_map_actions(game_state) -> list[dict[str, Any]]: - if not _has_map(game_state): - return [] - - current_location = game_state.world.locations.get(game_state.player.location) - if current_location is None: - return [] - - special_route_order = { - "村庄广场": ["村庄铁匠铺", "村庄旅店", "村口小路", "村庄杂货铺"], - "黑暗森林入口": ["村口小路", "溪边营地", "森林深处"], - "河边渡口": ["废弃矿洞入口", "山麓盗贼营", "村口小路"], - "山麓盗贼营": ["��灵遗迹", "河边渡口"], - "古塔废墟": ["村口小路"], - } - route_order = special_route_order.get( - current_location.name, - list(current_location.connected_to), - ) - - actions: list[dict[str, Any]] = [] - for index, destination in enumerate(route_order): - # 不显示"前往当前场景"的无效选项 - if destination == game_state.player.location: - continue - if destination not in current_location.connected_to: - continue - if not _is_accessible_destination(game_state, destination): - continue - required_map = LOCATION_MAP_REQUIREMENTS.get(destination) - if required_map and not _has_named_map(game_state, required_map): - continue - if not _is_route_visible(game_state, destination): - continue - if ( - destination == "森林深处" - and game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG) - and "森林之钥" not in game_state.player.inventory - ): - continue - actions.append( - _move_action( - destination, - priority=120 - index * 4, - ) - ) - - return _dedupe_actions(actions) - - -def build_shop_menu_actions(game_state, merchant_name: str) -> list[dict[str, Any]]: - npc = game_state.world.npcs.get(merchant_name) - if npc is None or not npc.can_trade: - return [] - - actions: list[dict[str, Any]] = [] - for index, item_name in enumerate(npc.shop_inventory): - item_info = game_state.world.item_registry.get(item_name) - price = int(item_info.value) if item_info else 0 - actions.append( - _make_action( - action_type="TRADE", - target={"merchant": merchant_name, "item": item_name, "confirm": False}, - text=f"购买{item_name}({price}金币)", - priority=120 - index * 4, - ) - ) - - actions.append( - _make_action( - action_type="SCENE_OPTIONS", - target=npc.location, - text="暂不购买,先离开柜台", - priority=60, - ) - ) - return _dedupe_actions(actions) - - -def build_scene_actions(game_state, location_name: str | None = None) -> list[dict[str, Any]]: - current_name = str(location_name or game_state.player.location) - actions: list[dict[str, Any]] = [] - - if current_name == "村庄广场": - actions.append( - _make_action( - action_type="TALK", - target="村长老伯", - text="与村长老伯对话", - priority=120, - ) - ) - actions.append( - _make_action( - action_type="RUMOR", - target={"source": "布告栏", "topic": "rumor_menu"}, - text="查看布告栏上的异闻", - priority=108, - ) - ) - if _has_named_map(game_state, "村庄地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=104, - ) - ) - actions.append(_move_action("村庄铁匠铺", priority=100)) - actions.append(_move_action("村庄旅店", priority=96)) - actions.append(_move_action("村口小路", priority=92)) - actions.append(_move_action("村庄杂货铺", priority=88)) - return _dedupe_actions(actions) - - if current_name == "村口小路": - traveler_available = ( - game_state.world.global_flags.get(TRAVELER_RUMOR_HEARD_FLAG) - and game_state.world.npcs.get("神秘旅人") - and game_state.world.npcs["神秘旅人"].location == "村口小路" - ) - if traveler_available: - actions.append( - _make_action( - action_type="TALK", - target="神秘旅人", - text="与神秘旅人交谈", - priority=120, - ) - ) - if game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG): - actions.append(_move_action("河边渡口", priority=112)) - if _has_named_map(game_state, "村庄地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=108, - ) - ) - if _is_main_quest_report_pending(game_state): - actions.append( - _move_action( - "村庄广场", - priority=120, - text="回村向村长汇报发现", - preserve_text=True, - ) - ) - else: - actions.append(_move_action("村庄广场", priority=112)) - if _has_named_map(game_state, "村庄地图"): - actions.append(_move_action("黑暗森林入口", priority=104)) - return _dedupe_actions(actions) - - if current_name == "村庄铁匠铺": - actions.append( - _make_action( - action_type="TALK", - target="铁匠格林", - text="与铁匠格林对话", - priority=120, - ) - ) - if _has_named_map(game_state, "村庄地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=104, - ) - ) - actions.append(_move_action("村庄广场", priority=96)) - return _dedupe_actions(actions) - - if current_name == "村庄旅店": - actions.append( - _make_action( - action_type="TALK", - target="旅店老板娘莉娜", - text="与旅店老板娘莉娜对话", - priority=120, - ) - ) - actions.append( - _make_action( - action_type="RUMOR", - target={"source": "旅店老板娘莉娜", "topic": "rumor_menu"}, - text="向莉娜打听最近的异状", - priority=116, - ) - ) - actions.append( - _make_action( - action_type="REST", - text="在旅店休息片刻", - priority=110, - ) - ) - if _has_named_map(game_state, "村庄地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=104, - ) - ) - actions.append(_move_action("村庄广场", priority=96)) - return _dedupe_actions(actions) - - if current_name == "村庄杂货铺": - actions.append( - _make_action( - action_type="TALK", - target="杂货商人阿尔", - text="与杂货商人阿尔对话", - priority=120, - ) - ) - if _has_named_map(game_state, "村庄地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=104, - ) - ) - actions.append(_move_action("村庄广场", priority=96)) - return _dedupe_actions(actions) - - if current_name == "黑暗森林入口": - goblin_defeated = game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG) - tracks_found = game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG) - barrier_seen = game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG) - troll_hunt_active = _is_forest_troll_hunt_active(game_state) - if not goblin_defeated: - actions.append( - _make_action( - action_type="ATTACK", - target="哥布林", - text="击败哥布林", - priority=120, - ) - ) - else: - if not tracks_found: - actions.append( - _make_action( - action_type="EXPLORE", - target="黑暗森林入口", - text="调查哥布林留下的痕迹", - priority=120, - ) - ) - elif troll_hunt_active and "森林之钥" in game_state.player.inventory: - actions.append( - _move_action( - "森林深处", - priority=120, - text="前往森林深处探索", - preserve_text=True, - ) - ) - elif not barrier_seen: - actions.append(_move_action("森林深处", priority=120)) - elif _is_main_quest_report_pending(game_state): - actions.append( - _move_action( - "村口小路", - priority=120, - text="返回村庄向村长汇报", - preserve_text=True, - ) - ) - if _has_named_map(game_state, "黑暗森林地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=112 if tracks_found else 108, - ) - ) - actions.append(_move_action("溪边营地", priority=104)) - if tracks_found and not barrier_seen and "森林之钥" in game_state.player.inventory: - actions.append( - _move_action( - "森林深处", - priority=116, - text="前往森���深处探索" if troll_hunt_active else None, - preserve_text=troll_hunt_active, - ) - ) - if _is_main_quest_report_pending(game_state) and not barrier_seen and not troll_hunt_active: - actions.append( - _move_action( - "村口小路", - priority=114, - text="先返回村庄汇报情况", - preserve_text=True, - ) - ) - elif not barrier_seen or troll_hunt_active: - actions.append(_move_action("村口小路", priority=96)) - return _dedupe_actions(actions) - - if current_name == "河边渡口": - actions.append( - _make_action( - action_type="TALK", - target="渡口老渔夫", - text="与渡口老渔夫对话", - priority=120, - ) - ) - if _has_named_map(game_state, "山麓地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=116, - ) - ) - actions.append(_move_action("废弃矿洞入口", priority=112)) - actions.append(_move_action("山麓盗贼营", priority=108)) - actions.append(_move_action("村口小路", priority=104)) - return _dedupe_actions(actions) - - if current_name == "山麓盗贼营": - if not game_state.world.global_flags.get("encounter::bandit_scout_defeated"): - actions.append( - _make_action( - action_type="ATTACK", - target="盗贼斥候", - text="击败盗贼斥候", - priority=120, - ) - ) - if _has_named_map(game_state, "山麓地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=112, - ) - ) - actions.append(_move_action("河边渡口", priority=108)) - return _dedupe_actions(actions) - - if current_name == "古塔废墟": - if not game_state.world.global_flags.get("encounter::ancient_tower_wraith_defeated"): - actions.append( - _make_action( - action_type="ATTACK", - target="游荡幽灵", - text="击退游荡幽灵", - priority=120, - ) - ) - if _has_named_map(game_state, "古塔地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=108, - ) - ) - actions.append(_move_action("村口小路", priority=104)) - return _dedupe_actions(actions) - - if current_name == "废弃矿洞入口": - if not game_state.world.global_flags.get("encounter::mine_skeleton_defeated"): - actions.append( - _make_action( - action_type="ATTACK", - target="骷髅兵", - text="击退骷髅兵", - priority=120, - ) - ) - else: - actions.append( - _make_action( - action_type="EXPLORE", - target="废弃矿洞入口", - text="搜查矿洞入口的遗留痕迹", - priority=120, - ) - ) - if _has_named_map(game_state, "山麓地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=108, - ) - ) - actions.append(_move_action("河边渡口", priority=104)) - return _dedupe_actions(actions) - - if current_name == "溪边营地": - actions.append( - _make_action( - action_type="REST", - text="在营地休息恢复体力", - priority=120, - ) - ) - actions.append( - _make_action( - action_type="EXPLORE", - target="溪边营地", - text="搜寻营地遗留的物资线索", - priority=112, - ) - ) - if _has_named_map(game_state, "黑暗森林地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=104, - ) - ) - actions.append(_move_action("黑暗森林入口", priority=96)) - return _dedupe_actions(actions) - - if current_name == "精灵遗迹": - actions.append( - _make_action( - action_type="TALK", - target="遗迹守护者", - text="与遗迹守护者对话", - priority=120, - ) - ) - if _has_named_map(game_state, "山麓地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=108, - ) - ) - actions.append(_move_action("山麓盗贼营", priority=100)) - return _dedupe_actions(actions) - - if current_name == "森林深处": - if game_state.world.global_flags.get(FOREST_TROLL_HOARD_PENDING_FLAG): - actions.append( - _make_action( - action_type="CLAIM_REWARD", - target={"source": "forest_troll_hoard"}, - text="确认拾取洞穴中的战利品", - priority=124, - ) - ) - elif ( - _is_forest_troll_hunt_active(game_state) - and game_state.world.global_flags.get(FOREST_TROLL_INTRO_SEEN_FLAG) - ): - actions.append( - _make_action( - action_type="ATTACK", - target="森林巨魔", - text="攻击森林巨魔", - priority=120, - ) - ) - else: - actions.append( - _make_action( - action_type="EXPLORE", - target="森林深处", - text="深入调查森林异变的根源", - priority=120, - ) - ) - if _has_named_map(game_state, "黑暗森林地图"): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=108, - ) - ) - actions.append(_move_action("黑暗森林入口", priority=100)) - return _dedupe_actions(actions) - - return build_adjacent_actions(game_state) - - -def build_arrival_event(game_state, location_name: str) -> dict[str, Any] | None: - config = ARRIVAL_EVENT_CONFIG.get(str(location_name)) - if config is None: - return None - return { - "event_key": config["event_key"], - "story_text": config["story_text"], - "options": build_scene_actions(game_state, location_name), - } - - -def build_goal_directed_actions(game_state) -> list[dict[str, Any]]: - quest, objective = _first_incomplete_objective(game_state) - if not quest or not objective: - return [] - - actions: list[dict[str, Any]] = [] - inventory = set(game_state.player.inventory) - current_location = game_state.world.locations.get(game_state.player.location) - dialogue_target = _extract_dialogue_target(objective) - if dialogue_target: - npc = game_state.world.npcs.get(dialogue_target) - if npc is None: - npc = next( - ( - candidate - for candidate in game_state.world.npcs.values() - if dialogue_target in candidate.name or candidate.name in objective - ), - None, - ) - if npc and npc.location == game_state.player.location: - actions.append( - _make_action( - action_type="TALK", - target=npc.name, - text=f"与{npc.name}对话", - priority=120, - ) - ) - elif npc: - next_step = _find_next_step(game_state, npc.location) - if next_step: - actions.append( - _make_action( - action_type="MOVE", - target=next_step, - text=f"前往{next_step}", - priority=112, - ) - ) - - location_target = _extract_location_target(objective) - if location_target: - if any("地图" in item for item in inventory): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=110, - ) - ) - if ( - location_target == "森林深处" - and _is_forest_troll_hunt_active(game_state) - and "森林之钥" in inventory - and game_state.player.location != "森林深处" - ): - actions.append( - _make_action( - action_type="MOVE", - target="森林深处", - text="赶赴森林深处", - priority=105, - ) - ) - else: - next_step = _find_next_step(game_state, location_target) - if next_step and next_step != game_state.player.location: - actions.append( - _make_action( - action_type="MOVE", - target=next_step, - text=f"前往{next_step}", - priority=105, - ) - ) - - if location_target == "黑暗森林入口" and game_state.player.location == "村庄广场": - if "火把" not in inventory: - actions.append( - _make_action( - action_type="MOVE", - target="村庄杂货铺", - text="前往村庄杂货铺准备火把", - priority=104, - ) - ) - if ( - not game_state.player.equipment.get("weapon") - and "铁剑" not in inventory - and "短剑" not in inventory - ): - actions.append( - _make_action( - action_type="MOVE", - target="村庄铁匠铺", - text="前往村庄铁匠铺准备武器", - priority=103, - ) - ) - - if "击败" in str(objective): - enemy_name = str(objective).replace("击败", "").replace("森林中的", "").replace("矿洞中的", "").replace("的怪物", "").strip() - encounter_location = _find_encounter_location(enemy_name) - if enemy_name in {"怪物", "敌人"} and current_location: - local_enemies = list(current_location.enemies or []) - if local_enemies: - enemy_name = local_enemies[0] - encounter_location = _find_encounter_location(enemy_name) - else: - hunt_step = next( - ( - neighbor - for neighbor in current_location.connected_to - if game_state.world.locations.get(neighbor) - and game_state.world.locations[neighbor].enemies - ), - None, - ) - if hunt_step: - actions.append( - _make_action( - action_type="MOVE", - target=hunt_step, - text=f"前往{hunt_step}搜索怪物", - priority=108, - ) - ) - enemy_name = "" - if enemy_name and encounter_location and encounter_location != game_state.player.location: - next_step = _find_next_step(game_state, encounter_location) - if next_step and next_step != game_state.player.location: - actions.append( - _make_action( - action_type="MOVE", - target=next_step, - text=f"前往{next_step}", - priority=108, - ) - ) - elif enemy_name: - actions.append( - _make_action( - action_type="ATTACK", - target=enemy_name, - text=f"击败{enemy_name}", - priority=100, - ) - ) - - if "调查" in str(objective) or "找到" in str(objective): - if ( - str(objective) == FOREST_CAUSE_OBJECTIVE - and game_state.player.location == "黑暗森林入口" - and game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG) - ): - actions.append( - _make_action( - action_type="EXPLORE", - target="黑暗森林入口", - text="调查哥布林留下的痕迹", - priority=118, - ) - ) - return _dedupe_actions(actions) - actions.append( - _make_action( - action_type="EXPLORE", - target=game_state.player.location, - text=f"围绕“{objective}”继续调查", - priority=92, - ) - ) - - return _dedupe_actions(actions) - - -def build_adjacent_actions(game_state) -> list[dict[str, Any]]: - current_location = game_state.world.locations.get(game_state.player.location) - if current_location is None: - return [] - - actions: list[dict[str, Any]] = [] - owned_items = set(game_state.player.inventory) | { - str(item) - for item in game_state.player.equipment.values() - if item - } - - for neighbor in current_location.connected_to: - # 不显示"前往当前场景"的无效选项 - if neighbor == game_state.player.location: - continue - target_location = game_state.world.locations.get(neighbor) - if target_location is None: - continue - if not target_location.is_accessible: - required_item = str(target_location.required_item or "") - if not required_item or required_item not in owned_items: - continue - actions.append( - _make_action( - action_type="MOVE", - target=neighbor, - text=f"前往{neighbor}", - priority=78 if not target_location.is_discovered else 72, - ) - ) - - for npc_name in current_location.npcs_present: - if npc_name not in game_state.world.npcs: - continue - actions.append( - _make_action( - action_type="TALK", - target=npc_name, - text=f"与{npc_name}对话", - priority=68, - ) - ) - - return _dedupe_actions(actions) - - -def merge_demo_options( - base_options: list[dict[str, Any]], - *extra_option_groups: list[dict[str, Any]], - limit: int = 3, -) -> list[dict[str, Any]]: - merged: list[dict[str, Any]] = [option for option in base_options if isinstance(option, dict)] - for group in extra_option_groups: - merged.extend(group) - deduped = _dedupe_actions(merged) - return deduped[:limit] - - -def build_contextual_actions( - game_state, - *, - recent_gain: str | None = None, -) -> list[dict[str, Any]]: - actions: list[dict[str, Any]] = [] - inventory = set(game_state.player.inventory) - light_level = str(game_state.world.light_level) - location = game_state.world.locations.get(game_state.player.location) - time_of_day = str(game_state.world.time_of_day) - - if recent_gain: - item_info = game_state.world.item_registry.get(recent_gain) - if item_info and item_info.item_type in {"weapon", "armor", "accessory"}: - slot = "weapon" if item_info.item_type == "weapon" else "armor" - if game_state.player.equipment.get(slot) != recent_gain: - actions.append( - _make_action( - action_type="EQUIP", - target=recent_gain, - text=f"装备{recent_gain}", - priority=100, - ) - ) - if "地图" in str(recent_gain): - actions.append( - _make_action( - action_type="VIEW_MAP", - text="查看地图", - priority=95, - ) - ) - - in_dark_area = ( - light_level in {"黑暗", "昏暗", "幽暗", "漆黑"} - or (location is not None and location.location_type == "dungeon") - or ( - location is not None - and location.location_type in {"wilderness", "special"} - and time_of_day in {"夜晚", "深夜"} - ) - ) - if "火把" in inventory and in_dark_area and not _has_status(game_state, "火把"): - actions.append( - _make_action( - action_type="USE_ITEM", - target="火把", - text="使用火把照明", - priority=90, - ) - ) - - if game_state.player.hp < max(1, game_state.player.max_hp // 2): - for potion_name in ("小型治疗药水", "治疗药水"): - if potion_name in inventory: - actions.append( - _make_action( - action_type="USE_ITEM", - target=potion_name, - text=f"使用{potion_name}", - priority=85, - ) - ) - break - - if game_state.player.hunger < 50: - for food_name in ("面包", "烤肉", "麦酒", "草药包"): - if food_name in inventory: - actions.append( - _make_action( - action_type="USE_ITEM", - target=food_name, - text=f"食用{food_name}", - priority=80, - ) - ) - break - - if ( - game_state.player.location in OVERNIGHT_REST_LOCATIONS - and hasattr(game_state, "can_overnight_rest") - and game_state.can_overnight_rest() - ): - actions.append( - _make_action( - action_type="OVERNIGHT_REST", - text="在此处过夜", - priority=88, - ) - ) - - return _dedupe_actions(actions) +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from typing import Any + + +ACTION_TIME_COSTS: dict[str, int] = { + "MOVE": 30, + "ATTACK": 30, + "COMBAT": 30, + "CLAIM_REWARD": 10, + "REST": 30, + "OVERNIGHT_REST": 30, + "TALK": 10, + "SHOP_MENU": 10, + "SCENE_OPTIONS": 10, + "TRADE": 10, + "EQUIP": 10, + "USE_ITEM": 10, + "VIEW_MAP": 10, + "MAP": 10, + "QUEST": 10, +} + +MAX_OPTION_COUNT = 6 +DEFAULT_OPTION_COUNT = 3 +OVERNIGHT_REST_LOCATIONS = {"村庄旅店", "溪边营地"} +MAIN_QUEST_ID = "main_quest_01" +MAIN_QUEST_TROLL_ID = "main_quest_02" +FOREST_GOBLIN_DEFEATED_FLAG = "encounter::dark_forest_gate_goblin_defeated" +FOREST_TROLL_TRACKS_FOUND_FLAG = "clue::forest_troll_tracks_found" +FOREST_TROLL_DEFEATED_FLAG = "encounter::forest_troll_defeated" +FOREST_TROLL_INTRO_SEEN_FLAG = "scene::forest_troll_intro_seen" +FOREST_TROLL_HOARD_PENDING_FLAG = "reward::forest_troll_hoard_pending" +FOREST_TROLL_HOARD_CLAIMED_FLAG = "reward::forest_troll_hoard_claimed" +DEEP_FOREST_BARRIER_SEEN_FLAG = "clue::deep_forest_barrier_seen" +FOREST_CAUSE_OBJECTIVE = "调查怪物活动的原因" +REPORT_TO_CHIEF_OBJECTIVE = "与村长老伯对话汇报发现" +FOREST_TROLL_TRAVEL_OBJECTIVE = "前往森林深处" +FOREST_TROLL_BOSS_OBJECTIVE = "击败森林巨魔" +SIDE_QUEST_TRAVELER_ID = "side_quest_01" +SIDE_QUEST_FERRY_ID = "side_quest_02" +SIDE_QUEST_GUARDIAN_ID = "side_quest_03" +TRAVELER_RUMOR_HEARD_FLAG = "rumor::traveler_lead_heard" +MINE_RUMOR_HEARD_FLAG = "rumor::mine_ghost_heard" +FERRY_ROUTE_UNLOCKED_FLAG = "rumor::ferry_route_unlocked" +TRAVELER_ENCOUNTERED_FLAG = "scene::mysterious_traveler_encountered" +GUARDIAN_INTRO_SEEN_FLAG = "scene::elf_guardian_introduced" + +LOCATION_MAP_REQUIREMENTS: dict[str, str] = { + "村庄广场": "村庄地图", + "村庄铁匠铺": "村庄地图", + "村庄旅店": "村庄地图", + "村庄杂货铺": "村庄地图", + "村口小路": "村庄地图", + # 黑暗森林入口 可以用村庄地图到达;击败哥布林后获得黑暗森林地图 + "黑暗森林入口": "村庄地图", + # 溪边营地/森林深处 需要黑暗森林地图(森林入口战斗奖励) + "溪边营地": "黑暗森林地图", + "森林深处": "黑暗森林地图", + # 河边渡口 用村庄地图可到达;与老渔夫对话后获得山麓地图 + "河边渡口": "村庄地图", + # 以下地点需要山麓地图(老渔夫给予) + "废弃矿洞入口": "山麓地图", + "山麓盗贼营": "山麓地图", + "精灵遗迹": "山麓地图", + # 古塔废墟 从村口小路可直接发现,村庄地图即可 + "古塔废墟": "村庄地图", +} + +SHOP_LOCATION_TO_MERCHANT: dict[str, str] = { + "村庄铁匠铺": "铁匠格林", + "村庄旅店": "旅店老板娘莉娜", + "村庄杂货铺": "杂货商人阿尔", +} + +ARRIVAL_EVENT_CONFIG: dict[str, dict[str, str]] = { + "村庄铁匠铺": { + "event_key": "arrival::village_blacksmith", + "story_text": ( + "一间热气腾腾的铁匠铺,炉火正旺。墙上挂满了各式武器和护甲。\n" + "炉火映红了铁匠粗犷的脸庞,空气中弥漫着金属和碳的气味。" + ), + }, + "村庄旅店": { + "event_key": "arrival::village_inn", + "story_text": ( + "一家温馨的小旅店,空气中弥漫着烤肉和麦酒的香气。壁炉里的火焰跳动着。\n" + "壁炉噼啪作响,几位旅客正低声交谈,空气温暖而舒适。" + ), + }, + "村庄杂货铺": { + "event_key": "arrival::village_general_store", + "story_text": ( + "一家琳琅满目的杂货铺,从草药到绳索应有尽有。老板是个精明的商人。\n" + "货架上摆满了稀奇古怪的商品,柜台后的商人正用算盘噼里啪啦地算账。" + ), + }, + "黑暗森林入口": { + "event_key": "arrival::dark_forest_gate", + "story_text": ( + "黑暗森林入口的树冠压低了天色,落叶间散着新鲜爪痕和被拖行过的泥印。\n" + "一只拎着弯刀的哥布林正伏在断木后张望,时不时发出刺耳怪叫,像是在替林中的东西守门。" + ), + }, + "河边渡口": { + "event_key": "arrival::river_ferry", + "story_text": ( + "破旧的渡口被河水拍得吱呀作响,湿冷水汽贴着木桩往上爬。\n" + "披着蓑衣的老渔夫正盯着对岸,矿洞与山麓营地的路径都从这里分开。" + ), + }, + "山麓盗贼营": { + "event_key": "arrival::bandit_camp", + "story_text": ( + "山麓盗贼营的篝火还留着余温,翻倒的酒桶和散落的干粮说明有人刚撤离不久。\n" + "营帐阴影里有个盗贼斥候正贴着木桩窥探四周,显然不打算让外来者轻易通过。" + ), + }, + "古塔废墟": { + "event_key": "arrival::ancient_tower", + "story_text": ( + "半坍塌的古塔在风里发出低鸣,残破石阶和裂墙间还能看到新鲜抓痕。\n" + "塔门内飘着一团幽蓝冷火,游荡幽灵在尘灰间若隐若现,像是在警告你别再向前。" + ), + }, + "废弃矿洞入口": { + "event_key": "arrival::mine_entrance", + "story_text": ( + "矿洞入口被枯枝和碎石半堵着,腐朽的矿车轨道向黑暗深处延伸,铁锈和硫磺气味扑面而来。\n" + "一具骷髅兵从废弃矿车后方立起,锈迹斑斑的武器指向你——矿洞里的东西不欢迎活人。" + ), + }, + "溪边营地": { + "event_key": "arrival::creek_camp", + "story_text": ( + "森林中一处难得的开阔地带,清澈的溪水从旁流过,树冠间的光在水面打出零碎金片。\n" + "篝火余烬和被压平的草丛说明有人不久前在此扎营——这里适合短暂休息和搜寻遗留物资。" + ), + }, + "精灵遗迹": { + "event_key": "arrival::elf_ruins", + "story_text": ( + "石柱在藤蔓间若隐若现,精灵文字的刻痕随你的步伐明灭,像是感应到了来者的意图。\n" + "一个穿着褪色绿袍的消瘦身影从石柱阴影里转出,用警惕的眼神审视着你——遗迹有它的守护者。" + ), + }, + "森林深处": { + "event_key": "arrival::deep_forest", + "story_text": ( + "古树盘根错节,荧光苔藓将深处映成幽蓝,腐朽与魔力的气息混杂难辨。\n" + "远处传来低沉咆哮,树影间有成双眼睛移动——有什么东西已经感知到了你的闯入。" + ), + }, +} + + +@dataclass(slots=True) +class BattleSnapshot: + hp: int + attack: int + defense: int + stamina: int + hit_rate: float + dodge_rate: float + live_state_multiplier: float = 1.0 + + @property + def power(self) -> float: + return ( + (self.attack * 0.6 + self.defense * 0.3 + self.stamina * 0.1) + * self.live_state_multiplier + ) + + +BATTLE_ENCOUNTER_CONFIG: dict[tuple[str, str], dict[str, Any]] = { + ("黑暗森林入口", "哥布林"): { + "enemy_snapshot": BattleSnapshot( + hp=45, + attack=8, + defense=3, + stamina=28, + hit_rate=0.82, + dodge_rate=0.08, + live_state_multiplier=1.0, + ), + "defeated_flag": "encounter::dark_forest_gate_goblin_defeated", + "reward_items": ["黑暗森林地图"], + "quest_objectives": ["击败森林中的怪物"], + }, + ("山麓盗贼营", "盗贼斥候"): { + "enemy_snapshot": BattleSnapshot( + hp=52, + attack=9, + defense=4, + stamina=30, + hit_rate=0.84, + dodge_rate=0.12, + live_state_multiplier=1.0, + ), + "defeated_flag": "encounter::bandit_scout_defeated", + "reward_items": ["山麓地图"], + "quest_objectives": [], + }, + ("古塔废墟", "游荡幽灵"): { + "enemy_snapshot": BattleSnapshot( + hp=58, + attack=10, + defense=5, + stamina=32, + hit_rate=0.86, + dodge_rate=0.14, + live_state_multiplier=1.0, + ), + "defeated_flag": "encounter::ancient_tower_wraith_defeated", + "reward_items": ["古塔地图"], + "quest_objectives": [], + }, + ("废弃矿洞入口", "骷髅兵"): { + "enemy_snapshot": BattleSnapshot( + hp=38, + attack=7, + defense=4, + stamina=22, + hit_rate=0.75, + dodge_rate=0.05, + live_state_multiplier=1.0, + ), + "defeated_flag": "encounter::mine_skeleton_defeated", + "reward_items": ["骷髅碎骨"], + "quest_objectives": ["前往废弃矿洞调查"], + }, + ("黑暗森林入口", "野狼"): { + "enemy_snapshot": BattleSnapshot( + hp=58, + attack=11, + defense=5, + stamina=36, + hit_rate=0.82, + dodge_rate=0.14, + live_state_multiplier=1.0, + ), + "defeated_flag": "", + "reward_items": [], + "quest_objectives": [], + }, + ("森林深处", "森林巨魔"): { + "enemy_snapshot": BattleSnapshot( + hp=120, + attack=15, + defense=10, + stamina=130, + hit_rate=0.88, + dodge_rate=0.1, + live_state_multiplier=1.0, + ), + "defeated_flag": FOREST_TROLL_DEFEATED_FLAG, + "reward_items": [], + "quest_id": MAIN_QUEST_TROLL_ID, + "quest_objectives": [FOREST_TROLL_BOSS_OBJECTIVE], + }, +} + + +def action_time_cost_minutes(action_type: str) -> int: + return ACTION_TIME_COSTS.get(str(action_type or "").upper(), 10) + + +def resolve_battle( + player: BattleSnapshot, + enemy: BattleSnapshot, + *, + player_unarmed: bool = False, +) -> dict[str, Any]: + enemy_power = max(enemy.power, 1.0) + player_power = player.power + + if player_unarmed and player.attack <= enemy.defense: + player_power *= 0.5 + + ratio = player_power / enemy_power + if ratio < 0.6: + outcome = "forced_retreat" + elif ratio < 1.0: + outcome = "pyrrhic_win" + elif ratio < 1.5: + outcome = "normal_win" + else: + outcome = "dominant_win" + + return { + "player_power": round(player_power, 2), + "enemy_power": round(enemy_power, 2), + "ratio": round(ratio, 3), + "outcome": outcome, + } + + +def resolve_trade( + game_state, + *, + merchant_name: str, + item_name: str, + confirm: bool, +) -> dict[str, Any]: + npc = game_state.world.npcs.get(merchant_name) + if npc is None or not npc.can_trade: + return {"applied": False, "reason": "invalid_merchant"} + if npc.location != game_state.player.location: + return {"applied": False, "reason": "merchant_not_here"} + if item_name not in npc.shop_inventory: + return {"applied": False, "reason": "item_not_sold_here"} + + item_info = game_state.world.item_registry.get(item_name) + if item_info is None: + return {"applied": False, "reason": "unknown_item"} + + if not confirm: + return { + "applied": False, + "reason": "awaiting_confirmation", + "price": item_info.value, + } + + if game_state.player.gold < item_info.value: + return {"applied": False, "reason": "insufficient_gold", "price": item_info.value} + + game_state.player.gold -= item_info.value + game_state.player.inventory.append(item_name) + game_state.last_recent_gain = item_name + follow_up_actions = build_contextual_actions(game_state, recent_gain=item_name) + return { + "applied": True, + "reason": "purchased", + "price": item_info.value, + "item_name": item_name, + "follow_up_actions": follow_up_actions, + } + + +def get_battle_encounter(location_name: str, enemy_name: str) -> dict[str, Any] | None: + return BATTLE_ENCOUNTER_CONFIG.get((str(location_name), str(enemy_name))) + + +def _has_status(game_state, keyword: str) -> bool: + keyword = str(keyword or "") + return any(keyword in effect.name for effect in game_state.player.status_effects) + + +def _has_map(game_state) -> bool: + owned_items = set(game_state.player.inventory) | { + str(item) + for item in game_state.player.equipment.values() + if item + } + return any("地图" in item for item in owned_items) + + +def _has_named_map(game_state, map_name: str) -> bool: + owned_items = set(game_state.player.inventory) | { + str(item) + for item in game_state.player.equipment.values() + if item + } + return str(map_name) in owned_items + + +def _is_accessible_destination(game_state, destination: str) -> bool: + target_location = game_state.world.locations.get(destination) + if target_location is None: + return False + if target_location.is_accessible: + return True + required_item = str(target_location.required_item or "") + owned_items = set(game_state.player.inventory) | { + str(item) + for item in game_state.player.equipment.values() + if item + } + return bool(required_item) and required_item in owned_items + + +def _is_route_visible(game_state, destination: str) -> bool: + if destination == "河边渡口": + return bool(game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG)) + return True + + +def _is_forest_troll_hunt_active(game_state) -> bool: + quest = game_state.world.quests.get(MAIN_QUEST_TROLL_ID) + if quest is None or quest.status != "active": + return False + return not bool(game_state.world.global_flags.get(FOREST_TROLL_DEFEATED_FLAG)) + + +def _is_main_quest_report_pending(game_state) -> bool: + quest = game_state.world.quests.get(MAIN_QUEST_ID) + if quest is None or quest.status != "active": + return False + if not game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG): + return False + return not bool(quest.objectives.get(REPORT_TO_CHIEF_OBJECTIVE)) + + +def _find_encounter_location(enemy_name: str) -> str | None: + enemy_name = str(enemy_name or "") + if not enemy_name: + return None + for (location_name, encounter_enemy), _config in BATTLE_ENCOUNTER_CONFIG.items(): + if str(encounter_enemy) == enemy_name: + return str(location_name) + return None + + +def _move_action( + target: str, + *, + priority: int, + text: str | None = None, + preserve_text: bool = False, +) -> dict[str, Any]: + action = _make_action( + action_type="MOVE", + target=target, + text=text or f"前往{target}", + priority=priority, + ) + if preserve_text: + action["preserve_text"] = True + return action + + +def _make_action( + *, + action_type: str, + text: str, + target: Any = None, + priority: int = 50, +) -> dict[str, Any]: + return { + "id": 0, + "text": text, + "action_type": action_type, + "target": target, + "priority": priority, + } + + +def _dedupe_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]: + deduped: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for action in sorted( + actions, + key=lambda item: int(item.get("priority", 0) or 0), + reverse=True, + ): + normalized = dict(action) + normalized.setdefault("priority", 0) + key = ( + str(normalized.get("action_type")), + str(normalized.get("target")), + ) + if key in seen: + continue + seen.add(key) + normalized["id"] = len(deduped) + 1 + deduped.append(normalized) + return deduped + + +def _first_incomplete_objective(game_state): + active_quests = [ + quest + for quest in game_state.world.quests.values() + if quest.status == "active" + ] + active_quests.sort(key=lambda quest: (quest.quest_type != "main", quest.quest_id)) + for quest in active_quests: + for objective, completed in quest.objectives.items(): + if not completed: + return quest, objective + return None, None + + +def _extract_dialogue_target(objective: str) -> str | None: + suffixes = ("对话", "交谈", "确认情报", "了解情况") + text = str(objective or "") + if not text.startswith("与"): + return None + candidate = text[1:] + for separator in ("对话", "交谈"): + if separator in candidate: + candidate = candidate.split(separator, 1)[0] + break + else: + for suffix in suffixes: + if candidate.endswith(suffix): + candidate = candidate[: -len(suffix)] + break + candidate = candidate.strip() + return candidate or None + + +def _extract_location_target(objective: str) -> str | None: + text = str(objective or "") + for prefix in ("前往",): + if text.startswith(prefix): + candidate = text[len(prefix):] + for suffix in ("调查", "探索", "查看", "侦察"): + if candidate.endswith(suffix): + candidate = candidate[: -len(suffix)] + break + return candidate or None + return None + + +def _find_next_step(game_state, destination: str) -> str | None: + if destination == game_state.player.location: + return destination + visited = {game_state.player.location} + queue: deque[tuple[str, list[str]]] = deque([(game_state.player.location, [])]) + while queue: + current, path = queue.popleft() + current_loc = game_state.world.locations.get(current) + if current_loc is None: + continue + for neighbor in current_loc.connected_to: + if neighbor in visited: + continue + visited.add(neighbor) + new_path = path + [neighbor] + if neighbor == destination: + return new_path[0] + queue.append((neighbor, new_path)) + return None + + +def build_village_chief_follow_up_actions(game_state) -> list[dict[str, Any]]: + blacksmith_text = "前往村庄铁匠铺准备武器" + path_text = None + if _is_forest_troll_hunt_active(game_state): + blacksmith_text = "前往村庄铁匠铺准备武器和防具" + path_text = "沿村口小路赶赴森林深处" + return _dedupe_actions( + [ + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=120, + ), + _move_action( + "森林深处" if path_text else "村口小路", + priority=116, + text=path_text, + preserve_text=bool(path_text), + ), + _move_action( + "村庄杂货铺", + priority=112, + text="前往村庄杂货铺准备火把", + preserve_text=True, + ), + _move_action( + "村庄铁匠铺", + priority=108, + text=blacksmith_text, + preserve_text=True, + ), + ] + ) + + +def build_map_actions(game_state) -> list[dict[str, Any]]: + if not _has_map(game_state): + return [] + + current_location = game_state.world.locations.get(game_state.player.location) + if current_location is None: + return [] + + special_route_order = { + "村庄广场": ["村庄铁匠铺", "村庄旅店", "村口小路", "村庄杂货铺"], + "黑暗森林入口": ["村口小路", "溪边营地", "森林深处"], + "河边渡口": ["废弃矿洞入口", "山麓盗贼营", "村口小路"], + "山麓盗贼营": ["精灵遗迹", "河边渡口"], + "古塔废墟": ["村口小路"], + } + route_order = special_route_order.get( + current_location.name, + list(current_location.connected_to), + ) + + actions: list[dict[str, Any]] = [] + for index, destination in enumerate(route_order): + # 不显示"前往当前场景"的无效选项 + if destination == game_state.player.location: + continue + if destination not in current_location.connected_to: + continue + if not _is_accessible_destination(game_state, destination): + continue + required_map = LOCATION_MAP_REQUIREMENTS.get(destination) + if required_map and not _has_named_map(game_state, required_map): + continue + if not _is_route_visible(game_state, destination): + continue + if ( + destination == "森林深处" + and game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG) + and "森林之钥" not in game_state.player.inventory + ): + continue + actions.append( + _move_action( + destination, + priority=120 - index * 4, + ) + ) + + return _dedupe_actions(actions) + + +def build_shop_menu_actions(game_state, merchant_name: str) -> list[dict[str, Any]]: + npc = game_state.world.npcs.get(merchant_name) + if npc is None or not npc.can_trade: + return [] + + actions: list[dict[str, Any]] = [] + player_gold = int(getattr(game_state.player, "gold", 0)) + for index, item_name in enumerate(npc.shop_inventory): + item_info = game_state.world.item_registry.get(item_name) + price = int(item_info.value) if item_info else 0 + affordable = player_gold >= price + if affordable: + label = f"【可购买】购买{item_name}({price}金币)" + priority = 130 - index * 4 + else: + label = f"【金币不足】购买{item_name}({price}金币,当前{player_gold})" + priority = 90 - index * 2 + actions.append( + _make_action( + action_type="TRADE", + target={"merchant": merchant_name, "item": item_name, "confirm": False}, + text=label, + priority=priority, + ) + ) + + actions.append( + _make_action( + action_type="SCENE_OPTIONS", + target=npc.location, + text="暂不购买,先离开柜台", + priority=60, + ) + ) + return _dedupe_actions(actions) + + +def build_scene_actions(game_state, location_name: str | None = None) -> list[dict[str, Any]]: + current_name = str(location_name or game_state.player.location) + actions: list[dict[str, Any]] = [] + + if current_name == "村庄广场": + actions.append( + _make_action( + action_type="TALK", + target="村长老伯", + text="与村长老伯对话", + priority=120, + ) + ) + actions.append( + _make_action( + action_type="RUMOR", + target={"source": "布告栏", "topic": "rumor_menu"}, + text="查看布告栏上的异闻", + priority=108, + ) + ) + if _has_named_map(game_state, "村庄地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=104, + ) + ) + actions.append(_move_action("村庄铁匠铺", priority=100)) + actions.append(_move_action("村庄旅店", priority=96)) + actions.append(_move_action("村口小路", priority=92)) + actions.append(_move_action("村庄杂货铺", priority=88)) + return _dedupe_actions(actions) + + if current_name == "村口小路": + traveler_available = ( + game_state.world.global_flags.get(TRAVELER_RUMOR_HEARD_FLAG) + and game_state.world.npcs.get("神秘旅人") + and game_state.world.npcs["神秘旅人"].location == "村口小路" + ) + if traveler_available: + actions.append( + _make_action( + action_type="TALK", + target="神秘旅人", + text="与神秘旅人交谈", + priority=120, + ) + ) + if game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG): + actions.append(_move_action("河边渡口", priority=112)) + if _has_named_map(game_state, "村庄地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=108, + ) + ) + if _is_main_quest_report_pending(game_state): + actions.append( + _move_action( + "村庄广场", + priority=120, + text="回村向村长汇报发现", + preserve_text=True, + ) + ) + else: + actions.append(_move_action("村庄广场", priority=112)) + if _has_named_map(game_state, "村庄地图"): + actions.append(_move_action("黑暗森林入口", priority=104)) + return _dedupe_actions(actions) + + if current_name == "村庄铁匠铺": + actions.append( + _make_action( + action_type="TALK", + target="铁匠格林", + text="与铁匠格林对话", + priority=120, + ) + ) + if _has_named_map(game_state, "村庄地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=104, + ) + ) + actions.append(_move_action("村庄广场", priority=96)) + return _dedupe_actions(actions) + + if current_name == "村庄旅店": + actions.append( + _make_action( + action_type="TALK", + target="旅店老板娘莉娜", + text="与旅店老板娘莉娜对话", + priority=120, + ) + ) + actions.append( + _make_action( + action_type="RUMOR", + target={"source": "旅店老板娘莉娜", "topic": "rumor_menu"}, + text="向莉娜打听最近的异状", + priority=116, + ) + ) + actions.append( + _make_action( + action_type="REST", + text="在旅店休息片刻", + priority=110, + ) + ) + if _has_named_map(game_state, "村庄地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=104, + ) + ) + actions.append(_move_action("村庄广场", priority=96)) + return _dedupe_actions(actions) + + if current_name == "村庄杂货铺": + actions.append( + _make_action( + action_type="TALK", + target="杂货商人阿尔", + text="与杂货商人阿尔对话", + priority=120, + ) + ) + if _has_named_map(game_state, "村庄地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=104, + ) + ) + actions.append(_move_action("村庄广场", priority=96)) + return _dedupe_actions(actions) + + if current_name == "黑暗森林入口": + goblin_defeated = game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG) + tracks_found = game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG) + barrier_seen = game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG) + troll_hunt_active = _is_forest_troll_hunt_active(game_state) + if not goblin_defeated: + actions.append( + _make_action( + action_type="ATTACK", + target="哥布林", + text="击败哥布林", + priority=120, + ) + ) + else: + if not tracks_found: + actions.append( + _make_action( + action_type="EXPLORE", + target="黑暗森林入口", + text="调查哥布林留下的痕迹", + priority=120, + ) + ) + elif troll_hunt_active and "森林之钥" in game_state.player.inventory: + actions.append( + _move_action( + "森林深处", + priority=120, + text="前往森林深处探索", + preserve_text=True, + ) + ) + elif not barrier_seen: + actions.append(_move_action("森林深处", priority=120)) + elif _is_main_quest_report_pending(game_state): + actions.append( + _move_action( + "村口小路", + priority=120, + text="返回村庄向村长汇报", + preserve_text=True, + ) + ) + if _has_named_map(game_state, "黑暗森林地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=112 if tracks_found else 108, + ) + ) + actions.append(_move_action("溪边营地", priority=104)) + if tracks_found and not barrier_seen and "森林之钥" in game_state.player.inventory: + actions.append( + _move_action( + "森林深处", + priority=116, + text="前往森林深处探索" if troll_hunt_active else None, + preserve_text=troll_hunt_active, + ) + ) + if _is_main_quest_report_pending(game_state) and not barrier_seen and not troll_hunt_active: + actions.append( + _move_action( + "村口小路", + priority=114, + text="先返回村庄汇报情况", + preserve_text=True, + ) + ) + elif not barrier_seen or troll_hunt_active: + actions.append(_move_action("村口小路", priority=96)) + return _dedupe_actions(actions) + + if current_name == "河边渡口": + actions.append( + _make_action( + action_type="TALK", + target="渡口老渔夫", + text="与渡口老渔夫对话", + priority=120, + ) + ) + if _has_named_map(game_state, "山麓地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=116, + ) + ) + actions.append(_move_action("废弃矿洞入口", priority=112)) + actions.append(_move_action("山麓盗贼营", priority=108)) + actions.append(_move_action("村口小路", priority=104)) + return _dedupe_actions(actions) + + if current_name == "山麓盗贼营": + if not game_state.world.global_flags.get("encounter::bandit_scout_defeated"): + actions.append( + _make_action( + action_type="ATTACK", + target="盗贼斥候", + text="击败盗贼斥候", + priority=120, + ) + ) + if _has_named_map(game_state, "山麓地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=112, + ) + ) + actions.append(_move_action("河边渡口", priority=108)) + return _dedupe_actions(actions) + + if current_name == "古塔废墟": + if not game_state.world.global_flags.get("encounter::ancient_tower_wraith_defeated"): + actions.append( + _make_action( + action_type="ATTACK", + target="游荡幽灵", + text="击退游荡幽灵", + priority=120, + ) + ) + if _has_named_map(game_state, "古塔地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=108, + ) + ) + actions.append(_move_action("村口小路", priority=104)) + return _dedupe_actions(actions) + + if current_name == "废弃矿洞入口": + if not game_state.world.global_flags.get("encounter::mine_skeleton_defeated"): + actions.append( + _make_action( + action_type="ATTACK", + target="骷髅兵", + text="击退骷髅兵", + priority=120, + ) + ) + else: + actions.append( + _make_action( + action_type="EXPLORE", + target="废弃矿洞入口", + text="搜查矿洞入口的遗留痕迹", + priority=120, + ) + ) + if _has_named_map(game_state, "山麓地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=108, + ) + ) + actions.append(_move_action("河边渡口", priority=104)) + return _dedupe_actions(actions) + + if current_name == "溪边营地": + actions.append( + _make_action( + action_type="REST", + text="在营地休息恢复体力", + priority=120, + ) + ) + actions.append( + _make_action( + action_type="EXPLORE", + target="溪边营地", + text="搜寻营地遗留的物资线索", + priority=112, + ) + ) + if _has_named_map(game_state, "黑暗森林地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=104, + ) + ) + actions.append(_move_action("黑暗森林入口", priority=96)) + return _dedupe_actions(actions) + + if current_name == "精灵遗迹": + actions.append( + _make_action( + action_type="TALK", + target="遗迹守护者", + text="与遗迹守护者对话", + priority=120, + ) + ) + if _has_named_map(game_state, "山麓地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=108, + ) + ) + actions.append(_move_action("山麓盗贼营", priority=100)) + return _dedupe_actions(actions) + + if current_name == "森林深处": + if game_state.world.global_flags.get(FOREST_TROLL_HOARD_PENDING_FLAG): + actions.append( + _make_action( + action_type="CLAIM_REWARD", + target={"source": "forest_troll_hoard"}, + text="确认拾取洞穴中的战利品", + priority=124, + ) + ) + elif ( + _is_forest_troll_hunt_active(game_state) + and game_state.world.global_flags.get(FOREST_TROLL_INTRO_SEEN_FLAG) + ): + actions.append( + _make_action( + action_type="ATTACK", + target="森林巨魔", + text="攻击森林巨魔", + priority=120, + ) + ) + else: + actions.append( + _make_action( + action_type="EXPLORE", + target="森林深处", + text="深入调查森林异变的根源", + priority=120, + ) + ) + if _has_named_map(game_state, "黑暗森林地图"): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=108, + ) + ) + actions.append(_move_action("黑暗森林入口", priority=100)) + return _dedupe_actions(actions) + + return build_adjacent_actions(game_state) + + +def build_arrival_event(game_state, location_name: str) -> dict[str, Any] | None: + config = ARRIVAL_EVENT_CONFIG.get(str(location_name)) + if config is None: + return None + return { + "event_key": config["event_key"], + "story_text": config["story_text"], + "options": build_scene_actions(game_state, location_name), + } + + +def build_goal_directed_actions(game_state) -> list[dict[str, Any]]: + quest, objective = _first_incomplete_objective(game_state) + if not quest or not objective: + return [] + + actions: list[dict[str, Any]] = [] + inventory = set(game_state.player.inventory) + current_location = game_state.world.locations.get(game_state.player.location) + dialogue_target = _extract_dialogue_target(objective) + if dialogue_target: + npc = game_state.world.npcs.get(dialogue_target) + if npc is None: + npc = next( + ( + candidate + for candidate in game_state.world.npcs.values() + if dialogue_target in candidate.name or candidate.name in objective + ), + None, + ) + if npc and npc.location == game_state.player.location: + actions.append( + _make_action( + action_type="TALK", + target=npc.name, + text=f"与{npc.name}对话", + priority=120, + ) + ) + elif npc: + next_step = _find_next_step(game_state, npc.location) + if next_step: + actions.append( + _make_action( + action_type="MOVE", + target=next_step, + text=f"前往{next_step}", + priority=112, + ) + ) + + location_target = _extract_location_target(objective) + if location_target: + if any("地图" in item for item in inventory): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=110, + ) + ) + if ( + location_target == "森林深处" + and _is_forest_troll_hunt_active(game_state) + and "森林之钥" in inventory + and game_state.player.location != "森林深处" + ): + actions.append( + _make_action( + action_type="MOVE", + target="森林深处", + text="赶赴森林深处", + priority=105, + ) + ) + else: + next_step = _find_next_step(game_state, location_target) + if next_step and next_step != game_state.player.location: + actions.append( + _make_action( + action_type="MOVE", + target=next_step, + text=f"前往{next_step}", + priority=105, + ) + ) + + if location_target == "黑暗森林入口" and game_state.player.location == "村庄广场": + if "火把" not in inventory: + actions.append( + _make_action( + action_type="MOVE", + target="村庄杂货铺", + text="前往村庄杂货铺准备火把", + priority=104, + ) + ) + if ( + not game_state.player.equipment.get("weapon") + and "铁剑" not in inventory + and "短剑" not in inventory + ): + actions.append( + _make_action( + action_type="MOVE", + target="村庄铁匠铺", + text="前往村庄铁匠铺准备武器", + priority=103, + ) + ) + + if "击败" in str(objective): + enemy_name = str(objective).replace("击败", "").replace("森林中的", "").replace("矿洞中的", "").replace("的怪物", "").strip() + encounter_location = _find_encounter_location(enemy_name) + if enemy_name in {"怪物", "敌人"} and current_location: + local_enemies = list(current_location.enemies or []) + if local_enemies: + enemy_name = local_enemies[0] + encounter_location = _find_encounter_location(enemy_name) + else: + hunt_step = next( + ( + neighbor + for neighbor in current_location.connected_to + if game_state.world.locations.get(neighbor) + and game_state.world.locations[neighbor].enemies + ), + None, + ) + if hunt_step: + actions.append( + _make_action( + action_type="MOVE", + target=hunt_step, + text=f"前往{hunt_step}搜索怪物", + priority=108, + ) + ) + enemy_name = "" + if enemy_name and encounter_location and encounter_location != game_state.player.location: + next_step = _find_next_step(game_state, encounter_location) + if next_step and next_step != game_state.player.location: + actions.append( + _make_action( + action_type="MOVE", + target=next_step, + text=f"前往{next_step}", + priority=108, + ) + ) + elif enemy_name: + actions.append( + _make_action( + action_type="ATTACK", + target=enemy_name, + text=f"击败{enemy_name}", + priority=100, + ) + ) + + if "调查" in str(objective) or "找到" in str(objective): + if ( + str(objective) == FOREST_CAUSE_OBJECTIVE + and game_state.player.location == "黑暗森林入口" + and game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG) + ): + actions.append( + _make_action( + action_type="EXPLORE", + target="黑暗森林入口", + text="调查哥布林留下的痕迹", + priority=118, + ) + ) + return _dedupe_actions(actions) + actions.append( + _make_action( + action_type="EXPLORE", + target=game_state.player.location, + text=f"围绕“{objective}”继续调���", + priority=92, + ) + ) + + return _dedupe_actions(actions) + + +def build_adjacent_actions(game_state) -> list[dict[str, Any]]: + current_location = game_state.world.locations.get(game_state.player.location) + if current_location is None: + return [] + + actions: list[dict[str, Any]] = [] + owned_items = set(game_state.player.inventory) | { + str(item) + for item in game_state.player.equipment.values() + if item + } + + for neighbor in current_location.connected_to: + # 不显示"前往当前场景"的无效选项 + if neighbor == game_state.player.location: + continue + target_location = game_state.world.locations.get(neighbor) + if target_location is None: + continue + if not target_location.is_accessible: + required_item = str(target_location.required_item or "") + if not required_item or required_item not in owned_items: + continue + actions.append( + _make_action( + action_type="MOVE", + target=neighbor, + text=f"前往{neighbor}", + priority=78 if not target_location.is_discovered else 72, + ) + ) + + for npc_name in current_location.npcs_present: + if npc_name not in game_state.world.npcs: + continue + actions.append( + _make_action( + action_type="TALK", + target=npc_name, + text=f"与{npc_name}对话", + priority=68, + ) + ) + + return _dedupe_actions(actions) + + +def merge_demo_options( + base_options: list[dict[str, Any]], + *extra_option_groups: list[dict[str, Any]], + limit: int = 3, +) -> list[dict[str, Any]]: + merged: list[dict[str, Any]] = [option for option in base_options if isinstance(option, dict)] + for group in extra_option_groups: + merged.extend(group) + deduped = _dedupe_actions(merged) + return deduped[:limit] + + +def build_contextual_actions( + game_state, + *, + recent_gain: str | None = None, +) -> list[dict[str, Any]]: + actions: list[dict[str, Any]] = [] + inventory = set(game_state.player.inventory) + light_level = str(game_state.world.light_level) + location = game_state.world.locations.get(game_state.player.location) + time_of_day = str(game_state.world.time_of_day) + + if recent_gain: + item_info = game_state.world.item_registry.get(recent_gain) + if item_info and item_info.item_type in {"weapon", "armor", "accessory"}: + slot = "weapon" if item_info.item_type == "weapon" else "armor" + if game_state.player.equipment.get(slot) != recent_gain: + actions.append( + _make_action( + action_type="EQUIP", + target=recent_gain, + text=f"装备{recent_gain}", + priority=100, + ) + ) + if "地图" in str(recent_gain): + actions.append( + _make_action( + action_type="VIEW_MAP", + text="查看地图", + priority=95, + ) + ) + + in_dark_area = ( + light_level in {"黑暗", "昏暗", "幽暗", "漆黑"} + or (location is not None and location.location_type == "dungeon") + or ( + location is not None + and location.location_type in {"wilderness", "special"} + and time_of_day in {"夜晚", "深夜"} + ) + ) + if "火把" in inventory and in_dark_area and not _has_status(game_state, "火把"): + actions.append( + _make_action( + action_type="USE_ITEM", + target="火把", + text="使用火把照明", + priority=90, + ) + ) + + if game_state.player.hp < max(1, game_state.player.max_hp // 2): + for potion_name in ("小型治疗药水", "治疗药水"): + if potion_name in inventory: + actions.append( + _make_action( + action_type="USE_ITEM", + target=potion_name, + text=f"使用{potion_name}", + priority=85, + ) + ) + break + + if game_state.player.hunger < 50: + for food_name in ("面包", "烤肉", "麦酒", "草药包"): + if food_name in inventory: + actions.append( + _make_action( + action_type="USE_ITEM", + target=food_name, + text=f"食用{food_name}", + priority=80, + ) + ) + break + + if ( + game_state.player.location in OVERNIGHT_REST_LOCATIONS + and hasattr(game_state, "can_overnight_rest") + and game_state.can_overnight_rest() + ): + actions.append( + _make_action( + action_type="OVERNIGHT_REST", + text="在此处过夜", + priority=88, + ) + ) + + return _dedupe_actions(actions)