Spaces:
Runtime error
Runtime error
PPP commited on
Commit ·
8fabe72
1
Parent(s): e598ece
feat(rules): surface equipment bonuses and fill missing consumable effects
Browse files- app.py +41 -20
- state_manager.py +72 -16
- 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 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
|
| 1167 |
-
"""
|
| 1168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 585 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 623 |
|
| 624 |
return {
|
| 625 |
"story_text": story_text,
|
|
@@ -1071,7 +1075,47 @@ class StoryEngine:
|
|
| 1071 |
|
| 1072 |
return changes, warnings
|
| 1073 |
|
| 1074 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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",
|