PPP commited on
Commit
8fabe72
·
1 Parent(s): e598ece

feat(rules): surface equipment bonuses and fill missing consumable effects

Browse files
Files changed (3) hide show
  1. app.py +41 -20
  2. state_manager.py +72 -16
  3. story_engine.py +58 -10
app.py CHANGED
@@ -64,6 +64,8 @@ def _json_safe(value):
64
  def _build_state_snapshot(gs: GameState) -> dict:
65
  """Build a compact state snapshot for reproducible evaluation logs."""
66
  active_quests = []
 
 
67
  for quest in gs.world.quests.values():
68
  if quest.status == "active":
69
  active_quests.append(
@@ -90,11 +92,18 @@ def _build_state_snapshot(gs: GameState) -> dict:
90
  "max_hp": gs.player.max_hp,
91
  "mp": gs.player.mp,
92
  "max_mp": gs.player.max_mp,
 
 
 
 
 
93
  "gold": gs.player.gold,
94
  "morale": gs.player.morale,
95
  "sanity": gs.player.sanity,
96
  "hunger": gs.player.hunger,
97
  "karma": gs.player.karma,
 
 
98
  "inventory": list(gs.player.inventory),
99
  "equipment": copy.deepcopy(gs.player.equipment),
100
  "skills": list(gs.player.skills),
@@ -801,13 +810,15 @@ def _get_button_updates(options: list[dict]) -> list:
801
  return updates
802
 
803
 
804
- def _format_status_panel(gs: GameState) -> str:
805
- """格式化状态面板文本(双列 HTML 布局,减少滚动)"""
806
- p = gs.player
807
- w = gs.world
808
-
809
- # 属性进度条
810
- hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
 
 
811
  mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
812
  hunger_bar = _progress_bar(p.hunger, 100, "饱食")
813
  sanity_bar = _progress_bar(p.sanity, 100, "理智")
@@ -819,9 +830,19 @@ def _format_status_panel(gs: GameState) -> str:
819
  "helmet": "头盔", "boots": "靴子",
820
  }
821
  equip_lines = []
822
- for slot, item in p.equipment.items():
823
- equip_lines.append(f"{slot_names.get(slot, slot)}: {item or '无'}")
824
- equip_text = "<br>".join(equip_lines)
 
 
 
 
 
 
 
 
 
 
825
 
826
  # 状态效果
827
  if p.status_effects:
@@ -895,16 +916,16 @@ def _format_status_panel(gs: GameState) -> str:
895
  </span>
896
  </div>
897
 
898
- <div>
899
- <h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
900
- <span style="font-size:0.85em;">
901
- 攻击: {p.attack}<br>
902
- 防御: {p.defense}<br>
903
- 速度: {p.speed}<br>
904
- 幸运: {p.luck}<br>
905
- 感知: {p.perception}
906
- </span>
907
- </div>
908
 
909
  <div>
910
  <h4 style="margin:4px 0 2px 0;">💰 资源</h4>
 
64
  def _build_state_snapshot(gs: GameState) -> dict:
65
  """Build a compact state snapshot for reproducible evaluation logs."""
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(
 
92
  "max_hp": gs.player.max_hp,
93
  "mp": gs.player.mp,
94
  "max_mp": gs.player.max_mp,
95
+ "attack": gs.player.attack,
96
+ "defense": gs.player.defense,
97
+ "speed": gs.player.speed,
98
+ "luck": gs.player.luck,
99
+ "perception": gs.player.perception,
100
  "gold": gs.player.gold,
101
  "morale": gs.player.morale,
102
  "sanity": gs.player.sanity,
103
  "hunger": gs.player.hunger,
104
  "karma": gs.player.karma,
105
+ "effective_stats": _json_safe(effective_stats),
106
+ "equipment_bonuses": _json_safe(equipment_bonuses),
107
  "inventory": list(gs.player.inventory),
108
  "equipment": copy.deepcopy(gs.player.equipment),
109
  "skills": list(gs.player.skills),
 
810
  return updates
811
 
812
 
813
+ def _format_status_panel(gs: GameState) -> str:
814
+ """格式化状态面板文本(双列 HTML 布局,减少滚动)"""
815
+ p = gs.player
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")
822
  mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
823
  hunger_bar = _progress_bar(p.hunger, 100, "饱食")
824
  sanity_bar = _progress_bar(p.sanity, 100, "理智")
 
830
  "helmet": "头盔", "boots": "靴子",
831
  }
