Spaces:
Runtime error
Runtime error
| 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" | |
| "远处传来低沉咆哮,树影间有成双眼睛移动——有什么东西已经感知到了你的闯入。" | |
| ), | |
| }, | |
| } | |
| class BattleSnapshot: | |
| hp: int | |
| attack: int | |
| defense: int | |
| stamina: int | |
| hit_rate: float | |
| dodge_rate: float | |
| live_state_multiplier: float = 1.0 | |
| 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) | |