PPP commited on
Commit
ed566a9
·
1 Parent(s): 67cdcb8

feat: add environment UI and tighten trade, state, and action-time rules

Browse files
Files changed (3) hide show
  1. app.py +107 -21
  2. state_manager.py +614 -260
  3. story_engine.py +217 -44
app.py CHANGED
@@ -66,6 +66,7 @@ def _build_state_snapshot(gs: GameState) -> dict:
66
  active_quests = []
67
  effective_stats = gs.get_effective_player_stats()
68
  equipment_bonuses = gs.get_equipment_stat_bonuses()
 
69
  for quest in gs.world.quests.values():
70
  if quest.status == "active":
71
  active_quests.append(
@@ -85,6 +86,8 @@ def _build_state_snapshot(gs: GameState) -> dict:
85
  "day": gs.world.day_count,
86
  "time_of_day": gs.world.time_of_day,
87
  "weather": gs.world.weather,
 
 
88
  "player": {
89
  "name": gs.player.name,
90
  "level": gs.player.level,
@@ -816,6 +819,8 @@ def _format_status_panel(gs: GameState) -> str:
816
  w = gs.world
817
  effective_stats = gs.get_effective_player_stats()
818
  equipment_bonuses = gs.get_equipment_stat_bonuses()
 
 
819
 
820
  # 属性进度条
821
  hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
@@ -843,6 +848,13 @@ def _format_status_panel(gs: GameState) -> str:
843
  if bonus_value < 0:
844
  return f"{label}: {effective_value} <span style='color:#b44;'>({bonus_value} 装备)</span>"
845
  return f"{label}: {base_value}"
 
 
 
 
 
 
 
846
 
847
  # 状态效果
848
  if p.status_effects:
@@ -855,8 +867,65 @@ def _format_status_panel(gs: GameState) -> str:
855
  # 背包
856
  if p.inventory:
857
  inventory_text = "<br>".join(p.inventory)
858
- else:
859
- inventory_text = "空"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
 
861
  # 活跃任务(完整展示:描述、子目标、奖励、来源)
862
  active_quests = [q for q in w.quests.values() if q.status == "active"]
@@ -949,30 +1018,47 @@ def _format_status_panel(gs: GameState) -> str:
949
  </span>
950
  </div>
951
 
952
- <div>
953
- <h4 style="margin:4px 0 2px 0;">🎒 背包</h4>
954
- <span style="font-size:0.85em;">
955
- {inventory_text}
956
- </span>
957
- </div>
958
-
959
- <div style="grid-column: 1 / -1;">
960
- <h4 style="margin:4px 0 2px 0;">📜 任务</h4>
961
- <span style="font-size:0.85em;">
962
- {quest_text}
963
- </span>
964
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
965
 
966
  <div>
967
  <h4 style="margin:4px 0 2px 0;">🌍 世界信息</h4>
968
  <span style="font-size:0.85em;">
969
  位置: {w.current_scene}<br>
970
- 时间: 第{w.day_count}天 {w.time_of_day}<br>
971
- 天气: {w.weather}<br>
972
- 季节: {w.season}<br>
973
- 回合: {gs.turn}
974
- </span>
975
- </div>
 
976
 
977
  </div>
978
  </div>"""
 
66
  active_quests = []
67
  effective_stats = gs.get_effective_player_stats()
68
  equipment_bonuses = gs.get_equipment_stat_bonuses()
69
+ environment_snapshot = gs.get_environment_snapshot(limit=3)
70
  for quest in gs.world.quests.values():
71
  if quest.status == "active":
72
  active_quests.append(
 
86
  "day": gs.world.day_count,
87
  "time_of_day": gs.world.time_of_day,
88
  "weather": gs.world.weather,
89
+ "light_level": gs.world.light_level,
90
+ "environment": _json_safe(environment_snapshot),
91
  "player": {
92
  "name": gs.player.name,
93
  "level": gs.player.level,
 
819
  w = gs.world
820
  effective_stats = gs.get_effective_player_stats()
821
  equipment_bonuses = gs.get_equipment_stat_bonuses()
822
+ env_snapshot = gs.get_environment_snapshot(limit=3)
823
+ scene_summary = gs.get_scene_summary().replace("\n", "<br>")
824
 
825
  # 属性进度条
826
  hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
 
848
  if bonus_value < 0:
849
  return f"{label}: {effective_value} <span style='color:#b44;'>({bonus_value} 装备)</span>"
850
  return f"{label}: {base_value}"
851
+
852
+ def badge(text: str, bg: str, fg: str = "#1f2937") -> str:
853
+ return (
854
+ f"<span style='display:inline-block;margin:0 6px 6px 0;padding:3px 10px;"
855
+ f"border-radius:999px;background:{bg};color:{fg};font-size:0.8em;"
856
+ f"font-weight:600;'>{text}</span>"
857
+ )
858
 
859
  # 状态效果
860
  if p.status_effects:
 
867
  # 背包
868
  if p.inventory:
869
  inventory_text = "<br>".join(p.inventory)
870
+ else:
871
+ inventory_text = "空"
872
+
873
+ weather_colors = {
874
+ "晴朗": "#fef3c7",
875
+ "多云": "#e5e7eb",
876
+ "小雨": "#dbeafe",
877
+ "浓雾": "#e0e7ff",
878
+ "暴风雨": "#c7d2fe",
879
+ "大雪": "#f3f4f6",
880
+ }
881
+ light_colors = {
882
+ "明亮": "#fde68a",
883
+ "柔和": "#fcd34d",
884
+ "昏暗": "#cbd5e1",
885
+ "幽暗": "#94a3b8",
886
+ "漆黑": "#334155",
887
+ }
888
+ danger_level = int(env_snapshot.get("danger_level", 0))
889
+ if danger_level >= 7:
890
+ danger_badge = badge(f"危险 {danger_level}/10", "#fecaca", "#7f1d1d")
891
+ elif danger_level >= 4:
892
+ danger_badge = badge(f"危险 {danger_level}/10", "#fed7aa", "#9a3412")
893
+ else:
894
+ danger_badge = badge(f"危险 {danger_level}/10", "#dcfce7", "#166534")
895
+
896
+ env_badges = "".join(
897
+ [
898
+ badge(f"天气 {w.weather}", weather_colors.get(w.weather, "#e5e7eb")),
899
+ badge(
900
+ f"光照 {w.light_level}",
901
+ light_colors.get(w.light_level, "#e5e7eb"),
902
+ "#0f172a" if w.light_level not in {"幽暗", "漆黑"} else "#f8fafc",
903
+ ),
904
+ danger_badge,
905
+ badge(f"场景 {env_snapshot.get('location_type', 'unknown')}", "#ede9fe", "#4c1d95"),
906
+ ]
907
+ )
908
+
909
+ recent_env_events = env_snapshot.get("recent_events", [])
910
+ if recent_env_events:
911
+ latest_event = recent_env_events[-1]
912
+ latest_event_html = (
913
+ f"<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
914
+ f"border:1px solid #dbeafe;margin-bottom:6px;'>"
915
+ f"<b>{latest_event.get('title', '环境事件')}</b>"
916
+ f"<br><span style='font-size:0.82em;color:#475569;'>{latest_event.get('description', '')}</span>"
917
+ f"</div>"
918
+ )
919
+ recent_event_lines = "<br>".join(
920
+ f"- {event.get('title', '环境事件')}"
921
+ for event in reversed(recent_env_events[-3:])
922
+ )
923
+ else:
924
+ latest_event_html = (
925
+ "<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
926
+ "border:1px dashed #cbd5e1;color:#64748b;'>本回合暂无显式环境事件</div>"
927
+ )
928
+ recent_event_lines = "无"
929
 
930
  # 活跃任务(完整展示:描述、子目标、奖励、来源)
931
  active_quests = [q for q in w.quests.values() if q.status == "active"]
 
1018
  </span>
1019
  </div>
1020
 
1021
+ <div>
1022
+ <h4 style="margin:4px 0 2px 0;">🎒 背包</h4>
1023
+ <span style="font-size:0.85em;">
1024
+ {inventory_text}
1025
+ </span>
1026
+ </div>
1027
+
1028
+ <div style="grid-column: 1 / -1;">
1029
+ <h4 style="margin:4px 0 2px 0;">🌦️ 环境态势</h4>
1030
+ <div style="font-size:0.85em;">
1031
+ {env_badges}
1032
+ {latest_event_html}
1033
+ <span style="color:#475569;">最���环境事件: {recent_event_lines}</span>
1034
+ </div>
1035
+ </div>
1036
+
1037
+ <div style="grid-column: 1 / -1;">
1038
+ <h4 style="margin:4px 0 2px 0;">🧭 场景摘要</h4>
1039
+ <div style="font-size:0.85em;line-height:1.5;padding:8px 10px;border-radius:10px;background:#fff7ed;border:1px solid #fed7aa;">
1040
+ {scene_summary}
1041
+ </div>
1042
+ </div>
1043
+
1044
+ <div style="grid-column: 1 / -1;">
1045
+ <h4 style="margin:4px 0 2px 0;">📜 任务</h4>
1046
+ <span style="font-size:0.85em;">
1047
+ {quest_text}
1048
+ </span>
1049
+ </div>
1050
 
1051
  <div>
1052
  <h4 style="margin:4px 0 2px 0;">🌍 世界信息</h4>
1053
  <span style="font-size:0.85em;">
1054
  位置: {w.current_scene}<br>
1055
+ 时间: 第{w.day_count}天 {w.time_of_day}<br>
1056
+ 天气: {w.weather}<br>
1057
+ 光照: {w.light_level}<br>
1058
+ 季节: {w.season}<br>
1059
+ 回合: {gs.turn}
1060
+ </span>
1061
+ </div>
1062
 
1063
  </div>
1064
  </div>"""
state_manager.py CHANGED
@@ -16,11 +16,12 @@ state_manager.py - StoryWeaver 状态管理器
16
 
17
  from __future__ import annotations
18
 
19
- import copy
20
- import logging
21
- import re
22
- from typing import Any, Optional
23
- from pydantic import BaseModel, Field
 
24
 
25
  from utils import clamp
26
 
@@ -288,7 +289,7 @@ class PlayerState(BaseModel):
288
  # ============================================================
289
 
290
 
291
- class WorldState(BaseModel):
292
  """
293
  世界状态容器
294
 
@@ -299,11 +300,13 @@ class WorldState(BaseModel):
299
  - rumors / active_threats 丰富 NPC 对话内容
300
  - faction_relations 支持阵营间动态关系
301
  """
302
- current_scene: str = "村庄广场" # 当前场景名称
303
- time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下午 / 黄昏 / 夜晚 / 深夜
304
- day_count: int = 1 # 当前天数
305
- weather: str = "晴朗" # 晴朗 / 多云 / 小雨 / 暴风雨 / 大雪 / 浓雾
306
- season: str = "" # / / /
 
 
307
 
308
  # --- 地图 ---
309
  locations: dict[str, LocationInfo] = Field(default_factory=dict)
@@ -319,11 +322,12 @@ class WorldState(BaseModel):
319
  item_registry: dict[str, ItemInfo] = Field(default_factory=dict)
320
 
321
  # --- 全局标记 ---
322
- global_flags: dict[str, bool] = Field(default_factory=dict)
323
- world_events: list[str] = Field(default_factory=list)
324
- # 已发生的全局事件
325
- active_threats: list[str] = Field(default_factory=list)
326
- # 当前全局威胁
 
327
  rumors: list[str] = Field(default_factory=list)
328
  # 流传的传闻
329
  faction_relations: dict[str, dict[str, str]] = Field(default_factory=dict)
@@ -335,7 +339,7 @@ class WorldState(BaseModel):
335
  # ============================================================
336
 
337
 
338
- class GameEvent(BaseModel):
339
  """
340
  事件日志模型(一致性维护的关键)
341
 
@@ -357,9 +361,27 @@ class GameEvent(BaseModel):
357
  state_changes: dict = Field(default_factory=dict)
358
  # 状态变更快照
359
  player_action: str = "" # 触发该事件的玩家操作
360
- consequence_tags: list[str] = Field(default_factory=list)
361
- # 后果标签
362
- is_reversible: bool = True # 是否可逆
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
 
365
  # ============================================================
@@ -383,11 +405,11 @@ class GameState:
383
  - check_consistency() 在生成前检测可能的矛盾
384
  """
385
 
386
- def __init__(self, player_name: str = "旅人"):
387
- """初始化游戏状态,创建默认的起世界"""
388
- self.player = PlayerState(name=player_name)
389
- self.world = WorldState()
390
- self.event_log: list[GameEvent] = []
391
  self.turn: int = 0
392
  self.game_mode: str = "exploration" # exploration / combat / dialogue / cutscene / game_over
393
  self.difficulty: str = "normal" # easy / normal / hard
@@ -395,9 +417,11 @@ class GameState:
395
  self.ending_flags: dict[str, bool] = {} # 结局条件追踪
396
  self.combat_log: list[str] = [] # 最近战斗记录
397
  self.achievement_list: list[str] = [] # 已解锁成就
398
-
399
- # 初始化起始世界
400
- self._init_starting_world()
 
 
401
 
402
  def _init_starting_world(self):
403
  """
@@ -590,7 +614,7 @@ class GameState:
590
  race="矮人",
591
  occupation="铁匠",
592
  can_trade=True,
593
- shop_inventory=["铁剑", "皮甲", "木盾"],
594
  relationship_level=0,
595
  schedule={"清晨": "村庄铁匠铺", "正午": "村庄铁匠铺", "夜晚": "村庄旅店"},
596
  backstory="曾经是王都的御用铁匠,因一场变故隐居此地。对远方的怪物有独到的了解。",
@@ -756,7 +780,9 @@ class GameState:
756
 
757
  # --- 初始物品注册表 ---
758
  self.world.item_registry = {
759
- "铁剑": ItemInfo(name="铁剑", item_type="weapon", description="一把标准铁制长剑,锋锐利。", rarity="common", stat_bonus={"attack": 5}, value=30),
 
 
760
  "皮甲": ItemInfo(name="皮甲", item_type="armor", description="硬化皮革制成的轻甲,兼顾防护与灵活性。", rarity="common", stat_bonus={"defense": 3}, value=25),
761
  "木盾": ItemInfo(name="木盾", item_type="armor", description="坚硬橡木制成的盾牌,可以抵挡基础攻击。", rarity="common", stat_bonus={"defense": 2}, value=15),
762
  "面包": ItemInfo(name="面包", item_type="consumable", description="新鲜烤制的面包,香气扑鼻。", usable=True, use_effect="恢复 10 饱食度", value=5),
@@ -791,21 +817,71 @@ class GameState:
791
  "有人在精灵遗迹附近见到过一个穿绿袍的身影,不知是人是鬼。",
792
  ]
793
 
794
- # --- 随机事件候选池 ---
795
- self.random_event_pool: list[str] = [
796
- "一个受伤的旅行商人跌跌撞撞地出现在你面前,他的货物散落一地。",
797
- "天空忽然暗了下来,远处传来隐约的雷声,一场暴雨似乎即将来临。",
798
- "你注意到路边有一个被藤蔓遮住的旧箱子,看起来已经放了很久。",
799
- "一只受伤的猎鹰坠落在你脚边,它的腿上绑着一小卷羊皮纸。",
800
- "远处传来激烈打斗声,似乎有人遭到伏击。",
801
- "一个衣衫褴褛的流浪汉向你走来,自称知道附近藏宝的秘密。",
802
- "你经过的路上发现了一串新鲜的、不属于任何已知生物的巨大脚印。",
803
- "一阵古怪的风带来了焦糊的味道,顺着风向看去,远处有一缕黑烟升起。",
804
- ]
805
- self.pending_random_event: str | None = None # 本回合触发的随机
806
-
807
- # --- 玩家初始装备 ---
808
- self.player.inventory = ["面包", "面包", "小型治疗药水"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
809
 
810
  # ============================================================
811
  # 核心方法
@@ -846,28 +922,31 @@ class GameState:
846
  changes = _filtered
847
 
848
  # --- 玩家属性变更 ---
849
- if "hp_change" in changes:
850
- old_hp = self.player.hp
851
- self.player.hp = clamp(
852
- self.player.hp + int(changes["hp_change"]),
853
- 0,
854
- self.player.max_hp,
855
- )
856
- change_log.append(f"HP: {old_hp} → {self.player.hp}")
857
-
858
- if "mp_change" in changes:
859
- old_mp = self.player.mp
860
- self.player.mp = clamp(
861
- self.player.mp + int(changes["mp_change"]),
862
- 0,
863
- self.player.max_mp,
864
- )
865
- change_log.append(f"MP: {old_mp} → {self.player.mp}")
866
-
867
- if "gold_change" in changes:
868
- old_gold = self.player.gold
869
- self.player.gold = max(0, self.player.gold + int(changes["gold_change"]))
870
- change_log.append(f"金币: {old_gold} {self.player.gold}")
 
 
 
871
 
872
  if "exp_change" in changes:
873
  old_exp = self.player.experience
@@ -878,48 +957,53 @@ class GameState:
878
  self._level_up()
879
  change_log.append(f"升级!当前等级: {self.player.level}")
880
 
881
- if "morale_change" in changes:
882
- old_morale = self.player.morale
883
- self.player.morale = clamp(
884
- self.player.morale + int(changes["morale_change"]),
885
- 0, 100,
886
- )
887
- change_log.append(f"士气: {old_morale} → {self.player.morale}")
888
-
889
- if "sanity_change" in changes:
890
- old_sanity = self.player.sanity
891
- self.player.sanity = clamp(
892
- self.player.sanity + int(changes["sanity_change"]),
893
- 0, 100,
894
- )
895
- change_log.append(f"理智: {old_sanity} → {self.player.sanity}")
896
-
897
- if "hunger_change" in changes:
898
- old_hunger = self.player.hunger
899
- self.player.hunger = clamp(
900
- self.player.hunger + int(changes["hunger_change"]),
901
- 0, 100,
902
- )
903
- change_log.append(f"饱食度: {old_hunger} → {self.player.hunger}")
904
-
905
- if "karma_change" in changes:
906
- old_karma = self.player.karma
907
- self.player.karma += int(changes["karma_change"])
908
- change_log.append(f"善恶值: {old_karma} → {self.player.karma}")
909
-
910
- # --- 位置变更 ---
911
- if "new_location" in changes:
912
- old_loc = self.player.location
913
- new_loc = str(changes["new_location"])
914
- self.player.location = new_loc
915
- self.world.current_scene = new_loc
916
- change_log.append(f"位置: {old_loc} {new_loc}")
917
- # 发现新地点
918
- if new_loc not in self.world.discovered_locations:
919
- self.world.discovered_locations.append(new_loc)
920
- change_log.append(f"发现新地点: {new_loc}")
921
- if new_loc in self.world.locations:
922
- self.world.locations[new_loc].is_discovered = True
 
 
 
 
 
923
 
924
  # --- 物品变更 ---
925
  # 货币关键词列表:这些物品不进背包,而是直接转换为金币
@@ -1004,33 +1088,42 @@ class GameState:
1004
  change_log.append(f"移除状态: {name}")
1005
 
1006
  # --- NPC 相关变更 ---
1007
- if "npc_changes" in changes:
1008
- for npc_name, npc_data in changes["npc_changes"].items():
1009
- if npc_name in self.world.npcs:
1010
- npc = self.world.npcs[npc_name]
1011
- if "attitude" in npc_data:
1012
- npc.attitude = str(npc_data["attitude"])
1013
- change_log.append(f"NPC {npc_name} 态度变为: {npc.attitude}")
1014
- if "is_alive" in npc_data:
1015
- npc.is_alive = bool(npc_data["is_alive"])
1016
- if not npc.is_alive:
1017
- change_log.append(f"NPC {npc_name} 已死亡")
1018
- if "relationship_change" in npc_data:
1019
- old_rel = npc.relationship_level
1020
- npc.relationship_level = clamp(
1021
- npc.relationship_level + int(npc_data["relationship_change"]),
1022
- -100, 100,
1023
- )
1024
- change_log.append(
1025
- f"NPC {npc_name} 好感度: {old_rel} → {npc.relationship_level}"
1026
- )
1027
- if "hp_change" in npc_data:
1028
- npc.hp = max(0, npc.hp + int(npc_data["hp_change"]))
1029
- if npc.hp <= 0:
1030
- npc.is_alive = False
1031
- change_log.append(f"NPC {npc_name} 被击败")
1032
- if "memory_add" in npc_data:
1033
- npc.memory.append(str(npc_data["memory_add"]))
 
 
 
 
 
 
 
 
 
1034
 
1035
  # --- 任务变更 ---
1036
  if "quest_updates" in changes:
@@ -1054,30 +1147,45 @@ class GameState:
1054
  status_cn = _QUEST_STATUS_CN.get(quest.status, quest.status)
1055
  change_log.append(f"任务【{quest.title}】进展至\"{status_cn}\"")
1056
 
1057
- # --- 世界状态变更 ---
1058
- if "weather_change" in changes:
1059
- self.world.weather = str(changes["weather_change"])
1060
- change_log.append(f"天气变为: {self.world.weather}")
1061
-
1062
- if "time_change" in changes:
1063
- valid_times = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
1064
- new_time = str(changes["time_change"])
1065
- if new_time in valid_times:
1066
- old_time = self.world.time_of_day
1067
- self.world.time_of_day = new_time
1068
- change_log.append(f"时间流逝: {old_time} → {self.world.time_of_day}")
1069
- else:
1070
- logger.warning(f"无效的 time_change 值 '{new_time}',已忽略。合法值: {valid_times}")
1071
-
1072
- if "global_flags_set" in changes:
1073
- for flag, value in changes["global_flags_set"].items():
1074
- self.world.global_flags[flag] = bool(value)
1075
- # 全局标记仅内部使用,不展示给用户
1076
- logger.info(f"全局标记设置: {flag} = {value}")
1077
-
1078
- if "world_event" in changes:
1079
- self.world.world_events.append(str(changes["world_event"]))
1080
- change_log.append(f"世界事件: {changes['world_event']}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1081
 
1082
  # --- 装备变更 ---
1083
  if "equip" in changes:
@@ -1298,16 +1406,19 @@ class GameState:
1298
  if self.world.rumors:
1299
  rumors_desc = "\n【流传的传闻】\n" + "\n".join(f" - {r}" for r in self.world.rumors[-3:])
1300
 
1301
- # 8. 随机事件
1302
- random_event_desc = ""
1303
- if getattr(self, 'pending_random_event', None):
1304
- random_event_desc = (
1305
- f"\n【本回合随机事件 —— 必须融入本次叙事】\n"
1306
- f"{self.pending_random_event}\n"
1307
- f"请将此事件自然地融入剧情描写中,作为额外的意外插曲。"
1308
- f"玩家可以选择回应或忽视此事件。至少一个选项应与此事件相关。"
1309
- )
1310
- self.pending_random_event = None # 用后清除
 
 
 
1311
 
1312
  # 9. 一致性约束指令
1313
  consistency_rules = (
@@ -1339,13 +1450,13 @@ class GameState:
1339
  scene_desc,
1340
  player_desc,
1341
  npc_desc,
1342
- quest_desc,
1343
- move_desc,
1344
- event_desc,
1345
- rumors_desc,
1346
- random_event_desc,
1347
- consistency_rules,
1348
- ])
1349
 
1350
  return full_prompt
1351
 
@@ -1419,20 +1530,21 @@ class GameState:
1419
  f"矛盾: 物品 '{item}' 不是消耗品,使用后不应消失。请将其从 items_lost 中移除。"
1420
  )
1421
 
1422
- # 检测3: 位置移动是否合法
1423
- if "new_location" in proposed_changes:
1424
- target = str(proposed_changes["new_location"])
1425
- current_loc = self.world.locations.get(self.player.location)
1426
- if current_loc and target not in current_loc.connected_to:
1427
- contradictions.append(
1428
- f"矛盾: 试图移动到不相邻的地点 '{target}'(当前位置: {self.player.location})"
1429
- )
1430
- target_loc = self.world.locations.get(target)
1431
- if target_loc and not target_loc.is_accessible:
1432
- if target_loc.required_item and target_loc.required_item not in self.player.inventory:
1433
- contradictions.append(
1434
- f"矛盾: 地点 '{target}' 被锁定,需要 '{target_loc.required_item}'"
1435
- )
 
1436
 
1437
  # 检测4: 金币是否足够(如果是消费操作)
1438
  if "gold_change" in proposed_changes:
@@ -1589,64 +1701,296 @@ class GameState:
1589
  return True
1590
  return False
1591
 
1592
- def tick_time(self) -> list[str]:
1593
- """
1594
- 推进游戏时间(每回合调用)
1595
-
1596
- 设计思路:
1597
- - 回合数递增
1598
- - 时间段按固定顺序轮转
1599
- - 每过一个完整日夜循环,天+1
1600
- - 自动减少饱食度模拟饥饿机制
1601
- - 结算状态效果持续时间
1602
- - 检查限任务
1603
-
1604
- Returns:
1605
- tick_log: 本回合时间流逝引起的状态变化描述列表
1606
- """
1607
- tick_log: list[str] = []
1608
- self.turn += 1
1609
-
1610
- # 时间推进
1611
- time_sequence = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
1612
- current_idx = time_sequence.index(self.world.time_of_day) if self.world.time_of_day in time_sequence else 0
1613
- next_idx = (current_idx + 1) % len(time_sequence)
1614
- old_time = self.world.time_of_day
1615
- self.world.time_of_day = time_sequence[next_idx]
1616
- tick_log.append(f"时间流逝: {old_time} {self.world.time_of_day}")
1617
-
1618
- # 新的一天
1619
- if next_idx == 0:
1620
- self.world.day_count += 1
1621
- tick_log.append(f"新的一天!第 {self.world.day_count} 天")
1622
- logger.info(f"新的一天开始了!第 {self.world.day_count} 天")
1623
-
1624
- # 饱食度衰减(每回合 -3)
1625
- old_hunger = self.player.hunger
1626
- self.player.hunger = max(0, self.player.hunger - 3)
1627
- if old_hunger != self.player.hunger:
1628
- tick_log.append(f"饱食度自然衰减: {old_hunger} → {self.player.hunger}")
1629
- if self.player.hunger <= 0:
1630
- tick_log.append("极度饥饿!属性受到惩罚")
1631
- logger.info("玩家非常饥饿,属性受到惩罚")
1632
-
1633
- # 结算状态效果
1634
- effect_log = self._apply_status_effects()
1635
- tick_log.extend(effect_log)
1636
-
1637
- # 检查限时任务
1638
- self._check_quest_deadlines()
1639
-
1640
- # 更新 NPC 位置(根据时间表)
1641
- self._update_npc_schedules()
1642
-
1643
- # 随机事件(约 25% 概率触发)
1644
- self._roll_random_event()
1645
-
1646
- return tick_log
1647
-
1648
- def _apply_status_effects(self) -> list[str]:
1649
- """每回合结算状态效果:应用修正、递减持续时间、移除过期效果
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1650
 
1651
  Returns:
1652
  effect_log: 状态效果结算引起的变化描述列表
@@ -1709,17 +2053,8 @@ class GameState:
1709
 
1710
  return effect_log
1711
 
1712
- def _roll_random_event(self):
1713
- """以一定概率从事件池中抽取一个随机事件,注入到下一次 Prompt 中"""
1714
- import random
1715
- self.pending_random_event = None
1716
- if self.random_event_pool and random.random() < 0.25:
1717
- event = random.choice(self.random_event_pool)
1718
- self.pending_random_event = event
1719
- logger.info(f"随机事件触发: {event}")
1720
-
1721
- def _check_quest_deadlines(self):
1722
- """检查限时任务是否过期"""
1723
  for quest in self.world.quests.values():
1724
  if quest.status == "active" and quest.turns_remaining > 0:
1725
  quest.turns_remaining -= 1
@@ -1920,3 +2255,22 @@ class GameState:
1920
  if self.player.sanity < 100:
1921
  filtered["sanity_change"] = base_recovery["sanity_change"]
1922
  return filtered
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  from __future__ import annotations
18
 
19
+ import copy
20
+ import logging
21
+ import random
22
+ import re
23
+ from typing import Any, Optional
24
+ from pydantic import BaseModel, Field
25
 
26
  from utils import clamp
27
 
 
289
  # ============================================================
290
 
291
 
292
+ class WorldState(BaseModel):
293
  """
294
  世界状态容器
295
 
 
300
  - rumors / active_threats 丰富 NPC 对话内容
301
  - faction_relations 支持阵营间动态关系
302
  """
303
+ current_scene: str = "村庄广场" # 当前场景名称
304
+ time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下午 / 黄昏 / 夜晚 / 深夜
305
+ day_count: int = 1 # 当前天数
306
+ weather: str = "晴朗" # 晴朗 / 多云 / 小雨 / 暴风雨 / 大雪 / 浓雾
307
+ light_level: str = "明亮" # 明亮 / 柔和 / 昏暗 / 幽暗 / 漆黑
308
+ time_progress_units: int = 0 # 当前时段内累积的动作耗时点数
309
+ season: str = "春" # 春 / 夏 / 秋 / 冬
310
 
311
  # --- 地图 ---
312
  locations: dict[str, LocationInfo] = Field(default_factory=dict)
 
322
  item_registry: dict[str, ItemInfo] = Field(default_factory=dict)
323
 
324
  # --- 全局标记 ---
325
+ global_flags: dict[str, bool] = Field(default_factory=dict)
326
+ world_events: list[str] = Field(default_factory=list)
327
+ # 已发生的全局事件
328
+ recent_environment_events: list["EnvironmentEvent"] = Field(default_factory=list)
329
+ active_threats: list[str] = Field(default_factory=list)
330
+ # 当前全局威胁
331
  rumors: list[str] = Field(default_factory=list)
332
  # 流传的传闻
333
  faction_relations: dict[str, dict[str, str]] = Field(default_factory=dict)
 
339
  # ============================================================
340
 
341
 
342
+ class GameEvent(BaseModel):
343
  """
344
  事件日志模型(一致性维护的关键)
345
 
 
361
  state_changes: dict = Field(default_factory=dict)
362
  # 状态变更快照
363
  player_action: str = "" # 触发该事件的玩家操作
364
+ consequence_tags: list[str] = Field(default_factory=list)
365
+ # 后果标签
366
+ is_reversible: bool = True # 是否可逆
367
+
368
+
369
+ class EnvironmentEvent(BaseModel):
370
+ """Structured environment event used by UI, logs, and prompt injection."""
371
+ event_id: str
372
+ category: str = "environment" # weather / light / environment
373
+ title: str = ""
374
+ description: str = ""
375
+ location: str = ""
376
+ time_of_day: str = ""
377
+ weather: str = ""
378
+ light_level: str = ""
379
+ severity: str = "low" # low / medium / high
380
+ state_changes: dict[str, Any] = Field(default_factory=dict)
381
+ prompt_hint: str = ""
382
+
383
+
384
+ WorldState.model_rebuild()
385
 
386
 
387
  # ============================================================
 
405
  - check_consistency() 在生成前检测可能的矛盾
406
  """
407
 
408
+ def __init__(self, player_name: str = "旅人"):
409
+ """初始化游戏状态,创建默认的起��世界"""
410
+ self.player = PlayerState(name=player_name)
411
+ self.world = WorldState()
412
+ self.event_log: list[GameEvent] = []
413
  self.turn: int = 0
414
  self.game_mode: str = "exploration" # exploration / combat / dialogue / cutscene / game_over
415
  self.difficulty: str = "normal" # easy / normal / hard
 
417
  self.ending_flags: dict[str, bool] = {} # 结局条件追踪
418
  self.combat_log: list[str] = [] # 最近战斗记录
419
  self.achievement_list: list[str] = [] # 已解锁成就
420
+
421
+ # 初始化起始世界
422
+ self._init_starting_world()
423
+ self.pending_environment_event: EnvironmentEvent | None = None
424
+ self.world.light_level = self._determine_light_level()
425
 
426
  def _init_starting_world(self):
427
  """
 
614
  race="矮人",
615
  occupation="铁匠",
616
  can_trade=True,
617
+ shop_inventory=["小刀", "短剑", "铁剑", "皮甲", "木盾"],
618
  relationship_level=0,
619
  schedule={"清晨": "村庄铁匠铺", "正午": "村庄铁匠铺", "夜晚": "村庄旅店"},
620
  backstory="曾经是王都的御用铁匠,因一场变故隐居此地。对远方的怪物有独到的了解。",
 
780
 
781
  # --- 初始物品注册表 ---
782
  self.world.item_registry = {
783
+ "小刀": ItemInfo(name="小刀", item_type="weapon", description="一把朴素但实用,便于近身防身。", rarity="common", stat_bonus={"attack": 2}, value=5),
784
+ "短剑": ItemInfo(name="短剑", item_type="weapon", description="一把适合新手携带的短剑,轻便易用。", rarity="common", stat_bonus={"attack": 3}, value=10),
785
+ "铁剑": ItemInfo(name="铁剑", item_type="weapon", description="一把标准的铁制长剑,刀锋锐利。", rarity="common", stat_bonus={"attack": 5}, value=30),
786
  "皮甲": ItemInfo(name="皮甲", item_type="armor", description="硬化皮革制成的轻甲,兼顾防护与灵活性。", rarity="common", stat_bonus={"defense": 3}, value=25),
787
  "木盾": ItemInfo(name="木盾", item_type="armor", description="坚硬橡木制成的盾牌,可以抵挡基础攻击。", rarity="common", stat_bonus={"defense": 2}, value=15),
788
  "面包": ItemInfo(name="面包", item_type="consumable", description="新鲜烤制的面包,香气扑鼻。", usable=True, use_effect="恢复 10 饱食度", value=5),
 
817
  "有人在精灵遗迹附近见到过一个穿绿袍的身影,不知是人是鬼。",
818
  ]
819
 
820
+ # --- 显式环境事件模板 ---
821
+ self.environment_event_pool: list[dict[str, Any]] = [
822
+ {
823
+ "event_id": "lanterns_dim",
824
+ "category": "light",
825
+ "title": "灯火忽暗",
826
+ "description": "屋内灯火突然暗一截,墙角的影子被拉得细长。",
827
+ "location_types": ["town", "shop"],
828
+ "time_slots": ["黄昏", "夜晚", "深夜"],
829
+ "severity": "medium",
830
+ "state_changes": {"sanity_change": -2},
831
+ "prompt_hint": "环境光线明显变暗,叙里要体现角色对阴影和氛围变化的反应。",
832
+ },
833
+ {
834
+ "event_id": "cold_gust",
835
+ "category": "environment",
836
+ "title": "冷风穿林",
837
+ "description": "一阵带着湿意的冷风从林间掠过,让人本能地绷紧肩背。",
838
+ "location_types": ["wilderness", "dungeon"],
839
+ "time_slots": ["下午", "黄昏", "夜晚", "深夜"],
840
+ "severity": "low",
841
+ "state_changes": {"morale_change": -3},
842
+ "prompt_hint": "风声和体感温度都发生了变化,适合让玩家察觉环境压力正在上升。",
843
+ },
844
+ {
845
+ "event_id": "forest_rustle",
846
+ "category": "environment",
847
+ "title": "林影骚动",
848
+ "description": "黑暗树影间传来急促的窸窣声,仿佛刚有什么东西贴着边缘掠过。",
849
+ "location_types": ["wilderness", "dungeon"],
850
+ "time_slots": ["黄昏", "夜晚", "深夜"],
851
+ "min_danger": 3,
852
+ "severity": "medium",
853
+ "state_changes": {"sanity_change": -3},
854
+ "prompt_hint": "这是偏悬疑的环境扰动,至少一个后续选项应允许玩家追查或回避。",
855
+ },
856
+ {
857
+ "event_id": "fireplace_relief",
858
+ "category": "environment",
859
+ "title": "火光回暖",
860
+ "description": "炉火和热气驱散了紧绷感,让呼吸也慢慢平稳下来。",
861
+ "location_types": ["shop", "town"],
862
+ "requires_rest_available": True,
863
+ "time_slots": ["黄昏", "夜晚", "深夜"],
864
+ "severity": "low",
865
+ "state_changes": {"morale_change": 4, "sanity_change": 2},
866
+ "prompt_hint": "这是偏正向的氛围事件,叙事里可以体现安全感和短暂放松。",
867
+ },
868
+ {
869
+ "event_id": "fog_pressures_in",
870
+ "category": "environment",
871
+ "title": "雾气压近",
872
+ "description": "潮湿的雾从地面漫上来,视野被一点点吞没,声音也变得含混。",
873
+ "location_types": ["wilderness", "dungeon"],
874
+ "time_slots": ["清晨", "黄昏", "夜晚", "深夜"],
875
+ "weathers": ["浓雾", "小雨"],
876
+ "severity": "medium",
877
+ "state_changes": {"sanity_change": -2},
878
+ "prompt_hint": "视野受限且不安感上升,叙事里应弱化远景、强调近距离感官细节。",
879
+ },
880
+ ]
881
+
882
+ # --- 玩家初始装备 ---
883
+ self.player.inventory = ["面包", "面包", "小型治疗药水"]
884
+ self.player.location = self.world.current_scene
885
 
886
  # ============================================================
887
  # 核心方法
 
922
  changes = _filtered
923
 
924
  # --- 玩家属性变更 ---
925
+ if "hp_change" in changes:
926
+ old_hp = self.player.hp
927
+ self.player.hp = clamp(
928
+ self.player.hp + int(changes["hp_change"]),
929
+ 0,
930
+ self.player.max_hp,
931
+ )
932
+ if self.player.hp != old_hp:
933
+ change_log.append(f"HP: {old_hp} → {self.player.hp}")
934
+
935
+ if "mp_change" in changes:
936
+ old_mp = self.player.mp
937
+ self.player.mp = clamp(
938
+ self.player.mp + int(changes["mp_change"]),
939
+ 0,
940
+ self.player.max_mp,
941
+ )
942
+ if self.player.mp != old_mp:
943
+ change_log.append(f"MP: {old_mp} → {self.player.mp}")
944
+
945
+ if "gold_change" in changes:
946
+ old_gold = self.player.gold
947
+ self.player.gold = max(0, self.player.gold + int(changes["gold_change"]))
948
+ if self.player.gold != old_gold:
949
+ change_log.append(f"金币: {old_gold} → {self.player.gold}")
950
 
951
  if "exp_change" in changes:
952
  old_exp = self.player.experience
 
957
  self._level_up()
958
  change_log.append(f"升级!当前等级: {self.player.level}")
959
 
960
+ if "morale_change" in changes:
961
+ old_morale = self.player.morale
962
+ self.player.morale = clamp(
963
+ self.player.morale + int(changes["morale_change"]),
964
+ 0, 100,
965
+ )
966
+ if self.player.morale != old_morale:
967
+ change_log.append(f"士气: {old_morale} → {self.player.morale}")
968
+
969
+ if "sanity_change" in changes:
970
+ old_sanity = self.player.sanity
971
+ self.player.sanity = clamp(
972
+ self.player.sanity + int(changes["sanity_change"]),
973
+ 0, 100,
974
+ )
975
+ if self.player.sanity != old_sanity:
976
+ change_log.append(f"理智: {old_sanity} → {self.player.sanity}")
977
+
978
+ if "hunger_change" in changes:
979
+ old_hunger = self.player.hunger
980
+ self.player.hunger = clamp(
981
+ self.player.hunger + int(changes["hunger_change"]),
982
+ 0, 100,
983
+ )
984
+ if self.player.hunger != old_hunger:
985
+ change_log.append(f"饱食度: {old_hunger} → {self.player.hunger}")
986
+
987
+ if "karma_change" in changes:
988
+ old_karma = self.player.karma
989
+ self.player.karma += int(changes["karma_change"])
990
+ if self.player.karma != old_karma:
991
+ change_log.append(f"善恶值: {old_karma} → {self.player.karma}")
992
+
993
+ # --- 位置变更 ---
994
+ if "new_location" in changes:
995
+ old_loc = self.player.location
996
+ new_loc = str(changes["new_location"])
997
+ if new_loc.strip().lower() not in ("", "none", "null") and new_loc != old_loc:
998
+ self.player.location = new_loc
999
+ self.world.current_scene = new_loc
1000
+ change_log.append(f"位置: {old_loc} {new_loc}")
1001
+ # 发现新地点
1002
+ if new_loc not in self.world.discovered_locations:
1003
+ self.world.discovered_locations.append(new_loc)
1004
+ change_log.append(f"发现新地点: {new_loc}")
1005
+ if new_loc in self.world.locations:
1006
+ self.world.locations[new_loc].is_discovered = True
1007
 
1008
  # --- 物品变更 ---
1009
  # 货币关键词列表:这些物品不进背包,而是直接转换为金币
 
1088
  change_log.append(f"移除状态: {name}")
1089
 
1090
  # --- NPC 相关变更 ---
1091
+ if "npc_changes" in changes:
1092
+ for npc_name, npc_data in changes["npc_changes"].items():
1093
+ if npc_name in self.world.npcs:
1094
+ npc = self.world.npcs[npc_name]
1095
+ if "attitude" in npc_data:
1096
+ new_attitude = str(npc_data["attitude"])
1097
+ if npc.attitude != new_attitude:
1098
+ npc.attitude = new_attitude
1099
+ change_log.append(f"NPC {npc_name} 态度变为: {npc.attitude}")
1100
+ if "is_alive" in npc_data:
1101
+ new_is_alive = bool(npc_data["is_alive"])
1102
+ was_alive = npc.is_alive
1103
+ if npc.is_alive != new_is_alive:
1104
+ npc.is_alive = new_is_alive
1105
+ if was_alive and not npc.is_alive:
1106
+ change_log.append(f"NPC {npc_name} 已死亡")
1107
+ if "relationship_change" in npc_data:
1108
+ old_rel = npc.relationship_level
1109
+ npc.relationship_level = clamp(
1110
+ npc.relationship_level + int(npc_data["relationship_change"]),
1111
+ -100, 100,
1112
+ )
1113
+ if npc.relationship_level != old_rel:
1114
+ change_log.append(
1115
+ f"NPC {npc_name} 好感度: {old_rel} → {npc.relationship_level}"
1116
+ )
1117
+ if "hp_change" in npc_data:
1118
+ old_hp = npc.hp
1119
+ npc.hp = max(0, npc.hp + int(npc_data["hp_change"]))
1120
+ if npc.hp <= 0:
1121
+ npc.is_alive = False
1122
+ change_log.append(f"NPC {npc_name} 被击败")
1123
+ elif npc.hp != old_hp:
1124
+ change_log.append(f"NPC {npc_name} HP: {old_hp} → {npc.hp}")
1125
+ if "memory_add" in npc_data:
1126
+ npc.memory.append(str(npc_data["memory_add"]))
1127
 
1128
  # --- 任务变更 ---
1129
  if "quest_updates" in changes:
 
1147
  status_cn = _QUEST_STATUS_CN.get(quest.status, quest.status)
1148
  change_log.append(f"任务【{quest.title}】进展至\"{status_cn}\"")
1149
 
1150
+ # --- 世界状态变更 ---
1151
+ if "weather_change" in changes:
1152
+ valid_weathers = {"晴朗", "多云", "小雨", "暴风雨", "大雪", "浓雾"}
1153
+ new_weather = str(changes["weather_change"])
1154
+ if new_weather in valid_weathers:
1155
+ if self.world.weather != new_weather:
1156
+ self.world.weather = new_weather
1157
+ change_log.append(f"天气变为: {self.world.weather}")
1158
+ else:
1159
+ logger.warning(f"无效的 weather_change 值 '{new_weather}',已忽略。")
1160
+
1161
+ if "time_change" in changes:
1162
+ valid_times = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
1163
+ new_time = str(changes["time_change"])
1164
+ if new_time in valid_times:
1165
+ old_time = self.world.time_of_day
1166
+ if new_time != old_time:
1167
+ self.world.time_of_day = new_time
1168
+ change_log.append(f"时间流逝: {old_time} → {self.world.time_of_day}")
1169
+ else:
1170
+ logger.warning(f"无效的 time_change 值 '{new_time}',已忽略。合法值: {valid_times}")
1171
+
1172
+ if "weather_change" in changes or "time_change" in changes:
1173
+ old_light = self.world.light_level
1174
+ self.world.light_level = self._determine_light_level()
1175
+ if old_light != self.world.light_level:
1176
+ change_log.append(f"光照变化: {old_light} → {self.world.light_level}")
1177
+
1178
+ if "global_flags_set" in changes:
1179
+ for flag, value in changes["global_flags_set"].items():
1180
+ self.world.global_flags[flag] = bool(value)
1181
+ # 全局标记仅内部使用,不展示给用户
1182
+ logger.info(f"全局标记设置: {flag} = {value}")
1183
+
1184
+ if "world_event" in changes:
1185
+ world_event = str(changes["world_event"])
1186
+ if not self.world.world_events or self.world.world_events[-1] != world_event:
1187
+ self.world.world_events.append(world_event)
1188
+ change_log.append(f"世界事件: {world_event}")
1189
 
1190
  # --- 装备变更 ---
1191
  if "equip" in changes:
 
1406
  if self.world.rumors:
1407
  rumors_desc = "\n【流传的传闻】\n" + "\n".join(f" - {r}" for r in self.world.rumors[-3:])
1408
 
1409
+ # 8. 显式环境事件
1410
+ environment_event_desc = ""
1411
+ if self.pending_environment_event:
1412
+ env = self.pending_environment_event
1413
+ environment_event_desc = (
1414
+ f"\n【本回合环境事件 —— 必须融入本次叙事】\n"
1415
+ f"[{env.category.upper()}|{env.severity}] {env.title}\n"
1416
+ f"{env.description}\n"
1417
+ f"{env.prompt_hint}\n"
1418
+ f"请将此事件自然地融入剧情描写中,作为本回合可感知的环境变化。"
1419
+ f"玩家可以选择回应、调查、规避或忽视它。至少一个选项应与此事件相关。"
1420
+ )
1421
+ self.pending_environment_event = None # 用后清除
1422
 
1423
  # 9. 一致性约束指令
1424
  consistency_rules = (
 
1450
  scene_desc,
1451
  player_desc,
1452
  npc_desc,
1453
+ quest_desc,
1454
+ move_desc,
1455
+ event_desc,
1456
+ rumors_desc,
1457
+ environment_event_desc,
1458
+ consistency_rules,
1459
+ ])
1460
 
1461
  return full_prompt
1462
 
 
1530
  f"矛盾: 物品 '{item}' 不是消耗品,使用后不应消失。请将其从 items_lost 中移除。"
1531
  )
1532
 
1533
+ # 检测3: 位置移动是否合法
1534
+ if "new_location" in proposed_changes:
1535
+ target = str(proposed_changes["new_location"])
1536
+ if target.strip().lower() not in ("", "none", "null") and target != self.player.location:
1537
+ current_loc = self.world.locations.get(self.player.location)
1538
+ if current_loc and target not in current_loc.connected_to:
1539
+ contradictions.append(
1540
+ f"矛盾: 试图移动到不相邻的地点 '{target}'(当前位置: {self.player.location})"
1541
+ )
1542
+ target_loc = self.world.locations.get(target)
1543
+ if target_loc and not target_loc.is_accessible:
1544
+ if target_loc.required_item and target_loc.required_item not in self.player.inventory:
1545
+ contradictions.append(
1546
+ f"矛盾: 地点 '{target}' 被锁定,需要 '{target_loc.required_item}'"
1547
+ )
1548
 
1549
  # 检测4: 金币是否足够(如果是消费操作)
1550
  if "gold_change" in proposed_changes:
 
1701
  return True
1702
  return False
1703
 
1704
+ def tick_time(self, player_intent: Optional[dict] = None) -> list[str]:
1705
+ """
1706
+ 按动作消耗推进游戏时间。
1707
+
1708
+ 设计思路:
1709
+ - 回合数递增
1710
+ - 不同动作消耗不同的时间点数
1711
+ - 累积点达到阈值时,时间段按固定顺序轮转
1712
+ - 每过一个完整日夜循环天数+1
1713
+ - 自动减少饱食度,模拟饥饿机制
1714
+ - 结算状态效果持续
1715
+ - 检查限时任务
1716
+
1717
+ Returns:
1718
+ tick_log: 本回合时间流逝引起的状态变化描述列表
1719
+ """
1720
+ tick_log: list[str] = []
1721
+ self.turn += 1
1722
+ time_threshold = 2
1723
+ action_units = self._estimate_time_cost_units(player_intent)
1724
+ self.world.time_progress_units += action_units
1725
+ elapsed_segments = self.world.time_progress_units // time_threshold
1726
+ self.world.time_progress_units = self.world.time_progress_units % time_threshold
1727
+
1728
+ if elapsed_segments <= 0:
1729
+ return tick_log
1730
+
1731
+ time_sequence = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
1732
+ for _ in range(elapsed_segments):
1733
+ current_idx = (
1734
+ time_sequence.index(self.world.time_of_day)
1735
+ if self.world.time_of_day in time_sequence
1736
+ else 0
1737
+ )
1738
+ next_idx = (current_idx + 1) % len(time_sequence)
1739
+ old_time = self.world.time_of_day
1740
+ self.world.time_of_day = time_sequence[next_idx]
1741
+ tick_log.append(f"时间流逝: {old_time} → {self.world.time_of_day}")
1742
+
1743
+ if next_idx == 0:
1744
+ self.world.day_count += 1
1745
+ tick_log.append(f"新的一天!第 {self.world.day_count} 天")
1746
+ logger.info(f"新的一天开始了!第 {self.world.day_count} 天")
1747
+
1748
+ old_hunger = self.player.hunger
1749
+ self.player.hunger = max(0, self.player.hunger - (3 * elapsed_segments))
1750
+ if old_hunger != self.player.hunger:
1751
+ tick_log.append(f"饱食度自然衰减: {old_hunger} → {self.player.hunger}")
1752
+ if self.player.hunger <= 0:
1753
+ tick_log.append("极度饥饿!属性受到惩罚")
1754
+ logger.info("玩家非常饥饿,属性受到惩罚")
1755
+
1756
+ for _ in range(elapsed_segments):
1757
+ effect_log = self._apply_status_effects()
1758
+ tick_log.extend(effect_log)
1759
+
1760
+ # 检查限时任务
1761
+ self._check_quest_deadlines()
1762
+
1763
+ # 更新 NPC 位置(根据时间表)
1764
+ self._update_npc_schedules()
1765
+
1766
+ # 显式环境系统:光照 / 天气 / 环境扰动
1767
+ self._update_environment_cycle(tick_log)
1768
+
1769
+ return tick_log
1770
+
1771
+ def _estimate_time_cost_units(self, player_intent: Optional[dict] = None) -> int:
1772
+ """Estimate how much in-world time a player action should consume."""
1773
+ if not isinstance(player_intent, dict):
1774
+ return 2
1775
+
1776
+ intent = str(player_intent.get("intent", "")).upper()
1777
+ raw_input = str(player_intent.get("raw_input", "") or "")
1778
+ details = str(player_intent.get("details", "") or "")
1779
+ target = str(player_intent.get("target", "") or "")
1780
+ full_text = f"{raw_input} {details} {target}"
1781
+
1782
+ base_costs = {
1783
+ "TALK": 0,
1784
+ "TRADE": 1,
1785
+ "USE_ITEM": 1,
1786
+ "EQUIP": 1,
1787
+ "SKILL": 1,
1788
+ "MOVE": 2,
1789
+ "EXPLORE": 2,
1790
+ "QUEST": 2,
1791
+ "ATTACK": 2,
1792
+ "REST": 4,
1793
+ "WAIT": 4,
1794
+ }
1795
+ cost = base_costs.get(intent, 1)
1796
+
1797
+ lightweight_markers = (
1798
+ "检查",
1799
+ "查看",
1800
+ "确认",
1801
+ "翻看",
1802
+ "端详",
1803
+ "研究",
1804
+ "询问",
1805
+ "交谈",
1806
+ "背包",
1807
+ "物品",
1808
+ "装备",
1809
+ "地图",
1810
+ )
1811
+ if intent in {"EXPLORE", "QUEST"} and any(marker in full_text for marker in lightweight_markers):
1812
+ return 0
1813
+
1814
+ return max(0, cost)
1815
+
1816
+ def _determine_light_level(self) -> str:
1817
+ """Derive current light level from time of day and weather."""
1818
+ base_levels = {
1819
+ "清晨": "柔和",
1820
+ "上午": "明亮",
1821
+ "正午": "明亮",
1822
+ "下午": "柔和",
1823
+ "黄昏": "昏暗",
1824
+ "夜晚": "幽暗",
1825
+ "深夜": "漆黑",
1826
+ }
1827
+ ordered_levels = ["明亮", "柔和", "昏暗", "幽暗", "漆黑"]
1828
+ weather_penalty = {
1829
+ "晴朗": 0,
1830
+ "多云": 0,
1831
+ "小雨": 1,
1832
+ "大雪": 1,
1833
+ "浓雾": 1,
1834
+ "暴风雨": 2,
1835
+ }
1836
+
1837
+ base_level = base_levels.get(self.world.time_of_day, "柔和")
1838
+ current_index = ordered_levels.index(base_level)
1839
+ darker_by = weather_penalty.get(self.world.weather, 0)
1840
+ next_index = min(len(ordered_levels) - 1, current_index + darker_by)
1841
+ return ordered_levels[next_index]
1842
+
1843
+ def _update_environment_cycle(self, tick_log: list[str]):
1844
+ """Advance light/weather and roll explicit environment events."""
1845
+ self.pending_environment_event = None
1846
+ self._update_light_level_event(tick_log)
1847
+ self._maybe_shift_weather(tick_log)
1848
+ self._update_light_level_event(tick_log)
1849
+ self._roll_environment_event(tick_log)
1850
+
1851
+ def _update_light_level_event(self, tick_log: list[str]):
1852
+ old_light = self.world.light_level
1853
+ new_light = self._determine_light_level()
1854
+ if new_light == old_light:
1855
+ return
1856
+
1857
+ self.world.light_level = new_light
1858
+ event = EnvironmentEvent(
1859
+ event_id=f"light-{self.turn}",
1860
+ category="light",
1861
+ title=f"光照转为{new_light}",
1862
+ description=f"随着时间与天气变化,周围环境现在呈现出{new_light}的光照状态。",
1863
+ location=self.player.location,
1864
+ time_of_day=self.world.time_of_day,
1865
+ weather=self.world.weather,
1866
+ light_level=new_light,
1867
+ severity="medium" if new_light in {"幽暗", "漆黑"} else "low",
1868
+ prompt_hint="请在叙事中体现能见度、阴影和角色主观感受的变化。",
1869
+ )
1870
+ self._register_environment_event(
1871
+ event,
1872
+ tick_log,
1873
+ inject_prompt=new_light in {"昏暗", "幽暗", "漆黑"},
1874
+ )
1875
+
1876
+ def _maybe_shift_weather(self, tick_log: list[str]):
1877
+ """Occasionally shift weather to keep the environment dynamic."""
1878
+ chance = 0.18 if self.world.time_of_day in {"清晨", "黄昏"} else 0.08
1879
+ if random.random() >= chance:
1880
+ return
1881
+
1882
+ weather_transitions = {
1883
+ "晴朗": ["多云", "小雨"],
1884
+ "多云": ["晴朗", "���雨", "浓雾"],
1885
+ "小雨": ["多云", "暴风雨", "浓雾"],
1886
+ "浓雾": ["多云", "小雨", "晴朗"],
1887
+ "暴风雨": ["小雨", "多云"],
1888
+ "大雪": ["多云"],
1889
+ }
1890
+ next_candidates = weather_transitions.get(self.world.weather, ["晴朗", "多云"])
1891
+ new_weather = random.choice(next_candidates)
1892
+ if new_weather == self.world.weather:
1893
+ return
1894
+
1895
+ loc = self.world.locations.get(self.player.location)
1896
+ weather_effects: dict[str, Any] = {"weather_change": new_weather}
1897
+ if loc and loc.location_type in {"wilderness", "dungeon"}:
1898
+ if new_weather == "暴风雨":
1899
+ weather_effects.update({"morale_change": -3, "sanity_change": -2})
1900
+ elif new_weather == "浓雾":
1901
+ weather_effects.update({"sanity_change": -1})
1902
+ elif new_weather == "晴朗" and self.world.weather in {"小雨", "浓雾", "暴风雨"}:
1903
+ weather_effects.update({"morale_change": 2})
1904
+
1905
+ event = EnvironmentEvent(
1906
+ event_id=f"weather-{self.turn}",
1907
+ category="weather",
1908
+ title=f"天气转为{new_weather}",
1909
+ description=f"周围的天象正在变化,空气与视野都随着天气转向{new_weather}。",
1910
+ location=self.player.location,
1911
+ time_of_day=self.world.time_of_day,
1912
+ weather=new_weather,
1913
+ light_level=self.world.light_level,
1914
+ severity="medium" if new_weather in {"暴风雨", "浓雾"} else "low",
1915
+ state_changes=weather_effects,
1916
+ prompt_hint="请把天气变化作为当前回合的重要氛围来源,影响角色观察和选择。",
1917
+ )
1918
+ self._register_environment_event(event, tick_log, inject_prompt=True)
1919
+ self.world.light_level = self._determine_light_level()
1920
+
1921
+ def _roll_environment_event(self, tick_log: list[str]):
1922
+ """Roll a structured environment event using explicit template filters."""
1923
+ loc = self.world.locations.get(self.player.location)
1924
+ if loc is None or not self.environment_event_pool:
1925
+ return
1926
+
1927
+ chance = 0.08
1928
+ if loc.danger_level >= 3:
1929
+ chance += 0.08
1930
+ if self.world.light_level in {"幽暗", "漆黑"}:
1931
+ chance += 0.06
1932
+ if self.world.weather in {"暴风雨", "浓雾"}:
1933
+ chance += 0.04
1934
+ if random.random() >= chance:
1935
+ return
1936
+
1937
+ candidates: list[dict[str, Any]] = []
1938
+ for template in self.environment_event_pool:
1939
+ if template.get("location_types") and loc.location_type not in template["location_types"]:
1940
+ continue
1941
+ if template.get("time_slots") and self.world.time_of_day not in template["time_slots"]:
1942
+ continue
1943
+ if template.get("weathers") and self.world.weather not in template["weathers"]:
1944
+ continue
1945
+ if template.get("requires_rest_available") and not loc.rest_available:
1946
+ continue
1947
+ if loc.danger_level < int(template.get("min_danger", 0)):
1948
+ continue
1949
+ candidates.append(template)
1950
+
1951
+ if not candidates:
1952
+ return
1953
+
1954
+ template = random.choice(candidates)
1955
+ event = EnvironmentEvent(
1956
+ event_id=f"{template['event_id']}-{self.turn}",
1957
+ category=str(template.get("category", "environment")),
1958
+ title=str(template.get("title", "环境异动")),
1959
+ description=str(template.get("description", "")),
1960
+ location=self.player.location,
1961
+ time_of_day=self.world.time_of_day,
1962
+ weather=self.world.weather,
1963
+ light_level=self.world.light_level,
1964
+ severity=str(template.get("severity", "low")),
1965
+ state_changes=copy.deepcopy(template.get("state_changes", {})),
1966
+ prompt_hint=str(template.get("prompt_hint", "")),
1967
+ )
1968
+ self._register_environment_event(event, tick_log, inject_prompt=True)
1969
+
1970
+ def _register_environment_event(
1971
+ self,
1972
+ event: EnvironmentEvent,
1973
+ tick_log: list[str],
1974
+ *,
1975
+ inject_prompt: bool,
1976
+ ):
1977
+ """Persist an environment event, optionally inject it into the next prompt, and apply effects."""
1978
+ self.world.recent_environment_events.append(event)
1979
+ self.world.recent_environment_events = self.world.recent_environment_events[-8:]
1980
+ if inject_prompt:
1981
+ self.pending_environment_event = event
1982
+
1983
+ if event.category == "light":
1984
+ tick_log.append(f"光照变化: {event.title}")
1985
+
1986
+ changes = copy.deepcopy(event.state_changes)
1987
+ changes.setdefault("world_event", event.title)
1988
+ change_log = self.apply_changes(changes)
1989
+ tick_log.extend(change_log)
1990
+ logger.info("环境事件触发: %s", event.title)
1991
+
1992
+ def _apply_status_effects(self) -> list[str]:
1993
+ """每回合结算状态效果:应用修正、递减持续时间、移除过期效果
1994
 
1995
  Returns:
1996
  effect_log: 状态效果结算引起的变化描述列表
 
2053
 
2054
  return effect_log
2055
 
2056
+ def _check_quest_deadlines(self):
2057
+ """检查限时任务是否过期"""
 
 
 
 
 
 
 
 
 
2058
  for quest in self.world.quests.values():
2059
  if quest.status == "active" and quest.turns_remaining > 0:
2060
  quest.turns_remaining -= 1
 
2255
  if self.player.sanity < 100:
2256
  filtered["sanity_change"] = base_recovery["sanity_change"]
2257
  return filtered
2258
+
2259
+ def get_environment_snapshot(self, limit: int = 3) -> dict[str, Any]:
2260
+ """Return a compact environment summary for UI and logs."""
2261
+ loc = self.world.locations.get(self.player.location)
2262
+ recent_events = [
2263
+ event.model_dump()
2264
+ for event in self.world.recent_environment_events[-limit:]
2265
+ ]
2266
+ return {
2267
+ "weather": self.world.weather,
2268
+ "light_level": self.world.light_level,
2269
+ "time_of_day": self.world.time_of_day,
2270
+ "season": self.world.season,
2271
+ "location_type": loc.location_type if loc else "unknown",
2272
+ "danger_level": loc.danger_level if loc else 0,
2273
+ "rest_available": bool(loc.rest_available) if loc else False,
2274
+ "shop_available": bool(loc.shop_available) if loc else False,
2275
+ "recent_events": recent_events,
2276
+ }
story_engine.py CHANGED
@@ -525,7 +525,7 @@ class StoryEngine:
525
  # 2. apply_changes 的 change_log 与状态栏显示一致
526
  # 3. 不会出现“文本说100、状态栏显示97”的同步Bug
527
  # ============================================
528
- tick_log = self.game_state.tick_time()
529
 
530
  # ============================================
531
  # 第一阶段:生成剧情大纲
@@ -1032,49 +1032,222 @@ class StoryEngine:
1032
  ),
1033
  }