832
  equip_lines = []
833
+ for slot, item in p.equipment.items():
834
+ equip_lines.append(f"{slot_names.get(slot, slot)}: {item or '无'}")
835
+ equip_text = "<br>".join(equip_lines)
836
+
837
+ def render_stat(stat_key: str, label: str) -> str:
838
+ base_value = int(getattr(p, stat_key))
839
+ bonus_value = int(equipment_bonuses.get(stat_key, 0))
840
+ effective_value = int(effective_stats.get(stat_key, base_value))
841
+ if bonus_value > 0:
842
+ return f"{label}: {effective_value} <span style='color:#4a6;'>(+{bonus_value} 装备)</span>"
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:
 
916
  </span>
917
  </div>
918
 
919
+ <div>
920
+ <h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
921
+ <span style="font-size:0.85em;">
922
+ {render_stat("attack", "攻击")}<br>
923
+ {render_stat("defense", "防御")}<br>
924
+ {render_stat("speed", "速度")}<br>
925
+ {render_stat("luck", "幸运")}<br>
926
+ {render_stat("perception", "感知")}
927
+ </span>
928
+ </div>
929
 
930
  <div>
931
  <h4 style="margin:4px 0 2px 0;">💰 资源</h4>
state_manager.py CHANGED
@@ -1115,9 +1115,9 @@ class GameState:
1115
 
1116
  return change_log
1117
 
1118
- def validate(self) -> tuple[bool, list[str]]:
1119
- """
1120
- 校验当前状态的合法性。
1121
 
1122
  设计思路:
1123
  - 检查所有数值是否在合法范围内
@@ -1160,12 +1160,34 @@ class GameState:
1160
  self.player.gold = 0
1161
  issues.append("金币不足。")
1162
 
1163
- is_valid = self.game_mode != "game_over"
1164
- return is_valid, issues
1165
-
1166
- def to_prompt(self) -> str:
1167
- """
1168
- 将当前完整状态序列化为自然语言描述,注入 System Prompt。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1169
 
1170
  设计思路(需求文档核心要求):
1171
  - System Prompt 必须包含当前状态描述
@@ -1794,16 +1816,50 @@ class GameState:
1794
 
1795
  return actions
1796
 
1797
- def get_scene_summary(self) -> str:
1798
- """获取当前场景的简短摘要(用于 UI 展示)"""
1799
- loc = self.world.locations.get(self.player.location)
1800
- desc = loc.description if loc else "未知区域"
1801
- ambient = loc.ambient_description if loc else ""
1802
 
1803
  npcs = [
1804
  npc.name for npc in self.world.npcs.values()
1805
  if npc.location == self.player.location and npc.is_alive
1806
  ]
1807
  npc_str = f"可见NPC: {'、'.join(npcs)}" if npcs else ""
1808
-
1809
- return f"{desc}\n{ambient}\n{npc_str}".strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1115
 
1116
  return change_log
1117
 
1118
+ def validate(self) -> tuple[bool, list[str]]:
1119
+ """
1120
+ 校验当前状态的合法性。
1121
 
1122
  设计思路:
1123
  - 检查所有数值是否在合法范围内
 
1160
  self.player.gold = 0
1161
  issues.append("金币不足。")
1162
 
1163
+ is_valid = self.game_mode != "game_over"
1164
+ return is_valid, issues
1165
+
1166
+ def get_equipment_stat_bonuses(self) -> dict[str, int]:
1167
+ """Aggregate stat bonuses from currently equipped items."""
1168
+ bonuses: dict[str, int] = {}
1169
+ for item_name in self.player.equipment.values():
1170
+ if not item_name:
1171
+ continue
1172
+ item_info = self.world.item_registry.get(str(item_name))
1173
+ if item_info is None:
1174
+ continue
1175
+ for stat_name, amount in item_info.stat_bonus.items():
1176
+ bonuses[stat_name] = bonuses.get(stat_name, 0) + int(amount)
1177
+ return bonuses
1178
+
1179
+ def get_effective_player_stats(self) -> dict[str, int]:
1180
+ """Return display-oriented effective stats after equipment bonuses."""
1181
+ bonuses = self.get_equipment_stat_bonuses()
1182
+ tracked_stats = ("attack", "defense", "speed", "luck", "perception")
1183
+ return {
1184
+ stat_name: int(getattr(self.player, stat_name)) + int(bonuses.get(stat_name, 0))
1185
+ for stat_name in tracked_stats
1186
+ }
1187
+
1188
+ def to_prompt(self) -> str:
1189
+ """
1190
+ 将当前完整状态序列化为自然语言描述,注入 System Prompt。
1191
 
