Spaces:
Runtime error
Runtime error
| """ | |
| state_manager.py - StoryWeaver 状态管理器 | |
| 职责: | |
| 1. 定义游戏世界的完整数据模型(Pydantic BaseModel) | |
| 2. 维护全局状态的唯一真相来源 (Single Source of Truth) | |
| 3. 提供状态变更、校验、一致性检查、序列化等核心方法 | |
| 4. 记录事件日志用于一致性维护 | |
| 设计思路: | |
| - 所有数据结构使用 Pydantic BaseModel,天然支持 JSON 序列化/反序列化 | |
| - GameState 是顶层容器,包含 PlayerState、WorldState、EventLog | |
| - event_log 是一致性维护的灵魂:每次操作都记录快照,用于矛盾检测 | |
| - to_prompt() 方法将结构化数据转为自然语言,注入 LLM 的 System Prompt | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import logging | |
| import random | |
| import re | |
| from typing import Any, Optional | |
| from pydantic import BaseModel, Field | |
| from demo_rules import OVERNIGHT_REST_LOCATIONS, action_time_cost_minutes, build_scene_actions | |
| from utils import clamp | |
| logger = logging.getLogger("StoryWeaver") | |
| # ============================================================ | |
| # 辅助数据模型 | |
| # ============================================================ | |
| class StatusEffect(BaseModel): | |
| """ | |
| 状态效果模型(Buff / Debuff) | |
| 设计思路: | |
| - 每个状态效果有持续时间和属性修正,每回合自动结算 | |
| - source 记录来源,便于在 Prompt 中说明"你身上中了哥布林的毒" | |
| - stackable 控制是否可叠加,防止无限叠加 bug | |
| """ | |
| name: str # 效果名(中毒、祝福、隐身…) | |
| effect_type: str = "debuff" # buff / debuff / neutral | |
| stat_modifiers: dict[str, int] = Field(default_factory=dict) | |
| # 属性修正 {"attack": -3, "defense": +2} | |
| duration: int = 3 # 剩余持续回合数(-1 = 永久) | |
| description: str = "" # 效果描述 | |
| source: str = "" # 来源("哥布林的毒刃") | |
| stackable: bool = False # 是否可叠加 | |
| class ItemInfo(BaseModel): | |
| """ | |
| 物品详情模型 | |
| 设计思路: | |
| - item_type 区分装备/消耗品/任务道具等,不同类型有不同交互逻辑 | |
| - rarity 影响掉落概率和商店价格 | |
| - quest_related 标记任务道具,防止玩家丢弃关键物品 | |
| - lore_text 提供物品背景,丰富生成文本的细节 | |
| """ | |
| name: str # 物品名称 | |
| item_type: str = "misc" # weapon / armor / consumable / quest_item / material / key / misc | |
| description: str = "" # 物品描述 | |
| rarity: str = "common" # common / uncommon / rare / epic / legendary | |
| stat_bonus: dict[str, int] = Field(default_factory=dict) | |
| # 装备时属性加成 {"attack": +5} | |
| usable: bool = False # 是否可主动使用 | |
| use_effect: str = "" # 使用效果描述(如"恢复 30 HP") | |
| value: int = 0 # 商店价值(金币) | |
| quest_related: bool = False # 是否为任务道具 | |
| lore_text: str = "" # 物品背景故事 | |
| class NPCState(BaseModel): | |
| """ | |
| NPC 状态模型 | |
| 设计思路: | |
| - npc_type 决定交互方式(商人可交易、任务NPC可接任务、敌人可战斗) | |
| - memory 是一致性维护的关键:NPC"记住"与玩家的互动历史 | |
| - schedule 模拟 NPC 日常行为,不同时间段出现在不同地点 | |
| - relationship_level 影响对话态度和任务可用性 | |
| """ | |
| name: str # NPC 名称 | |
| npc_type: str = "civilian" # civilian / merchant / quest_giver / enemy / companion / boss | |
| location: str = "" # 所在地点 | |
| attitude: str = "neutral" # friendly / neutral / cautious / hostile | |
| is_alive: bool = True # 是否存活 | |
| description: str = "" # 外观描述 | |
| race: str = "人类" # 种族 | |
| occupation: str = "" # 职业(铁匠、旅店老板、守卫…) | |
| faction: str = "" # 所属阵营 | |
| # --- 交互相关 --- | |
| relationship_level: int = 0 # 与玩家好感度(-100 ~ 100) | |
| dialogue_tags: list[str] = Field(default_factory=list) | |
| # 已触发的对话标签(防止重复触发) | |
| can_trade: bool = False # 是否可交易 | |
| shop_inventory: list[str] = Field(default_factory=list) | |
| # 商店物品(如果是商人) | |
| can_give_quest: bool = False # 是否可发布任务 | |
| available_quests: list[str] = Field(default_factory=list) | |
| # 可发布的任务 ID | |
| # --- 战斗相关(敌人/Boss) --- | |
| hp: int = 0 | |
| max_hp: int = 0 | |
| attack: int = 0 | |
| defense: int = 0 | |
| loot_table: list[str] = Field(default_factory=list) | |
| # 击败后掉落物品 | |
| weakness: str = "" # 弱点(火、光…) | |
| special_ability: str = "" # 特殊能力 | |
| # --- 记忆与行为 --- | |
| memory: list[str] = Field(default_factory=list) | |
| # NPC 记住的关键事件 | |
| schedule: dict[str, str] = Field(default_factory=dict) | |
| # 时间行为表 {"清晨": "在市场摆摊"} | |
| backstory: str = "" # 背景故事 | |
| class QuestRewards(BaseModel): | |
| """ | |
| 任务奖励模型 | |
| 设计思路: | |
| - 奖励类型丰富,覆盖经济、声望、技能、称号等多维度 | |
| - 每种奖励都可选,通过组合实现多样化的奖励体验 | |
| """ | |
| gold: int = 0 # 金币奖励 | |
| experience: int = 0 # 经验值奖励 | |
| items: list[str] = Field(default_factory=list) | |
| # 奖励物品 | |
| reputation_changes: dict[str, int] = Field(default_factory=dict) | |
| # 声望变化 {"精灵族": +10} | |
| karma_change: int = 0 # 善恶值变化 | |
| unlock_location: str = "" # 解锁新地点 | |
| unlock_skill: str = "" # 解锁新技能 | |
| title: str = "" # 解锁称号 | |
| class QuestState(BaseModel): | |
| """ | |
| 任务状态模型 | |
| 设计思路: | |
| - quest_type 区分主线/支线/隐藏任务,影响 UI 展示和优先级 | |
| - objectives 是任务子目标字典,每个子目标独立追踪 | |
| - branching_choices 支持任务内分支(如"放走囚犯"导向不同结局) | |
| - time_limit / turns_remaining 支持限时任务机制 | |
| - prerequisites 保证任务链的逻辑顺序 | |
| """ | |
| quest_id: str # 任务唯一 ID | |
| title: str # 任务名称 | |
| description: str # 任务描述 | |
| quest_type: str = "main" # main / side / hidden / daily | |
| status: str = "active" # active / completed / failed / expired | |
| giver_npc: str = "" # 任务发布者 NPC | |
| # --- 目标 --- | |
| objectives: dict[str, bool] = Field(default_factory=dict) | |
| # 子目标 {"找到钥匙": False, "打开宝箱": False} | |
| # --- 奖励 --- | |
| rewards: QuestRewards = Field(default_factory=QuestRewards) | |
| # --- 约束 --- | |
| time_limit: int = -1 # 限时回合数(-1 = 无限) | |
| turns_remaining: int = -1 # 剩余回合数 | |
| prerequisites: list[str] = Field(default_factory=list) | |
| # 前置任务 ID | |
| level_requirement: int = 1 # 等级要求 | |
| karma_requirement: Optional[int] = None # 善恶值要求 | |
| # --- 分支 --- | |
| branching_choices: dict[str, str] = Field(default_factory=dict) | |
| # 关键选择 {"放走囚犯": "mercy_path"} | |
| chosen_path: str = "" # 已选择的路线 | |
| consequences: list[str] = Field(default_factory=list) | |
| # 完成后的剧情后果描述 | |
| class LocationInfo(BaseModel): | |
| """ | |
| 地点详情模型 | |
| 设计思路: | |
| - connected_to 构成游戏地图的拓扑结构,控制玩家可移动范围 | |
| - danger_level 影响遭遇概率和 NPC 行为 | |
| - is_accessible + required_item 实现"锁门/钥匙"机制 | |
| - ambient_description 用于丰富 LLM 生成的场景描写 | |
| - special_events 支持地点触发式事件 | |
| """ | |
| name: str # 地点名称 | |
| location_type: str = "town" # town / dungeon / wilderness / shop / special | |
| description: str = "" # 地点描述 | |
| connected_to: list[str] = Field(default_factory=list) | |
| # 可前往的相邻地点 | |
| npcs_present: list[str] = Field(default_factory=list) | |
| # 当前在该地点的 NPC | |
| available_items: list[str] = Field(default_factory=list) | |
| # 可拾取/发现的物品 | |
| enemies: list[str] = Field(default_factory=list) | |
| # 可能遭遇的敌人 | |
| danger_level: int = 0 # 危险等级 (0=安全, 10=极度危险) | |
| weather: str = "晴朗" # 当前天气 | |
| is_discovered: bool = False # 是否已被玩家发现 | |
| is_accessible: bool = True # 是否可进入 | |
| required_item: str = "" # 进入所需道具 | |
| ambient_description: str = "" # 环境氛围描述 | |
| special_events: list[str] = Field(default_factory=list) | |
| # 该地点可触发的特殊事件 | |
| rest_available: bool = False # 是否可以休息恢复 | |
| shop_available: bool = False # 是否有商店 | |
| # ============================================================ | |
| # 玩家状态 | |
| # ============================================================ | |
| class PlayerState(BaseModel): | |
| """ | |
| 玩家角色状态(RPG 核心属性) | |
| 设计思路: | |
| - 基础属性 + 战斗属性 + 装备栏 + 社交属性 构成完整的角色模型 | |
| - reputation / karma / relationships 影响 NPC 态度和剧情分支 | |
| - morale / sanity / hunger 增加生存维度,丰富游戏体验 | |
| - known_lore 记录玩家获得的情报,影响可用对话选项 | |
| - death_count 支持"轮回"类剧情彩蛋 | |
| """ | |
| # --- 基础属性 --- | |
| name: str = "旅人" # 玩家名称 | |
| title: str = "无名冒险者" # 称号(随剧情解锁) | |
| level: int = 1 # 等级 | |
| experience: int = 0 # 当前经验值 | |
| exp_to_next_level: int = 100 # 升级所需经验 | |
| # --- 战斗属性 --- | |
| hp: int = 100 # 当前生命值 | |
| max_hp: int = 100 # 最大生命值 | |
| mp: int = 50 # 魔力值 | |
| max_mp: int = 50 # 最大魔力值 | |
| attack: int = 10 # 攻击力 | |
| defense: int = 5 # 防御力 | |
| attack_power: int = 10 # 实战攻击力(基础攻击+装备加成) | |
| defense_power: int = 5 # 实战防御力(基础防御+装备加成) | |
| stamina: int = 100 # 体力值 | |
| max_stamina: int = 100 # 最大体力值 | |
| speed: int = 8 # 速度(影响行动顺序) | |
| luck: int = 5 # 幸运(影响暴击、掉落) | |
| perception: int = 5 # 感知(影响探索发现、陷阱识别) | |
| # --- 装备栏 --- | |
| equipment: dict[str, Optional[str]] = Field(default_factory=lambda: { | |
| "weapon": None, # 武器 | |
| "armor": None, # 护甲 | |
| "accessory": None, # 饰品 | |
| "helmet": None, # 头盔 | |
| "boots": None, # 靴子 | |
| }) | |
| # --- 状态 --- | |
| location: str = "村庄" # 当前所在地点 | |
| inventory: list[str] = Field(default_factory=list) | |
| # 背包物品列表 | |
| skills: list[str] = Field(default_factory=list) | |
| # 已习得技能列表 | |
| status_effects: list[StatusEffect] = Field(default_factory=list) | |
| # 状态效果列表 | |
| gold: int = 50 # 金币 | |
| reputation: dict[str, int] = Field(default_factory=dict) | |
| # 阵营声望 {"精灵族": 10} | |
| morale: int = 100 # 士气(0=崩溃, 100=高昂) | |
| sanity: int = 100 # 理智值(探索黑暗区域消耗) | |
| hunger: int = 100 # 饱食度(0=饥饿惩罚) | |
| karma: int = 0 # 善恶值(正=善, 负=恶) | |
| known_lore: list[str] = Field(default_factory=list) | |
| # 已知传说/情报片段 | |
| relationships: dict[str, int] = Field(default_factory=dict) | |
| # 与特定 NPC 的好感度 | |
| death_count: int = 0 # 累计死亡次数 | |
| # ============================================================ | |
| # 世界状态 | |
| # ============================================================ | |
| class WorldState(BaseModel): | |
| """ | |
| 世界状态容器 | |
| 设计思路: | |
| - 包含所有非玩家的世界数据:地图、NPC、任务、物品注册表 | |
| - time_of_day + day_count + weather + season 构成动态环境系统 | |
| - global_flags 是灵活的剧情标记系统,支持分支判断 | |
| - rumors / active_threats 丰富 NPC 对话内容 | |
| - faction_relations 支持阵营间动态关系 | |
| """ | |
| current_scene: str = "村庄广场" # 当前场景名称 | |
| time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下午 / 黄昏 / 夜晚 / 深夜 | |
| day_count: int = 1 # 当前天数 | |
| weather: str = "晴朗" # 晴朗 / 多云 / 小雨 / 暴风雨 / 大雪 / 浓雾 | |
| light_level: str = "明亮" # 明亮 / 柔和 / 昏暗 / 幽暗 / 漆黑 | |
| time_progress_units: int = 0 # 当前时段内累积的动作耗时点数 | |
| last_weather_change_minutes: int = -999999 # 上次天气变化时的累计分钟数 | |
| season: str = "春" # 春 / 夏 / 秋 / 冬 | |
| # --- 地图 --- | |
| locations: dict[str, LocationInfo] = Field(default_factory=dict) | |
| discovered_locations: list[str] = Field(default_factory=list) | |
| # --- NPC --- | |
| npcs: dict[str, NPCState] = Field(default_factory=dict) | |
| # --- 任务 --- | |
| quests: dict[str, QuestState] = Field(default_factory=dict) | |
| # --- 物品注册表 --- | |
| item_registry: dict[str, ItemInfo] = Field(default_factory=dict) | |
| # --- 全局标记 --- | |
| global_flags: dict[str, bool] = Field(default_factory=dict) | |
| world_events: list[str] = Field(default_factory=list) | |
| # 已发生的全局事件 | |
| recent_environment_events: list["EnvironmentEvent"] = Field(default_factory=list) | |
| active_threats: list[str] = Field(default_factory=list) | |
| # 当前全局威胁 | |
| rumors: list[str] = Field(default_factory=list) | |
| # 流传的传闻 | |
| faction_relations: dict[str, dict[str, str]] = Field(default_factory=dict) | |
| # 阵营间关系 | |
| # ============================================================ | |
| # 事件日志 | |
| # ============================================================ | |
| class GameEvent(BaseModel): | |
| """ | |
| 事件日志模型(一致性维护的关键) | |
| 设计思路: | |
| - 每次状态变更都记录为一个事件,包含完整的上下文信息 | |
| - state_changes 记录该事件引发的状态变更快照 | |
| - consequence_tags 用于后续一致性检查(如 "killed_goblin_king") | |
| - is_reversible 标记不可逆事件,LLM 生成时需特别注意 | |
| - involved_npcs + location 便于按维度检索历史事件 | |
| """ | |
| turn: int # 发生在第几回合 | |
| day: int = 1 # 发生在第几天 | |
| time_of_day: str = "" # 发生时的时段 | |
| event_type: str = "" # COMBAT / DIALOGUE / MOVE / ITEM / QUEST / TRADE / REST / DISCOVERY / DEATH / LEVEL_UP | |
| description: str = "" # 事件简述 | |
| location: str = "" # 事件发生地点 | |
| involved_npcs: list[str] = Field(default_factory=list) | |
| # 涉及的 NPC | |
| state_changes: dict = Field(default_factory=dict) | |
| # 状态变更快照 | |
| player_action: str = "" # 触发该事件的玩家操作 | |
| consequence_tags: list[str] = Field(default_factory=list) | |
| # 后果标签 | |
| is_reversible: bool = True # 是否可逆 | |
| class EnvironmentEvent(BaseModel): | |
| """Structured environment event used by UI, logs, and prompt injection.""" | |
| event_id: str | |
| category: str = "environment" # weather / light / environment | |
| title: str = "" | |
| description: str = "" | |
| location: str = "" | |
| time_of_day: str = "" | |
| weather: str = "" | |
| light_level: str = "" | |
| severity: str = "low" # low / medium / high | |
| state_changes: dict[str, Any] = Field(default_factory=dict) | |
| prompt_hint: str = "" | |
| WorldState.model_rebuild() | |
| # ============================================================ | |
| # 游戏主控类 | |
| # ============================================================ | |
| class GameState: | |
| """ | |
| 游戏全局状态管理器 —— 项目的灵魂 | |
| 职责: | |
| 1. 持有并管理 PlayerState、WorldState、EventLog | |
| 2. 提供状态变更、校验、一致性检查的统一入口 | |
| 3. 将结构化状态序列化为自然语言 Prompt | |
| 4. 每回合自动结算状态效果、时间推进、任务超时等 | |
| 核心设计原则: | |
| - 所有状态修改必须通过 apply_changes() 进入 | |
| - 每次修改都伴随 validate() 校验和 log_event() 记录 | |
| - check_consistency() 在生成前检测可能的矛盾 | |
| """ | |
| def __init__(self, player_name: str = "旅人"): | |
| """初始化游戏状态,创建默认的起始世界""" | |
| self.player = PlayerState(name=player_name) | |
| self.world = WorldState() | |
| self.event_log: list[GameEvent] = [] | |
| self.turn: int = 0 | |
| self.game_mode: str = "exploration" # exploration / combat / dialogue / cutscene / game_over | |
| self.difficulty: str = "normal" # easy / normal / hard | |
| self.story_arc: str = "序章" # 当前故事章节 | |
| self.ending_flags: dict[str, bool] = {} # 结局条件追踪 | |
| self.combat_log: list[str] = [] # 最近战斗记录 | |
| self.achievement_list: list[str] = [] # 已解锁成就 | |
| self.elapsed_minutes_total: int = 0 | |
| self.last_recent_gain: str | None = None | |
| self.last_interacted_npc: str | None = None | |
| # 初始化起始世界 | |
| self._init_starting_world() | |
| self.refresh_combat_stats() | |
| # 純文本地图渲染使用的“当前位置 + 足迹历史” | |
| # current_location 必须始终与 self.player.location 保持一致。 | |
| self.current_location: str = str(self.player.location) | |
| self.location_history: list[str] = [] | |
| self.world.time_progress_units = 36 | |
| self.pending_environment_event: EnvironmentEvent | None = None | |
| self._sync_world_clock() | |
| self.world.light_level = self._determine_light_level() | |
| def _init_starting_world(self): | |
| """ | |
| 创建游戏的起始世界设定。 | |
| 包含初始地点、NPC、任务和物品,为故事提供起点。 | |
| """ | |
| # --- 初始地点 --- | |
| self.world.locations = { | |
| "村庄广场": LocationInfo( | |
| name="村庄广场", | |
| location_type="town", | |
| description="一个宁静的小村庄中心广场,阳光温暖地照耀着鹅卵石路面。周围有几家商铺和一口古老的水井。", | |
| connected_to=["村庄铁匠铺", "村庄旅店", "村口小路", "村庄杂货铺"], | |
| npcs_present=["村长老伯"], | |
| danger_level=0, | |
| is_discovered=True, | |
| rest_available=False, | |
| ambient_description="阳光斑驳地洒在广场上,远处传来铁匠铺叮叮当当的锤声。", | |
| ), | |
| "村庄铁匠铺": LocationInfo( | |
| name="村庄铁匠铺", | |
| location_type="shop", | |
| description="一间热气腾腾的铁匠铺,炉火正旺。墙上挂满了各式武器和护甲。", | |
| connected_to=["村庄广场"], | |
| npcs_present=["铁匠格林"], | |
| danger_level=0, | |
| is_discovered=True, | |
| shop_available=True, | |
| ambient_description="炉火映红了铁匠粗犷的脸庞,空气中弥漫着金属和碳的气味。", | |
| ), | |
| "村庄旅店": LocationInfo( | |
| name="村庄旅店", | |
| location_type="shop", | |
| description="一家温馨的小旅店,空气中弥漫着烤肉和麦酒的香气。壁炉里的火焰跳动着。", | |
| connected_to=["村庄广场"], | |
| npcs_present=["旅店老板娘莉娜"], | |
| danger_level=0, | |
| is_discovered=True, | |
| rest_available=True, | |
| shop_available=True, | |
| ambient_description="壁炉噼啪作响,几位旅客正低声交谈,空气温暖而舒适。", | |
| ), | |
| "村庄杂货铺": LocationInfo( | |
| name="村庄杂货铺", | |
| location_type="shop", | |
| description="一家琳琅满目的杂货铺,从草药到绳索应有尽有。老板是个精明的商人。", | |
| connected_to=["村庄广场"], | |
| npcs_present=["杂货商人阿尔"], | |
| danger_level=0, | |
| is_discovered=True, | |
| shop_available=True, | |
| ambient_description="货架上摆满了稀奇古怪的商品,柜台后的商人正用算盘噼里啪啦地算账。", | |
| ), | |
| "村口小路": LocationInfo( | |
| name="村口小路", | |
| location_type="wilderness", | |
| description="通往村外的一条泥泞小路,两旁长满了野草。远处隐约可见黑暗森林的轮廓。", | |
| connected_to=["村庄广场", "黑暗森林入口"], | |
| danger_level=2, | |
| is_discovered=True, | |
| ambient_description="微风拂过野草,远处的森林在薄雾中若隐若现,传来不知名鸟兽的叫声。", | |
| ), | |
| "黑暗森林入口": LocationInfo( | |
| name="黑暗森林入口", | |
| location_type="wilderness", | |
| description="森林的入口处,参天大树遮蔽了阳光,地面覆盖着厚厚的落叶。一股不祥的气息扑面而来。", | |
| connected_to=["村口小路", "森林深处", "溪边营地"], | |
| enemies=["哥布林", "野狼"], | |
| danger_level=4, | |
| is_discovered=False, | |
| ambient_description="树冠密集得几乎遮蔽了所有阳光,偶尔传来树枝折断的声音,不知道是风还是别的什么。", | |
| ), | |
| "森林深处": LocationInfo( | |
| name="森林深处", | |
| location_type="dungeon", | |
| description="森林的最深处,古树盘根错节。空气中弥漫着腐朽和魔力的气息,据说这里住着森林的主人。", | |
| connected_to=["黑暗森林入口"], | |
| enemies=["哥布林巫师", "巨型蜘蛛", "森林巨魔"], | |
| danger_level=7, | |
| is_discovered=False, | |
| is_accessible=True, | |
| ambient_description="黑暗几乎吞噬了一切,只有奇异的荧光苔藓发出微弱的光。远处传来低沉的咆哮。", | |
| ), | |
| "溪边营地": LocationInfo( | |
| name="溪边营地", | |
| location_type="wilderness", | |
| description="森林中一处难得的开阔地带,一条清澈的小溪从旁流过。适合扎营休息。", | |
| connected_to=["黑暗森林入口"], | |
| danger_level=2, | |
| is_discovered=False, | |
| rest_available=True, | |
| ambient_description="溪水潺潺流淌,偶有鸟鸣声在林间回荡,这里是森林中的一片安宁绿洲。", | |
| ), | |
| # -------- 扩展地点 -------- | |
| "河边渡口": LocationInfo( | |
| name="河边渡口", | |
| location_type="wilderness", | |
| description="一座破旧的木制渡口,宽阔的河流在此缓缓流淌。一艘半沉的渡船拴在码头桩上。", | |
| connected_to=["村口小路", "废弃矿洞入口", "山麓盗贼营"], | |
| npcs_present=["渡口老渔夫"], | |
| danger_level=3, | |
| is_discovered=False, | |
| ambient_description="河水拍打着朽烂的木桩,远处有鹰在盘旋,对岸隐约可见矿洞的轮廓。", | |
| ), | |
| "废弃矿洞入口": LocationInfo( | |
| name="废弃矿洞入口", | |
| location_type="dungeon", | |
| description="荒废多年的铁矿洞,入口被蛛网和碎石半堵。矿道里传来金属碰撞的回声。", | |
| connected_to=["河边渡口", "矿洞深层"], | |
| enemies=["骷髅兵", "矿洞蝙蝠群", "锈铁傀儡"], | |
| danger_level=5, | |
| is_discovered=False, | |
| ambient_description="腐朽的矿车轨道延伸向黑暗深处,空气里弥漫着铁锈和硫磺的气味。", | |
| ), | |
| "矿洞深层": LocationInfo( | |
| name="矿洞深层", | |
| location_type="dungeon", | |
| description="矿洞最深处,一个巨大的地下空间。墙壁上嵌着发光的矿石,中央有一座被遗忘的祭坛。", | |
| connected_to=["废弃矿洞入口"], | |
| enemies=["亡灵矿工", "岩石巨像"], | |
| danger_level=8, | |
| is_discovered=False, | |
| is_accessible=False, | |
| required_item="矿工旧钥匙", | |
| ambient_description="发光矿石将洞穴映成幽蓝色,祭坛上刻着无人能读的文字,隐约有低沉的嗡鸣。", | |
| ), | |
| "山麓盗贼营": LocationInfo( | |
| name="山麓盗贼营", | |
| location_type="wilderness", | |
| description="藏在山脚灌木丛后的盗贼据点,几顶破帐篷围着一堆余烬。看起来已被匆忙弃置。", | |
| connected_to=["河边渡口", "精灵遗迹"], | |
| enemies=["盗贼斥候", "盗贼头目"], | |
| danger_level=5, | |
| is_discovered=False, | |
| ambient_description="地上散落着翻倒的酒桶和吃了一半的干粮,有人走得很匆忙。", | |
| ), | |
| "精灵遗迹": LocationInfo( | |
| name="精灵遗迹", | |
| location_type="special", | |
| description="一片被藤蔓覆盖的古老石柱林,精灵文字在月光下隐约发光。空气中有淡淡的魔力涌动。", | |
| connected_to=["山麓盗贼营"], | |
| npcs_present=["遗迹守护者"], | |
| danger_level=4, | |
| is_discovered=False, | |
| ambient_description="石柱上的符文随风明灭,仿佛在回应某种古老的感召。脚下的青苔异常柔软。", | |
| ), | |
| "古塔废墟": LocationInfo( | |
| name="古塔废墟", | |
| location_type="dungeon", | |
| description="一座半坍塌的石塔,据说曾是某位法师的研究所。顶层似乎还有东西在闪烁。", | |
| connected_to=["村口小路"], | |
| enemies=["石像鬼", "游荡幽灵"], | |
| danger_level=6, | |
| is_discovered=False, | |
| ambient_description="风从塔身的裂缝中呼啸而过,残破的阶梯上覆满了青苔和鸟粪。", | |
| ), | |
| } | |
| self.world.discovered_locations = ["村庄广场", "村庄铁匠铺", "村庄旅店", "村庄杂货铺", "村口小路"] | |
| # 扩展村口小路的连接 —— 链接到新区域 | |
| self.world.locations["村口小路"].connected_to = [ | |
| "村庄广场", "黑暗森林入口", "河边渡口", "古塔废墟", | |
| ] | |
| # --- 初始 NPC --- | |
| self.world.npcs = { | |
| "村长老伯": NPCState( | |
| name="村长老伯", | |
| npc_type="quest_giver", | |
| location="村庄广场", | |
| attitude="friendly", | |
| description="一位白发苍苍但精神矍铄的老人,是这个村庄的领导者。他的眼中带着忧虑。", | |
| race="人类", | |
| occupation="村长", | |
| relationship_level=20, | |
| can_give_quest=True, | |
| available_quests=["main_quest_01"], | |
| memory=[], | |
| schedule={"清晨": "村庄广场", "上午": "村庄广场", "正午": "村庄广场", "下午": "村庄广场", "黄昏": "村庄广场", "夜晚": "村庄旅店"}, | |
| backstory="在这个村庄生活了七十年的老者,见证过上一次暗潮来袭,深知森林中潜伏的危险。", | |
| ), | |
| "铁匠格林": NPCState( | |
| name="铁匠格林", | |
| npc_type="merchant", | |
| location="村庄铁匠铺", | |
| attitude="neutral", | |
| description="一个肌肉发达的中年矮人,手臂上布满烧伤痕迹。沉默寡言但手艺精湛。", | |
| race="矮人", | |
| occupation="铁匠", | |
| can_trade=True, | |
| shop_inventory=["小刀", "短剑", "铁剑", "皮甲", "木盾"], | |
| relationship_level=0, | |
| schedule={"清晨": "村庄铁匠铺", "上午": "村庄铁匠铺", "正午": "村庄铁匠铺", "下午": "村庄铁匠铺", "黄昏": "村庄铁匠铺", "夜晚": "村庄旅店"}, | |
| backstory="曾经是王都的御用铁匠,因一场变故隐居此地。对远方的怪物有独到的了解。", | |
| ), | |
| "旅店老板娘莉娜": NPCState( | |
| name="旅店老板娘莉娜", | |
| npc_type="merchant", | |
| location="村庄旅店", | |
| attitude="friendly", | |
| description="一位热情开朗的红发女子,笑容温暖。她的旅店是村里情报的集散地。", | |
| race="人类", | |
| occupation="旅店老板", | |
| can_trade=True, | |
| shop_inventory=["面包", "烤肉", "麦酒", "草药包"], | |
| relationship_level=10, | |
| schedule={"清晨": "村庄旅店", "上午": "村庄旅店", "正午": "村庄旅店", "下午": "村庄旅店", "黄昏": "村庄旅店", "夜晚": "村庄旅店"}, | |
| backstory="年轻时曾是一名冒险者,后来受伤退役经营旅店。对旅行者总是格外关照。", | |
| ), | |
| "杂货商人阿尔": NPCState( | |
| name="杂货商人阿尔", | |
| npc_type="merchant", | |
| location="村庄杂货铺", | |
| attitude="neutral", | |
| description="一个精明的瘦长男子,鹰钩鼻上架着一副圆框眼镜。善于讨价还价。", | |
| race="人类", | |
| occupation="商人", | |
| can_trade=True, | |
| shop_inventory=["火把", "绳索", "解毒药水", "小型治疗药水"], | |
| relationship_level=-5, | |
| schedule={"清晨": "村庄杂货铺", "上午": "村庄杂货铺", "正午": "村庄广场", "下午": "村庄杂货铺", "黄昏": "村庄杂货铺", "夜晚": "村庄杂货铺"}, | |
| backstory="来自远方的行商,在村中定居多年。对各地的传闻消息灵通,但消息总是要收费的。", | |
| ), | |
| "神秘旅人": NPCState( | |
| name="神秘旅人", | |
| npc_type="quest_giver", | |
| location="村庄旅店", | |
| attitude="cautious", | |
| description="一个身披灰色斗篷的旅人,面容隐藏在兜帽之下,只露出锐利的双眼。", | |
| race="未知", | |
| occupation="旅人", | |
| relationship_level=-10, | |
| can_give_quest=True, | |
| available_quests=["side_quest_01"], | |
| memory=[], | |
| schedule={"清晨": "村庄旅店", "夜晚": "村口小路"}, | |
| backstory="似乎在寻找什么。偶尔从斗篷下露出的手指上有奇异的魔法纹路。", | |
| ), | |
| # -------- 扩展 NPC -------- | |
| "渡口老渔夫": NPCState( | |
| name="渡口老渔夫", | |
| npc_type="quest_giver", | |
| location="河边渡口", | |
| attitude="friendly", | |
| description="一个皮肤黝黑、满脸皱纹的老人,正坐在码头上修补渔网。", | |
| race="人类", | |
| occupation="渔夫", | |
| relationship_level=5, | |
| can_give_quest=True, | |
| available_quests=["side_quest_02"], | |
| memory=[], | |
| schedule={"清晨": "河边渡口", "上午": "河边渡口", "正午": "河边渡口", "下午": "河边渡口", "黄昏": "河边渡口", "夜晚": "村庄旅店"}, | |
| backstory="在这条河边住了四十年,对河流两岸的地形了如指掌。最近总念叨对岸矿洞里的怪响。", | |
| ), | |
| "遗迹守护者": NPCState( | |
| name="遗迹守护者", | |
| npc_type="quest_giver", | |
| location="精灵遗迹", | |
| attitude="cautious", | |
| description="一个身形消瘦的半精灵,穿着褪色的绿袍,眼神中有深深的疲惫。", | |
| race="半精灵", | |
| occupation="守护者", | |
| relationship_level=-5, | |
| can_give_quest=True, | |
| available_quests=["side_quest_03"], | |
| memory=[], | |
| schedule={"清晨": "精灵遗迹", "正午": "精灵遗迹", "夜晚": "精灵遗迹"}, | |
| backstory="最后一位遗迹守护者,独自守护这片先祖的圣地已有三十年。对外来者充满警惕,但内心渴望帮助。", | |
| ), | |
| } | |
| # --- 初始任务 --- | |
| self.world.quests = { | |
| "main_quest_01": QuestState( | |
| quest_id="main_quest_01", | |
| title="森林中的阴影", | |
| description="村长老伯告诉你,最近森林中频繁出现怪物袭击事件。他请求你前往调查。", | |
| quest_type="main", | |
| status="active", | |
| giver_npc="村长老伯", | |
| objectives={ | |
| "与村长对话了解情况": False, | |
| "前往黑暗森林入口调查": False, | |
| "击败森林中的怪物": False, | |
| "调查怪物活动的原因": False, | |
| "与村长老伯对话汇报发现": False, | |
| }, | |
| rewards=QuestRewards( | |
| gold=100, | |
| experience=50, | |
| items=["森林之钥"], | |
| reputation_changes={"村庄": 20}, | |
| karma_change=5, | |
| ), | |
| ), | |
| "main_quest_02": QuestState( | |
| quest_id="main_quest_02", | |
| title="森林深处的咆哮", | |
| description="村长老伯确认森林巨魔已经苏醒,并命你持森林之钥深入黑暗森林,将这头怪物彻底斩杀。", | |
| quest_type="main", | |
| status="inactive", | |
| giver_npc="村长老伯", | |
| objectives={ | |
| "前往森林深处": False, | |
| "击败森林巨魔": False, | |
| }, | |
| rewards=QuestRewards( | |
| gold=0, | |
| experience=90, | |
| reputation_changes={"村庄": 30}, | |
| karma_change=10, | |
| ), | |
| prerequisites=["main_quest_01"], | |
| ), | |
| "side_quest_01": QuestState( | |
| quest_id="side_quest_01", | |
| title="失落的传承", | |
| description="神秘旅人似乎在寻找一件古老的遗物。也许帮助他能得到意想不到的回报。", | |
| quest_type="side", | |
| status="inactive", | |
| giver_npc="神秘旅人", | |
| objectives={ | |
| "与神秘旅人交谈": False, | |
| "找到古老遗物的线索": False, | |
| }, | |
| rewards=QuestRewards( | |
| experience=30, | |
| items=["神秘卷轴"], | |
| unlock_skill="暗影感知", | |
| ), | |
| prerequisites=[], | |
| ), | |
| # -------- 扩展任务 -------- | |
| "side_quest_02": QuestState( | |
| quest_id="side_quest_02", | |
| title="河底的秘密", | |
| description="渡口老渔夫说他最近总在河里捞到奇怪的骨头,而且对岸矿洞方向夜里总是有光。他请你去查明真相。", | |
| quest_type="side", | |
| status="inactive", | |
| giver_npc="渡口老渔夫", | |
| objectives={ | |
| "与渡口老渔夫交谈": False, | |
| "前往废弃矿洞调查": False, | |
| "找到矿洞异常的原因": False, | |
| }, | |
| rewards=QuestRewards( | |
| gold=60, | |
| experience=40, | |
| items=["矿工旧钥匙"], | |
| reputation_changes={"村庄": 10}, | |
| ), | |
| prerequisites=[], | |
| ), | |
| "side_quest_03": QuestState( | |
| quest_id="side_quest_03", | |
| title="守护者的试炼", | |
| description="精灵遗迹的守护者提出一个交换条件:通过她设下的试炼,就能获得古老的精灵祝福。", | |
| quest_type="side", | |
| status="inactive", | |
| giver_npc="遗迹守护者", | |
| objectives={ | |
| "与遗迹守护者交谈": False, | |
| "通过守护者的试炼": False, | |
| }, | |
| rewards=QuestRewards( | |
| experience=50, | |
| unlock_skill="精灵祝福", | |
| karma_change=10, | |
| title="遗迹认可者", | |
| ), | |
| prerequisites=[], | |
| ), | |
| } | |
| # --- 初始物品注册表 --- | |
| self.world.item_registry = { | |
| "小刀": ItemInfo(name="小刀", item_type="weapon", description="一把朴素但实用的小刀,便于近身防身。", rarity="common", stat_bonus={"attack": 2}, value=5), | |
| "短剑": ItemInfo(name="短剑", item_type="weapon", description="一把适合新手携带的短剑,轻便易用。", rarity="common", stat_bonus={"attack": 3}, value=10), | |
| "铁剑": ItemInfo(name="铁剑", item_type="weapon", description="一把标准的铁制长剑,刀锋锐利。", rarity="common", stat_bonus={"attack": 5}, value=30), | |
| "皮甲": ItemInfo(name="皮甲", item_type="armor", description="硬化皮革制成的轻甲,兼顾防护与灵活性。", rarity="common", stat_bonus={"defense": 3}, value=25), | |
| "木盾": ItemInfo(name="木盾", item_type="armor", description="坚硬橡木制成的盾牌,可以抵挡基础攻击。", rarity="common", stat_bonus={"defense": 2}, value=15), | |
| "面包": ItemInfo(name="面包", item_type="consumable", description="新鲜烤制的面包,香气扑鼻。", usable=True, use_effect="恢复 10 饱食度", value=5), | |
| "烤肉": ItemInfo(name="烤肉", item_type="consumable", description="多汁的烤肉,令人食指大动。", usable=True, use_effect="恢复 25 饱食度", value=10), | |
| "麦酒": ItemInfo(name="麦酒", item_type="consumable", description="村庄特产的麦酒,味道醇厚。", usable=True, use_effect="恢复 10 士气,降低 5 理智", value=8), | |
| "草药包": ItemInfo(name="草药包", item_type="consumable", description="采集的新鲜草药,可以制作简单药剂。", usable=True, use_effect="恢复 20 HP", value=15), | |
| "火把": ItemInfo(name="火把", item_type="misc", description="浸过油脂的火把,可在黑暗中照明。", usable=True, use_effect="照亮周围区域", value=3), | |
| "绳索": ItemInfo(name="绳索", item_type="misc", description="结实的麻绳,在探险中很实用。", value=5), | |
| "解毒药水": ItemInfo(name="解毒药水", item_type="consumable", description="散发着清苦气味的药水,可以解除中毒状态。", usable=True, use_effect="解除中毒状态", value=20), | |
| "小型治疗药水": ItemInfo(name="小型治疗药水", item_type="consumable", description="泛着淡红色光芒的药水。", usable=True, use_effect="恢复 30 HP", value=25), | |
| "村庄地图": ItemInfo(name="村庄地图", item_type="quest_item", description="一张画着村庄与周边道路的实用地图,边角处有村长留下的简短记号。", quest_related=True, value=0, lore_text="这张地图把村庄广场、店铺和村口小路都标得很清楚,显然是给初次上路的人准备的。"), | |
| "黑暗森林地图": ItemInfo(name="黑暗森林地图", item_type="quest_item", description="一张补全了森林入口、溪边营地和深处路径的地图。", quest_related=True, value=0, lore_text="地图边缘沾着泥水和血迹,像是刚从危险地带抢出来的。"), | |
| "山麓地图": ItemInfo(name="山麓地图", item_type="quest_item", description="记着渡口、盗贼营和遗迹路径的山麓地图。", quest_related=True, value=0, lore_text="粗糙的炭笔线条标出了山道、渡口和盗贼常走的隐蔽小径。"), | |
| "古塔地图": ItemInfo(name="古塔地图", item_type="quest_item", description="一张标记古塔废墟出入口和危险区域的旧图。", quest_related=True, value=0, lore_text="纸面上反复描重的几处塔层,似乎都是前人特意警告的危险位置。"), | |
| "森林之钥": ItemInfo(name="森林之钥", item_type="key", description="一把散发着微弱绿光的古老钥匙,似乎能打开森林深处的某个入口。", rarity="rare", quest_related=True, value=0, lore_text="钥匙上刻着精灵文字,翻译过来是:'唯有勇者可通行'。"), | |
| "神秘卷轴": ItemInfo(name="神秘卷轴", item_type="quest_item", description="记载着古老知识的卷轴,散发着微弱的魔力波动。", rarity="rare", quest_related=True, value=0), | |
| # -------- 扩展物品 -------- | |
| "矿工旧钥匙": ItemInfo(name="矿工旧钥匙", item_type="key", description="一把生锈的铜钥匙,上面刻着一个矿镐图案。", rarity="uncommon", quest_related=True, value=0, lore_text="钥匙柄上隐约可见'B-7采掘区'的刻字。"), | |
| "骷髅碎骨": ItemInfo(name="骷髅碎骨", item_type="material", description="从骷髅兵身上掉落的骨头碎片,泛着不自然的寒光。", rarity="common", value=5), | |
| "盗贼日志": ItemInfo(name="盗贼日志", item_type="quest_item", description="一本沾满泥渍的笔记本,记录着盗贼团伙近期的行动计划。", rarity="uncommon", quest_related=True, value=0), | |
| "精灵护符": ItemInfo(name="精灵护符", item_type="accessory", description="由精灵遗迹守护者亲手制作的小型护符,散发着柔和的绿色微光。", rarity="rare", stat_bonus={"perception": 3, "sanity": 5}, value=50, lore_text="佩戴者能感受到来自远古精灵的庇佑。"), | |
| "锈蚀铁锤": ItemInfo(name="锈蚀铁锤", item_type="weapon", description="矿洞里发现的旧铁锤,虽然锈迹斑斑但依然沉重有力。", rarity="common", stat_bonus={"attack": 4}, value=15), | |
| "荧光苔藓": ItemInfo(name="荧光苔藓", item_type="consumable", description="矿洞深处生长的发光苔藓,据说有微弱的疗伤效果。", usable=True, use_effect="恢复 15 HP,恢复 5 理智", rarity="uncommon", value=12), | |
| "古塔法师笔记": ItemInfo(name="古塔法师笔记", item_type="quest_item", description="在古塔废墟中找到的残破笔记,记载着某种仪式的片段。", rarity="rare", quest_related=True, value=0, lore_text="字迹已经模糊,但仍能辨认出几个关键的魔法符号。"), | |
| } | |
| # --- 初始传闻 --- | |
| self.world.rumors = [ | |
| "最近森林里的哥布林越来越嚣张了,好几个猎人都不敢进去了。", | |
| "听说铁匠格林以前在王都待过,不知道为什么来了这个小村子。", | |
| "旅店里来了个奇怪的旅人,整天把自己裹得严严实实的。", | |
| "河对岸的旧矿洞晚上闹鬼,渡口的老渔夫说他亲眼看见过蓝色的火光。", | |
| "山脚下好像有一伙盗贼扎了营,最近有商队被劫的消息。", | |
| "村子东边的古塔里据说住过一个法师,后来不知为何法师消失了,塔也荒废了。", | |
| "有人在精灵遗迹附近见到过一个穿绿袍的身影,不知是人是鬼。", | |
| ] | |
| # --- 显式环境事件模板 --- | |
| self.environment_event_pool: list[dict[str, Any]] = [ | |
| { | |
| "event_id": "lanterns_dim", | |
| "category": "light", | |
| "title": "灯火忽暗", | |
| "description": "屋内的灯火突然暗了一截,墙角的影子被拉得细长。", | |
| "location_types": ["town", "shop"], | |
| "time_slots": ["黄昏", "夜晚", "深夜"], | |
| "severity": "medium", | |
| "state_changes": {"sanity_change": -2}, | |
| "prompt_hint": "环境光线明显变暗,叙事里要体现角色对阴影和氛围变化的反应。", | |
| }, | |
| { | |
| "event_id": "cold_gust", | |
| "category": "environment", | |
| "title": "冷风穿林", | |
| "description": "一阵带着湿意的冷风从林间掠过,让人本能地绷紧肩背。", | |
| "location_types": ["wilderness", "dungeon"], | |
| "time_slots": ["下午", "黄昏", "夜晚", "深夜"], | |
| "severity": "low", | |
| "state_changes": {"morale_change": -3}, | |
| "prompt_hint": "风声和体感温度都发生了变化,适合让玩家察觉环境压力正在上升。", | |
| }, | |
| { | |
| "event_id": "forest_rustle", | |
| "category": "environment", | |
| "title": "林影骚动", | |
| "description": "黑暗树影间传来急促的窸窣声,仿佛刚有什么东西贴着边缘掠过。", | |
| "location_types": ["wilderness", "dungeon"], | |
| "time_slots": ["黄昏", "夜晚", "深夜"], | |
| "min_danger": 3, | |
| "severity": "medium", | |
| "state_changes": {"sanity_change": -3}, | |
| "prompt_hint": "这是偏悬疑的环境扰动,至少一个后续选项应允许玩家追查或回避。", | |
| }, | |
| { | |
| "event_id": "fireplace_relief", | |
| "category": "environment", | |
| "title": "火光回暖", | |
| "description": "炉火和热气驱散了紧绷感,让呼吸也慢慢平稳下来。", | |
| "location_types": ["shop", "town"], | |
| "requires_rest_available": True, | |
| "time_slots": ["黄昏", "夜晚", "深夜"], | |
| "severity": "low", | |
| "state_changes": {"morale_change": 4, "sanity_change": 2}, | |
| "prompt_hint": "这是偏正向的氛围事件,叙事里可以体现安全感和短暂放松。", | |
| }, | |
| { | |
| "event_id": "fog_pressures_in", | |
| "category": "environment", | |
| "title": "雾气压近", | |
| "description": "潮湿的雾从地面漫上来,视野被一点点吞没,声音也变得含混。", | |
| "location_types": ["wilderness", "dungeon"], | |
| "time_slots": ["清晨", "黄昏", "夜晚", "深夜"], | |
| "weathers": ["浓雾", "小雨"], | |
| "severity": "medium", | |
| "state_changes": {"sanity_change": -2}, | |
| "prompt_hint": "视野受限且不安感上升,叙事里应弱化远景、强调近距离感官细节。", | |
| }, | |
| ] | |
| # --- 玩家初始装备 --- | |
| self.player.inventory = ["面包", "面包", "小型治疗药水"] | |
| self.player.location = self.world.current_scene | |
| # ============================================================ | |
| # 核心方法 | |
| # ============================================================ | |
| def update_location(self, new_location: str) -> None: | |
| """ | |
| 更新当前位置并维护足迹历史。 | |
| 规则: | |
| - 当 new_location 与当前地点不同:把旧地点写入 location_history,然后更新 current_location | |
| - 当 new_location 相同:不追加历史 | |
| - 同步更新 self.player.location 与 self.world.current_scene,确保一致性 | |
| """ | |
| target = str(new_location or "").strip() | |
| if not target: | |
| return | |
| if target == self.current_location: | |
| return | |
| old_location = self.current_location | |
| if old_location: | |
| self.location_history.append(old_location) | |
| # 让游戏状态和地图状态始终一致 | |
| self.current_location = target | |
| self.player.location = target | |
| self.world.current_scene = target | |
| # ============================================================ | |
| # 状态变更应用 | |
| # ============================================================ | |
| def apply_changes(self, changes: dict) -> list[str]: | |
| """ | |
| 接收 Qwen 返回的状态变更 JSON,校验并应用到当前状态。 | |
| 设计思路: | |
| - LLM 返回的变更是增量式的(如 hp_change: -10),而非绝对值 | |
| - 逐字段解析和应用,确保每个变更都经过校验 | |
| - 返回变更日志列表,方便 UI 展示 | |
| Args: | |
| changes: Qwen 输出中解析出的状态变更字典 | |
| Returns: | |
| 变更描述列表 ["HP: 100 → 90", "位置: 村庄 → 森林"] | |
| """ | |
| change_log: list[str] = [] | |
| # --- 过滤 None 值:LLM 可能将 null 字段返回为 None,全部跳过 --- | |
| _filtered = {} | |
| for k, v in changes.items(): | |
| if v is None: | |
| continue | |
| # 字符串 "None" / "null" 也视为空 | |
| if isinstance(v, str) and v.strip().lower() in ("none", "null", ""): | |
| continue | |
| # 数值 0 的 change 字段无意义,也跳过 | |
| if isinstance(v, (int, float)) and v == 0 and k.endswith("_change"): | |
| continue | |
| # 空列表 / 空字典跳过 | |
| if isinstance(v, (list, dict)) and len(v) == 0: | |
| continue | |
| _filtered[k] = v | |
| changes = _filtered | |
| # --- 玩家属性变更 --- | |
| if "hp_change" in changes: | |
| old_hp = self.player.hp | |
| self.player.hp = clamp( | |
| self.player.hp + int(changes["hp_change"]), | |
| 0, | |
| self.player.max_hp, | |
| ) | |
| if self.player.hp != old_hp: | |
| change_log.append(f"HP: {old_hp} → {self.player.hp}") | |
| if "mp_change" in changes: | |
| old_mp = self.player.mp | |
| self.player.mp = clamp( | |
| self.player.mp + int(changes["mp_change"]), | |
| 0, | |
| self.player.max_mp, | |
| ) | |
| if self.player.mp != old_mp: | |
| change_log.append(f"MP: {old_mp} → {self.player.mp}") | |
| if "gold_change" in changes: | |
| old_gold = self.player.gold | |
| self.player.gold = max(0, self.player.gold + int(changes["gold_change"])) | |
| if self.player.gold != old_gold: | |
| change_log.append(f"金币: {old_gold} → {self.player.gold}") | |
| if "exp_change" in changes: | |
| old_exp = self.player.experience | |
| self.player.experience += int(changes["exp_change"]) | |
| change_log.append(f"经验: {old_exp} → {self.player.experience}") | |
| # 检查是否升级 | |
| while self.player.experience >= self.player.exp_to_next_level: | |
| self._level_up() | |
| change_log.append(f"升级!当前等级: {self.player.level}") | |
| if "morale_change" in changes: | |
| old_morale = self.player.morale | |
| self.player.morale = clamp( | |
| self.player.morale + int(changes["morale_change"]), | |
| 0, 100, | |
| ) | |
| if self.player.morale != old_morale: | |
| change_log.append(f"士气: {old_morale} → {self.player.morale}") | |
| if "sanity_change" in changes: | |
| old_sanity = self.player.sanity | |
| self.player.sanity = clamp( | |
| self.player.sanity + int(changes["sanity_change"]), | |
| 0, 100, | |
| ) | |
| if self.player.sanity != old_sanity: | |
| change_log.append(f"理智: {old_sanity} → {self.player.sanity}") | |
| if "hunger_change" in changes: | |
| old_hunger = self.player.hunger | |
| self.player.hunger = clamp( | |
| self.player.hunger + int(changes["hunger_change"]), | |
| 0, 100, | |
| ) | |
| if self.player.hunger != old_hunger: | |
| change_log.append(f"饱食度: {old_hunger} → {self.player.hunger}") | |
| if "stamina_change" in changes: | |
| old_stamina = self.player.stamina | |
| self.player.stamina = clamp( | |
| self.player.stamina + int(changes["stamina_change"]), | |
| 0, | |
| self.player.max_stamina, | |
| ) | |
| if self.player.stamina != old_stamina: | |
| change_log.append(f"体力: {old_stamina} → {self.player.stamina}") | |
| if "karma_change" in changes: | |
| old_karma = self.player.karma | |
| self.player.karma += int(changes["karma_change"]) | |
| if self.player.karma != old_karma: | |
| change_log.append(f"善恶值: {old_karma} → {self.player.karma}") | |
| # --- 位置变更 --- | |
| if "new_location" in changes: | |
| old_loc = self.player.location | |
| new_loc = str(changes["new_location"]) | |
| if new_loc.strip().lower() not in ("", "none", "null") and new_loc != old_loc: | |
| current_loc = self.world.locations.get(old_loc) | |
| target_loc = self.world.locations.get(new_loc) | |
| if target_loc is None: | |
| change_log.append(f"忽略非法位置变更: {new_loc}") | |
| elif current_loc and new_loc not in current_loc.connected_to: | |
| change_log.append(f"忽略非法位置变更: {old_loc} → {new_loc}") | |
| elif ( | |
| not target_loc.is_accessible | |
| and target_loc.required_item | |
| and target_loc.required_item not in self.player.inventory | |
| ): | |
| change_log.append(f"忽略未解锁地点: {new_loc}") | |
| else: | |
| self.update_location(new_loc) | |
| change_log.append(f"位置: {old_loc} → {new_loc}") | |
| # 发现新地点 | |
| if new_loc not in self.world.discovered_locations: | |
| self.world.discovered_locations.append(new_loc) | |
| change_log.append(f"发现新地点: {new_loc}") | |
| if new_loc in self.world.locations: | |
| self.world.locations[new_loc].is_discovered = True | |
| # --- 物品变更 --- | |
| # 货币关键词列表:这些物品不进背包,而是直接转换为金币 | |
| _CURRENCY_KEYWORDS = ["铜币", "银币", "铜钱", "银两", "金币", "货币", "钱袋", "钱币", "硬币"] | |
| if "items_gained" in changes: | |
| for item in changes["items_gained"]: | |
| item_str = str(item) | |
| # 检查是否为货币类物品 —— 如果是,跳过入背包(金币已通过 gold_change 处理) | |
| is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS) | |
| if is_currency: | |
| # 如果 gold_change 没有设置,尝试自动补偿少量金币 | |
| if "gold_change" not in changes: | |
| old_gold = self.player.gold | |
| self.player.gold += 3 # 默认少量金币 | |
| change_log.append(f"金币: {old_gold} → {self.player.gold}") | |
| logger.info(f"货币物品 '{item_str}' 已转换为金币,不放入背包") | |
| continue | |
| self.player.inventory.append(item_str) | |
| self.last_recent_gain = item_str | |
| change_log.append(f"获得物品: {item}") | |
| if "items_lost" in changes: | |
| for item in changes["items_lost"]: | |
| item_str = str(item) | |
| # 货币类物品也不需要从背包移除 | |
| is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS) | |
| if is_currency: | |
| continue | |
| if item_str in self.player.inventory: | |
| self.player.inventory.remove(item_str) | |
| change_log.append(f"失去物品: {item}") | |
| # --- 技能变更 --- | |
| if "skills_gained" in changes: | |
| for skill in changes["skills_gained"]: | |
| skill_str = str(skill) | |
| if skill_str not in self.player.skills: | |
| self.player.skills.append(skill_str) | |
| change_log.append(f"习得技能: {skill}") | |
| # --- 状态效果 --- | |
| if "status_effects_added" in changes: | |
| for effect_data in changes["status_effects_added"]: | |
| if isinstance(effect_data, dict): | |
| effect = StatusEffect(**effect_data) | |
| self.player.status_effects.append(effect) | |
| # 构建详细的状态效果日志 | |
| parts = [f"获得状态: {effect.name}"] | |
| if effect.description: | |
| parts.append(f"({effect.description})") | |
| if effect.stat_modifiers: | |
| mod_strs = [] | |
| _STAT_CN = { | |
| "hp": "生命", "mp": "魔力", | |
| "attack": "攻击力", "defense": "防御力", | |
| "speed": "速度", "luck": "幸运", | |
| "perception": "感知", "sanity": "理智", | |
| "hunger": "饱食度", "morale": "士气", | |
| "gold": "金币", "karma": "善恶值", | |
| "experience": "经验", | |
| } | |
| for stat, val in effect.stat_modifiers.items(): | |
| cn = _STAT_CN.get(stat, stat) | |
| sign = "+" if val > 0 else "" | |
| mod_strs.append(f"{cn}{sign}{val}/回合") | |
| parts.append(f"[{', '.join(mod_strs)}]") | |
| if effect.duration > 0: | |
| parts.append(f"持续{effect.duration}回合") | |
| elif effect.duration == -1: | |
| parts.append("永久") | |
| change_log.append(" ".join(parts)) | |
| elif isinstance(effect_data, str): | |
| effect = StatusEffect(name=effect_data) | |
| self.player.status_effects.append(effect) | |
| change_log.append(f"获得状态: {effect_data}") | |
| if "status_effects_removed" in changes: | |
| for name in changes["status_effects_removed"]: | |
| self.player.status_effects = [ | |
| e for e in self.player.status_effects if e.name != str(name) | |
| ] | |
| change_log.append(f"移除状态: {name}") | |
| # --- NPC 相关变更 --- | |
| if "npc_changes" in changes: | |
| for npc_name, npc_data in changes["npc_changes"].items(): | |
| if npc_name in self.world.npcs: | |
| npc = self.world.npcs[npc_name] | |
| if "attitude" in npc_data: | |
| new_attitude = str(npc_data["attitude"]) | |
| if npc.attitude != new_attitude: | |
| npc.attitude = new_attitude | |
| change_log.append(f"NPC {npc_name} 态度变为: {npc.attitude}") | |
| if "is_alive" in npc_data: | |
| new_is_alive = bool(npc_data["is_alive"]) | |
| was_alive = npc.is_alive | |
| if npc.is_alive != new_is_alive: | |
| npc.is_alive = new_is_alive | |
| if was_alive and not npc.is_alive: | |
| change_log.append(f"NPC {npc_name} 已死亡") | |
| if "relationship_change" in npc_data: | |
| old_rel = npc.relationship_level | |
| npc.relationship_level = clamp( | |
| npc.relationship_level + int(npc_data["relationship_change"]), | |
| -100, 100, | |
| ) | |
| if npc.relationship_level != old_rel: | |
| change_log.append( | |
| f"NPC {npc_name} 好感度: {old_rel} → {npc.relationship_level}" | |
| ) | |
| if "hp_change" in npc_data: | |
| old_hp = npc.hp | |
| npc.hp = max(0, npc.hp + int(npc_data["hp_change"])) | |
| if npc.hp <= 0: | |
| npc.is_alive = False | |
| change_log.append(f"NPC {npc_name} 被击败") | |
| elif npc.hp != old_hp: | |
| change_log.append(f"NPC {npc_name} HP: {old_hp} → {npc.hp}") | |
| if "memory_add" in npc_data: | |
| npc.memory.append(str(npc_data["memory_add"])) | |
| # --- 任务变更 --- | |
| if "quest_updates" in changes: | |
| for quest_id, quest_data in changes["quest_updates"].items(): | |
| if quest_id in self.world.quests: | |
| quest = self.world.quests[quest_id] | |
| if "objectives_completed" in quest_data: | |
| for obj in quest_data["objectives_completed"]: | |
| if str(obj) in quest.objectives: | |
| quest.objectives[str(obj)] = True | |
| change_log.append(f"完成目标: {obj}") | |
| if "status" in quest_data: | |
| quest.status = str(quest_data["status"]) | |
| _QUEST_STATUS_CN = { | |
| "active": "进行中", "in_progress": "进行中", | |
| "IN_PROGRESS": "进行中", "ACTIVE": "进行中", | |
| "completed": "已完成", "COMPLETED": "已完成", | |
| "failed": "已失败", "FAILED": "已失败", | |
| "expired": "已过期", "EXPIRED": "已过期", | |
| } | |
| status_cn = _QUEST_STATUS_CN.get(quest.status, quest.status) | |
| change_log.append(f"任务【{quest.title}】进展至\"{status_cn}\"") | |
| # --- 世界状态变更 --- | |
| if "weather_change" in changes: | |
| valid_weathers = {"晴朗", "多云", "小雨", "暴风雨", "大雪", "浓雾"} | |
| new_weather = str(changes["weather_change"]) | |
| if new_weather in valid_weathers: | |
| if self.world.weather != new_weather: | |
| self.world.weather = new_weather | |
| self.world.last_weather_change_minutes = self.elapsed_minutes_total | |
| change_log.append(f"天气变为: {self.world.weather}") | |
| else: | |
| logger.warning(f"无效的 weather_change 值 '{new_weather}',已忽略。") | |
| if "time_change" in changes: | |
| valid_times = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"] | |
| new_time = str(changes["time_change"]) | |
| if new_time in valid_times: | |
| old_time = self.world.time_of_day | |
| if new_time != old_time: | |
| self.world.time_of_day = new_time | |
| change_log.append(f"时间流逝: {old_time} → {self.world.time_of_day}") | |
| else: | |
| logger.warning(f"无效的 time_change 值 '{new_time}',已忽略。合法值: {valid_times}") | |
| if "weather_change" in changes or "time_change" in changes: | |
| old_light = self.world.light_level | |
| self.world.light_level = self._determine_light_level() | |
| if old_light != self.world.light_level: | |
| change_log.append(f"光照变化: {old_light} → {self.world.light_level}") | |
| if "global_flags_set" in changes: | |
| for flag, value in changes["global_flags_set"].items(): | |
| self.world.global_flags[flag] = bool(value) | |
| # 全局标记仅内部使用,不展示给用户 | |
| logger.info(f"全局标记设置: {flag} = {value}") | |
| if "world_event" in changes: | |
| world_event = str(changes["world_event"]) | |
| if not self.world.world_events or self.world.world_events[-1] != world_event: | |
| self.world.world_events.append(world_event) | |
| change_log.append(f"世界事件: {world_event}") | |
| # --- 装备变更 --- | |
| if "equip" in changes: | |
| for slot, item_name in changes["equip"].items(): | |
| if slot in self.player.equipment: | |
| old_item = self.player.equipment[slot] | |
| new_item = item_name if item_name and str(item_name).lower() not in ("none", "null", "") else None | |
| # 1. 如果旧装备栏有物品,卸下时放回背包 | |
| if old_item and old_item != "无": | |
| if old_item not in self.player.inventory: | |
| self.player.inventory.append(old_item) | |
| logger.info(f"卸下装备 '{old_item}' 放回背包") | |
| # 2. 如果要装备新物品,从背包中移除 | |
| if new_item: | |
| new_item_str = str(new_item) | |
| if new_item_str in self.player.inventory: | |
| self.player.inventory.remove(new_item_str) | |
| logger.info(f"从背包取出 '{new_item_str}' 装备到 [{slot}]") | |
| self.player.equipment[slot] = new_item | |
| display_old = old_item or "无" | |
| display_new = new_item or "无" | |
| change_log.append(f"装备 [{slot}]: {display_old} → {display_new}") | |
| # --- 玩家称号变更 --- | |
| if "title_change" in changes: | |
| old_title = self.player.title | |
| self.player.title = str(changes["title_change"]) | |
| change_log.append(f"称号: {old_title} → {self.player.title}") | |
| # 战斗派生属性需要与装备和基础属性保持同步 | |
| self.refresh_combat_stats() | |
| if change_log: | |
| logger.info(f"状态变更: {change_log}") | |
| return change_log | |
| def validate(self) -> tuple[bool, list[str]]: | |
| """ | |
| 校验当前状态的合法性。 | |
| 设计思路: | |
| - 检查所有数值是否在合法范围内 | |
| - HP <= 0 时标记游戏结束 | |
| - 理智过低时施加特殊效果 | |
| - 返回 (是否合法, 问题列表) | |
| Returns: | |
| (is_valid, issues): 合法性标志和问题描述列表 | |
| """ | |
| issues: list[str] = [] | |
| # HP 校验 —— 核心逻辑:HP <= 0 触发死亡 | |
| if self.player.hp <= 0: | |
| self.player.hp = 0 | |
| self.game_mode = "game_over" | |
| self.player.death_count += 1 | |
| issues.append("玩家生命值归零,触发死亡结局!") | |
| # MP 范围校验 | |
| self.player.mp = clamp(self.player.mp, 0, self.player.max_mp) | |
| self.player.stamina = clamp(self.player.stamina, 0, self.player.max_stamina) | |
| # 饱食度惩罚 | |
| if self.player.hunger <= 0: | |
| self.player.hunger = 0 | |
| issues.append("玩家极度饥饿,攻击力和防御力下降!") | |
| # 理智值校验 | |
| if self.player.sanity <= 0: | |
| self.player.sanity = 0 | |
| self.game_mode = "game_over" | |
| issues.append("玩家理智归零,陷入疯狂!触发疯狂结局!") | |
| # 士气校验 | |
| if self.player.morale <= 10: | |
| issues.append("玩家士气极低,行动效率降低。") | |
| # 金币不能为负 | |
| if self.player.gold < 0: | |
| self.player.gold = 0 | |
| issues.append("金币不足。") | |
| is_valid = self.game_mode != "game_over" | |
| return is_valid, issues | |
| def get_equipment_stat_bonuses(self) -> dict[str, int]: | |
| """Aggregate stat bonuses from currently equipped items.""" | |
| bonuses: dict[str, int] = {} | |
| for item_name in self.player.equipment.values(): | |
| if not item_name: | |
| continue | |
| item_info = self.world.item_registry.get(str(item_name)) | |
| if item_info is None: | |
| continue | |
| for stat_name, amount in item_info.stat_bonus.items(): | |
| bonuses[stat_name] = bonuses.get(stat_name, 0) + int(amount) | |
| return bonuses | |
| def refresh_combat_stats(self) -> None: | |
| """Refresh deterministic combat stats from base values + equipment bonuses.""" | |
| bonuses = self.get_equipment_stat_bonuses() | |
| self.player.attack_power = max(1, int(self.player.attack) + int(bonuses.get("attack", 0))) | |
| self.player.defense_power = max(0, int(self.player.defense) + int(bonuses.get("defense", 0))) | |
| def _status_multiplier(self, value: int) -> float: | |
| if value >= 90: | |
| return 1.5 | |
| if value >= 80: | |
| return 1.2 | |
| if value >= 30: | |
| return 1.0 | |
| if value >= 20: | |
| return 0.9 | |
| if value >= 10: | |
| return 0.7 | |
| if value >= 5: | |
| return 0.6 | |
| return 0.3 | |
| def get_survival_state_snapshot(self) -> dict[str, Any]: | |
| hunger_multiplier = self._status_multiplier(self.player.hunger) | |
| sanity_multiplier = self._status_multiplier(self.player.sanity) | |
| morale_multiplier = self._status_multiplier(self.player.morale) | |
| # 体力也纳入战斗乘数:低体力会显著降低战斗能力 | |
| stamina_multiplier = self._status_multiplier(self.player.stamina) | |
| combined_multiplier = min( | |
| hunger_multiplier, | |
| sanity_multiplier, | |
| morale_multiplier, | |
| stamina_multiplier, | |
| ) | |
| peak_state = all( | |
| value >= 80 | |
| for value in (self.player.hunger, self.player.sanity, self.player.morale, self.player.stamina) | |
| ) | |
| near_death = sum( | |
| value < 10 | |
| for value in (self.player.hunger, self.player.sanity, self.player.morale) | |
| ) >= 2 | |
| if peak_state: | |
| combined_multiplier *= 1.1 | |
| if near_death: | |
| combined_multiplier *= 0.5 | |
| return { | |
| "hunger_multiplier": hunger_multiplier, | |
| "sanity_multiplier": sanity_multiplier, | |
| "morale_multiplier": morale_multiplier, | |
| "stamina_multiplier": stamina_multiplier, | |
| "combined_multiplier": round(combined_multiplier, 3), | |
| "peak_state": peak_state, | |
| "near_death": near_death, | |
| } | |
| def get_effective_player_stats(self) -> dict[str, int]: | |
| """Return display-oriented effective stats after equipment bonuses.""" | |
| bonuses = self.get_equipment_stat_bonuses() | |
| tracked_stats = ("attack", "defense", "speed", "luck", "perception", "stamina") | |
| state_snapshot = self.get_survival_state_snapshot() | |
| multiplier = float(state_snapshot["combined_multiplier"]) | |
| effective_stats: dict[str, int] = {} | |
| for stat_name in tracked_stats: | |
| base_value = int(getattr(self.player, stat_name)) | |
| bonus_value = int(bonuses.get(stat_name, 0)) | |
| boosted_value = int(round((base_value + bonus_value) * multiplier)) | |
| cap = self.player.max_stamina if stat_name == "stamina" else max(base_value + bonus_value, 1) | |
| if multiplier >= 1: | |
| effective_stats[stat_name] = max(base_value + bonus_value, boosted_value) | |
| else: | |
| effective_stats[stat_name] = clamp(boosted_value, 1, max(cap, boosted_value)) | |
| return effective_stats | |
| def get_clock_minutes(self) -> int: | |
| return int(self.world.time_progress_units) * 10 | |
| def get_minute_of_day(self) -> int: | |
| return self.get_clock_minutes() % (24 * 60) | |
| def get_clock_display(self) -> str: | |
| total_minutes = self.get_clock_minutes() % (24 * 60) | |
| hours = total_minutes // 60 | |
| minutes = total_minutes % 60 | |
| return f"{hours:02d}:{minutes:02d}" | |
| def _time_of_day_from_minutes(self, total_minutes: int) -> str: | |
| minute_of_day = total_minutes % (24 * 60) | |
| if 300 <= minute_of_day < 480: | |
| return "清晨" | |
| if 480 <= minute_of_day < 720: | |
| return "上午" | |
| if 720 <= minute_of_day < 840: | |
| return "正午" | |
| if 840 <= minute_of_day < 1080: | |
| return "下午" | |
| if 1080 <= minute_of_day < 1260: | |
| return "黄昏" | |
| if 1260 <= minute_of_day < 1440: | |
| return "夜晚" | |
| return "深夜" | |
| def _sync_world_clock(self): | |
| self.world.time_of_day = self._time_of_day_from_minutes(self.get_clock_minutes()) | |
| def can_overnight_rest(self) -> bool: | |
| current_loc = self.world.locations.get(self.player.location) | |
| if current_loc is None or not current_loc.rest_available: | |
| return False | |
| if self.player.location not in OVERNIGHT_REST_LOCATIONS: | |
| return False | |
| return self.get_minute_of_day() >= 19 * 60 | |
| def prepare_overnight_rest(self) -> tuple[list[str], dict[str, int]]: | |
| """Advance to next morning and return full-recovery deltas for overnight rest.""" | |
| if not self.can_overnight_rest(): | |
| return [], {} | |
| old_clock = self.get_clock_display() | |
| old_time_of_day = self.world.time_of_day | |
| old_day_count = self.world.day_count | |
| old_light = self.world.light_level | |
| minute_of_day = self.get_minute_of_day() | |
| minutes_until_midnight = (24 * 60) - minute_of_day | |
| target_elapsed_minutes = self.elapsed_minutes_total + minutes_until_midnight + 6 * 60 | |
| self.elapsed_minutes_total = target_elapsed_minutes | |
| self.world.day_count = target_elapsed_minutes // (24 * 60) + 1 | |
| self.world.time_progress_units = (target_elapsed_minutes % (24 * 60)) // 10 | |
| self._sync_world_clock() | |
| self.world.light_level = self._determine_light_level() | |
| tick_log: list[str] = [] | |
| if self.world.day_count != old_day_count: | |
| tick_log.append(f"新的一天!第{self.world.day_count}天") | |
| new_clock = self.get_clock_display() | |
| if new_clock != old_clock: | |
| tick_log.append(f"时间流逝: {old_clock} → {new_clock}") | |
| if self.world.time_of_day != old_time_of_day: | |
| tick_log.append(f"时段变化: {old_time_of_day} → {self.world.time_of_day}") | |
| if self.world.light_level != old_light: | |
| tick_log.append(f"光照变化: {old_light} → {self.world.light_level}") | |
| hunger_cost = 20 if self.player.location == "村庄旅店" else 25 | |
| recovery_changes: dict[str, int] = { | |
| "hp_change": self.player.max_hp - self.player.hp, | |
| "mp_change": self.player.max_mp - self.player.mp, | |
| "stamina_change": self.player.max_stamina - self.player.stamina, | |
| "morale_change": 100 - self.player.morale, | |
| "sanity_change": 100 - self.player.sanity, | |
| "hunger_change": -hunger_cost, | |
| } | |
| return tick_log, { | |
| key: value | |
| for key, value in recovery_changes.items() | |
| if int(value) != 0 | |
| } | |
| def to_prompt(self) -> str: | |
| """ | |
| 将当前完整状态序列化为自然语言描述,注入 System Prompt。 | |
| 设计思路(需求文档核心要求): | |
| - System Prompt 必须包含当前状态描述 | |
| - 描述要全面但简洁,避免 token 浪费 | |
| - 包括:场景、玩家状态、已发生的重要事件、NPC 信息 | |
| - 加入一致性约束指令,提醒 LLM 不要产生矛盾 | |
| """ | |
| # 1. 场景与环境 | |
| scene_desc = ( | |
| f"【当前场景】{self.world.current_scene}\n" | |
| f"【时间】第{self.world.day_count}天 {self.world.time_of_day}\n" | |
| f"【天气】{self.world.weather}\n" | |
| f"【季节】{self.world.season}" | |
| ) | |
| # 2. 玩家状态 | |
| effects_str = "、".join(e.name for e in self.player.status_effects) if self.player.status_effects else "无" | |
| equipped = {k: (v or "无") for k, v in self.player.equipment.items()} | |
| equip_str = "、".join(f"{k}={v}" for k, v in equipped.items()) | |
| # 背包物品标注消耗品/可重复使用 | |
| if self.player.inventory: | |
| inv_items = [] | |
| for item_name in self.player.inventory: | |
| if self.is_item_consumable(item_name): | |
| inv_items.append(f"{item_name}[消耗品]") | |
| else: | |
| inv_items.append(f"{item_name}[可重复使用]") | |
| inventory_str = "、".join(inv_items) | |
| else: | |
| inventory_str = "空" | |
| skills_str = "、".join(self.player.skills) if self.player.skills else "无" | |
| player_desc = ( | |
| f"【玩家】{self.player.name}({self.player.title})\n" | |
| f" 等级: {self.player.level} | 经验: {self.player.experience}/{self.player.exp_to_next_level}\n" | |
| f" HP: {self.player.hp}/{self.player.max_hp}\n" | |
| f" MP: {self.player.mp}/{self.player.max_mp}\n" | |
| f" 攻击: {self.player.attack} | 防御: {self.player.defense} | 实战攻击: {self.player.attack_power} | 实战防御: {self.player.defense_power}\n" | |
| f" 速度: {self.player.speed} | 幸运: {self.player.luck} | 感知: {self.player.perception}\n" | |
| f" 金币: {self.player.gold} | 善恶值: {self.player.karma}\n" | |
| f" 士气: {self.player.morale} | 理智: {self.player.sanity} | 饱食度: {self.player.hunger}\n" | |
| f" 装备: {equip_str}\n" | |
| f" 背包: {inventory_str}\n" | |
| f" 技能: {skills_str}\n" | |
| f" 状态效果: {effects_str}\n" | |
| f" 所在位置: {self.player.location}" | |
| ) | |
| # 3. 当前场景中的 NPC | |
| current_npcs = [ | |
| npc for npc in self.world.npcs.values() | |
| if npc.location == self.player.location and npc.is_alive | |
| ] | |
| if current_npcs: | |
| npc_lines = [] | |
| for npc in current_npcs: | |
| mem = ";".join(npc.memory[-3:]) if npc.memory else "无记忆" | |
| npc_lines.append( | |
| f" - {npc.name}({npc.occupation}, {npc.race}, 态度: {npc.attitude}, " | |
| f"好感度: {npc.relationship_level}, 记忆: {mem})" | |
| ) | |
| npc_desc = "【场景中的NPC】\n" + "\n".join(npc_lines) | |
| else: | |
| npc_desc = "【场景中的NPC】无" | |
| # 4. 当前活跃任务 | |
| active_quests = [q for q in self.world.quests.values() if q.status == "active"] | |
| if active_quests: | |
| quest_lines = [] | |
| for q in active_quests: | |
| objectives = ";".join( | |
| f"{'✅' if done else '❌'}{obj}" for obj, done in q.objectives.items() | |
| ) | |
| time_info = f"(剩余 {q.turns_remaining} 回合)" if q.turns_remaining > 0 else "" | |
| quest_lines.append(f" - [{q.quest_type.upper()}] {q.title}: {objectives}{time_info}") | |
| quest_desc = "【活跃任务】\n" + "\n".join(quest_lines) | |
| else: | |
| quest_desc = "【活跃任务】无" | |
| # 5. 已发现地点的连接关系 | |
| loc_info = self.world.locations.get(self.player.location) | |
| if loc_info: | |
| accessible = [ | |
| name for name in loc_info.connected_to | |
| if name in self.world.locations | |
| and self.world.locations[name].is_accessible | |
| ] | |
| blocked = [ | |
| f"{name}(需要: {self.world.locations[name].required_item})" | |
| for name in loc_info.connected_to | |
| if name in self.world.locations | |
| and not self.world.locations[name].is_accessible | |
| ] | |
| move_desc = f"【可前往的地点】{'、'.join(accessible) if accessible else '无'}" | |
| if blocked: | |
| move_desc += f"\n【被阻挡的地点】{'、'.join(blocked)}" | |
| else: | |
| move_desc = "【可前往的地点】未知" | |
| # 6. 近期事件(最近 5 条) | |
| if self.event_log: | |
| recent = self.event_log[-5:] | |
| event_lines = [f" - [回合{e.turn}] {e.description}" for e in recent] | |
| event_desc = "【近期事件】\n" + "\n".join(event_lines) | |
| else: | |
| event_desc = "【近期事件】无" | |
| # 7. 传闻 | |
| rumors_desc = "" | |
| if self.world.rumors: | |
| rumors_desc = "\n【流传的传闻】\n" + "\n".join(f" - {r}" for r in self.world.rumors[-3:]) | |
| # 8. 显式环境事件 | |
| environment_event_desc = "" | |
| if self.pending_environment_event: | |
| env = self.pending_environment_event | |
| environment_event_desc = ( | |
| f"\n【本回合环境事件 —— 必须融入本次叙事】\n" | |
| f"[{env.category.upper()}|{env.severity}] {env.title}\n" | |
| f"{env.description}\n" | |
| f"{env.prompt_hint}\n" | |
| f"请将此事件自然地融入剧情描写中,作为本回合可感知的环境变化。" | |
| f"玩家可以选择回应、调查、规避或忽视它。至少一个选项应与此事件相关。" | |
| ) | |
| self.pending_environment_event = None # 用后清除 | |
| # 9. 一致性约束指令 | |
| consistency_rules = ( | |
| "\n【一致性约束 —— 你必须严格遵守】\n" | |
| "1. 已死亡的NPC不可再出现或对话(除非有特殊复活剧情)。\n" | |
| "2. 玩家背包中没有的物品不可使用或赠送。\n" | |
| "3. 玩家不可到达未连接的地点,被阻挡的地点需要对应物品才能进入。\n" | |
| "4. 时间线不可回退,已发生的事件不可矛盾。\n" | |
| "5. NPC的态度和记忆应与历史事件一致。\n" | |
| "6. 战斗伤害应考虑攻击力和防御力的差值,结果要合理。\n" | |
| "7. 所有状态变更必须在 state_changes 字段中明确输出。\n" | |
| "8. 每次生成的文本描写必须使用全新的比喻和意象,严禁重复之前回合用过的修辞和句式。\n" | |
| "9. 【物品消耗规则】只有消耗品(药水、食物等一次性物品)在使用后才会消失,应放入 items_lost。" | |
| "非消耗品(哨子、武器、工具、乐器、钥匙等可重复使用的物品)使用后仍然保留在背包中," | |
| "绝对不要将它们放入 items_lost。例如:吹响哨子后哨子仍在背包中;使用火把照明后火把仍在。\n" | |
| "10. 【选项物品约束】生成的选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。" | |
| "不要生成使用玩家不拥有的物品的选项。\n" | |
| '11. 【货币规则】游戏内货币统一为"金币",对应 gold_change 字段。严禁使用"铜币""银币""银两"等名称。' | |
| "任何钱财/财物类收获(如击败怪物掉落的钱币、交易获得的货款等)必须通过 gold_change 表达," | |
| "严禁将任何种类的钱币放入 items_gained。\n" | |
| '12. 【装备规则】装备物品时必须使用 equip 字段指定槽位和物品名称(如 "weapon": "小刀")。' | |
| "系统会自动将装备的物品从背包移到装备栏,并将旧装备放回背包。" | |
| "因此装备操作时不要在 items_lost/items_gained 中重复处理该物品。" | |
| "合法槽位:weapon / armor / accessory / helmet / boots。卸下装备时将对应槽位设为 null。" | |
| ) | |
| # 组合完整 Prompt | |
| full_prompt = "\n\n".join([ | |
| scene_desc, | |
| player_desc, | |
| npc_desc, | |
| quest_desc, | |
| move_desc, | |
| event_desc, | |
| rumors_desc, | |
| environment_event_desc, | |
| consistency_rules, | |
| ]) | |
| return full_prompt | |
| def log_event( | |
| self, | |
| event_type: str, | |
| description: str, | |
| player_action: str = "", | |
| involved_npcs: list[str] | None = None, | |
| state_changes: dict | None = None, | |
| consequence_tags: list[str] | None = None, | |
| is_reversible: bool = True, | |
| ): | |
| """ | |
| 记录一条事件到 event_log。 | |
| 每次状态变更都应该调用此方法,确保完整的历史记录。 | |
| 事件日志是一致性维护的基石。 | |
| """ | |
| event = GameEvent( | |
| turn=self.turn, | |
| day=self.world.day_count, | |
| time_of_day=self.world.time_of_day, | |
| event_type=event_type, | |
| description=description, | |
| location=self.player.location, | |
| involved_npcs=involved_npcs or [], | |
| state_changes=state_changes or {}, | |
| player_action=player_action, | |
| consequence_tags=consequence_tags or [], | |
| is_reversible=is_reversible, | |
| ) | |
| self.event_log.append(event) | |
| logger.info(f"事件记录: [{event_type}] {description}") | |
| def check_consistency(self, proposed_changes: dict) -> list[str]: | |
| """ | |
| 对比事件日志和当前状态,检测拟议变更中的矛盾。 | |
| 设计思路: | |
| - 在 apply_changes 之前调用,预防性检测 | |
| - 返回所有发现的矛盾描述列表 | |
| - 空列表 = 无矛盾,可以安全应用 | |
| 检测维度: | |
| 1. 已死亡 NPC 是否被重新引用 | |
| 2. 不存在的物品是否被消耗 | |
| 3. 不可达的地点是否被移动到 | |
| 4. 任务目标是否已经跳跃完成 | |
| """ | |
| contradictions: list[str] = [] | |
| # 检测1: 已死亡NPC是否被引用 | |
| if "npc_changes" in proposed_changes: | |
| for npc_name in proposed_changes["npc_changes"]: | |
| if npc_name in self.world.npcs and not self.world.npcs[npc_name].is_alive: | |
| contradictions.append( | |
| f"矛盾: 试图与已死亡的NPC '{npc_name}' 交互" | |
| ) | |
| # 检测2: 不存在的物品是否被消耗 | |
| if "items_lost" in proposed_changes: | |
| for item in proposed_changes["items_lost"]: | |
| if str(item) not in self.player.inventory: | |
| contradictions.append( | |
| f"矛盾: 试图消耗不在背包中的物品 '{item}'" | |
| ) | |
| elif not self.is_item_consumable(str(item)): | |
| # 非消耗品不应因使用而消失(交易/丢弃除外,由引擎层判断) | |
| contradictions.append( | |
| f"矛盾: 物品 '{item}' 不是消耗品,使用后不应消失。请将其从 items_lost 中移除。" | |
| ) | |
| # 检测3: 位置移动是否合法 | |
| if "new_location" in proposed_changes: | |
| target = str(proposed_changes["new_location"]) | |
| if target.strip().lower() not in ("", "none", "null") and target != self.player.location: | |
| current_loc = self.world.locations.get(self.player.location) | |
| target_loc = self.world.locations.get(target) | |
| if target_loc is None: | |
| contradictions.append( | |
| f"矛盾: 试图移动到未注册的地点 '{target}'" | |
| ) | |
| if current_loc and target not in current_loc.connected_to: | |
| contradictions.append( | |
| f"矛盾: 试图移动到不相邻的地点 '{target}'(当前位置: {self.player.location})" | |
| ) | |
| if target_loc and not target_loc.is_accessible: | |
| if target_loc.required_item and target_loc.required_item not in self.player.inventory: | |
| contradictions.append( | |
| f"矛盾: 地点 '{target}' 被锁定,需要 '{target_loc.required_item}'" | |
| ) | |
| # 检测4: 金币是否足够(如果是消费操作) | |
| if "gold_change" in proposed_changes: | |
| change = int(proposed_changes["gold_change"]) | |
| if change < 0 and self.player.gold + change < 0: | |
| contradictions.append( | |
| f"矛盾: 金币不足(当前: {self.player.gold},需要: {abs(change)})" | |
| ) | |
| if contradictions: | |
| logger.warning(f"一致性检查发现矛盾: {contradictions}") | |
| return contradictions | |
| def pre_validate_action(self, intent: dict) -> tuple[bool, str]: | |
| """ | |
| 预校验玩家意图的合法性(在 LLM 调用前立即拦截非法操作)。 | |
| 设计思路: | |
| - 在任何 API 调用之前就检测明显违反一致性的操作 | |
| - 不合法时立即驳回,避免浪费 API 调用和回合 | |
| - 检测维度:物品是否在背包/装备中、技能是否已习得、 | |
| raw_input 中是否提及使用不存在的物品 | |
| Returns: | |
| (is_valid, rejection_reason): 合法返回 (True, ""), 否则返回 (False, "拒绝原因") | |
| """ | |
| action = intent.get("intent", "") | |
| raw_target = intent.get("target") | |
| target = intent.get("target", "") or "" | |
| details = intent.get("details", "") or "" | |
| raw_input = intent.get("raw_input", "") or "" | |
| action_upper = str(action or "").upper() | |
| inventory = list(self.player.inventory) | |
| equipped_items = [v for v in self.player.equipment.values() if v] | |
| all_owned = set(inventory) | set(equipped_items) | |
| def normalize_item_phrase(text: str) -> str: | |
| cleaned = str(text or "").strip() | |
| cleaned = re.sub( | |
| r"^(?:喝掉|吃掉|使用|服用|装备|穿上|戴上|拿出|掏出|拔出|举起|喝|吃|用|掉)", | |
| "", | |
| cleaned, | |
| ) | |
| cleaned = re.sub(r"^(?:一瓶|一杯|一口|一个|一份|一块|一把)", "", cleaned) | |
| cleaned = re.sub(r"(?:照明|攻击|挥舞|挥动|一下|试试)$", "", cleaned) | |
| return cleaned.strip() | |
| normalized_target = normalize_item_phrase(target) | |
| def _resolve_dialogue_npc() -> NPCState | None: | |
| """Resolve TALK target from explicit target or alias mentions in free text.""" | |
| text_blob = f"{target} {details} {raw_input}".strip() | |
| if not text_blob: | |
| return None | |
| # 1) Exact NPC name match first. | |
| explicit_target = str(target or "").strip() | |
| if explicit_target and explicit_target in self.world.npcs: | |
| npc = self.world.npcs.get(explicit_target) | |
| if npc and npc.is_alive: | |
| return npc | |
| # 2) Name / occupation fuzzy match from free text (e.g. "和村长聊天"). | |
| alive_npcs = [npc for npc in self.world.npcs.values() if npc.is_alive] | |
| ranked_candidates: list[tuple[int, NPCState]] = [] | |
| for npc in alive_npcs: | |
| score = 0 | |
| if npc.name and npc.name in text_blob: | |
| score += 3 | |
| if explicit_target and ( | |
| (npc.name and explicit_target in npc.name) | |
| or (npc.name and npc.name in explicit_target) | |
| ): | |
| score += 2 | |
| if npc.occupation and npc.occupation in text_blob: | |
| score += 2 | |
| if explicit_target and npc.occupation and ( | |
| explicit_target in npc.occupation or npc.occupation in explicit_target | |
| ): | |
| score += 1 | |
| if score > 0: | |
| ranked_candidates.append((score, npc)) | |
| if ranked_candidates: | |
| ranked_candidates.sort(key=lambda item: item[0], reverse=True) | |
| return ranked_candidates[0][1] | |
| return None | |
| # --- 检测 1: USE_ITEM / EQUIP: target 必须在背包或装备中 --- | |
| if action_upper in ("USE_ITEM", "EQUIP") and target: | |
| if target not in all_owned: | |
| return False, f"你的背包中没有「{target}」,无法使用或装备。" | |
| if action_upper == "EQUIP" and target not in inventory: | |
| if target in equipped_items: | |
| return False, f"「{target}」已经装备在身上了。" | |
| return False, f"你的背包中没有「{target}」,无法装备。" | |
| # 体力耗尽时禁止移动和战斗 | |
| if action_upper in ("MOVE", "ATTACK", "COMBAT") and self.player.stamina <= 0: | |
| return False, "你精疲力竭,体力耗尽,无法行动。需要先在旅店或营地休息恢复体力。" | |
| if action_upper == "TRADE": | |
| if not isinstance(raw_target, dict): | |
| return False, "交易指令缺少商品信息,请从商店列表中选择要购买的物品。" | |
| merchant_name = str(raw_target.get("merchant") or "") | |
| item_name = str(raw_target.get("item") or raw_target.get("item_name") or "") | |
| if not merchant_name or not item_name: | |
| return False, "交易信息不完整,请重新从商店列表选择商品。" | |
| if action_upper in ("ATTACK", "COMBAT"): | |
| scene_actions = build_scene_actions(self, self.player.location) | |
| attack_targets = [ | |
| str(option.get("target")) | |
| for option in scene_actions | |
| if str(option.get("action_type", "")).upper() == "ATTACK" | |
| and isinstance(option.get("target"), str) | |
| and str(option.get("target")).strip() | |
| ] | |
| if target: | |
| if target not in attack_targets: | |
| if attack_targets: | |
| return ( | |
| False, | |
| f"当前无法攻击「{target}」。你现在可攻击的目标只有:{'、'.join(attack_targets)}。", | |
| ) | |
| return False, "当前场景没有可攻击目标,先观察环境或移动到有敌人的地点。" | |
| elif not attack_targets: | |
| return False, "当前场景没有可攻击目标,先观察环境或移动到有敌人的地点。" | |
| if action_upper == "MOVE" and target: | |
| current_loc = self.world.locations.get(self.player.location) | |
| target_loc = self.world.locations.get(str(target)) | |
| if current_loc is None or target_loc is None: | |
| return False, "你无法前往一个未注册的地点。" | |
| if str(target) not in current_loc.connected_to: | |
| return False, f"当前位置只能前往相邻地点,不能直接前往「{target}」。" | |
| if not target_loc.is_accessible: | |
| if target_loc.required_item and target_loc.required_item not in all_owned: | |
| return False, f"「{target}」尚未解锁,需要「{target_loc.required_item}」。" | |
| return False, f"「{target}」当前无法进入。" | |
| if action_upper == "VIEW_MAP": | |
| if not any("地图" in item for item in all_owned): | |
| return False, "你还没有获得可查看的地图。" | |
| # --- 检测 2: TALK: 对话对象必须在当前地点 --- | |
| if action_upper == "TALK": | |
| dialogue_npc = _resolve_dialogue_npc() | |
| if dialogue_npc is not None: | |
| if dialogue_npc.location != self.player.location: | |
| return ( | |
| False, | |
| f"「{dialogue_npc.name}」目前在「{dialogue_npc.location}」,你现在在「{self.player.location}」。" | |
| f"无法隔空对话,请先前往对方所在地点。" | |
| ) | |
| else: | |
| local_alive_npcs = [ | |
| npc.name | |
| for npc in self.world.npcs.values() | |
| if npc.is_alive and npc.location == self.player.location | |
| ] | |
| if not local_alive_npcs: | |
| return False, "这里没有可对话的角色,无法进行聊天。" | |
| # --- 检测 2: SKILL: 必须已习得 --- | |
| if action_upper == "SKILL" and target: | |
| if target not in self.player.skills: | |
| return False, f"你尚未习得技能「{target}」。" | |
| wants_overnight_rest = action_upper == "OVERNIGHT_REST" or ( | |
| action_upper == "REST" | |
| and any(keyword in f"{details} {raw_input}" for keyword in ("过夜", "睡一晚", "住一晚", "睡到天亮")) | |
| ) | |
| # --- 检测 3: REST/OVERNIGHT_REST: 当前位置必须允许休息 --- | |
| if action_upper in ("REST", "OVERNIGHT_REST"): | |
| current_loc = self.world.locations.get(self.player.location) | |
| if current_loc is None or not current_loc.rest_available: | |
| return False, "这里不适合休息,试着前往旅店或营地。" | |
| if wants_overnight_rest and not self.can_overnight_rest(): | |
| return False, "现在还不能在这里过夜。晚上七点后,且仅限旅店或溪边营地。" | |
| # --- 检测 4: 扫描 raw_input 中是否在使用上下文中引用了未拥有的已知物品 --- | |
| known_items: set[str] = set(self.world.item_registry.keys()) | |
| for event in self.event_log: | |
| sc = event.state_changes | |
| if isinstance(sc, dict): | |
| for item in sc.get("items_gained", []): | |
| known_items.add(str(item)) | |
| for item in sc.get("items_lost", []): | |
| known_items.add(str(item)) | |
| unavailable_known = { | |
| item for item in known_items | |
| if item not in all_owned and len(item) >= 2 | |
| } | |
| use_verbs = [ | |
| "使用", "用", "吃", "喝", "装备", "穿上", "戴上", | |
| "拿出", "掏出", "挥舞", "举起", "服用", "食用", "拔出", "拿起", | |
| ] | |
| for item_name in unavailable_known: | |
| if item_name not in raw_input: | |
| continue | |
| for verb in use_verbs: | |
| if verb + item_name in raw_input: | |
| return False, f"你的背包中没有「{item_name}」,无法{verb}。" | |
| # --- 检测 5: 检查 raw_input 中是否提及使用完全未知的物品 --- | |
| # 匹配常见的"使用物品"语句模式,提取物品名称并校验 | |
| extraction_patterns = [ | |
| (r'(?:掏出|拿出|拔出|举起)(.{2,8}?)(?:$|[,。!?,\s来])', "使用"), | |
| (r'吃(?:一个|一块|一份|了个|了一个)?(.{2,8}?)(?:$|[,。!?,\s来])', "吃"), | |
| (r'喝(?:一瓶|一杯|一口|了一瓶|了)?(.{2,8}?)(?:$|[,。!?,\s来])', "喝"), | |
| (r'用(.{2,6}?)(?:打|攻击|砍|刺|射|劈|挡|切|割)', "使用"), | |
| ] | |
| non_item_words = { | |
| "拳头", "双手", "手", "脚", "头", "身体", | |
| "魔法", "技能", "力量", "勇气", "智慧", | |
| "办法", "方法", "速度", "周围", "四周", | |
| } | |
| full_text = raw_input + " " + details | |
| for pattern, verb_desc in extraction_patterns: | |
| match = re.search(pattern, full_text) | |
| if match: | |
| mentioned = normalize_item_phrase(match.group(1).strip()) | |
| if not mentioned or mentioned in non_item_words: | |
| continue | |
| if mentioned not in all_owned: | |
| if normalized_target and ( | |
| mentioned in normalized_target | |
| or normalized_target in mentioned | |
| ): | |
| continue | |
| # 模糊匹配:"剑" 可能是 "铁剑" 的简称 | |
| fuzzy_match = any( | |
| mentioned in normalize_item_phrase(owned) | |
| or normalize_item_phrase(owned) in mentioned | |
| for owned in all_owned | |
| ) | |
| if not fuzzy_match: | |
| return False, f"你并没有「{mentioned}」,请检查你的背包。" | |
| return True, "" | |
| def is_game_over(self) -> bool: | |
| """ | |
| 判断游戏是否结束。 | |
| 结束条件: | |
| 1. HP <= 0(死亡) | |
| 2. 理智 <= 0(疯狂) | |
| 3. 触发终局标记 | |
| """ | |
| if self.player.hp <= 0: | |
| return True | |
| if self.player.sanity <= 0: | |
| return True | |
| if self.game_mode == "game_over": | |
| return True | |
| # 检查终局标记 | |
| if self.ending_flags.get("game_complete", False): | |
| return True | |
| return False | |
| def tick_time(self, player_intent: Optional[dict] = None) -> list[str]: | |
| """ | |
| 按动作消耗推进游戏时间。 | |
| 设计思路: | |
| - 回合数递增 | |
| - 不同动作消耗不同的时间点数 | |
| - 累积点数达到阈值时,时间段按固定顺序轮转 | |
| - 每过一个完整日夜循环,天数+1 | |
| - 自动减少饱食度,模拟饥饿机制 | |
| - 结算状态效果持续时间 | |
| - 检查限时任务 | |
| Returns: | |
| tick_log: 本回合时间流逝引起的状态变化描述列表 | |
| """ | |
| tick_log: list[str] = [] | |
| self.turn += 1 | |
| action_units = self._estimate_time_cost_units(player_intent) | |
| if action_units <= 0: | |
| return tick_log | |
| old_clock = self.get_clock_display() | |
| old_time_of_day = self.world.time_of_day | |
| previous_units = self.world.time_progress_units | |
| total_units = previous_units + action_units | |
| day_rollovers = total_units // 144 | |
| self.world.time_progress_units = total_units % 144 | |
| if day_rollovers > 0: | |
| self.world.day_count += day_rollovers | |
| tick_log.append(f"新的一天!第 {self.world.day_count} 天") | |
| action_minutes = action_units * 10 | |
| self.elapsed_minutes_total += action_minutes | |
| self._sync_world_clock() | |
| new_clock = self.get_clock_display() | |
| if new_clock != old_clock: | |
| tick_log.append(f"时间流逝: {old_clock} → {new_clock}") | |
| if self.world.time_of_day != old_time_of_day: | |
| tick_log.append(f"时段变化: {old_time_of_day} → {self.world.time_of_day}") | |
| intent_name = str((player_intent or {}).get("intent", "")).upper() | |
| previous_elapsed_minutes = self.elapsed_minutes_total - action_minutes | |
| crossed_half_hours = ( | |
| self.elapsed_minutes_total // 30 | |
| - previous_elapsed_minutes // 30 | |
| ) | |
| hunger_delta = -int(max(crossed_half_hours, 0)) | |
| thirty_minute_blocks = max(1, (action_minutes + 29) // 30) | |
| if intent_name in {"MOVE"}: | |
| stamina_delta = -3 * thirty_minute_blocks | |
| elif intent_name in {"ATTACK", "COMBAT"}: | |
| stamina_delta = -5 * thirty_minute_blocks | |
| elif intent_name == "REST": | |
| stamina_delta = 12 * thirty_minute_blocks | |
| else: | |
| stamina_delta = 0 | |
| old_hunger = self.player.hunger | |
| self.player.hunger = clamp(self.player.hunger + hunger_delta, 0, 100) | |
| if self.player.hunger != old_hunger: | |
| tick_log.append(f"饱食度: {old_hunger} → {self.player.hunger}") | |
| old_stamina = self.player.stamina | |
| self.player.stamina = clamp(self.player.stamina + stamina_delta, 0, self.player.max_stamina) | |
| if self.player.stamina != old_stamina: | |
| tick_log.append(f"体力: {old_stamina} → {self.player.stamina}") | |
| crossed_half_days = self.elapsed_minutes_total // 720 - (self.elapsed_minutes_total - action_minutes) // 720 | |
| for _ in range(max(crossed_half_days, 0)): | |
| if self.player.hunger <= 0: | |
| old_hp = self.player.hp | |
| hp_loss = max(1, int(round(self.player.max_hp * 0.1))) | |
| self.player.hp = max(0, self.player.hp - hp_loss) | |
| tick_log.append(f"饥饿伤害: {old_hp} → {self.player.hp}") | |
| elif self.player.hunger > 80: | |
| old_hp = self.player.hp | |
| hp_gain = max(1, int(round(self.player.max_hp * 0.03))) | |
| self.player.hp = min(self.player.max_hp, self.player.hp + hp_gain) | |
| if self.player.hp != old_hp: | |
| tick_log.append(f"充足补给恢复: {old_hp} → {self.player.hp}") | |
| effect_log = self._apply_status_effects() | |
| tick_log.extend(effect_log) | |
| self._check_quest_deadlines() | |
| self._update_npc_schedules() | |
| self._update_environment_cycle(tick_log) | |
| return tick_log | |
| def _estimate_time_cost_units(self, player_intent: Optional[dict] = None) -> int: | |
| """Estimate how much in-world time a player action should consume.""" | |
| if not isinstance(player_intent, dict): | |
| return 3 | |
| action_type = str(player_intent.get("intent", "")).upper() | |
| return max(1, action_time_cost_minutes(action_type) // 10) | |
| def _determine_light_level(self) -> str: | |
| """Derive current light level from time of day and weather.""" | |
| base_levels = { | |
| "清晨": "柔和", | |
| "上午": "明亮", | |
| "正午": "明亮", | |
| "下午": "柔和", | |
| "黄昏": "昏暗", | |
| "夜晚": "幽暗", | |
| "深夜": "漆黑", | |
| } | |
| ordered_levels = ["明亮", "柔和", "昏暗", "幽暗", "漆黑"] | |
| weather_penalty = { | |
| "晴朗": 0, | |
| "多云": 0, | |
| "小雨": 1, | |
| "大雪": 1, | |
| "浓雾": 1, | |
| "暴风雨": 2, | |
| } | |
| base_level = base_levels.get(self.world.time_of_day, "柔和") | |
| current_index = ordered_levels.index(base_level) | |
| darker_by = weather_penalty.get(self.world.weather, 0) | |
| next_index = min(len(ordered_levels) - 1, current_index + darker_by) | |
| return ordered_levels[next_index] | |
| def _update_environment_cycle(self, tick_log: list[str]): | |
| """Advance light/weather and roll explicit environment events.""" | |
| self.pending_environment_event = None | |
| self._update_light_level_event(tick_log) | |
| self._maybe_shift_weather(tick_log) | |
| self._update_light_level_event(tick_log) | |
| self._roll_environment_event(tick_log) | |
| def _update_light_level_event(self, tick_log: list[str]): | |
| old_light = self.world.light_level | |
| new_light = self._determine_light_level() | |
| if new_light == old_light: | |
| return | |
| self.world.light_level = new_light | |
| event = EnvironmentEvent( | |
| event_id=f"light-{self.turn}", | |
| category="light", | |
| title=f"光照转为{new_light}", | |
| description=f"随着时间与天气变化,周围环境现在呈现出{new_light}的光照状态。", | |
| location=self.player.location, | |
| time_of_day=self.world.time_of_day, | |
| weather=self.world.weather, | |
| light_level=new_light, | |
| severity="medium" if new_light in {"幽暗", "漆黑"} else "low", | |
| prompt_hint="请在叙事中体现能见度、阴影和角色主观感受的变化。", | |
| ) | |
| self._register_environment_event( | |
| event, | |
| tick_log, | |
| inject_prompt=new_light in {"昏暗", "幽暗", "漆黑"}, | |
| ) | |
| def _maybe_shift_weather(self, tick_log: list[str]): | |
| """Occasionally shift weather to keep the environment dynamic.""" | |
| if self.elapsed_minutes_total - int(self.world.last_weather_change_minutes) < 180: | |
| return | |
| chance = 0.18 if self.world.time_of_day in {"清晨", "黄昏"} else 0.08 | |
| if random.random() >= chance: | |
| return | |
| weather_transitions = { | |
| "晴朗": ["多云", "小雨"], | |
| "多云": ["晴朗", "小雨", "浓雾"], | |
| "小雨": ["多云", "暴风雨", "浓雾"], | |
| "浓雾": ["多云", "小雨", "晴朗"], | |
| "暴风雨": ["小雨", "多云"], | |
| "大雪": ["多云"], | |
| } | |
| next_candidates = weather_transitions.get(self.world.weather, ["晴朗", "多云"]) | |
| new_weather = random.choice(next_candidates) | |
| if new_weather == self.world.weather: | |
| return | |
| loc = self.world.locations.get(self.player.location) | |
| weather_effects: dict[str, Any] = {"weather_change": new_weather} | |
| if loc and loc.location_type in {"wilderness", "dungeon"}: | |
| if new_weather == "暴风雨": | |
| weather_effects.update({"morale_change": -3, "sanity_change": -2}) | |
| elif new_weather == "浓雾": | |
| weather_effects.update({"sanity_change": -1}) | |
| elif new_weather == "晴朗" and self.world.weather in {"小雨", "浓雾", "暴风雨"}: | |
| weather_effects.update({"morale_change": 2}) | |
| event = EnvironmentEvent( | |
| event_id=f"weather-{self.turn}", | |
| category="weather", | |
| title=f"天气转为{new_weather}", | |
| description=f"周围的天象正在变化,空气与视野都随着天气转向{new_weather}。", | |
| location=self.player.location, | |
| time_of_day=self.world.time_of_day, | |
| weather=new_weather, | |
| light_level=self.world.light_level, | |
| severity="medium" if new_weather in {"暴风雨", "浓雾"} else "low", | |
| state_changes=weather_effects, | |
| prompt_hint="请把天气变化作为当前回合的重要氛围来源,影响角色观察和选择。", | |
| ) | |
| self._register_environment_event(event, tick_log, inject_prompt=True) | |
| self.world.light_level = self._determine_light_level() | |
| def _roll_environment_event(self, tick_log: list[str]): | |
| """Roll a structured environment event using explicit template filters.""" | |
| loc = self.world.locations.get(self.player.location) | |
| if loc is None or not self.environment_event_pool: | |
| return | |
| # 提高基础触发率,让环境事件更常进入叙事与决策反馈 | |
| chance = 0.15 | |
| if loc.danger_level >= 3: | |
| chance += 0.1 | |
| if self.world.light_level in {"幽暗", "漆黑"}: | |
| chance += 0.08 | |
| if self.world.weather in {"暴风雨", "浓雾"}: | |
| chance += 0.06 | |
| if random.random() >= chance: | |
| return | |
| candidates: list[dict[str, Any]] = [] | |
| for template in self.environment_event_pool: | |
| if template.get("location_types") and loc.location_type not in template["location_types"]: | |
| continue | |
| if template.get("time_slots") and self.world.time_of_day not in template["time_slots"]: | |
| continue | |
| if template.get("weathers") and self.world.weather not in template["weathers"]: | |
| continue | |
| if template.get("requires_rest_available") and not loc.rest_available: | |
| continue | |
| if loc.danger_level < int(template.get("min_danger", 0)): | |
| continue | |
| candidates.append(template) | |
| if not candidates: | |
| return | |
| template = random.choice(candidates) | |
| event = EnvironmentEvent( | |
| event_id=f"{template['event_id']}-{self.turn}", | |
| category=str(template.get("category", "environment")), | |
| title=str(template.get("title", "环境异动")), | |
| description=str(template.get("description", "")), | |
| location=self.player.location, | |
| time_of_day=self.world.time_of_day, | |
| weather=self.world.weather, | |
| light_level=self.world.light_level, | |
| severity=str(template.get("severity", "low")), | |
| state_changes=copy.deepcopy(template.get("state_changes", {})), | |
| prompt_hint=str(template.get("prompt_hint", "")), | |
| ) | |
| self._register_environment_event(event, tick_log, inject_prompt=True) | |
| def _register_environment_event( | |
| self, | |
| event: EnvironmentEvent, | |
| tick_log: list[str], | |
| *, | |
| inject_prompt: bool, | |
| ): | |
| """Persist an environment event, optionally inject it into the next prompt, and apply effects.""" | |
| self.world.recent_environment_events.append(event) | |
| self.world.recent_environment_events = self.world.recent_environment_events[-8:] | |
| if inject_prompt: | |
| self.pending_environment_event = event | |
| if event.category == "light": | |
| tick_log.append(f"光照变化: {event.title}") | |
| changes = copy.deepcopy(event.state_changes) | |
| changes.setdefault("world_event", event.title) | |
| change_log = self.apply_changes(changes) | |
| tick_log.extend(change_log) | |
| logger.info("环境事件触发: %s", event.title) | |
| def _apply_status_effects(self) -> list[str]: | |
| """每回合结算状态效果:应用修正、递减持续时间、移除过期效果 | |
| Returns: | |
| effect_log: 状态效果结算引起的变化描述列表 | |
| """ | |
| effect_log: list[str] = [] | |
| expired = [] | |
| for effect in self.player.status_effects: | |
| # 应用属性修正(每回合) | |
| if "hp" in effect.stat_modifiers: | |
| old_hp = self.player.hp | |
| self.player.hp = clamp( | |
| self.player.hp + effect.stat_modifiers["hp"], | |
| 0, self.player.max_hp, | |
| ) | |
| if old_hp != self.player.hp: | |
| effect_log.append(f"{effect.name}: HP {old_hp} → {self.player.hp}") | |
| if "mp" in effect.stat_modifiers: | |
| old_mp = self.player.mp | |
| self.player.mp = clamp( | |
| self.player.mp + effect.stat_modifiers["mp"], | |
| 0, self.player.max_mp, | |
| ) | |
| if old_mp != self.player.mp: | |
| effect_log.append(f"{effect.name}: MP {old_mp} → {self.player.mp}") | |
| if "sanity" in effect.stat_modifiers: | |
| old_sanity = self.player.sanity | |
| self.player.sanity = clamp( | |
| self.player.sanity + effect.stat_modifiers["sanity"], | |
| 0, 100, | |
| ) | |
| if old_sanity != self.player.sanity: | |
| effect_log.append(f"{effect.name}: 理智 {old_sanity} → {self.player.sanity}") | |
| # 感知、攻击、防御、速度、幸运、士气、饱食度等直接加减属性 | |
| for stat_key, stat_cn in [("perception", "感知"), ("attack", "攻击力"), | |
| ("defense", "防御力"), ("speed", "速度"), | |
| ("luck", "幸运"), ("morale", "士气"), | |
| ("hunger", "饱食度")]: | |
| if stat_key in effect.stat_modifiers: | |
| old_val = getattr(self.player, stat_key) | |
| max_val = 100 if stat_key in ("morale", "hunger") else None | |
| new_val = old_val + effect.stat_modifiers[stat_key] | |
| if max_val is not None: | |
| new_val = clamp(new_val, 0, max_val) | |
| setattr(self.player, stat_key, new_val) | |
| if old_val != new_val: | |
| effect_log.append(f"{effect.name}: {stat_cn} {old_val} → {new_val}") | |
| # 递减持续时间 | |
| if effect.duration > 0: | |
| effect.duration -= 1 | |
| if effect.duration <= 0: | |
| expired.append(effect) | |
| # duration == -1 表示永久效果,不递减 | |
| # 移除过期效果 | |
| for effect in expired: | |
| self.player.status_effects.remove(effect) | |
| effect_log.append(f"状态效果 '{effect.name}' 已过期并移除") | |
| logger.info(f"状态效果 '{effect.name}' 已过期") | |
| return effect_log | |
| def _check_quest_deadlines(self): | |
| """检查限时任务是否过期""" | |
| for quest in self.world.quests.values(): | |
| if quest.status == "active" and quest.turns_remaining > 0: | |
| quest.turns_remaining -= 1 | |
| if quest.turns_remaining <= 0: | |
| quest.status = "failed" | |
| logger.info(f"任务 '{quest.title}' 已超时失败!") | |
| def _update_npc_schedules(self): | |
| """根据当前时间段更新 NPC 位置""" | |
| for npc in self.world.npcs.values(): | |
| if not npc.is_alive: | |
| continue | |
| if self.world.time_of_day in npc.schedule: | |
| old_loc = npc.location | |
| new_loc = npc.schedule[self.world.time_of_day] | |
| if old_loc != new_loc: | |
| npc.location = new_loc | |
| # 更新地点的 NPC 列表 | |
| if old_loc in self.world.locations: | |
| loc = self.world.locations[old_loc] | |
| if npc.name in loc.npcs_present: | |
| loc.npcs_present.remove(npc.name) | |
| if new_loc in self.world.locations: | |
| loc = self.world.locations[new_loc] | |
| if npc.name not in loc.npcs_present: | |
| loc.npcs_present.append(npc.name) | |
| def _level_up(self): | |
| """ | |
| 角色升级逻辑。 | |
| 每次升级: | |
| - 等级+1 | |
| - 扣除当前升级所需经验 | |
| - 下次升级所需经验提升 50% | |
| - 属性随机增长 | |
| - HP/MP 完全恢复 | |
| """ | |
| self.player.experience -= self.player.exp_to_next_level | |
| self.player.level += 1 | |
| self.player.exp_to_next_level = int(self.player.exp_to_next_level * 1.5) | |
| # 属性提升 | |
| self.player.max_hp += 10 | |
| self.player.max_mp += 5 | |
| self.player.attack += 2 | |
| self.player.defense += 1 | |
| self.player.speed += 1 | |
| self.player.perception += 1 | |
| # 升级后满血满蓝 | |
| self.player.hp = self.player.max_hp | |
| self.player.mp = self.player.max_mp | |
| logger.info( | |
| f"升级!等级: {self.player.level}, " | |
| f"HP: {self.player.max_hp}, MP: {self.player.max_mp}, " | |
| f"ATK: {self.player.attack}, DEF: {self.player.defense}" | |
| ) | |
| def get_death_narrative_context(self) -> str: | |
| """生成死亡结局的上下文信息(供 story_engine 使用)""" | |
| cause = "生命值归零" if self.player.hp <= 0 else "理智崩溃" | |
| last_event = self.event_log[-1].description if self.event_log else "未知" | |
| return ( | |
| f"玩家 {self.player.name} 因{cause}而倒下。\n" | |
| f"最后发生的事件: {last_event}\n" | |
| f"死亡次数: {self.player.death_count}\n" | |
| f"存活天数: {self.world.day_count}\n" | |
| f"最终善恶值: {self.player.karma}" | |
| ) | |
| def is_item_consumable(self, item_name: str) -> bool: | |
| """ | |
| 判断物品是否为消耗品(使用后会消失)。 | |
| 规则: | |
| - item_registry 中 item_type == "consumable" 的物品是消耗品 | |
| - item_type == "material" 的物品也视为消耗品(合成材料,用完即消失) | |
| - 其他类型(weapon, armor, key, quest_item, misc 等)为可重复使用物品 | |
| - 未注册物品默认为非消耗品(更安全,避免误删) | |
| """ | |
| if item_name in self.world.item_registry: | |
| item_info = self.world.item_registry[item_name] | |
| return item_info.item_type in ("consumable", "material") | |
| # 对未注册物品,用关键词启发式判断 | |
| consumable_keywords = ["药水", "药剂", "食物", "面包", "烤肉", "麦酒", | |
| "草药", "卷轴", "炸弹", "手雷", "箭矢", "弹药", | |
| "丹药", "果实", "干粮", "肉干", "饮料", "汤", | |
| "符咒", "一次性"] | |
| for keyword in consumable_keywords: | |
| if keyword in item_name: | |
| return True | |
| return False # 默认为非消耗品 | |
| def get_available_actions(self) -> list[str]: | |
| """根据当前场景和状态返回可用的行动类型""" | |
| actions = ["观察", "对话", "移动"] | |
| # 当前场景信息 | |
| loc = self.world.locations.get(self.player.location) | |
| if loc: | |
| if loc.rest_available: | |
| actions.append("休息") | |
| if loc.shop_available: | |
| actions.append("交易") | |
| if loc.enemies: | |
| actions.append("战斗") | |
| if loc.available_items: | |
| actions.append("搜索") | |
| # 背包中有可用物品 | |
| if self.player.inventory: | |
| actions.append("使用物品") | |
| # 有技能可用 | |
| if self.player.skills: | |
| actions.append("使用技能") | |
| return actions | |
| def get_scene_summary(self) -> str: | |
| """获取当前场景的简短摘要(用于 UI 展示)""" | |
| loc = self.world.locations.get(self.player.location) | |
| desc = loc.description if loc else "未知区域" | |
| ambient = loc.ambient_description if loc else "" | |
| npcs = [ | |
| npc.name for npc in self.world.npcs.values() | |
| if npc.location == self.player.location and npc.is_alive | |
| ] | |
| npc_str = f"可见NPC: {'、'.join(npcs)}" if npcs else "" | |
| return f"{desc}\n{ambient}\n{npc_str}".strip() | |
| def get_consumable_rule_effects(self, item_name: str) -> dict[str, Any]: | |
| """Parse deterministic consumable effects from item metadata.""" | |
| item_info = self.world.item_registry.get(str(item_name)) | |
| if item_info is None or not item_info.usable: | |
| return {} | |
| effect_text = str(item_info.use_effect or "").strip() | |
| if not effect_text: | |
| return {} | |
| changes: dict[str, Any] = {} | |
| numeric_rules = [ | |
| (r"恢复\s*(\d+)\s*HP", "hp_change", 1), | |
| (r"恢复\s*(\d+)\s*MP", "mp_change", 1), | |
| (r"恢复\s*(\d+)\s*饱食度", "hunger_change", 1), | |
| (r"恢复\s*(\d+)\s*士气", "morale_change", 1), | |
| (r"恢复\s*(\d+)\s*理智", "sanity_change", 1), | |
| (r"降低\s*(\d+)\s*HP", "hp_change", -1), | |
| (r"降低\s*(\d+)\s*MP", "mp_change", -1), | |
| (r"降低\s*(\d+)\s*饱食度", "hunger_change", -1), | |
| (r"降低\s*(\d+)\s*士气", "morale_change", -1), | |
| (r"降低\s*(\d+)\s*理智", "sanity_change", -1), | |
| ] | |
| for pattern, key, sign in numeric_rules: | |
| for match in re.finditer(pattern, effect_text): | |
| amount = int(match.group(1)) * sign | |
| changes[key] = int(changes.get(key, 0)) + amount | |
| if "解除中毒状态" in effect_text: | |
| changes["status_effects_removed"] = ["中毒"] | |
| return changes | |
| def get_rest_rule_effects(self) -> dict[str, int]: | |
| """Return conservative default recovery when resting at a valid location.""" | |
| loc = self.world.locations.get(self.player.location) | |
| if loc is None or not loc.rest_available: | |
| return {} | |
| if loc.shop_available: | |
| base_recovery = { | |
| "hp_change": 20, | |
| "mp_change": 10, | |
| "morale_change": 10, | |
| "sanity_change": 6, | |
| } | |
| else: | |
| base_recovery = { | |
| "hp_change": 12, | |
| "mp_change": 6, | |
| "morale_change": 6, | |
| "sanity_change": 4, | |
| } | |
| filtered: dict[str, int] = {} | |
| if self.player.hp < self.player.max_hp: | |
| filtered["hp_change"] = base_recovery["hp_change"] | |
| if self.player.mp < self.player.max_mp: | |
| filtered["mp_change"] = base_recovery["mp_change"] | |
| if self.player.morale < 100: | |
| filtered["morale_change"] = base_recovery["morale_change"] | |
| if self.player.sanity < 100: | |
| filtered["sanity_change"] = base_recovery["sanity_change"] | |
| return filtered | |
| def get_environment_snapshot(self, limit: int = 3) -> dict[str, Any]: | |
| """Return a compact environment summary for UI and logs.""" | |
| loc = self.world.locations.get(self.player.location) | |
| recent_events = [ | |
| event.model_dump() | |
| for event in self.world.recent_environment_events[-limit:] | |
| ] | |
| return { | |
| "weather": self.world.weather, | |
| "light_level": self.world.light_level, | |
| "time_of_day": self.world.time_of_day, | |
| "season": self.world.season, | |
| "location_type": loc.location_type if loc else "unknown", | |
| "danger_level": loc.danger_level if loc else 0, | |
| "rest_available": bool(loc.rest_available) if loc else False, | |
| "shop_available": bool(loc.shop_available) if loc else False, | |
| "recent_events": recent_events, | |
| } | |