1034
 
1035
- def _sanitize_state_changes(self, changes: dict, event_type: str = "") -> tuple[dict, list[str]]:
1036
- """
1037
- 清理状态变更:阻止消耗品因使而被移除
1038
-
1039
- 规则:
1040
- - 交易(TRADE)、赠送、丢弃等行为可以移除任何物品
1041
- - 其他行为(使用、探索、对话等)只能移除消耗品
1042
-
1043
- Args:
1044
- changes: 状态变更字典
1045
- event_type: 事件类型(来自大纲)
1046
-
1047
- Returns:
1048
- (清理后的changes, 警告列表)
1049
- """
1050
- warnings = []
1051
- if "items_lost" not in changes:
1052
- return changes, warnings
1053
-
1054
- # 交易/赠送行为可以移除任何物品
1055
- trade_events = {"TRADE", "GIVE", "DROP"}
1056
- if event_type.upper() in trade_events:
1057
- return changes, warnings
1058
-
1059
- # 其他行为:只允许移除消耗品
1060
- changes = dict(changes) # 浅拷贝,避免修改原始数据
1061
- sanitized_items_lost = []
1062
- for item_name in changes["items_lost"]:
1063
- item_str = str(item_name)
1064
- if self.game_state.is_item_consumable(item_str):
1065
- sanitized_items_lost.append(item_name)
1066
- else:
1067
- warnings.append(
1068
- f"物品 '{item_str}' 不是消耗品,使用后仍保留在背包中"
1069
- )
1070
- logger.warning(f"阻止移除非消耗品: {item_str}")
1071
-
1072
- if sanitized_items_lost:
1073
- changes["items_lost"] = sanitized_items_lost
1074
- else:
1075
- changes.pop("items_lost", None)
1076
 