1192
  设计思路(需求文档核心要求):
1193
  - System Prompt 必须包含当前状态描述
 
1816
 
1817
  return actions
1818
 
1819
+ def get_scene_summary(self) -> str:
1820
+ """获取当前场景的简短摘要(用于 UI 展示)"""
1821
+ loc = self.world.locations.get(self.player.location)
1822
+ desc = loc.description if loc else "未知区域"
1823
+ ambient = loc.ambient_description if loc else ""
1824
 
1825
  npcs = [
1826
  npc.name for npc in self.world.npcs.values()
1827
  if npc.location == self.player.location and npc.is_alive
1828
  ]
1829
  npc_str = f"可见NPC: {'、'.join(npcs)}" if npcs else ""
1830
+
1831
+ return f"{desc}\n{ambient}\n{npc_str}".strip()
1832
+
1833
+ def get_consumable_rule_effects(self, item_name: str) -> dict[str, Any]:
1834
+ """Parse deterministic consumable effects from item metadata."""
1835
+ item_info = self.world.item_registry.get(str(item_name))
1836
+ if item_info is None or not item_info.usable:
1837
+ return {}
1838
+
1839
+ effect_text = str(item_info.use_effect or "").strip()
1840
+ if not effect_text:
1841
+ return {}
1842
+
1843
+ changes: dict[str, Any] = {}
1844
+ numeric_rules = [
1845
+ (r"恢复\s*(\d+)\s*HP", "hp_change", 1),
1846
+ (r"恢复\s*(\d+)\s*MP", "mp_change", 1),
1847
+ (r"恢复\s*(\d+)\s*饱食度", "hunger_change", 1),
1848
+ (r"恢复\s*(\d+)\s*士气", "morale_change", 1),
1849
+ (r"恢复\s*(\d+)\s*理智", "sanity_change", 1),
1850
+ (r"降低\s*(\d+)\s*HP", "hp_change", -1),
1851
+ (r"降低\s*(\d+)\s*MP", "mp_change", -1),
1852
+ (r"降低\s*(\d+)\s*饱食度", "hunger_change", -1),
1853
+ (r"降低\s*(\d+)\s*士气", "morale_change", -1),
1854
+ (r"降低\s*(\d+)\s*理智", "sanity_change", -1),
1855
+ ]
1856
+
1857
+ for pattern, key, sign in numeric_rules:
1858
+ for match in re.finditer(pattern, effect_text):
1859
+ amount = int(match.group(1)) * sign
1860
+ changes[key] = int(changes.get(key, 0)) + amount
1861
+
1862
+ if "解除中毒状态" in effect_text:
1863
+ changes["status_effects_removed"] = ["中毒"]
1864
+
1865
+ return changes
story_engine.py CHANGED
@@ -580,9 +580,10 @@ class StoryEngine:
580
  # 清理状态变更:阻止非消耗品被错误移除
581
  # ============================================
582
  event_type = outline.get("event_type", "")
583
- state_changes, sanitize_warnings = self._sanitize_state_changes(state_changes, event_type)
584
- if sanitize_warnings:
585
- logger.info(f"状态变更清理: {sanitize_warnings}")
 
586
 
587
  # ============================================
588
  # 应用状态变更
@@ -619,7 +620,10 @@ class StoryEngine:
619
  options = self._validate_options(options)
620
 
621
  # 合并 tick_log 和 change_log 中的重复属性条目
622
- merged_log = _merge_change_logs(tick_log, change_log + validation_issues)
 
 
 
623
 
