diff --git "a/state_manager.py" "b/state_manager.py" --- "a/state_manager.py" +++ "b/state_manager.py" @@ -16,13 +16,14 @@ state_manager.py - StoryWeaver 状态管理器 from __future__ import annotations -import copy -import logging -import random -import re -from typing import Any, Optional -from pydantic import BaseModel, Field - +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 from utils import clamp logger = logging.getLogger("StoryWeaver") @@ -249,6 +250,8 @@ class PlayerState(BaseModel): max_mp: int = 50 # 最大魔力值 attack: int = 10 # 攻击力 defense: int = 5 # 防御力 + stamina: int = 100 # 体力值 + max_stamina: int = 100 # 最大体力值 speed: int = 8 # 速度(影响行动顺序) luck: int = 5 # 幸运(影响暴击、掉落) perception: int = 5 # 感知(影响探索发现、陷阱识别) @@ -289,7 +292,7 @@ class PlayerState(BaseModel): # ============================================================ -class WorldState(BaseModel): +class WorldState(BaseModel): """ 世界状态容器 @@ -300,12 +303,13 @@ class WorldState(BaseModel): - rumors / active_threats 丰富 NPC 对话内容 - faction_relations 支持阵营间动态关系 """ - current_scene: str = "村庄广场" # 当前场景名称 - time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下午 / 黄昏 / 夜晚 / 深夜 - day_count: int = 1 # 当前天数 + 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 = "春" # 春 / 夏 / 秋 / 冬 # --- 地图 --- @@ -322,12 +326,12 @@ class WorldState(BaseModel): 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) - # 当前全局威胁 + 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) @@ -339,7 +343,7 @@ class WorldState(BaseModel): # ============================================================ -class GameEvent(BaseModel): +class GameEvent(BaseModel): """ 事件日志模型(一致性维护的关键) @@ -361,27 +365,27 @@ class GameEvent(BaseModel): 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() + 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() # ============================================================ @@ -405,11 +409,11 @@ class GameState: - check_consistency() 在生成前检测可能的矛盾 """ - def __init__(self, player_name: str = "旅人"): - """初始化游戏状态,创建默认的起始世界""" - self.player = PlayerState(name=player_name) - self.world = WorldState() - self.event_log: list[GameEvent] = [] + 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 @@ -417,11 +421,16 @@ class GameState: self.ending_flags: dict[str, bool] = {} # 结局条件追踪 self.combat_log: list[str] = [] # 最近战斗记录 self.achievement_list: list[str] = [] # 已解锁成就 - - # 初始化起始世界 - self._init_starting_world() - self.pending_environment_event: EnvironmentEvent | None = None - self.world.light_level = self._determine_light_level() + 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.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): """ @@ -502,8 +511,7 @@ class GameState: enemies=["哥布林巫师", "巨型蜘蛛", "森林巨魔"], danger_level=7, is_discovered=False, - is_accessible=False, - required_item="森林之钥", + is_accessible=True, ambient_description="黑暗几乎吞噬了一切,只有奇异的荧光苔藓发出微弱的光。远处传来低沉的咆哮。", ), "溪边营地": LocationInfo( @@ -602,7 +610,7 @@ class GameState: can_give_quest=True, available_quests=["main_quest_01"], memory=[], - schedule={"清晨": "村庄广场", "正午": "村庄广场", "夜晚": "村庄旅店"}, + schedule={"清晨": "村庄广场", "上午": "村庄广场", "正午": "村庄广场", "下午": "村庄广场", "黄昏": "村庄广场", "夜晚": "村庄旅店"}, backstory="在这个村庄生活了七十年的老者,见证过上一次暗潮来袭,深知森林中潜伏的危险。", ), "铁匠格林": NPCState( @@ -614,9 +622,9 @@ class GameState: race="矮人", occupation="铁匠", can_trade=True, - shop_inventory=["小刀", "短剑", "铁剑", "皮甲", "木盾"], + shop_inventory=["小刀", "短剑", "铁剑", "皮甲", "木盾"], relationship_level=0, - schedule={"清晨": "村庄铁匠铺", "正午": "村庄铁匠铺", "夜晚": "村庄旅店"}, + schedule={"清晨": "村庄铁匠铺", "上午": "村庄铁匠铺", "正午": "村庄铁匠铺", "下午": "村庄铁匠铺", "黄昏": "村庄铁匠铺", "夜晚": "村庄旅店"}, backstory="曾经是王都的御用铁匠,因一场变故隐居此地。对远方的怪物有独到的了解。", ), "旅店老板娘莉娜": NPCState( @@ -630,7 +638,7 @@ class GameState: can_trade=True, shop_inventory=["面包", "烤肉", "麦酒", "草药包"], relationship_level=10, - schedule={"清晨": "村庄旅店", "正午": "村庄旅店", "夜晚": "村庄旅店"}, + schedule={"清晨": "村庄旅店", "上午": "村庄旅店", "正午": "村庄旅店", "下午": "村庄旅店", "黄昏": "村庄旅店", "夜晚": "村庄旅店"}, backstory="年轻时曾是一名冒险者,后来受伤退役经营旅店。对旅行者总是格外关照。", ), "杂货商人阿尔": NPCState( @@ -642,9 +650,9 @@ class GameState: race="人类", occupation="商人", can_trade=True, - shop_inventory=["火把", "绳索", "解毒药水", "小型治疗药水", "地图碎片"], + shop_inventory=["火把", "绳索", "解毒药水", "小型治疗药水"], relationship_level=-5, - schedule={"清晨": "村庄杂货铺", "正午": "村庄广场", "夜晚": "村庄杂货铺"}, + schedule={"清晨": "村庄杂货铺", "上午": "村庄杂货铺", "正午": "村庄广场", "下午": "村庄杂货铺", "黄昏": "村庄杂货铺", "夜晚": "村庄杂货铺"}, backstory="来自远方的行商,在村中定居多年。对各地的传闻消息灵通,但消息总是要收费的。", ), "神秘旅人": NPCState( @@ -675,7 +683,7 @@ class GameState: can_give_quest=True, available_quests=["side_quest_02"], memory=[], - schedule={"清晨": "河边渡口", "正午": "河边渡口", "夜晚": "村庄旅店"}, + schedule={"清晨": "河边渡口", "上午": "河边渡口", "正午": "河边渡口", "下午": "河边渡口", "黄昏": "河边渡口", "夜晚": "村庄旅店"}, backstory="在这条河边住了四十年,对河流两岸的地形了如指掌。最近总念叨对岸矿洞里的怪响。", ), "遗迹守护者": NPCState( @@ -697,38 +705,58 @@ class GameState: # --- 初始任务 --- self.world.quests = { - "main_quest_01": QuestState( - quest_id="main_quest_01", - title="森林中的阴影", - description="村长老伯告诉你,最近森林中频繁出现怪物袭击事件。他请求你前往调查。", - quest_type="main", + "main_quest_01": QuestState( + quest_id="main_quest_01", + title="森林中的阴影", + description="村长老伯告诉你,最近森林中频繁出现怪物袭击事件。他请求你前往调查。", + quest_type="main", status="active", giver_npc="村长老伯", - objectives={ - "与村长对话了解情况": False, - "前往黑暗森林入口调查": False, - "击败森林中的怪物": False, - "调查怪物活动的原因": False, - }, + objectives={ + "与村长对话了解情况": False, + "前往黑暗森林入口调查": False, + "击败森林中的怪物": False, + "调查怪物活动的原因": False, + "与村长老伯对话汇报发现": False, + }, rewards=QuestRewards( gold=100, experience=50, items=["森林之钥"], reputation_changes={"村庄": 20}, - karma_change=5, - ), - ), - "side_quest_01": QuestState( - quest_id="side_quest_01", - title="失落的传承", - description="神秘旅人似乎在寻找一件古老的遗物。也许帮助他能得到意想不到的回报。", - quest_type="side", - status="active", - giver_npc="神秘旅人", - objectives={ - "与神秘旅人交谈": False, - "找到古老遗物的线索": False, - }, + 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=["神秘卷轴"], @@ -737,17 +765,17 @@ class GameState: prerequisites=[], ), # -------- 扩展任务 -------- - "side_quest_02": QuestState( - quest_id="side_quest_02", - title="河底的秘密", - description="渡口老渔夫说他最近总在河里捞到奇怪的骨头,而且对岸矿洞方向夜里总是有光。他请你去查明真相。", - quest_type="side", - status="active", - giver_npc="渡口老渔夫", - objectives={ - "与渡口老渔夫交谈": False, - "前往废弃矿洞调查": False, - "找到矿洞异常的原因": False, + "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, @@ -757,17 +785,17 @@ class GameState: ), prerequisites=[], ), - "side_quest_03": QuestState( - quest_id="side_quest_03", - title="守护者的试炼", - description="精灵遗迹的守护者提出一个交换条件:通过她设下的试炼,就能获得古老的精灵祝福。", - quest_type="side", - status="active", - giver_npc="遗迹守护者", - objectives={ - "与遗迹守护者交谈": False, - "通过守护者的试炼": False, - }, + "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="精灵祝福", @@ -780,9 +808,9 @@ class GameState: # --- 初始物品注册表 --- 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="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), @@ -793,7 +821,10 @@ class GameState: "绳索": 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="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), # -------- 扩展物品 -------- @@ -817,71 +848,71 @@ class GameState: "有人在精灵遗迹附近见到过一个穿绿袍的身影,不知是人是鬼。", ] - # --- 显式环境事件模板 --- - 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 + # --- 显式环境事件模板 --- + 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 # ============================================================ # 核心方法 @@ -922,31 +953,31 @@ class GameState: 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 "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 @@ -957,53 +988,76 @@ class GameState: 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 "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: - self.player.location = new_loc - self.world.current_scene = 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 + 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.player.location = new_loc + self.world.current_scene = 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 # --- 物品变更 --- # 货币关键词列表:这些物品不进背包,而是直接转换为金币 @@ -1023,6 +1077,7 @@ class GameState: 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: @@ -1088,42 +1143,42 @@ class GameState: 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 "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: @@ -1147,45 +1202,46 @@ class GameState: 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 "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: @@ -1223,9 +1279,9 @@ class GameState: return change_log - def validate(self) -> tuple[bool, list[str]]: - """ - 校验当前状态的合法性。 + def validate(self) -> tuple[bool, list[str]]: + """ + 校验当前状态的合法性。 设计思路: - 检查所有数值是否在合法范围内 @@ -1247,6 +1303,7 @@ class GameState: # 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: @@ -1268,34 +1325,182 @@ class GameState: self.player.gold = 0 issues.append("金币不足。") - is_valid = self.game_mode != "game_over" - return is_valid, issues + 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 _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_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 get_minute_of_day(self) -> int: + return self.get_clock_minutes() % (24 * 60) - 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") - return { - stat_name: int(getattr(self.player, stat_name)) + int(bonuses.get(stat_name, 0)) - for stat_name in tracked_stats - } + 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}天") - def to_prompt(self) -> str: - """ - 将当前完整状态序列化为自然语言描述,注入 System Prompt。 + 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 必须包含当前状态描述 @@ -1406,19 +1611,19 @@ class GameState: 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 # 用后清除 + # 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 = ( @@ -1450,13 +1655,13 @@ class GameState: scene_desc, player_desc, npc_desc, - quest_desc, - move_desc, - event_desc, - rumors_desc, - environment_event_desc, - consistency_rules, - ]) + quest_desc, + move_desc, + event_desc, + rumors_desc, + environment_event_desc, + consistency_rules, + ]) return full_prompt @@ -1530,21 +1735,25 @@ class GameState: 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) - if current_loc and target not in current_loc.connected_to: - contradictions.append( - f"矛盾: 试图移动到不相邻的地点 '{target}'(当前位置: {self.player.location})" - ) - target_loc = self.world.locations.get(target) - 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}'" - ) + # 检测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: @@ -1577,22 +1786,22 @@ class GameState: details = intent.get("details", "") or "" raw_input = intent.get("raw_input", "") or "" - 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) + 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) # --- 检测 1: USE_ITEM / EQUIP: target 必须在背包或装备中 --- if action in ("USE_ITEM", "EQUIP") and target: @@ -1603,19 +1812,46 @@ class GameState: return False, f"「{target}」已经装备在身上了。" return False, f"你的背包中没有「{target}」,无法装备。" - # --- 检测 2: SKILL: 必须已习得 --- - if action == "SKILL" and target: - if target not in self.player.skills: - return False, f"你尚未习得技能「{target}」。" + # 体力耗尽时禁止移动和战斗 + if action in ("MOVE", "ATTACK", "COMBAT") and self.player.stamina <= 0: + return False, "你精疲力竭,体力耗尽,无法行动。需要先在旅店或营地休息恢复体力。" + + if action == "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 == "VIEW_MAP": + if not any("地图" in item for item in all_owned): + return False, "你还没有获得可查看的地图。" + + # --- 检测 2: SKILL: 必须已习得 --- + if action == "SKILL" and target: + if target not in self.player.skills: + return False, f"你尚未习得技能「{target}」。" + + wants_overnight_rest = str(action).upper() == "OVERNIGHT_REST" or ( + str(action).upper() == "REST" + and any(keyword in f"{details} {raw_input}" for keyword in ("过夜", "睡一晚", "住一晚", "睡到天亮")) + ) - # --- 检测 3: REST: 当前位置必须允许休息 --- - if action == "REST": + # --- 检测 3: REST/OVERNIGHT_REST: 当前位置���须允许休息 --- + if action 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, "这里不适合休息,试着前往旅店或营地。" - - # --- 检测 4: 扫描 raw_input 中是否在使用上下文中引用了未拥有的已知物品 --- - known_items: set[str] = set(self.world.item_registry.keys()) + 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): @@ -1641,7 +1877,7 @@ class GameState: if verb + item_name in raw_input: return False, f"你的背包中没有「{item_name}」,无法{verb}。" - # --- 检测 5: 检查 raw_input 中是否提及使用完全未知的物品 --- + # --- 检测 5: 检查 raw_input 中是否提及使用完全未知的物品 --- # 匹配常见的"使用物品"语句模式,提取物品名称并校验 extraction_patterns = [ (r'(?:掏出|拿出|拔出|举起)(.{2,8}?)(?:$|[,。!?,\s来])', "使用"), @@ -1661,21 +1897,21 @@ class GameState: 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 - ) + 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}」,请检查你的背包。" @@ -1701,296 +1937,285 @@ class GameState: 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 - time_threshold = 2 - action_units = self._estimate_time_cost_units(player_intent) - self.world.time_progress_units += action_units - elapsed_segments = self.world.time_progress_units // time_threshold - self.world.time_progress_units = self.world.time_progress_units % time_threshold - - if elapsed_segments <= 0: - return tick_log - - time_sequence = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"] - for _ in range(elapsed_segments): - current_idx = ( - time_sequence.index(self.world.time_of_day) - if self.world.time_of_day in time_sequence - else 0 - ) - next_idx = (current_idx + 1) % len(time_sequence) - old_time = self.world.time_of_day - self.world.time_of_day = time_sequence[next_idx] - tick_log.append(f"时间流逝: {old_time} → {self.world.time_of_day}") - - if next_idx == 0: - self.world.day_count += 1 - tick_log.append(f"新的一天!第 {self.world.day_count} 天") - logger.info(f"新的一天开始了!第 {self.world.day_count} 天") - - old_hunger = self.player.hunger - self.player.hunger = max(0, self.player.hunger - (3 * elapsed_segments)) - if old_hunger != self.player.hunger: - tick_log.append(f"饱食度自然衰减: {old_hunger} → {self.player.hunger}") - if self.player.hunger <= 0: - tick_log.append("极度饥饿!属性受到惩罚") - logger.info("玩家非常饥饿,属性受到惩罚") - - for _ in range(elapsed_segments): - effect_log = self._apply_status_effects() - tick_log.extend(effect_log) - - # 检查限时任务 - self._check_quest_deadlines() - - # 更新 NPC 位置(根据时间表) - 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 2 - - intent = str(player_intent.get("intent", "")).upper() - raw_input = str(player_intent.get("raw_input", "") or "") - details = str(player_intent.get("details", "") or "") - target = str(player_intent.get("target", "") or "") - full_text = f"{raw_input} {details} {target}" - - base_costs = { - "TALK": 0, - "TRADE": 1, - "USE_ITEM": 1, - "EQUIP": 1, - "SKILL": 1, - "MOVE": 2, - "EXPLORE": 2, - "QUEST": 2, - "ATTACK": 2, - "REST": 4, - "WAIT": 4, - } - cost = base_costs.get(intent, 1) - - lightweight_markers = ( - "检查", - "查看", - "确认", - "翻看", - "端详", - "研究", - "询问", - "交谈", - "背包", - "物品", - "装备", - "地图", - ) - if intent in {"EXPLORE", "QUEST"} and any(marker in full_text for marker in lightweight_markers): - return 0 - - return max(0, cost) - - 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 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.""" - 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: + if self.elapsed_minutes_total - int(self.world.last_weather_change_minutes) < 180: return - - chance = 0.08 - if loc.danger_level >= 3: - chance += 0.08 - if self.world.light_level in {"幽暗", "漆黑"}: - chance += 0.06 - if self.world.weather in {"暴风雨", "浓雾"}: - chance += 0.04 + chance = 0.18 if self.world.time_of_day in {"清晨", "黄昏"} else 0.08 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]: - """每回合结算状态效果:应用修正、递减持续时间、移除过期效果 + + 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.08 + if loc.danger_level >= 3: + chance += 0.08 + if self.world.light_level in {"幽暗", "漆黑"}: + chance += 0.06 + if self.world.weather in {"暴风雨", "浓雾"}: + chance += 0.04 + 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: 状态效果结算引起的变化描述列表 @@ -2053,8 +2278,8 @@ class GameState: return effect_log - def _check_quest_deadlines(self): - """检查限时任务是否过期""" + 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 @@ -2176,101 +2401,101 @@ class GameState: 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 "" + 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, - } + + 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, + }