1077
- return changes, warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1078
 
1079
  def _fill_missing_consumable_effects(
1080
  self,
@@ -1396,7 +1569,7 @@ class StoryEngine:
1396
  logger.info(f"[流式/合并] 生成故事响应,玩家意图: {player_intent}")
1397
 
1398
  # 推进时间
1399
- tick_log = self.game_state.tick_time()
1400
 
1401
  # 构建合并 Prompt
1402
  system_prompt = MERGED_SYSTEM_PROMPT_TEMPLATE.format(
 
525
  # 2. apply_changes 的 change_log 与状态栏显示一致
526
  # 3. 不会出现“文本说100、状态栏显示97”的同步Bug
527
  # ============================================
528
+ tick_log = self.game_state.tick_time(player_intent)
529
 
530
  # ============================================
531
  # 第一阶段:生成剧情大纲
 
1032
  ),
1033
  }
1034
 
1035
+ def _sanitize_state_changes(self, changes: dict, event_type: str = "") -> tuple[dict, list[str]]:
1036
+ """
1037
+ 清理状态变更,避免预期的高风险副作进入正式状态
1038
+
1039
+ 规则:
1040
+ - 交易(TRADE)、赠送、丢弃等行为可以移除任何物品
1041
+ - 其他行为(使用、探索、对话等)只能移除消耗品
1042
+ - 非战斗事件不能直接造成 NPC 死亡
1043
+
1044
+ Args:
1045
+ changes: 状态变更字典
1046
+ event_type: 事件类型(来自大纲)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1047
 
1048
+ Returns:
1049
+ (清理后的changes, 警告列表)
1050
+ """
1051
+ warnings = []
1052
+ changes = dict(changes)
1053
+
1054
+ if "items_lost" in changes:
1055
+ # 交易/赠送行为可以移除任何物品
1056
+ trade_events = {"TRADE", "GIVE", "DROP"}
1057
+ if event_type.upper() not in trade_events:
1058
+ # 其他行为:只允许移除消耗品
1059
+ sanitized_items_lost = []
1060
+ for item_name in changes["items_lost"]:
1061
+ item_str = str(item_name)
1062
+ if self.game_state.is_item_consumable(item_str):
1063
+ sanitized_items_lost.append(item_name)
1064
+ else:
1065
+ warnings.append(
1066
+ f"物品 '{item_str}' 不是消耗品,使用后仍保留在背包中"
1067
+ )
1068
+ logger.warning(f"阻止移除非消耗品: {item_str}")
1069
+
1070
+ if sanitized_items_lost:
1071
+ changes["items_lost"] = sanitized_items_lost
1072
+ else:
1073
+ changes.pop("items_lost", None)
1074
+
1075
+ changes, item_warnings = self._sanitize_controlled_item_changes(changes, event_type)
1076
+ warnings.extend(item_warnings)
1077
+ changes, npc_warnings = self._sanitize_noncombat_npc_changes(changes, event_type)
1078
+ warnings.extend(npc_warnings)
1079
+
1080
+ return changes, warnings
1081
+
1082
+ def _sanitize_controlled_item_changes(
1083
+ self,
1084
+ changes: dict,
1085
+ event_type: str = "",
1086
+ ) -> tuple[dict, list[str]]:
1087
+ """Restrict item gain/equip to registered items and paid trade outcomes."""
1088
+ sanitized_changes = dict(changes)
1089
+ warnings: list[str] = []
1090
+ registered_items = set(self.game_state.world.item_registry.keys())
1091
+ inventory_items = set(self.game_state.player.inventory)
1092
+ equipped_items = {
1093
+ str(item)
1094
+ for item in self.game_state.player.equipment.values()
1095
+ if item and str(item).lower() not in {"none", "null", ""}
1096
+ }
1097
+ allowed_trade_items = {
1098
+ str(item_name)
1099
+ for npc in self.game_state.world.npcs.values()
1100
+ if npc.can_trade
1101
+ for item_name in npc.shop_inventory
1102
+ }
1103
+
1104
+ original_items_gained = [
1105
+ str(item)
1106
+ for item in sanitized_changes.get("items_gained", [])
1107
+ ]
1108
+ valid_items_gained: list[str] = []
1109
+ for item_name in original_items_gained:
1110
+ if item_name not in registered_items:
1111
+ warning = f"移除未注册物品获取: {item_name}"
1112
+ warnings.append(warning)
1113
+ logger.warning(warning)
1114
+ continue
1115
+ if event_type.upper() == "TRADE" and allowed_trade_items and item_name not in allowed_trade_items:
1116
+ warning = f"移除不在商店配置中的交易物品: {item_name}"
1117
+ warnings.append(warning)
1118
+ logger.warning(warning)
1119
+ continue
1120
+ valid_items_gained.append(item_name)
1121
+
1122
+ if valid_items_gained:
1123
+ sanitized_changes["items_gained"] = valid_items_gained
1124
+ else:
1125
+ sanitized_changes.pop("items_gained", None)
1126
+
1127
+ if event_type.upper() == "TRADE":
1128
+ gold_change = sanitized_changes.get("gold_change")
1129
+ items_lost = sanitized_changes.get("items_lost", [])
1130
+ is_paid_trade = (
1131
+ isinstance(gold_change, (int, float)) and gold_change < 0
1132
+ ) or bool(items_lost)
1133
+ if not is_paid_trade:
1134
+ if "items_gained" in sanitized_changes:
1135
+ warning = "移除未支付的交易物品获取"
1136
+ warnings.append(warning)
1137
+ logger.warning(warning)
1138
+ sanitized_changes.pop("items_gained", None)
1139
+ if "equip" in sanitized_changes:
1140
+ warning = "移除未支付的交易装备变更"
1141
+ warnings.append(warning)
1142
+ logger.warning(warning)
1143
+ sanitized_changes.pop("equip", None)
1144
+
1145
+ if "equip" in sanitized_changes and isinstance(sanitized_changes["equip"], dict):
1146
+ valid_equippable = inventory_items | equipped_items | set(
1147
+ sanitized_changes.get("items_gained", [])
1148
+ )
1149
+ sanitized_equip: dict[str, str] = {}
1150
+ for slot, item_name in sanitized_changes["equip"].items():
1151
+ item_str = str(item_name)
1152
+ if item_str.lower() in {"none", "null", ""}:
1153
+ sanitized_equip[str(slot)] = item_name
1154
+ continue
1155
+ if item_str not in registered_items:
1156
+ warning = f"移除未注册装备变更: {slot} -> {item_str}"
1157
+ warnings.append(warning)
1158
+ logger.warning(warning)
1159
+ continue
1160
+ if event_type.upper() == "TRADE" and allowed_trade_items and item_str not in allowed_trade_items:
1161
+ warning = f"移除不在商店配置中的交易装备: {slot} -> {item_str}"
1162
+ warnings.append(warning)
1163
+ logger.warning(warning)
1164
+ continue
1165
+ if item_str not in valid_equippable:
1166
+ warning = f"移除未持有装备变更: {slot} -> {item_str}"
1167
+ warnings.append(warning)
1168
+ logger.warning(warning)
1169
+ continue
1170
+ sanitized_equip[str(slot)] = item_str
1171
+
1172
+ if sanitized_equip:
1173
+ sanitized_changes["equip"] = sanitized_equip
1174
+ else:
1175
+ sanitized_changes.pop("equip", None)
1176
+
1177
+ return sanitized_changes, warnings
1178
+
1179
+ def _sanitize_noncombat_npc_changes(
1180
+ self,
1181
+ changes: dict,
1182
+ event_type: str = "",
1183
+ ) -> tuple[dict, list[str]]:
1184
+ """移除非战斗事件中意外出现的 NPC 致死变更。"""
1185
+ npc_changes = changes.get("npc_changes")
1186
+ if not isinstance(npc_changes, dict):
1187
+ return changes, []
1188
+
1189
+ noncombat_events = {
1190
+ "TRADE",
1191
+ "GIVE",
1192
+ "DROP",
1193
+ "TALK",
1194
+ "DIALOGUE",
1195
+ "MOVE",
1196
+ "EXPLORE",
1197
+ "REST",
1198
+ "DISCOVERY",
1199
+ }
1200
+ if event_type.upper() not in noncombat_events:
1201
+ return changes, []
1202
+
1203
+ sanitized_changes = dict(changes)
1204
+ sanitized_npc_changes: dict[str, dict] = {}
1205
+ warnings: list[str] = []
1206
+
1207
+ for npc_name, npc_data in npc_changes.items():
1208
+ if not isinstance(npc_data, dict):
1209
+ sanitized_npc_changes[str(npc_name)] = npc_data
1210
+ continue
1211
+
1212
+ cleaned_data = dict(npc_data)
1213
+ removed_keys: list[str] = []
1214
+
1215
+ if cleaned_data.get("is_alive") is False:
1216
+ cleaned_data.pop("is_alive", None)
1217
+ removed_keys.append("is_alive")
1218
+
1219
+ hp_delta = cleaned_data.get("hp_change")
1220
+ npc = self.game_state.world.npcs.get(str(npc_name))
1221
+ try:
1222
+ hp_delta_value = int(hp_delta) if hp_delta is not None else None
1223
+ except (TypeError, ValueError):
1224
+ hp_delta_value = None
1225
+
1226
+ if (
1227
+ hp_delta_value is not None
1228
+ and npc is not None
1229
+ and npc.hp + hp_delta_value <= 0
1230
+ ):
1231
+ cleaned_data.pop("hp_change", None)
1232
+ removed_keys.append("hp_change")
1233
+
1234
+ if removed_keys:
1235
+ warning = (
1236
+ f"移除非战斗事件中的 NPC 致死变更: {npc_name} -> "
1237
+ f"{', '.join(removed_keys)}"
1238
+ )
1239
+ warnings.append(warning)
1240
+ logger.warning(warning)
1241
+
1242
+ if cleaned_data:
1243
+ sanitized_npc_changes[str(npc_name)] = cleaned_data
1244
+
1245
+ if sanitized_npc_changes:
1246
+ sanitized_changes["npc_changes"] = sanitized_npc_changes
1247
+ else:
1248
+ sanitized_changes.pop("npc_changes", None)
1249
+
1250
+ return sanitized_changes, warnings
1251
 
1252
  def _fill_missing_consumable_effects(
1253
  self,
 
1569
  logger.info(f"[流式/合并] 生成故事响应,玩家意图: {player_intent}")
1570
 
1571
  # 推进时间
1572
+ tick_log = self.game_state.tick_time(player_intent)
1573
 
1574
  # 构建合并 Prompt
1575
  system_prompt = MERGED_SYSTEM_PROMPT_TEMPLATE.format(