624
  return {
625
  "story_text": story_text,
@@ -1071,7 +1075,47 @@ class StoryEngine:
1071
 
1072
  return changes, warnings
1073
 
1074
- def _strip_invalid_item_effects(self, state_changes: dict) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1075
  """
1076
  当 LLM 生成了涉及不存在物品的状态变更时,移除相关效果(安全网)。
1077
 
@@ -1450,10 +1494,11 @@ class StoryEngine:
1450
 
1451
  # 清理状态变更
1452
  event_type = outline.get("event_type", "")
1453
- state_changes, sanitize_warnings = self._sanitize_state_changes(state_changes, event_type)
1454
-
1455
- # 应用状态变更
1456
- change_log = self.game_state.apply_changes(state_changes)
 
1457
 
1458
  # 校验状态合法性
1459
  is_valid, validation_issues = self.game_state.validate()
@@ -1482,7 +1527,10 @@ class StoryEngine:
1482
  options = self._ensure_three_options(options)
1483
 
1484
  # 合并日志
1485
- merged_log = _merge_change_logs(tick_log, change_log + validation_issues)
 
 
 
1486
 
1487
  yield {
1488
  "type": "final",
 
580
  # 清理状态变更:阻止非消耗品被错误移除
581
  # ============================================
582
  event_type = outline.get("event_type", "")
583
+ state_changes, sanitize_warnings = self._sanitize_state_changes(state_changes, event_type)
584
+ state_changes, item_rule_notes = self._fill_missing_consumable_effects(state_changes, player_intent)
585
+ if sanitize_warnings:
586
+ logger.info(f"状态变更清理: {sanitize_warnings}")
587
 
588
  # ============================================
589
  # 应用状态变更
 
620
  options = self._validate_options(options)
621
 
622
  # 合并 tick_log 和 change_log 中的重复属性条目
623
+ merged_log = _merge_change_logs(
624
+ tick_log,
625
+ change_log + validation_issues + item_rule_notes,
626
+ )
627
 
628
  return {
629
  "story_text": story_text,
 
1075
 
1076
  return changes, warnings
1077
 
1078
+ def _fill_missing_consumable_effects(
1079
+ self,
1080
+ state_changes: dict,
1081
+ player_intent: dict,
1082
+ ) -> tuple[dict, list[str]]:
1083
+ """
1084
+ Fill missing consumable effects from item metadata without overriding model output.
1085
+ """
1086
+ if str(player_intent.get("intent", "")).upper() != "USE_ITEM":
1087
+ return state_changes, []
1088
+
1089
+ consumed_items = [
1090
+ str(item)
1091
+ for item in state_changes.get("items_lost", [])
1092
+ if self.game_state.is_item_consumable(str(item))
1093
+ ]
1094
+ if not consumed_items:
1095
+ return state_changes, []
1096
+
1097
+ merged_changes = dict(state_changes)
1098
+ applied_notes: list[str] = []
1099
+
1100
+ for item_name in consumed_items:
1101
+ inferred = self.game_state.get_consumable_rule_effects(item_name)
1102
+ if not inferred:
1103
+ continue
1104
+
1105
+ applied_keys = []
1106
+ for key, value in inferred.items():
1107
+ if key in merged_changes:
1108
+ continue
1109
+ merged_changes[key] = list(value) if isinstance(value, list) else value
1110
+ applied_keys.append(key)
1111
+
1112
+ if applied_keys:
1113
+ logger.info("按物品规则补全缺失效果: %s -> %s", item_name, applied_keys)
1114
+ applied_notes.append(f"物品规则补全: {item_name}")
1115
+
1116
+ return merged_changes, applied_notes
1117
+
1118
+ def _strip_invalid_item_effects(self, state_changes: dict) -> dict:
1119
  """
1120
  当 LLM 生成了涉及不存在物品的状态变更时,移除相关效果(安全网)。
1121
 
 
1494
 
1495
  # 清理状态变更
1496
  event_type = outline.get("event_type", "")
1497
+ state_changes, sanitize_warnings = self._sanitize_state_changes(state_changes, event_type)
1498
+ state_changes, item_rule_notes = self._fill_missing_consumable_effects(state_changes, player_intent)
1499
+
1500
+ # 应用状态变更
1501
+ change_log = self.game_state.apply_changes(state_changes)
1502
 
1503
  # 校验状态合法性
1504
  is_valid, validation_issues = self.game_state.validate()
 
1527
  options = self._ensure_three_options(options)
1528
 
1529
  # 合并日志
1530
+ merged_log = _merge_change_logs(
1531
+ tick_log,
1532
+ change_log + validation_issues + item_rule_notes,
1533
+ )
1534
 
1535
  yield {
1536
  "type": "final",