Spaces:
Runtime error
Runtime error
Upload 6 files
Browse files- app.py +633 -0
- nlu_engine.py +310 -0
- requirements.txt +4 -0
- state_manager.py +1481 -0
- story_engine.py +852 -0
- utils.py +278 -0
app.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py - StoryWeaver Gradio 交互界面
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 构建游戏的 Web 前端界面 (Gradio)
|
| 6 |
+
2. 串联 NLU 引擎、叙事引擎、状态管理器
|
| 7 |
+
3. 管理用户交互流程(文本输入 + 选项点击)
|
| 8 |
+
4. 展示游戏状态(HP、背包、任务等)
|
| 9 |
+
|
| 10 |
+
数据流转:
|
| 11 |
+
用户输入 → NLU 引擎(意图识别) → 叙事引擎(两阶段生成)
|
| 12 |
+
↕ ↕
|
| 13 |
+
Gradio UI ← 状态管理器(校验 + 更新) ← 叙事引擎(文本 + 选项)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import gradio as gr
|
| 19 |
+
|
| 20 |
+
from state_manager import GameState
|
| 21 |
+
from nlu_engine import NLUEngine
|
| 22 |
+
from story_engine import StoryEngine
|
| 23 |
+
from utils import logger
|
| 24 |
+
|
| 25 |
+
# ============================================================
|
| 26 |
+
# 全局游戏实例(每个会话独立)
|
| 27 |
+
# ============================================================
|
| 28 |
+
|
| 29 |
+
# 使用 Gradio State 管理每个用户的游戏状态
|
| 30 |
+
# 这里先定义工厂函数
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def create_new_game(player_name: str = "旅人") -> dict:
|
| 34 |
+
"""创建新游戏实例,返回包含所有引擎的字典"""
|
| 35 |
+
game_state = GameState(player_name=player_name)
|
| 36 |
+
nlu = NLUEngine(game_state)
|
| 37 |
+
story = StoryEngine(game_state)
|
| 38 |
+
return {
|
| 39 |
+
"game_state": game_state,
|
| 40 |
+
"nlu": nlu,
|
| 41 |
+
"story": story,
|
| 42 |
+
"current_options": [],
|
| 43 |
+
"started": False,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def restart_game() -> tuple:
|
| 48 |
+
"""
|
| 49 |
+
重启冒险:清空所有数据,回到初始输入名称阶段。
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
(空聊天历史, 初始状态面板, 隐藏选项按钮×3, 空游戏会话,
|
| 53 |
+
禁用文本输入框, 重置角色名称)
|
| 54 |
+
"""
|
| 55 |
+
return (
|
| 56 |
+
[], # 清空聊天历史
|
| 57 |
+
"## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
|
| 58 |
+
gr.update(visible=False, interactive=False), # 隐藏选项按钮1
|
| 59 |
+
gr.update(visible=False, interactive=False), # 隐藏选项按钮2
|
| 60 |
+
gr.update(visible=False, interactive=False), # 隐藏选项按钮3
|
| 61 |
+
{}, # 清空游戏会话
|
| 62 |
+
gr.update(value="", interactive=False), # 禁用并清空文本输入
|
| 63 |
+
gr.update(value="旅人"), # 重置角色名称
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ============================================================
|
| 68 |
+
# 核心交互函数
|
| 69 |
+
# ============================================================
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def start_game(player_name: str, game_session: dict) -> tuple:
|
| 73 |
+
"""
|
| 74 |
+
开始新游戏:生成开场叙事。
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
(聊天历史, 状态面板文本, 选项按钮文本, 游戏会话)
|
| 78 |
+
"""
|
| 79 |
+
if not player_name.strip():
|
| 80 |
+
player_name = "旅人"
|
| 81 |
+
|
| 82 |
+
# 创建新游戏
|
| 83 |
+
game_session = create_new_game(player_name)
|
| 84 |
+
game_session["started"] = True
|
| 85 |
+
|
| 86 |
+
# 生成开场
|
| 87 |
+
result = game_session["story"].generate_opening()
|
| 88 |
+
|
| 89 |
+
# 保存当前选项
|
| 90 |
+
game_session["current_options"] = result.get("options", [])
|
| 91 |
+
|
| 92 |
+
# 构建聊天历史
|
| 93 |
+
story_text = result["story_text"]
|
| 94 |
+
options_text = _format_options(result.get("options", []))
|
| 95 |
+
full_message = f"{story_text}\n\n{options_text}"
|
| 96 |
+
|
| 97 |
+
chat_history = [{"role": "assistant", "content": full_message}]
|
| 98 |
+
|
| 99 |
+
# 状态面板
|
| 100 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 101 |
+
|
| 102 |
+
# 选项按钮更新(静态按钮 + 动态更新模式)
|
| 103 |
+
btn_updates = _get_button_updates(result.get("options", []))
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
chat_history,
|
| 107 |
+
status_text,
|
| 108 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 109 |
+
game_session,
|
| 110 |
+
gr.update(interactive=True), # 文本输入框
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def process_user_input(user_input: str, chat_history: list, game_session: dict) -> tuple:
|
| 115 |
+
"""
|
| 116 |
+
处理用户文本输入。
|
| 117 |
+
|
| 118 |
+
流程:
|
| 119 |
+
1. NLU 引擎解析意图
|
| 120 |
+
2. 叙事引擎生成故事
|
| 121 |
+
3. 更新 UI
|
| 122 |
+
"""
|
| 123 |
+
if not game_session or not game_session.get("started"):
|
| 124 |
+
chat_history = chat_history or []
|
| 125 |
+
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 126 |
+
return (
|
| 127 |
+
chat_history, "",
|
| 128 |
+
gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
|
| 129 |
+
game_session,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
if not user_input.strip():
|
| 133 |
+
return (
|
| 134 |
+
chat_history, _format_status_panel(game_session["game_state"]),
|
| 135 |
+
gr.update(), gr.update(), gr.update(),
|
| 136 |
+
game_session,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
gs: GameState = game_session["game_state"]
|
| 140 |
+
nlu: NLUEngine = game_session["nlu"]
|
| 141 |
+
story: StoryEngine = game_session["story"]
|
| 142 |
+
|
| 143 |
+
# 检查游戏是否已结束
|
| 144 |
+
if gs.is_game_over():
|
| 145 |
+
chat_history.append({"role": "user", "content": user_input})
|
| 146 |
+
chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
|
| 147 |
+
return (
|
| 148 |
+
chat_history,
|
| 149 |
+
_format_status_panel(gs),
|
| 150 |
+
gr.update(value="重新开始", visible=True, interactive=True),
|
| 151 |
+
gr.update(visible=False), gr.update(visible=False),
|
| 152 |
+
game_session,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# 1. NLU 解析
|
| 156 |
+
intent = nlu.parse_intent(user_input)
|
| 157 |
+
|
| 158 |
+
# 2. 生成故事
|
| 159 |
+
result = story.generate_story(intent)
|
| 160 |
+
|
| 161 |
+
# 3. 更新选项
|
| 162 |
+
game_session["current_options"] = result.get("options", [])
|
| 163 |
+
|
| 164 |
+
# 4. 构建聊天消息
|
| 165 |
+
chat_history.append({"role": "user", "content": user_input})
|
| 166 |
+
|
| 167 |
+
# 变更日志
|
| 168 |
+
change_log = result.get("change_log", [])
|
| 169 |
+
log_text = ""
|
| 170 |
+
if change_log:
|
| 171 |
+
log_text = "\n".join(f" {c}" for c in change_log)
|
| 172 |
+
log_text = f"\n\n**状态变化:**\n{log_text}"
|
| 173 |
+
|
| 174 |
+
# 一致性问题
|
| 175 |
+
issues = result.get("consistency_issues", [])
|
| 176 |
+
issues_text = ""
|
| 177 |
+
if issues:
|
| 178 |
+
issues_text = "\n".join(f" {i}" for i in issues)
|
| 179 |
+
issues_text = f"\n\n**一致性提示:**\n{issues_text}"
|
| 180 |
+
|
| 181 |
+
# 选项文本
|
| 182 |
+
options_text = _format_options(result.get("options", []))
|
| 183 |
+
|
| 184 |
+
full_message = f"{result['story_text']}{log_text}{issues_text}\n\n{options_text}"
|
| 185 |
+
chat_history.append({"role": "assistant", "content": full_message})
|
| 186 |
+
|
| 187 |
+
# 5. 更新状态面板
|
| 188 |
+
status_text = _format_status_panel(gs)
|
| 189 |
+
btn_updates = _get_button_updates(result.get("options", []))
|
| 190 |
+
|
| 191 |
+
return (
|
| 192 |
+
chat_history,
|
| 193 |
+
status_text,
|
| 194 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 195 |
+
game_session,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def process_option_click(option_idx: int, chat_history: list, game_session: dict) -> tuple:
|
| 200 |
+
"""
|
| 201 |
+
处理玩家点击选项按钮。
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
option_idx: 选项索引 (0, 1, 2)
|
| 205 |
+
"""
|
| 206 |
+
if not game_session or not game_session.get("started"):
|
| 207 |
+
chat_history = chat_history or []
|
| 208 |
+
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 209 |
+
return (
|
| 210 |
+
chat_history, "",
|
| 211 |
+
gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
|
| 212 |
+
game_session,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
options = game_session.get("current_options", [])
|
| 216 |
+
if option_idx >= len(options):
|
| 217 |
+
return (
|
| 218 |
+
chat_history,
|
| 219 |
+
_format_status_panel(game_session["game_state"]),
|
| 220 |
+
gr.update(), gr.update(), gr.update(),
|
| 221 |
+
game_session,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
selected_option = options[option_idx]
|
| 225 |
+
gs: GameState = game_session["game_state"]
|
| 226 |
+
story: StoryEngine = game_session["story"]
|
| 227 |
+
|
| 228 |
+
# 检查特殊选项:重新开始
|
| 229 |
+
if selected_option.get("action_type") == "RESTART":
|
| 230 |
+
full_result = start_game(gs.player.name, game_session)
|
| 231 |
+
# start_game 返回 7 个值(含 user_input 更新),此处只需前 6 个
|
| 232 |
+
return full_result[:6]
|
| 233 |
+
|
| 234 |
+
# 检查特殊选项:退出
|
| 235 |
+
if selected_option.get("action_type") == "QUIT":
|
| 236 |
+
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 237 |
+
chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
|
| 238 |
+
return (
|
| 239 |
+
chat_history,
|
| 240 |
+
_format_status_panel(gs),
|
| 241 |
+
gr.update(value="重新开始", visible=True, interactive=True),
|
| 242 |
+
gr.update(visible=False), gr.update(visible=False),
|
| 243 |
+
game_session,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# 正常选项处理
|
| 247 |
+
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 248 |
+
|
| 249 |
+
result = story.process_option_selection(selected_option)
|
| 250 |
+
game_session["current_options"] = result.get("options", [])
|
| 251 |
+
|
| 252 |
+
# 构建响应消息
|
| 253 |
+
change_log = result.get("change_log", [])
|
| 254 |
+
log_text = ""
|
| 255 |
+
if change_log:
|
| 256 |
+
log_text = "\n".join(f" {c}" for c in change_log)
|
| 257 |
+
log_text = f"\n\n**状态变化:**\n{log_text}"
|
| 258 |
+
|
| 259 |
+
options_text = _format_options(result.get("options", []))
|
| 260 |
+
full_message = f"{result['story_text']}{log_text}\n\n{options_text}"
|
| 261 |
+
chat_history.append({"role": "assistant", "content": full_message})
|
| 262 |
+
|
| 263 |
+
status_text = _format_status_panel(gs)
|
| 264 |
+
btn_updates = _get_button_updates(result.get("options", []))
|
| 265 |
+
|
| 266 |
+
return (
|
| 267 |
+
chat_history,
|
| 268 |
+
status_text,
|
| 269 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 270 |
+
game_session,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ============================================================
|
| 275 |
+
# UI 辅助函数
|
| 276 |
+
# ============================================================
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _format_options(options: list[dict]) -> str:
|
| 280 |
+
"""将选项列表格式化为可读的文本"""
|
| 281 |
+
if not options:
|
| 282 |
+
return ""
|
| 283 |
+
lines = ["---", "**你的选择:**"]
|
| 284 |
+
for opt in options:
|
| 285 |
+
idx = opt.get("id", "?")
|
| 286 |
+
text = opt.get("text", "未知选项")
|
| 287 |
+
lines.append(f" **[{idx}]** {text}")
|
| 288 |
+
return "\n".join(lines)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _get_button_updates(options: list[dict]) -> list:
|
| 292 |
+
"""从选项列表生成按钮的 gr.update() 对象(静态按钮 + 动态更新模式)"""
|
| 293 |
+
updates = []
|
| 294 |
+
for i in range(3):
|
| 295 |
+
if i < len(options):
|
| 296 |
+
text = options[i].get("text", "...")
|
| 297 |
+
updates.append(gr.update(value=text, visible=True, interactive=True))
|
| 298 |
+
else:
|
| 299 |
+
updates.append(gr.update(value="", visible=False, interactive=False))
|
| 300 |
+
return updates
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _format_status_panel(gs: GameState) -> str:
|
| 304 |
+
"""格式化状态面板文本(双列 HTML 布局,减少滚动)"""
|
| 305 |
+
p = gs.player
|
| 306 |
+
w = gs.world
|
| 307 |
+
|
| 308 |
+
# 属性进度条
|
| 309 |
+
hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
|
| 310 |
+
mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
|
| 311 |
+
hunger_bar = _progress_bar(p.hunger, 100, "饱食")
|
| 312 |
+
sanity_bar = _progress_bar(p.sanity, 100, "理智")
|
| 313 |
+
morale_bar = _progress_bar(p.morale, 100, "士气")
|
| 314 |
+
|
| 315 |
+
# 装备
|
| 316 |
+
slot_names = {
|
| 317 |
+
"weapon": "武器", "armor": "护甲", "accessory": "饰品",
|
| 318 |
+
"helmet": "头盔", "boots": "靴子",
|
| 319 |
+
}
|
| 320 |
+
equip_lines = []
|
| 321 |
+
for slot, item in p.equipment.items():
|
| 322 |
+
equip_lines.append(f"{slot_names.get(slot, slot)}: {item or '无'}")
|
| 323 |
+
equip_text = "<br>".join(equip_lines)
|
| 324 |
+
|
| 325 |
+
# 状态效果
|
| 326 |
+
if p.status_effects:
|
| 327 |
+
effect_lines = "<br>".join(
|
| 328 |
+
f"{e.name}({e.duration}回合)" for e in p.status_effects
|
| 329 |
+
)
|
| 330 |
+
else:
|
| 331 |
+
effect_lines = "无"
|
| 332 |
+
|
| 333 |
+
# 背包
|
| 334 |
+
if p.inventory:
|
| 335 |
+
inventory_text = "<br>".join(p.inventory)
|
| 336 |
+
else:
|
| 337 |
+
inventory_text = "空"
|
| 338 |
+
|
| 339 |
+
# 活跃任务
|
| 340 |
+
active_quests = [q for q in w.quests.values() if q.status == "active"]
|
| 341 |
+
if active_quests:
|
| 342 |
+
quest_lines = []
|
| 343 |
+
for q in active_quests:
|
| 344 |
+
done = sum(1 for v in q.objectives.values() if v)
|
| 345 |
+
total = len(q.objectives)
|
| 346 |
+
quest_lines.append(f"{q.title}({done}/{total})")
|
| 347 |
+
quest_text = "<br>".join(quest_lines)
|
| 348 |
+
else:
|
| 349 |
+
quest_text = "无活跃任务"
|
| 350 |
+
|
| 351 |
+
# 使用 HTML 双列布局
|
| 352 |
+
status = f"""<div style="font-size:0.9em;">
|
| 353 |
+
<h3 style="margin:0 0 4px 0;text-align:center;">{p.name} — {p.title}</h3>
|
| 354 |
+
<p style="text-align:center;margin:2px 0 6px 0;">等级 {p.level} | 经验 {p.experience}/{p.exp_to_next_level}</p>
|
| 355 |
+
|
| 356 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;">
|
| 357 |
+
|
| 358 |
+
<div>
|
| 359 |
+
<h4 style="margin:4px 0 2px 0;">🩸 生命与状态</h4>
|
| 360 |
+
<span style="font-size:0.85em;">
|
| 361 |
+
{hp_bar}<br>
|
| 362 |
+
{mp_bar}<br>
|
| 363 |
+
{hunger_bar}<br>
|
| 364 |
+
{sanity_bar}<br>
|
| 365 |
+
{morale_bar}
|
| 366 |
+
</span>
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
<div>
|
| 370 |
+
<h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
|
| 371 |
+
<span style="font-size:0.85em;">
|
| 372 |
+
攻击: {p.attack}<br>
|
| 373 |
+
防御: {p.defense}<br>
|
| 374 |
+
速度: {p.speed}<br>
|
| 375 |
+
幸运: {p.luck}<br>
|
| 376 |
+
感知: {p.perception}
|
| 377 |
+
</span>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<div>
|
| 381 |
+
<h4 style="margin:4px 0 2px 0;">💰 资源</h4>
|
| 382 |
+
<span style="font-size:0.85em;">
|
| 383 |
+
金币: {p.gold}<br>
|
| 384 |
+
善恶值: {p.karma}
|
| 385 |
+
</span>
|
| 386 |
+
</div>
|
| 387 |
+
|
| 388 |
+
<div>
|
| 389 |
+
<h4 style="margin:4px 0 2px 0;">🛡️ 装备</h4>
|
| 390 |
+
<span style="font-size:0.85em;">
|
| 391 |
+
{equip_text}
|
| 392 |
+
</span>
|
| 393 |
+
</div>
|
| 394 |
+
|
| 395 |
+
<div>
|
| 396 |
+
<h4 style="margin:4px 0 2px 0;">✨ 状态效果</h4>
|
| 397 |
+
<span style="font-size:0.85em;">
|
| 398 |
+
{effect_lines}
|
| 399 |
+
</span>
|
| 400 |
+
</div>
|
| 401 |
+
|
| 402 |
+
<div>
|
| 403 |
+
<h4 style="margin:4px 0 2px 0;">🎒 背包</h4>
|
| 404 |
+
<span style="font-size:0.85em;">
|
| 405 |
+
{inventory_text}
|
| 406 |
+
</span>
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
<div>
|
| 410 |
+
<h4 style="margin:4px 0 2px 0;">📜 任务</h4>
|
| 411 |
+
<span style="font-size:0.85em;">
|
| 412 |
+
{quest_text}
|
| 413 |
+
</span>
|
| 414 |
+
</div>
|
| 415 |
+
|
| 416 |
+
<div>
|
| 417 |
+
<h4 style="margin:4px 0 2px 0;">🌍 世界信息</h4>
|
| 418 |
+
<span style="font-size:0.85em;">
|
| 419 |
+
位置: {w.current_scene}<br>
|
| 420 |
+
时间: 第{w.day_count}天 {w.time_of_day}<br>
|
| 421 |
+
天气: {w.weather}<br>
|
| 422 |
+
季节: {w.season}<br>
|
| 423 |
+
回合: {gs.turn}
|
| 424 |
+
</span>
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
</div>
|
| 428 |
+
</div>"""
|
| 429 |
+
return status
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _progress_bar(current: int, maximum: int, label: str, length: int = 10) -> str:
|
| 433 |
+
"""生成文本进度条"""
|
| 434 |
+
ratio = current / maximum if maximum > 0 else 0
|
| 435 |
+
filled = int(ratio * length)
|
| 436 |
+
empty = length - filled
|
| 437 |
+
bar = "█" * filled + "░" * empty
|
| 438 |
+
return f"{label}: {bar} {current}/{maximum}"
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
# ============================================================
|
| 442 |
+
# Gradio 界面构建
|
| 443 |
+
# ============================================================
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def build_app() -> gr.Blocks:
|
| 447 |
+
"""构建 Gradio 界面"""
|
| 448 |
+
|
| 449 |
+
with gr.Blocks(
|
| 450 |
+
title="StoryWeaver - 交互式叙事系统",
|
| 451 |
+
) as app:
|
| 452 |
+
|
| 453 |
+
gr.Markdown(
|
| 454 |
+
"""
|
| 455 |
+
# StoryWeaver — 交互式叙事系统
|
| 456 |
+
*基于 AI 的动态分支剧情 RPG 体验*
|
| 457 |
+
"""
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
# 游戏会话状��(Gradio State)
|
| 461 |
+
game_session = gr.State(value={})
|
| 462 |
+
|
| 463 |
+
with gr.Row():
|
| 464 |
+
# ==================
|
| 465 |
+
# 左侧:聊天区域
|
| 466 |
+
# ==================
|
| 467 |
+
with gr.Column(scale=3):
|
| 468 |
+
# 玩家姓名输入 + 开始按钮
|
| 469 |
+
with gr.Row():
|
| 470 |
+
player_name_input = gr.Textbox(
|
| 471 |
+
label="角色名称",
|
| 472 |
+
placeholder="输入你的角色名称(默认: 旅人)",
|
| 473 |
+
value="旅人",
|
| 474 |
+
scale=3,
|
| 475 |
+
)
|
| 476 |
+
start_btn = gr.Button(
|
| 477 |
+
"开始冒险",
|
| 478 |
+
variant="primary",
|
| 479 |
+
scale=1,
|
| 480 |
+
)
|
| 481 |
+
restart_btn = gr.Button(
|
| 482 |
+
"重启冒险",
|
| 483 |
+
variant="stop",
|
| 484 |
+
scale=1,
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
# 聊天窗口
|
| 488 |
+
chatbot = gr.Chatbot(
|
| 489 |
+
label="故事",
|
| 490 |
+
height=480,
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
# 选项按钮
|
| 494 |
+
with gr.Row():
|
| 495 |
+
option_btn_1 = gr.Button(
|
| 496 |
+
"选项 1", visible=False
|
| 497 |
+
)
|
| 498 |
+
option_btn_2 = gr.Button(
|
| 499 |
+
"选项 2", visible=False
|
| 500 |
+
)
|
| 501 |
+
option_btn_3 = gr.Button(
|
| 502 |
+
"选项 3", visible=False
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# 自由输入
|
| 506 |
+
with gr.Row():
|
| 507 |
+
user_input = gr.Textbox(
|
| 508 |
+
label="自由输入(也可以直接点击上方选项)",
|
| 509 |
+
placeholder="输入你想做的事情,例如:和村长说话、攻击哥布林、搜索这个区域...",
|
| 510 |
+
scale=4,
|
| 511 |
+
interactive=False,
|
| 512 |
+
)
|
| 513 |
+
send_btn = gr.Button("发送", variant="primary", scale=1)
|
| 514 |
+
|
| 515 |
+
# ==================
|
| 516 |
+
# 右侧:状态面板
|
| 517 |
+
# ==================
|
| 518 |
+
with gr.Column(scale=1):
|
| 519 |
+
status_panel = gr.Markdown(
|
| 520 |
+
value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
|
| 521 |
+
label="角色状态",
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
# ============================================================
|
| 525 |
+
# 事件绑定
|
| 526 |
+
# ============================================================
|
| 527 |
+
|
| 528 |
+
# 开始游戏
|
| 529 |
+
start_btn.click(
|
| 530 |
+
fn=start_game,
|
| 531 |
+
inputs=[player_name_input, game_session],
|
| 532 |
+
outputs=[
|
| 533 |
+
chatbot, status_panel,
|
| 534 |
+
option_btn_1, option_btn_2, option_btn_3,
|
| 535 |
+
game_session, user_input,
|
| 536 |
+
],
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
# 重启冒险
|
| 540 |
+
restart_btn.click(
|
| 541 |
+
fn=restart_game,
|
| 542 |
+
inputs=[],
|
| 543 |
+
outputs=[
|
| 544 |
+
chatbot, status_panel,
|
| 545 |
+
option_btn_1, option_btn_2, option_btn_3,
|
| 546 |
+
game_session, user_input, player_name_input,
|
| 547 |
+
],
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
# 文本输入发送
|
| 551 |
+
send_btn.click(
|
| 552 |
+
fn=process_user_input,
|
| 553 |
+
inputs=[user_input, chatbot, game_session],
|
| 554 |
+
outputs=[
|
| 555 |
+
chatbot, status_panel,
|
| 556 |
+
option_btn_1, option_btn_2, option_btn_3,
|
| 557 |
+
game_session,
|
| 558 |
+
],
|
| 559 |
+
).then(
|
| 560 |
+
fn=lambda: "",
|
| 561 |
+
outputs=[user_input],
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
# 回车发送
|
| 565 |
+
user_input.submit(
|
| 566 |
+
fn=process_user_input,
|
| 567 |
+
inputs=[user_input, chatbot, game_session],
|
| 568 |
+
outputs=[
|
| 569 |
+
chatbot, status_panel,
|
| 570 |
+
option_btn_1, option_btn_2, option_btn_3,
|
| 571 |
+
game_session,
|
| 572 |
+
],
|
| 573 |
+
).then(
|
| 574 |
+
fn=lambda: "",
|
| 575 |
+
outputs=[user_input],
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
# 选项按钮点击
|
| 579 |
+
option_btn_1.click(
|
| 580 |
+
fn=lambda ch, gs: process_option_click(0, ch, gs),
|
| 581 |
+
inputs=[chatbot, game_session],
|
| 582 |
+
outputs=[
|
| 583 |
+
chatbot, status_panel,
|
| 584 |
+
option_btn_1, option_btn_2, option_btn_3,
|
| 585 |
+
game_session,
|
| 586 |
+
],
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
option_btn_2.click(
|
| 590 |
+
fn=lambda ch, gs: process_option_click(1, ch, gs),
|
| 591 |
+
inputs=[chatbot, game_session],
|
| 592 |
+
outputs=[
|
| 593 |
+
chatbot, status_panel,
|
| 594 |
+
option_btn_1, option_btn_2, option_btn_3,
|
| 595 |
+
game_session,
|
| 596 |
+
],
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
option_btn_3.click(
|
| 600 |
+
fn=lambda ch, gs: process_option_click(2, ch, gs),
|
| 601 |
+
inputs=[chatbot, game_session],
|
| 602 |
+
outputs=[
|
| 603 |
+
chatbot, status_panel,
|
| 604 |
+
option_btn_1, option_btn_2, option_btn_3,
|
| 605 |
+
game_session,
|
| 606 |
+
],
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
return app
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
# ============================================================
|
| 613 |
+
# 启动入口
|
| 614 |
+
# ============================================================
|
| 615 |
+
|
| 616 |
+
if __name__ == "__main__":
|
| 617 |
+
logger.info("启动 StoryWeaver 交互式叙事系统...")
|
| 618 |
+
app = build_app()
|
| 619 |
+
app.launch(
|
| 620 |
+
server_name="0.0.0.0",
|
| 621 |
+
server_port=7860,
|
| 622 |
+
share=False,
|
| 623 |
+
show_error=True,
|
| 624 |
+
theme=gr.themes.Soft(
|
| 625 |
+
primary_hue="emerald",
|
| 626 |
+
secondary_hue="blue",
|
| 627 |
+
),
|
| 628 |
+
css="""
|
| 629 |
+
.story-chat {min-height: 500px;}
|
| 630 |
+
.status-panel {font-family: monospace; font-size: 0.85em;}
|
| 631 |
+
.option-btn {min-height: 50px !important;}
|
| 632 |
+
""",
|
| 633 |
+
)
|
nlu_engine.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
nlu_engine.py - StoryWeaver 自然语言理解引擎
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 解析用户自然语言输入,提取结构化意图
|
| 6 |
+
2. 将玩家"乱七八糟的输入"映射到具体的动作类型
|
| 7 |
+
3. 封装意图识别的 Prompt 与 API 调用
|
| 8 |
+
|
| 9 |
+
设计思路:
|
| 10 |
+
- 使用 Qwen API 进行意图识别,利用 LLM 的语义理解能力
|
| 11 |
+
- Prompt 设计中明确列出所有可能的意图类型和示例
|
| 12 |
+
- 低温度 (0.2) 确保输出的 JSON 格式稳定可靠
|
| 13 |
+
- 提供降级机制:如果 API 调用失败,使用关键词匹配兜底
|
| 14 |
+
|
| 15 |
+
输入/输出示例(来自需求文档):
|
| 16 |
+
Input: "我想攻击那个哥布林"
|
| 17 |
+
Output: {"intent": "ATTACK", "target": "哥布林", "details": null}
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import re
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
from utils import safe_json_call, DEFAULT_MODEL
|
| 25 |
+
from state_manager import GameState
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger("StoryWeaver")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ============================================================
|
| 31 |
+
# 意图识别 Prompt 模板
|
| 32 |
+
#
|
| 33 |
+
# 设计思路:
|
| 34 |
+
# - System Prompt 提供完整的意图类型列表和示例
|
| 35 |
+
# - 注入当前可用的行动上下文(当前场景的NPC、物品等)
|
| 36 |
+
# - 要求严格输出 JSON 格式
|
| 37 |
+
# - 低温度确保稳定性
|
| 38 |
+
# ============================================================
|
| 39 |
+
|
| 40 |
+
NLU_SYSTEM_PROMPT_TEMPLATE = """你是一个 RPG 游戏的自然语言理解模块(NLU)。你的任务是将玩家的自然语言输入解析为结构化的 JSON 意图数据。
|
| 41 |
+
|
| 42 |
+
【当前游戏上下文】
|
| 43 |
+
{context}
|
| 44 |
+
|
| 45 |
+
【支持的意图类型】
|
| 46 |
+
以下是所有合法的意图类型及其说明和示例:
|
| 47 |
+
|
| 48 |
+
| 意图 (intent) | 说明 | 示例输入 |
|
| 49 |
+
|:--|:--|:--|
|
| 50 |
+
| ATTACK | 攻击目标 | "攻击哥布林"、"打那个怪物"、"我要和它战斗" |
|
| 51 |
+
| TALK | 与NPC对话 | "和村长说话"、"找铁匠聊聊"、"我想打听消息" |
|
| 52 |
+
| MOVE | 移动到某地 | "去森林"、"回村庄"、"我要离开这里" |
|
| 53 |
+
| EXPLORE | 探索/观察环境 | "看看周围"、"仔细搜索"、"调查这个地方" |
|
| 54 |
+
| USE_ITEM | 使用物品 | "喝治疗药水"、"使用火把"、"吃面包" |
|
| 55 |
+
| TRADE | 交易(买/卖) | "买一把剑"、"卖掉这个"、"看看有什么卖的" |
|
| 56 |
+
| EQUIP | 装备物品 | "装备铁剑"、"穿上皮甲" |
|
| 57 |
+
| REST | 休息恢复 | "休息一下"、"在旅店过夜"、"睡觉" |
|
| 58 |
+
| QUEST | 接受/查看任务 | "接受任务"、"查看任务"、"任务完成了" |
|
| 59 |
+
| SKILL | 使用技能 | "施放火球术"、"使用隐身技能" |
|
| 60 |
+
| PICKUP | 拾取物品 | "捡起来"、"拿走那个东西" |
|
| 61 |
+
| FLEE | 逃跑 | "快跑"、"逃离这里"、"我要撤退" |
|
| 62 |
+
| CUSTOM | 其他自由行动 | "给NPC唱首歌"、"在墙上涂鸦" |
|
| 63 |
+
|
| 64 |
+
【当前场景中可交互的对象】
|
| 65 |
+
{interactables}
|
| 66 |
+
|
| 67 |
+
【输出格式要求】
|
| 68 |
+
请严格输出以下 JSON 格式(不要输出任何其他文字):
|
| 69 |
+
{{
|
| 70 |
+
"intent": "意图类型(从上表中选择)",
|
| 71 |
+
"target": "行动目标(NPC名称、物品名称、地点名称等,如果没有明确目标则为 null)",
|
| 72 |
+
"details": "补充细节(如 '用剑攻击'、'询问关于森林的事情' 等,如果没有额外细节则为 null)"
|
| 73 |
+
}}
|
| 74 |
+
|
| 75 |
+
【解析规则】
|
| 76 |
+
1. 如果玩家输入模糊(如"我不知道该干什么"),意图设为 EXPLORE。
|
| 77 |
+
2. 如果玩家输入包含多个动作,提取最主要的一个。
|
| 78 |
+
3. target 应尽量匹配当前场景中实际存在的对象。
|
| 79 |
+
4. 如果输入完全无法理解,设 intent 为 CUSTOM。
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class NLUEngine:
|
| 84 |
+
"""
|
| 85 |
+
自然语言理解引擎
|
| 86 |
+
|
| 87 |
+
核心能力:将玩家自由文本输入映射到结构化意图。
|
| 88 |
+
|
| 89 |
+
工作流程:
|
| 90 |
+
1. 收集当前场景上下文(NPC、物品、可达地点等)
|
| 91 |
+
2. 构造 Prompt 并调用 Qwen API
|
| 92 |
+
3. 解析返回的 JSON 意图
|
| 93 |
+
4. 如果 API 失败,使用关键词匹配降级
|
| 94 |
+
|
| 95 |
+
为什么用 LLM 而不是规则匹配:
|
| 96 |
+
- 玩家输入千变万化,规则难以覆盖
|
| 97 |
+
- LLM 能理解同义词、口语化表达、上下文隐含意图
|
| 98 |
+
- 例如:"我饿了" → 可能是 USE_ITEM(吃东西)或 MOVE(去旅店)
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def __init__(self, game_state: GameState, model: str = DEFAULT_MODEL):
|
| 102 |
+
self.game_state = game_state
|
| 103 |
+
self.model = model
|
| 104 |
+
|
| 105 |
+
def parse_intent(self, user_input: str) -> dict:
|
| 106 |
+
"""
|
| 107 |
+
解析用户输入,返回结构化意图。
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
user_input: 玩家的原始文本输入
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
{
|
| 114 |
+
"intent": "ATTACK",
|
| 115 |
+
"target": "哥布林",
|
| 116 |
+
"details": "用剑攻击",
|
| 117 |
+
"raw_input": "我想用剑攻击那个哥布林"
|
| 118 |
+
}
|
| 119 |
+
"""
|
| 120 |
+
if not user_input or not user_input.strip():
|
| 121 |
+
return {
|
| 122 |
+
"intent": "EXPLORE",
|
| 123 |
+
"target": None,
|
| 124 |
+
"details": "玩家沉默不语",
|
| 125 |
+
"raw_input": "",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
user_input = user_input.strip()
|
| 129 |
+
logger.info(f"NLU 解析输入: '{user_input}'")
|
| 130 |
+
|
| 131 |
+
# 尝试 LLM 解析
|
| 132 |
+
result = self._llm_parse(user_input)
|
| 133 |
+
|
| 134 |
+
# 如果 LLM 解析失败,使用关键词降级
|
| 135 |
+
if result is None:
|
| 136 |
+
logger.warning("LLM 解析失败,使用关键词降级")
|
| 137 |
+
result = self._keyword_fallback(user_input)
|
| 138 |
+
|
| 139 |
+
# 附加原始输入
|
| 140 |
+
result["raw_input"] = user_input
|
| 141 |
+
|
| 142 |
+
logger.info(f"NLU 解析结果: {result}")
|
| 143 |
+
return result
|
| 144 |
+
|
| 145 |
+
def _llm_parse(self, user_input: str) -> Optional[dict]:
|
| 146 |
+
"""
|
| 147 |
+
使用 Qwen API 进行意图识别。
|
| 148 |
+
低温度 (0.2) 确保 JSON 输出稳定。
|
| 149 |
+
"""
|
| 150 |
+
context = self._build_context()
|
| 151 |
+
interactables = self._build_interactables()
|
| 152 |
+
|
| 153 |
+
system_prompt = NLU_SYSTEM_PROMPT_TEMPLATE.format(
|
| 154 |
+
context=context,
|
| 155 |
+
interactables=interactables,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
messages = [
|
| 159 |
+
{"role": "system", "content": system_prompt},
|
| 160 |
+
{"role": "user", "content": user_input},
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
result = safe_json_call(
|
| 164 |
+
messages,
|
| 165 |
+
model=self.model,
|
| 166 |
+
temperature=0.2,
|
| 167 |
+
max_tokens=300,
|
| 168 |
+
max_retries=2,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
if result and isinstance(result, dict) and "intent" in result:
|
| 172 |
+
# 验证意图类型合法
|
| 173 |
+
valid_intents = {
|
| 174 |
+
"ATTACK", "TALK", "MOVE", "EXPLORE", "USE_ITEM",
|
| 175 |
+
"TRADE", "EQUIP", "REST", "QUEST", "SKILL",
|
| 176 |
+
"PICKUP", "FLEE", "CUSTOM",
|
| 177 |
+
}
|
| 178 |
+
if result["intent"] not in valid_intents:
|
| 179 |
+
result["intent"] = "CUSTOM"
|
| 180 |
+
return result
|
| 181 |
+
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
def _keyword_fallback(self, user_input: str) -> dict:
|
| 185 |
+
"""
|
| 186 |
+
关键词匹配降级方案。
|
| 187 |
+
|
| 188 |
+
设计思路:
|
| 189 |
+
- 当 API 不可用时的兜底策略
|
| 190 |
+
- 使用正则匹配常见中文关键词
|
| 191 |
+
- 覆盖最常见的意图类型
|
| 192 |
+
- 无法匹配时默认为 EXPLORE
|
| 193 |
+
"""
|
| 194 |
+
text = user_input.lower()
|
| 195 |
+
|
| 196 |
+
# 关键词 → 意图映射(按优先级排序)
|
| 197 |
+
keyword_rules = [
|
| 198 |
+
# 攻击相关
|
| 199 |
+
(r"攻击|打|杀|战斗|砍|刺|射|揍", "ATTACK"),
|
| 200 |
+
# 逃跑相关
|
| 201 |
+
(r"逃|跑|撤退|逃离|闪", "FLEE"),
|
| 202 |
+
# 对话相关
|
| 203 |
+
(r"说话|对话|交谈|聊|打听|询问|问", "TALK"),
|
| 204 |
+
# 移动相关
|
| 205 |
+
(r"去|前往|移动|走|回|离开|进入", "MOVE"),
|
| 206 |
+
# 物品使用
|
| 207 |
+
(r"使用|喝|吃|用|服用", "USE_ITEM"),
|
| 208 |
+
# 交易
|
| 209 |
+
(r"买|卖|交易|购买|出售|商店", "TRADE"),
|
| 210 |
+
# 装备
|
| 211 |
+
(r"装备|穿|戴|换装", "EQUIP"),
|
| 212 |
+
# 休息
|
| 213 |
+
(r"休息|睡|过夜|恢复|歇", "REST"),
|
| 214 |
+
# 任务
|
| 215 |
+
(r"任务|接受|完成|查看任务", "QUEST"),
|
| 216 |
+
# 技能
|
| 217 |
+
(r"施放|技能|魔法|法术|释放", "SKILL"),
|
| 218 |
+
# 拾取
|
| 219 |
+
(r"捡|拾|拿|拿走|拾取|收集", "PICKUP"),
|
| 220 |
+
# 探索
|
| 221 |
+
(r"看|观察|搜索|调查|探索|检查|四周", "EXPLORE"),
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
+
detected_intent = "CUSTOM"
|
| 225 |
+
for pattern, intent in keyword_rules:
|
| 226 |
+
if re.search(pattern, text):
|
| 227 |
+
detected_intent = intent
|
| 228 |
+
break
|
| 229 |
+
|
| 230 |
+
# 尝试提取目标
|
| 231 |
+
target = self._extract_target_from_text(user_input)
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
"intent": detected_intent,
|
| 235 |
+
"target": target,
|
| 236 |
+
"details": None,
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
def _extract_target_from_text(self, text: str) -> Optional[str]:
|
| 240 |
+
"""
|
| 241 |
+
从文本中提取可能的目标对象。
|
| 242 |
+
尝试匹配当前场景中的 NPC、物品、地点名称。
|
| 243 |
+
"""
|
| 244 |
+
# 检查 NPC 名称
|
| 245 |
+
for npc_name in self.game_state.world.npcs:
|
| 246 |
+
if npc_name in text:
|
| 247 |
+
return npc_name
|
| 248 |
+
|
| 249 |
+
# 检查物品名称(背包 + 当前场景)
|
| 250 |
+
for item in self.game_state.player.inventory:
|
| 251 |
+
if item in text:
|
| 252 |
+
return item
|
| 253 |
+
|
| 254 |
+
# 检查地点名称
|
| 255 |
+
current_loc = self.game_state.world.locations.get(self.game_state.player.location)
|
| 256 |
+
if current_loc:
|
| 257 |
+
for loc_name in current_loc.connected_to:
|
| 258 |
+
if loc_name in text:
|
| 259 |
+
return loc_name
|
| 260 |
+
|
| 261 |
+
# 检查物品注册表
|
| 262 |
+
for item_name in self.game_state.world.item_registry:
|
| 263 |
+
if item_name in text:
|
| 264 |
+
return item_name
|
| 265 |
+
|
| 266 |
+
return None
|
| 267 |
+
|
| 268 |
+
def _build_context(self) -> str:
|
| 269 |
+
"""构建当前场景的简要上下文描述"""
|
| 270 |
+
gs = self.game_state
|
| 271 |
+
return (
|
| 272 |
+
f"场景: {gs.world.current_scene}\n"
|
| 273 |
+
f"时间: 第{gs.world.day_count}天 {gs.world.time_of_day}\n"
|
| 274 |
+
f"玩家位置: {gs.player.location}\n"
|
| 275 |
+
f"玩家 HP: {gs.player.hp}/{gs.player.max_hp}\n"
|
| 276 |
+
f"玩家背包: {', '.join(gs.player.inventory) if gs.player.inventory else '空'}"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
def _build_interactables(self) -> str:
|
| 280 |
+
"""构建当前场景中可交互对象的列表"""
|
| 281 |
+
gs = self.game_state
|
| 282 |
+
lines = []
|
| 283 |
+
|
| 284 |
+
# 当前场景的 NPC
|
| 285 |
+
current_npcs = [
|
| 286 |
+
npc for npc in gs.world.npcs.values()
|
| 287 |
+
if npc.location == gs.player.location and npc.is_alive
|
| 288 |
+
]
|
| 289 |
+
if current_npcs:
|
| 290 |
+
npc_names = [f"{npc.name}({npc.occupation})" for npc in current_npcs]
|
| 291 |
+
lines.append(f"NPC: {', '.join(npc_names)}")
|
| 292 |
+
|
| 293 |
+
# 可前往的地点
|
| 294 |
+
loc = gs.world.locations.get(gs.player.location)
|
| 295 |
+
if loc and loc.connected_to:
|
| 296 |
+
lines.append(f"可前往: {', '.join(loc.connected_to)}")
|
| 297 |
+
|
| 298 |
+
# 场景中的敌人
|
| 299 |
+
if loc and loc.enemies:
|
| 300 |
+
lines.append(f"可能的敌人: {', '.join(loc.enemies)}")
|
| 301 |
+
|
| 302 |
+
# 背包物品
|
| 303 |
+
if gs.player.inventory:
|
| 304 |
+
lines.append(f"背包物品: {', '.join(gs.player.inventory)}")
|
| 305 |
+
|
| 306 |
+
# 技能
|
| 307 |
+
if gs.player.skills:
|
| 308 |
+
lines.append(f"可用技能: {', '.join(gs.player.skills)}")
|
| 309 |
+
|
| 310 |
+
return "\n".join(lines) if lines else "当前场景中没有特别的可交互对象"
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai>=1.0.0
|
| 2 |
+
gradio>=4.0.0
|
| 3 |
+
python-dotenv>=1.0.0
|
| 4 |
+
pydantic>=2.0.0
|
state_manager.py
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
state_manager.py - StoryWeaver 状态管理器
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 定义游戏世界的完整数据模型(Pydantic BaseModel)
|
| 6 |
+
2. 维护全局状态的唯一真相来源 (Single Source of Truth)
|
| 7 |
+
3. 提供状态变更、校验、一致性检查、序列化等核心方法
|
| 8 |
+
4. 记录事件日志用于一致性维护
|
| 9 |
+
|
| 10 |
+
设计思路:
|
| 11 |
+
- 所有数据结构使用 Pydantic BaseModel,天然支持 JSON 序列化/反序列化
|
| 12 |
+
- GameState 是顶层容器,包含 PlayerState、WorldState、EventLog
|
| 13 |
+
- event_log 是一致性维护的灵魂:每次操作都记录快照,用于矛盾检测
|
| 14 |
+
- to_prompt() 方法将结构化数据转为自然语言,注入 LLM 的 System Prompt
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import copy
|
| 20 |
+
import logging
|
| 21 |
+
from typing import Any, Optional
|
| 22 |
+
from pydantic import BaseModel, Field
|
| 23 |
+
|
| 24 |
+
from utils import clamp
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger("StoryWeaver")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ============================================================
|
| 30 |
+
# 辅助数据模型
|
| 31 |
+
# ============================================================
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class StatusEffect(BaseModel):
|
| 35 |
+
"""
|
| 36 |
+
状态效果模型(Buff / Debuff)
|
| 37 |
+
|
| 38 |
+
设计思路:
|
| 39 |
+
- 每个状态效果有持续时间和属性修正,每回合自动结算
|
| 40 |
+
- source 记录来源,便于在 Prompt 中说明"你身上中了哥布林的毒"
|
| 41 |
+
- stackable 控制是否可叠加,防止无限叠加 bug
|
| 42 |
+
"""
|
| 43 |
+
name: str # 效果名(中毒、祝福、隐身…)
|
| 44 |
+
effect_type: str = "debuff" # buff / debuff / neutral
|
| 45 |
+
stat_modifiers: dict[str, int] = Field(default_factory=dict)
|
| 46 |
+
# 属性修正 {"attack": -3, "defense": +2}
|
| 47 |
+
duration: int = 3 # 剩余持续回合数(-1 = 永久)
|
| 48 |
+
description: str = "" # 效果描述
|
| 49 |
+
source: str = "" # 来源("哥布林的毒刃")
|
| 50 |
+
stackable: bool = False # 是否可叠加
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ItemInfo(BaseModel):
|
| 54 |
+
"""
|
| 55 |
+
物品详情模型
|
| 56 |
+
|
| 57 |
+
设计思路:
|
| 58 |
+
- item_type 区分装备/消耗品/任务道具等,不同类型有不同交互逻辑
|
| 59 |
+
- rarity 影响掉落概率和商店价格
|
| 60 |
+
- quest_related 标记任务道具,防止玩家丢弃关键物品
|
| 61 |
+
- lore_text 提供物品背景,丰富生成文本的细节
|
| 62 |
+
"""
|
| 63 |
+
name: str # 物品名称
|
| 64 |
+
item_type: str = "misc" # weapon / armor / consumable / quest_item / material / key / misc
|
| 65 |
+
description: str = "" # 物品描述
|
| 66 |
+
rarity: str = "common" # common / uncommon / rare / epic / legendary
|
| 67 |
+
stat_bonus: dict[str, int] = Field(default_factory=dict)
|
| 68 |
+
# 装备时属性加成 {"attack": +5}
|
| 69 |
+
usable: bool = False # 是否可主动使用
|
| 70 |
+
use_effect: str = "" # 使用效果描述(如"恢复 30 HP")
|
| 71 |
+
value: int = 0 # 商店价值(金币)
|
| 72 |
+
quest_related: bool = False # 是否为任务道具
|
| 73 |
+
lore_text: str = "" # 物品背景故事
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class NPCState(BaseModel):
|
| 77 |
+
"""
|
| 78 |
+
NPC 状态模型
|
| 79 |
+
|
| 80 |
+
设计思路:
|
| 81 |
+
- npc_type 决定交互方式(商人可交易、任务NPC可接任务、敌人可战斗)
|
| 82 |
+
- memory 是一致性维护的关键:NPC"记住"与玩家的互动历史
|
| 83 |
+
- schedule 模拟 NPC 日常行为,不同时间段出现在不同地点
|
| 84 |
+
- relationship_level 影响对话态度和任务可用性
|
| 85 |
+
"""
|
| 86 |
+
name: str # NPC 名称
|
| 87 |
+
npc_type: str = "civilian" # civilian / merchant / quest_giver / enemy / companion / boss
|
| 88 |
+
location: str = "" # 所在地点
|
| 89 |
+
attitude: str = "neutral" # friendly / neutral / cautious / hostile
|
| 90 |
+
is_alive: bool = True # 是否存活
|
| 91 |
+
description: str = "" # 外观描述
|
| 92 |
+
race: str = "人类" # 种族
|
| 93 |
+
occupation: str = "" # 职业(铁匠、旅店老板、守卫…)
|
| 94 |
+
faction: str = "" # 所属阵营
|
| 95 |
+
|
| 96 |
+
# --- 交互相关 ---
|
| 97 |
+
relationship_level: int = 0 # 与玩家好感度(-100 ~ 100)
|
| 98 |
+
dialogue_tags: list[str] = Field(default_factory=list)
|
| 99 |
+
# 已触发的对话标签(防止重复触发)
|
| 100 |
+
can_trade: bool = False # 是否可交易
|
| 101 |
+
shop_inventory: list[str] = Field(default_factory=list)
|
| 102 |
+
# 商店物品(如果是商人)
|
| 103 |
+
can_give_quest: bool = False # 是否可发布任务
|
| 104 |
+
available_quests: list[str] = Field(default_factory=list)
|
| 105 |
+
# 可发布的任务 ID
|
| 106 |
+
|
| 107 |
+
# --- 战斗相关(敌人/Boss) ---
|
| 108 |
+
hp: int = 0
|
| 109 |
+
max_hp: int = 0
|
| 110 |
+
attack: int = 0
|
| 111 |
+
defense: int = 0
|
| 112 |
+
loot_table: list[str] = Field(default_factory=list)
|
| 113 |
+
# 击败后掉落物品
|
| 114 |
+
weakness: str = "" # 弱点(火、光…)
|
| 115 |
+
special_ability: str = "" # 特殊能力
|
| 116 |
+
|
| 117 |
+
# --- 记忆与行为 ---
|
| 118 |
+
memory: list[str] = Field(default_factory=list)
|
| 119 |
+
# NPC 记住的关键事件
|
| 120 |
+
schedule: dict[str, str] = Field(default_factory=dict)
|
| 121 |
+
# 时间行为表 {"清晨": "在市场摆摊"}
|
| 122 |
+
backstory: str = "" # 背景故事
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class QuestRewards(BaseModel):
|
| 126 |
+
"""
|
| 127 |
+
任务奖励模型
|
| 128 |
+
|
| 129 |
+
设计思路:
|
| 130 |
+
- 奖励类型丰富,覆盖经济、声望、技能、称号等多维度
|
| 131 |
+
- 每种奖励都可选,通过组合实现多样化的奖励体验
|
| 132 |
+
"""
|
| 133 |
+
gold: int = 0 # 金币奖励
|
| 134 |
+
experience: int = 0 # 经验值奖励
|
| 135 |
+
items: list[str] = Field(default_factory=list)
|
| 136 |
+
# 奖励物品
|
| 137 |
+
reputation_changes: dict[str, int] = Field(default_factory=dict)
|
| 138 |
+
# 声望变化 {"精灵族": +10}
|
| 139 |
+
karma_change: int = 0 # 善恶值变化
|
| 140 |
+
unlock_location: str = "" # 解锁新地点
|
| 141 |
+
unlock_skill: str = "" # 解锁新技能
|
| 142 |
+
title: str = "" # 解锁称号
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class QuestState(BaseModel):
|
| 146 |
+
"""
|
| 147 |
+
任务状态模型
|
| 148 |
+
|
| 149 |
+
设计思路:
|
| 150 |
+
- quest_type 区分主线/支线/隐藏任务,影响 UI 展示和优先级
|
| 151 |
+
- objectives 是任务子目标字典,每个子目标独立追踪
|
| 152 |
+
- branching_choices 支持任务内分支(如"放走囚犯"导向不同结局)
|
| 153 |
+
- time_limit / turns_remaining 支持限时任务机制
|
| 154 |
+
- prerequisites 保证任务链的逻辑顺序
|
| 155 |
+
"""
|
| 156 |
+
quest_id: str # 任务唯一 ID
|
| 157 |
+
title: str # 任务名称
|
| 158 |
+
description: str # 任务描述
|
| 159 |
+
quest_type: str = "main" # main / side / hidden / daily
|
| 160 |
+
status: str = "active" # active / completed / failed / expired
|
| 161 |
+
giver_npc: str = "" # 任务发布者 NPC
|
| 162 |
+
|
| 163 |
+
# --- 目标 ---
|
| 164 |
+
objectives: dict[str, bool] = Field(default_factory=dict)
|
| 165 |
+
# 子目标 {"找到钥匙": False, "打开宝箱": False}
|
| 166 |
+
|
| 167 |
+
# --- 奖励 ---
|
| 168 |
+
rewards: QuestRewards = Field(default_factory=QuestRewards)
|
| 169 |
+
|
| 170 |
+
# --- 约束 ---
|
| 171 |
+
time_limit: int = -1 # 限时回合数(-1 = 无限)
|
| 172 |
+
turns_remaining: int = -1 # 剩余回合数
|
| 173 |
+
prerequisites: list[str] = Field(default_factory=list)
|
| 174 |
+
# 前置任务 ID
|
| 175 |
+
level_requirement: int = 1 # 等级要求
|
| 176 |
+
karma_requirement: Optional[int] = None # 善恶值要求
|
| 177 |
+
|
| 178 |
+
# --- 分支 ---
|
| 179 |
+
branching_choices: dict[str, str] = Field(default_factory=dict)
|
| 180 |
+
# 关键选择 {"放走囚犯": "mercy_path"}
|
| 181 |
+
chosen_path: str = "" # 已选择的路线
|
| 182 |
+
consequences: list[str] = Field(default_factory=list)
|
| 183 |
+
# 完成后的剧情后果描述
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class LocationInfo(BaseModel):
|
| 187 |
+
"""
|
| 188 |
+
地点详情模型
|
| 189 |
+
|
| 190 |
+
设计思路:
|
| 191 |
+
- connected_to 构成游戏地图的拓扑结构,控制玩家可移动范围
|
| 192 |
+
- danger_level 影响遭遇概率和 NPC 行为
|
| 193 |
+
- is_accessible + required_item 实现"锁门/钥匙"机制
|
| 194 |
+
- ambient_description 用于丰富 LLM 生成的场景描写
|
| 195 |
+
- special_events 支持地点触发式事件
|
| 196 |
+
"""
|
| 197 |
+
name: str # 地点名称
|
| 198 |
+
location_type: str = "town" # town / dungeon / wilderness / shop / special
|
| 199 |
+
description: str = "" # 地点描述
|
| 200 |
+
connected_to: list[str] = Field(default_factory=list)
|
| 201 |
+
# 可前往的相邻地点
|
| 202 |
+
npcs_present: list[str] = Field(default_factory=list)
|
| 203 |
+
# 当前在该地点的 NPC
|
| 204 |
+
available_items: list[str] = Field(default_factory=list)
|
| 205 |
+
# 可拾取/发现的物品
|
| 206 |
+
enemies: list[str] = Field(default_factory=list)
|
| 207 |
+
# 可能遭遇的敌人
|
| 208 |
+
danger_level: int = 0 # 危险等级 (0=安全, 10=极度危险)
|
| 209 |
+
weather: str = "晴朗" # 当前天气
|
| 210 |
+
is_discovered: bool = False # 是否已被玩家发现
|
| 211 |
+
is_accessible: bool = True # 是否可进入
|
| 212 |
+
required_item: str = "" # 进入所需道具
|
| 213 |
+
ambient_description: str = "" # 环境氛围描述
|
| 214 |
+
special_events: list[str] = Field(default_factory=list)
|
| 215 |
+
# 该地点可触发的特殊事件
|
| 216 |
+
rest_available: bool = False # 是否可以休息恢复
|
| 217 |
+
shop_available: bool = False # 是否有商店
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ============================================================
|
| 221 |
+
# 玩家状态
|
| 222 |
+
# ============================================================
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class PlayerState(BaseModel):
|
| 226 |
+
"""
|
| 227 |
+
玩家角色状态(RPG 核心属性)
|
| 228 |
+
|
| 229 |
+
设计思路:
|
| 230 |
+
- 基础属性 + 战斗属性 + 装备栏 + 社交属性 构成完整的角色模型
|
| 231 |
+
- reputation / karma / relationships 影响 NPC 态度和剧情分支
|
| 232 |
+
- morale / sanity / hunger 增加生存维度,丰富游戏体验
|
| 233 |
+
- known_lore 记录玩家获得的情报,影响可用对话选项
|
| 234 |
+
- death_count 支持"轮回"类剧情彩蛋
|
| 235 |
+
"""
|
| 236 |
+
# --- 基础属性 ---
|
| 237 |
+
name: str = "旅人" # 玩家名称
|
| 238 |
+
title: str = "无名冒险者" # 称号(随剧情解锁)
|
| 239 |
+
level: int = 1 # 等级
|
| 240 |
+
experience: int = 0 # 当前经验值
|
| 241 |
+
exp_to_next_level: int = 100 # 升级所需经验
|
| 242 |
+
|
| 243 |
+
# --- 战斗属性 ---
|
| 244 |
+
hp: int = 100 # 当前生命值
|
| 245 |
+
max_hp: int = 100 # 最大生命值
|
| 246 |
+
mp: int = 50 # 魔力值
|
| 247 |
+
max_mp: int = 50 # 最大魔力值
|
| 248 |
+
attack: int = 10 # 攻击力
|
| 249 |
+
defense: int = 5 # 防御力
|
| 250 |
+
speed: int = 8 # 速度(影响行动顺序)
|
| 251 |
+
luck: int = 5 # 幸运(影响暴击、掉落)
|
| 252 |
+
perception: int = 5 # 感知(影响探索发现、陷阱识别)
|
| 253 |
+
|
| 254 |
+
# --- 装备栏 ---
|
| 255 |
+
equipment: dict[str, Optional[str]] = Field(default_factory=lambda: {
|
| 256 |
+
"weapon": None, # 武器
|
| 257 |
+
"armor": None, # 护甲
|
| 258 |
+
"accessory": None, # 饰品
|
| 259 |
+
"helmet": None, # 头盔
|
| 260 |
+
"boots": None, # 靴子
|
| 261 |
+
})
|
| 262 |
+
|
| 263 |
+
# --- 状态 ---
|
| 264 |
+
location: str = "村庄" # 当前所在地点
|
| 265 |
+
inventory: list[str] = Field(default_factory=list)
|
| 266 |
+
# 背包物品列表
|
| 267 |
+
skills: list[str] = Field(default_factory=list)
|
| 268 |
+
# 已习得技能列表
|
| 269 |
+
status_effects: list[StatusEffect] = Field(default_factory=list)
|
| 270 |
+
# 状态效果列表
|
| 271 |
+
gold: int = 50 # 金币
|
| 272 |
+
reputation: dict[str, int] = Field(default_factory=dict)
|
| 273 |
+
# 阵营声望 {"精灵族": 10}
|
| 274 |
+
morale: int = 100 # 士气(0=崩溃, 100=高昂)
|
| 275 |
+
sanity: int = 100 # 理智值(探索黑暗区域消耗)
|
| 276 |
+
hunger: int = 100 # 饱食度(0=饥饿惩罚)
|
| 277 |
+
karma: int = 0 # 善恶值(正=善, 负=恶)
|
| 278 |
+
known_lore: list[str] = Field(default_factory=list)
|
| 279 |
+
# 已知传说/情报片段
|
| 280 |
+
relationships: dict[str, int] = Field(default_factory=dict)
|
| 281 |
+
# 与特定 NPC 的好感度
|
| 282 |
+
death_count: int = 0 # 累计死亡次数
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# ============================================================
|
| 286 |
+
# 世界状态
|
| 287 |
+
# ============================================================
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class WorldState(BaseModel):
|
| 291 |
+
"""
|
| 292 |
+
世界状态容器
|
| 293 |
+
|
| 294 |
+
设计思路:
|
| 295 |
+
- 包含所有非玩家的世界数据:地图、NPC、任务、物品注册表
|
| 296 |
+
- time_of_day + day_count + weather + season 构成动态环境系统
|
| 297 |
+
- global_flags 是灵活的剧情标记系统,支持分支判断
|
| 298 |
+
- rumors / active_threats 丰富 NPC 对话内容
|
| 299 |
+
- faction_relations 支持阵营间动态关系
|
| 300 |
+
"""
|
| 301 |
+
current_scene: str = "村庄广场" # 当前场景名称
|
| 302 |
+
time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下�� / 黄昏 / 夜晚 / 深夜
|
| 303 |
+
day_count: int = 1 # 当前天数
|
| 304 |
+
weather: str = "晴朗" # 晴朗 / 多云 / 小雨 / 暴风雨 / 大雪 / 浓雾
|
| 305 |
+
season: str = "春" # 春 / 夏 / 秋 / 冬
|
| 306 |
+
|
| 307 |
+
# --- 地图 ---
|
| 308 |
+
locations: dict[str, LocationInfo] = Field(default_factory=dict)
|
| 309 |
+
discovered_locations: list[str] = Field(default_factory=list)
|
| 310 |
+
|
| 311 |
+
# --- NPC ---
|
| 312 |
+
npcs: dict[str, NPCState] = Field(default_factory=dict)
|
| 313 |
+
|
| 314 |
+
# --- 任务 ---
|
| 315 |
+
quests: dict[str, QuestState] = Field(default_factory=dict)
|
| 316 |
+
|
| 317 |
+
# --- 物品注册表 ---
|
| 318 |
+
item_registry: dict[str, ItemInfo] = Field(default_factory=dict)
|
| 319 |
+
|
| 320 |
+
# --- 全局标记 ---
|
| 321 |
+
global_flags: dict[str, bool] = Field(default_factory=dict)
|
| 322 |
+
world_events: list[str] = Field(default_factory=list)
|
| 323 |
+
# 已发生的全局事件
|
| 324 |
+
active_threats: list[str] = Field(default_factory=list)
|
| 325 |
+
# 当前全局威胁
|
| 326 |
+
rumors: list[str] = Field(default_factory=list)
|
| 327 |
+
# 流传的传闻
|
| 328 |
+
faction_relations: dict[str, dict[str, str]] = Field(default_factory=dict)
|
| 329 |
+
# 阵营间关系
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ============================================================
|
| 333 |
+
# 事件日志
|
| 334 |
+
# ============================================================
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
class GameEvent(BaseModel):
|
| 338 |
+
"""
|
| 339 |
+
事件日志模型(一致性维护的关键)
|
| 340 |
+
|
| 341 |
+
设计思路:
|
| 342 |
+
- 每次状态变更都记录为一个事件,包含完整的上下文信息
|
| 343 |
+
- state_changes 记录该事件引发的状态变更快照
|
| 344 |
+
- consequence_tags 用于后续一致性检查(如 "killed_goblin_king")
|
| 345 |
+
- is_reversible 标记不可逆事件,LLM 生成时需特别注意
|
| 346 |
+
- involved_npcs + location 便于按维度检索历史事件
|
| 347 |
+
"""
|
| 348 |
+
turn: int # 发生在第几回合
|
| 349 |
+
day: int = 1 # 发生在第几天
|
| 350 |
+
time_of_day: str = "" # 发生时的时段
|
| 351 |
+
event_type: str = "" # COMBAT / DIALOGUE / MOVE / ITEM / QUEST / TRADE / REST / DISCOVERY / DEATH / LEVEL_UP
|
| 352 |
+
description: str = "" # 事件简述
|
| 353 |
+
location: str = "" # 事件发生地点
|
| 354 |
+
involved_npcs: list[str] = Field(default_factory=list)
|
| 355 |
+
# 涉及的 NPC
|
| 356 |
+
state_changes: dict = Field(default_factory=dict)
|
| 357 |
+
# 状态变更快照
|
| 358 |
+
player_action: str = "" # 触发该事件的玩家操作
|
| 359 |
+
consequence_tags: list[str] = Field(default_factory=list)
|
| 360 |
+
# 后果标签
|
| 361 |
+
is_reversible: bool = True # 是否可逆
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ============================================================
|
| 365 |
+
# 游戏主控类
|
| 366 |
+
# ============================================================
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
class GameState:
|
| 370 |
+
"""
|
| 371 |
+
游戏全局状态管理器 —— 项目的灵魂
|
| 372 |
+
|
| 373 |
+
职责:
|
| 374 |
+
1. 持有并管理 PlayerState、WorldState、EventLog
|
| 375 |
+
2. 提供状态变更、校验、一致性检查的统一入口
|
| 376 |
+
3. 将结构化状态序列化为自然语言 Prompt
|
| 377 |
+
4. 每回合自动结算状态效果、时间推进、任务超时等
|
| 378 |
+
|
| 379 |
+
核心设计原则:
|
| 380 |
+
- 所有状态修改必须通过 apply_changes() 进入
|
| 381 |
+
- 每次修改都伴随 validate() 校验和 log_event() 记录
|
| 382 |
+
- check_consistency() 在生成前检测可能的矛盾
|
| 383 |
+
"""
|
| 384 |
+
|
| 385 |
+
def __init__(self, player_name: str = "旅人"):
|
| 386 |
+
"""初始化游戏状态,创建默认的起始世界"""
|
| 387 |
+
self.player = PlayerState(name=player_name)
|
| 388 |
+
self.world = WorldState()
|
| 389 |
+
self.event_log: list[GameEvent] = []
|
| 390 |
+
self.turn: int = 0
|
| 391 |
+
self.game_mode: str = "exploration" # exploration / combat / dialogue / cutscene / game_over
|
| 392 |
+
self.difficulty: str = "normal" # easy / normal / hard
|
| 393 |
+
self.story_arc: str = "序章" # 当前故事章节
|
| 394 |
+
self.ending_flags: dict[str, bool] = {} # 结局条件追踪
|
| 395 |
+
self.combat_log: list[str] = [] # 最近战斗记录
|
| 396 |
+
self.achievement_list: list[str] = [] # 已解锁成就
|
| 397 |
+
|
| 398 |
+
# 初始化起始世界
|
| 399 |
+
self._init_starting_world()
|
| 400 |
+
|
| 401 |
+
def _init_starting_world(self):
|
| 402 |
+
"""
|
| 403 |
+
创建游戏的起始世界设定。
|
| 404 |
+
包含初始地点、NPC、任务和物品,为故事提供起点。
|
| 405 |
+
"""
|
| 406 |
+
# --- 初始地点 ---
|
| 407 |
+
self.world.locations = {
|
| 408 |
+
"村庄广场": LocationInfo(
|
| 409 |
+
name="村庄广场",
|
| 410 |
+
location_type="town",
|
| 411 |
+
description="一个宁静的小村庄中心广场,阳光温暖地照耀着鹅卵石路面。周围有几家商铺和一口古老的水井。",
|
| 412 |
+
connected_to=["村庄铁匠铺", "村庄旅店", "村口小路", "村庄杂货铺"],
|
| 413 |
+
npcs_present=["村长老伯"],
|
| 414 |
+
danger_level=0,
|
| 415 |
+
is_discovered=True,
|
| 416 |
+
rest_available=False,
|
| 417 |
+
ambient_description="阳光斑驳地洒在广场上,远处传来铁匠铺叮叮当当的锤声。",
|
| 418 |
+
),
|
| 419 |
+
"村庄铁匠铺": LocationInfo(
|
| 420 |
+
name="村庄铁匠铺",
|
| 421 |
+
location_type="shop",
|
| 422 |
+
description="一间热气腾腾的铁匠铺,炉火正旺。墙上挂满了各式武器和护甲。",
|
| 423 |
+
connected_to=["村庄广场"],
|
| 424 |
+
npcs_present=["铁匠格林"],
|
| 425 |
+
danger_level=0,
|
| 426 |
+
is_discovered=True,
|
| 427 |
+
shop_available=True,
|
| 428 |
+
ambient_description="炉火映红了铁匠粗犷的脸庞,空气中弥漫着金属和碳的气味。",
|
| 429 |
+
),
|
| 430 |
+
"村庄旅店": LocationInfo(
|
| 431 |
+
name="村庄旅店",
|
| 432 |
+
location_type="shop",
|
| 433 |
+
description="一家温馨的小旅店,空气中弥漫着烤肉和麦酒的香气。壁炉里的火焰跳动着。",
|
| 434 |
+
connected_to=["村庄广场"],
|
| 435 |
+
npcs_present=["旅店老板娘莉娜"],
|
| 436 |
+
danger_level=0,
|
| 437 |
+
is_discovered=True,
|
| 438 |
+
rest_available=True,
|
| 439 |
+
shop_available=True,
|
| 440 |
+
ambient_description="壁炉噼啪作响,几位旅客正低声交谈,空气温暖而舒适。",
|
| 441 |
+
),
|
| 442 |
+
"村庄杂货铺": LocationInfo(
|
| 443 |
+
name="村庄杂货铺",
|
| 444 |
+
location_type="shop",
|
| 445 |
+
description="一家琳琅满目的杂货铺,从草药到绳索应有尽有。老板是个精明的商人。",
|
| 446 |
+
connected_to=["村庄广场"],
|
| 447 |
+
npcs_present=["杂货商人阿尔"],
|
| 448 |
+
danger_level=0,
|
| 449 |
+
is_discovered=True,
|
| 450 |
+
shop_available=True,
|
| 451 |
+
ambient_description="货架上摆满了稀奇古怪的商品,柜台后的商人正用算盘噼里啪啦地算账。",
|
| 452 |
+
),
|
| 453 |
+
"村口小路": LocationInfo(
|
| 454 |
+
name="村口小路",
|
| 455 |
+
location_type="wilderness",
|
| 456 |
+
description="通往村外的一条泥泞小路,两旁长满了野草。远处隐约可见黑暗森林的轮廓。",
|
| 457 |
+
connected_to=["村庄广场", "黑暗森林入口"],
|
| 458 |
+
danger_level=2,
|
| 459 |
+
is_discovered=True,
|
| 460 |
+
ambient_description="微风拂过野草,远处的森林在薄雾中若隐若现,传来不知名鸟兽的叫声。",
|
| 461 |
+
),
|
| 462 |
+
"黑暗森林入口": LocationInfo(
|
| 463 |
+
name="黑暗森林入口",
|
| 464 |
+
location_type="wilderness",
|
| 465 |
+
description="森林的入口处,参天大树遮蔽了阳光,地面覆盖着厚厚的落叶。一股不祥的气息扑面而来。",
|
| 466 |
+
connected_to=["村口小路", "森林深处", "溪边营地"],
|
| 467 |
+
enemies=["哥布林", "野狼"],
|
| 468 |
+
danger_level=4,
|
| 469 |
+
is_discovered=False,
|
| 470 |
+
ambient_description="树冠密集得几乎遮蔽了所有阳光,偶尔传来树枝折断的声音,不知道是风还是别的什么。",
|
| 471 |
+
),
|
| 472 |
+
"森林深处": LocationInfo(
|
| 473 |
+
name="森林深处",
|
| 474 |
+
location_type="dungeon",
|
| 475 |
+
description="森林的最深处,古树盘根错节。空气中弥漫着腐朽和魔力的气息,据说这里住着森林的主人。",
|
| 476 |
+
connected_to=["黑暗森林入口"],
|
| 477 |
+
enemies=["哥布林巫师", "巨型蜘蛛", "森林巨魔"],
|
| 478 |
+
danger_level=7,
|
| 479 |
+
is_discovered=False,
|
| 480 |
+
is_accessible=False,
|
| 481 |
+
required_item="森林之钥",
|
| 482 |
+
ambient_description="黑暗几乎吞噬了一切,只有奇异的荧光苔藓发出微弱的光。远处传来低沉的咆哮。",
|
| 483 |
+
),
|
| 484 |
+
"溪边营地": LocationInfo(
|
| 485 |
+
name="溪边营地",
|
| 486 |
+
location_type="wilderness",
|
| 487 |
+
description="森林中一处难得的开阔地带,一条清澈的小溪从旁流过。适合扎营休息。",
|
| 488 |
+
connected_to=["黑暗森林入口"],
|
| 489 |
+
danger_level=2,
|
| 490 |
+
is_discovered=False,
|
| 491 |
+
rest_available=True,
|
| 492 |
+
ambient_description="溪水潺潺流淌,偶有鸟鸣声在林间回荡,这里是森林中的一片安宁绿洲。",
|
| 493 |
+
),
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
self.world.discovered_locations = ["村庄广场", "村庄铁匠铺", "村庄旅店", "村庄杂货铺", "村口小路"]
|
| 497 |
+
|
| 498 |
+
# --- 初始 NPC ---
|
| 499 |
+
self.world.npcs = {
|
| 500 |
+
"村长老伯": NPCState(
|
| 501 |
+
name="村长老伯",
|
| 502 |
+
npc_type="quest_giver",
|
| 503 |
+
location="村庄广场",
|
| 504 |
+
attitude="friendly",
|
| 505 |
+
description="一位白发苍苍但精神矍铄的老人,是这个村庄的领导者。他的眼中带着忧虑。",
|
| 506 |
+
race="人类",
|
| 507 |
+
occupation="村长",
|
| 508 |
+
relationship_level=20,
|
| 509 |
+
can_give_quest=True,
|
| 510 |
+
available_quests=["main_quest_01"],
|
| 511 |
+
memory=[],
|
| 512 |
+
schedule={"清晨": "村庄广场", "正午": "村庄广场", "夜晚": "村庄旅店"},
|
| 513 |
+
backstory="在这个村庄生活了七十年的老者,见证过上一次暗潮来袭,深知森林中潜伏的危险。",
|
| 514 |
+
),
|
| 515 |
+
"铁匠格林": NPCState(
|
| 516 |
+
name="铁匠格林",
|
| 517 |
+
npc_type="merchant",
|
| 518 |
+
location="村庄铁匠铺",
|
| 519 |
+
attitude="neutral",
|
| 520 |
+
description="一个肌肉发达的中年矮人,手臂上布满烧伤痕迹。沉默寡言但手艺精湛。",
|
| 521 |
+
race="矮人",
|
| 522 |
+
occupation="铁匠",
|
| 523 |
+
can_trade=True,
|
| 524 |
+
shop_inventory=["铁剑", "皮甲", "木盾"],
|
| 525 |
+
relationship_level=0,
|
| 526 |
+
schedule={"清晨": "村庄铁匠铺", "正午": "村庄铁匠铺", "夜晚": "村庄旅店"},
|
| 527 |
+
backstory="曾经是王都的御用铁匠,因一场变故隐居此地。对远方的怪物有独到的了解。",
|
| 528 |
+
),
|
| 529 |
+
"旅店老板娘莉娜": NPCState(
|
| 530 |
+
name="旅店老板娘莉娜",
|
| 531 |
+
npc_type="merchant",
|
| 532 |
+
location="村庄旅店",
|
| 533 |
+
attitude="friendly",
|
| 534 |
+
description="一位热情开朗的红发女子,笑容温暖。她的旅店是村里情报的集散地。",
|
| 535 |
+
race="人类",
|
| 536 |
+
occupation="旅店老板",
|
| 537 |
+
can_trade=True,
|
| 538 |
+
shop_inventory=["面包", "烤肉", "麦酒", "草药包"],
|
| 539 |
+
relationship_level=10,
|
| 540 |
+
schedule={"清晨": "村庄旅店", "正午": "村庄旅店", "夜晚": "村庄旅店"},
|
| 541 |
+
backstory="年轻时曾是一名冒险者,后来受伤退役经营旅店。对旅行者总是格外关照。",
|
| 542 |
+
),
|
| 543 |
+
"杂货商人阿尔": NPCState(
|
| 544 |
+
name="杂货商人阿尔",
|
| 545 |
+
npc_type="merchant",
|
| 546 |
+
location="村庄杂货铺",
|
| 547 |
+
attitude="neutral",
|
| 548 |
+
description="一个精明的瘦长男子,鹰钩鼻上架着一副圆框眼镜。善于讨价还价。",
|
| 549 |
+
race="人类",
|
| 550 |
+
occupation="商人",
|
| 551 |
+
can_trade=True,
|
| 552 |
+
shop_inventory=["火把", "绳索", "解毒药水", "小型治疗药水", "地图碎片"],
|
| 553 |
+
relationship_level=-5,
|
| 554 |
+
schedule={"清晨": "村庄杂货铺", "正午": "村庄广场", "夜晚": "村庄杂货铺"},
|
| 555 |
+
backstory="来自远方的行商,在村中定居多年。对各地的传闻消息灵通,但消息总是要收费的。",
|
| 556 |
+
),
|
| 557 |
+
"神秘旅人": NPCState(
|
| 558 |
+
name="神秘旅人",
|
| 559 |
+
npc_type="quest_giver",
|
| 560 |
+
location="村庄旅店",
|
| 561 |
+
attitude="cautious",
|
| 562 |
+
description="一个身披灰色斗篷的旅人,面容隐藏在兜帽之下,只露出锐利的双眼。",
|
| 563 |
+
race="未知",
|
| 564 |
+
occupation="旅人",
|
| 565 |
+
relationship_level=-10,
|
| 566 |
+
can_give_quest=True,
|
| 567 |
+
available_quests=["side_quest_01"],
|
| 568 |
+
memory=[],
|
| 569 |
+
schedule={"清晨": "村庄旅店", "夜晚": "村口小路"},
|
| 570 |
+
backstory="似乎在寻找什么。偶尔从斗篷下露出的手指上有奇异的魔法纹路。",
|
| 571 |
+
),
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
# --- 初始任务 ---
|
| 575 |
+
self.world.quests = {
|
| 576 |
+
"main_quest_01": QuestState(
|
| 577 |
+
quest_id="main_quest_01",
|
| 578 |
+
title="森林中的阴影",
|
| 579 |
+
description="村长老伯告诉你,最近森林中频繁出现怪物袭击事件。他请求你前往调查。",
|
| 580 |
+
quest_type="main",
|
| 581 |
+
status="active",
|
| 582 |
+
giver_npc="村长老伯",
|
| 583 |
+
objectives={
|
| 584 |
+
"与村长对话了解情况": False,
|
| 585 |
+
"前往黑暗森林入口调查": False,
|
| 586 |
+
"击败森林中的怪物": False,
|
| 587 |
+
"调查怪物活动的原因": False,
|
| 588 |
+
},
|
| 589 |
+
rewards=QuestRewards(
|
| 590 |
+
gold=100,
|
| 591 |
+
experience=50,
|
| 592 |
+
items=["森林之钥"],
|
| 593 |
+
reputation_changes={"村庄": 20},
|
| 594 |
+
karma_change=5,
|
| 595 |
+
),
|
| 596 |
+
),
|
| 597 |
+
"side_quest_01": QuestState(
|
| 598 |
+
quest_id="side_quest_01",
|
| 599 |
+
title="失落的传承",
|
| 600 |
+
description="神秘旅人似乎在寻找一件古老的遗物。也许帮助他能得到意想不到的回报。",
|
| 601 |
+
quest_type="side",
|
| 602 |
+
status="active",
|
| 603 |
+
giver_npc="神秘旅人",
|
| 604 |
+
objectives={
|
| 605 |
+
"与神秘旅人交谈": False,
|
| 606 |
+
"找到古老遗物的线索": False,
|
| 607 |
+
},
|
| 608 |
+
rewards=QuestRewards(
|
| 609 |
+
experience=30,
|
| 610 |
+
items=["神秘卷轴"],
|
| 611 |
+
unlock_skill="暗影感知",
|
| 612 |
+
),
|
| 613 |
+
prerequisites=[],
|
| 614 |
+
),
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
# --- 初始物品注册表 ---
|
| 618 |
+
self.world.item_registry = {
|
| 619 |
+
"铁剑": ItemInfo(name="铁剑", item_type="weapon", description="一把标准的铁制长剑,刀锋锐利。", rarity="common", stat_bonus={"attack": 5}, value=30),
|
| 620 |
+
"皮甲": ItemInfo(name="皮甲", item_type="armor", description="硬化皮革制成的轻甲,兼顾防护与灵活性。", rarity="common", stat_bonus={"defense": 3}, value=25),
|
| 621 |
+
"木盾": ItemInfo(name="木盾", item_type="armor", description="坚硬橡木制成的盾牌,可以抵挡基础攻击。", rarity="common", stat_bonus={"defense": 2}, value=15),
|
| 622 |
+
"面包": ItemInfo(name="面包", item_type="consumable", description="新鲜烤制的面包,香气扑鼻。", usable=True, use_effect="恢复 10 饱食度", value=5),
|
| 623 |
+
"烤肉": ItemInfo(name="烤肉", item_type="consumable", description="多汁的烤肉,令人食指大动。", usable=True, use_effect="恢复 25 饱食度", value=10),
|
| 624 |
+
"麦酒": ItemInfo(name="麦酒", item_type="consumable", description="村庄特产的麦酒,味道醇厚。", usable=True, use_effect="恢复 10 士气,降低 5 理智", value=8),
|
| 625 |
+
"草药包": ItemInfo(name="草药包", item_type="consumable", description="采集的新鲜草药,可以制作简单药剂。", usable=True, use_effect="恢复 20 HP", value=15),
|
| 626 |
+
"火把": ItemInfo(name="火把", item_type="misc", description="浸过油脂的火把,可在黑暗中照明。", usable=True, use_effect="照亮周围区域", value=3),
|
| 627 |
+
"绳索": ItemInfo(name="绳索", item_type="misc", description="结实的麻绳,在探险中很实用。", value=5),
|
| 628 |
+
"解毒药水": ItemInfo(name="解毒药水", item_type="consumable", description="散发着清苦气味的药水,可以解除中毒状态。", usable=True, use_effect="解除中毒状态", value=20),
|
| 629 |
+
"小型治疗药水": ItemInfo(name="小型治疗药水", item_type="consumable", description="泛着淡红色光芒的药水。", usable=True, use_effect="恢复 30 HP", value=25),
|
| 630 |
+
"地图碎片": ItemInfo(name="地图碎片", item_type="quest_item", description="一片残破的地图,标记着森林深处的某个位置。", quest_related=True, value=0, lore_text="这张地图似乎非常古老,纸张已经泛黄,但上面的墨迹依然清晰。"),
|
| 631 |
+
"森林之钥": ItemInfo(name="森林之钥", item_type="key", description="一把散发着微弱绿光的古老钥匙,似乎能打开森林深处的某个入口。", rarity="rare", quest_related=True, value=0, lore_text="钥匙上刻着精灵文字,翻译过来是:'唯有勇者可通行'。"),
|
| 632 |
+
"神秘卷轴": ItemInfo(name="神秘卷轴", item_type="quest_item", description="记载着古老知识的卷轴,散发着微弱的魔力波动。", rarity="rare", quest_related=True, value=0),
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
# --- 初始传闻 ---
|
| 636 |
+
self.world.rumors = [
|
| 637 |
+
"最近森林里的哥布林越来越嚣张了,好几个猎人都不敢进去了。",
|
| 638 |
+
"听说铁匠格林以前在王都待过,不知道为什么来了这个小村子。",
|
| 639 |
+
"旅店里来了个奇怪的旅人,整天把自己裹得严严实实的。",
|
| 640 |
+
]
|
| 641 |
+
|
| 642 |
+
# --- 玩家初始装备 ---
|
| 643 |
+
self.player.inventory = ["面包", "面包", "小型治疗药水"]
|
| 644 |
+
|
| 645 |
+
# ============================================================
|
| 646 |
+
# 核心方法
|
| 647 |
+
# ============================================================
|
| 648 |
+
|
| 649 |
+
def apply_changes(self, changes: dict) -> list[str]:
|
| 650 |
+
"""
|
| 651 |
+
接收 Qwen 返回的状态变更 JSON,校验并应用到当前状态。
|
| 652 |
+
|
| 653 |
+
设计思路:
|
| 654 |
+
- LLM 返回的变更是增量式的(如 hp_change: -10),而非绝对值
|
| 655 |
+
- 逐字段解析和应用,确保每个变更都经过校验
|
| 656 |
+
- 返回变更日志列表,方便 UI 展示
|
| 657 |
+
|
| 658 |
+
Args:
|
| 659 |
+
changes: Qwen 输出中解析出的状态变更字典
|
| 660 |
+
|
| 661 |
+
Returns:
|
| 662 |
+
变更描述列表 ["HP: 100 → 90", "位置: 村庄 → 森林"]
|
| 663 |
+
"""
|
| 664 |
+
change_log: list[str] = []
|
| 665 |
+
|
| 666 |
+
# --- 过滤 None 值:LLM 可能将 null 字段返回为 None,全部跳过 ---
|
| 667 |
+
_filtered = {}
|
| 668 |
+
for k, v in changes.items():
|
| 669 |
+
if v is None:
|
| 670 |
+
continue
|
| 671 |
+
# 字符串 "None" / "null" 也视为空
|
| 672 |
+
if isinstance(v, str) and v.strip().lower() in ("none", "null", ""):
|
| 673 |
+
continue
|
| 674 |
+
# 数值 0 的 change 字段无意义,也跳过
|
| 675 |
+
if isinstance(v, (int, float)) and v == 0 and k.endswith("_change"):
|
| 676 |
+
continue
|
| 677 |
+
# 空列表 / 空字典跳过
|
| 678 |
+
if isinstance(v, (list, dict)) and len(v) == 0:
|
| 679 |
+
continue
|
| 680 |
+
_filtered[k] = v
|
| 681 |
+
changes = _filtered
|
| 682 |
+
|
| 683 |
+
# --- 玩家属性变更 ---
|
| 684 |
+
if "hp_change" in changes:
|
| 685 |
+
old_hp = self.player.hp
|
| 686 |
+
self.player.hp = clamp(
|
| 687 |
+
self.player.hp + int(changes["hp_change"]),
|
| 688 |
+
0,
|
| 689 |
+
self.player.max_hp,
|
| 690 |
+
)
|
| 691 |
+
change_log.append(f"HP: {old_hp} → {self.player.hp}")
|
| 692 |
+
|
| 693 |
+
if "mp_change" in changes:
|
| 694 |
+
old_mp = self.player.mp
|
| 695 |
+
self.player.mp = clamp(
|
| 696 |
+
self.player.mp + int(changes["mp_change"]),
|
| 697 |
+
0,
|
| 698 |
+
self.player.max_mp,
|
| 699 |
+
)
|
| 700 |
+
change_log.append(f"MP: {old_mp} → {self.player.mp}")
|
| 701 |
+
|
| 702 |
+
if "gold_change" in changes:
|
| 703 |
+
old_gold = self.player.gold
|
| 704 |
+
self.player.gold = max(0, self.player.gold + int(changes["gold_change"]))
|
| 705 |
+
change_log.append(f"金币: {old_gold} → {self.player.gold}")
|
| 706 |
+
|
| 707 |
+
if "exp_change" in changes:
|
| 708 |
+
old_exp = self.player.experience
|
| 709 |
+
self.player.experience += int(changes["exp_change"])
|
| 710 |
+
change_log.append(f"经验: {old_exp} → {self.player.experience}")
|
| 711 |
+
# 检查是否升级
|
| 712 |
+
while self.player.experience >= self.player.exp_to_next_level:
|
| 713 |
+
self._level_up()
|
| 714 |
+
change_log.append(f"升级!当前等级: {self.player.level}")
|
| 715 |
+
|
| 716 |
+
if "morale_change" in changes:
|
| 717 |
+
old_morale = self.player.morale
|
| 718 |
+
self.player.morale = clamp(
|
| 719 |
+
self.player.morale + int(changes["morale_change"]),
|
| 720 |
+
0, 100,
|
| 721 |
+
)
|
| 722 |
+
change_log.append(f"士气: {old_morale} → {self.player.morale}")
|
| 723 |
+
|
| 724 |
+
if "sanity_change" in changes:
|
| 725 |
+
old_sanity = self.player.sanity
|
| 726 |
+
self.player.sanity = clamp(
|
| 727 |
+
self.player.sanity + int(changes["sanity_change"]),
|
| 728 |
+
0, 100,
|
| 729 |
+
)
|
| 730 |
+
change_log.append(f"理智: {old_sanity} → {self.player.sanity}")
|
| 731 |
+
|
| 732 |
+
if "hunger_change" in changes:
|
| 733 |
+
old_hunger = self.player.hunger
|
| 734 |
+
self.player.hunger = clamp(
|
| 735 |
+
self.player.hunger + int(changes["hunger_change"]),
|
| 736 |
+
0, 100,
|
| 737 |
+
)
|
| 738 |
+
change_log.append(f"饱食度: {old_hunger} → {self.player.hunger}")
|
| 739 |
+
|
| 740 |
+
if "karma_change" in changes:
|
| 741 |
+
old_karma = self.player.karma
|
| 742 |
+
self.player.karma += int(changes["karma_change"])
|
| 743 |
+
change_log.append(f"善恶值: {old_karma} → {self.player.karma}")
|
| 744 |
+
|
| 745 |
+
# --- 位置变更 ---
|
| 746 |
+
if "new_location" in changes:
|
| 747 |
+
old_loc = self.player.location
|
| 748 |
+
new_loc = str(changes["new_location"])
|
| 749 |
+
self.player.location = new_loc
|
| 750 |
+
self.world.current_scene = new_loc
|
| 751 |
+
change_log.append(f"位置: {old_loc} → {new_loc}")
|
| 752 |
+
# 发现新地点
|
| 753 |
+
if new_loc not in self.world.discovered_locations:
|
| 754 |
+
self.world.discovered_locations.append(new_loc)
|
| 755 |
+
change_log.append(f"发现新地点: {new_loc}")
|
| 756 |
+
if new_loc in self.world.locations:
|
| 757 |
+
self.world.locations[new_loc].is_discovered = True
|
| 758 |
+
|
| 759 |
+
# --- 物品变更 ---
|
| 760 |
+
if "items_gained" in changes:
|
| 761 |
+
for item in changes["items_gained"]:
|
| 762 |
+
self.player.inventory.append(str(item))
|
| 763 |
+
change_log.append(f"获得物品: {item}")
|
| 764 |
+
|
| 765 |
+
if "items_lost" in changes:
|
| 766 |
+
for item in changes["items_lost"]:
|
| 767 |
+
item_str = str(item)
|
| 768 |
+
if item_str in self.player.inventory:
|
| 769 |
+
self.player.inventory.remove(item_str)
|
| 770 |
+
change_log.append(f"失去物品: {item}")
|
| 771 |
+
|
| 772 |
+
# --- 技能变更 ---
|
| 773 |
+
if "skills_gained" in changes:
|
| 774 |
+
for skill in changes["skills_gained"]:
|
| 775 |
+
skill_str = str(skill)
|
| 776 |
+
if skill_str not in self.player.skills:
|
| 777 |
+
self.player.skills.append(skill_str)
|
| 778 |
+
change_log.append(f"习得技能: {skill}")
|
| 779 |
+
|
| 780 |
+
# --- 状态效果 ---
|
| 781 |
+
if "status_effects_added" in changes:
|
| 782 |
+
for effect_data in changes["status_effects_added"]:
|
| 783 |
+
if isinstance(effect_data, dict):
|
| 784 |
+
effect = StatusEffect(**effect_data)
|
| 785 |
+
self.player.status_effects.append(effect)
|
| 786 |
+
# 构建详细的状态效果日志
|
| 787 |
+
parts = [f"获得状态: {effect.name}"]
|
| 788 |
+
if effect.description:
|
| 789 |
+
parts.append(f"({effect.description})")
|
| 790 |
+
if effect.stat_modifiers:
|
| 791 |
+
mod_strs = []
|
| 792 |
+
_STAT_CN = {
|
| 793 |
+
"hp": "生命", "mp": "魔力",
|
| 794 |
+
"attack": "攻击力", "defense": "防御力",
|
| 795 |
+
"speed": "速度", "luck": "幸运",
|
| 796 |
+
"perception": "感知", "sanity": "理智",
|
| 797 |
+
"hunger": "饱食度", "morale": "士气",
|
| 798 |
+
"gold": "金币", "karma": "善恶值",
|
| 799 |
+
"experience": "经验",
|
| 800 |
+
}
|
| 801 |
+
for stat, val in effect.stat_modifiers.items():
|
| 802 |
+
cn = _STAT_CN.get(stat, stat)
|
| 803 |
+
sign = "+" if val > 0 else ""
|
| 804 |
+
mod_strs.append(f"{cn}{sign}{val}/回合")
|
| 805 |
+
parts.append(f"[{', '.join(mod_strs)}]")
|
| 806 |
+
if effect.duration > 0:
|
| 807 |
+
parts.append(f"持续{effect.duration}回合")
|
| 808 |
+
elif effect.duration == -1:
|
| 809 |
+
parts.append("永久")
|
| 810 |
+
change_log.append(" ".join(parts))
|
| 811 |
+
elif isinstance(effect_data, str):
|
| 812 |
+
effect = StatusEffect(name=effect_data)
|
| 813 |
+
self.player.status_effects.append(effect)
|
| 814 |
+
change_log.append(f"获得状态: {effect_data}")
|
| 815 |
+
|
| 816 |
+
if "status_effects_removed" in changes:
|
| 817 |
+
for name in changes["status_effects_removed"]:
|
| 818 |
+
self.player.status_effects = [
|
| 819 |
+
e for e in self.player.status_effects if e.name != str(name)
|
| 820 |
+
]
|
| 821 |
+
change_log.append(f"移除状态: {name}")
|
| 822 |
+
|
| 823 |
+
# --- NPC 相关变更 ---
|
| 824 |
+
if "npc_changes" in changes:
|
| 825 |
+
for npc_name, npc_data in changes["npc_changes"].items():
|
| 826 |
+
if npc_name in self.world.npcs:
|
| 827 |
+
npc = self.world.npcs[npc_name]
|
| 828 |
+
if "attitude" in npc_data:
|
| 829 |
+
npc.attitude = str(npc_data["attitude"])
|
| 830 |
+
change_log.append(f"NPC {npc_name} 态度变为: {npc.attitude}")
|
| 831 |
+
if "is_alive" in npc_data:
|
| 832 |
+
npc.is_alive = bool(npc_data["is_alive"])
|
| 833 |
+
if not npc.is_alive:
|
| 834 |
+
change_log.append(f"NPC {npc_name} 已死亡")
|
| 835 |
+
if "relationship_change" in npc_data:
|
| 836 |
+
old_rel = npc.relationship_level
|
| 837 |
+
npc.relationship_level = clamp(
|
| 838 |
+
npc.relationship_level + int(npc_data["relationship_change"]),
|
| 839 |
+
-100, 100,
|
| 840 |
+
)
|
| 841 |
+
change_log.append(
|
| 842 |
+
f"NPC {npc_name} 好感度: {old_rel} → {npc.relationship_level}"
|
| 843 |
+
)
|
| 844 |
+
if "hp_change" in npc_data:
|
| 845 |
+
npc.hp = max(0, npc.hp + int(npc_data["hp_change"]))
|
| 846 |
+
if npc.hp <= 0:
|
| 847 |
+
npc.is_alive = False
|
| 848 |
+
change_log.append(f"NPC {npc_name} 被击败")
|
| 849 |
+
if "memory_add" in npc_data:
|
| 850 |
+
npc.memory.append(str(npc_data["memory_add"]))
|
| 851 |
+
|
| 852 |
+
# --- 任务变更 ---
|
| 853 |
+
if "quest_updates" in changes:
|
| 854 |
+
for quest_id, quest_data in changes["quest_updates"].items():
|
| 855 |
+
if quest_id in self.world.quests:
|
| 856 |
+
quest = self.world.quests[quest_id]
|
| 857 |
+
if "objectives_completed" in quest_data:
|
| 858 |
+
for obj in quest_data["objectives_completed"]:
|
| 859 |
+
if str(obj) in quest.objectives:
|
| 860 |
+
quest.objectives[str(obj)] = True
|
| 861 |
+
change_log.append(f"完成目标: {obj}")
|
| 862 |
+
if "status" in quest_data:
|
| 863 |
+
quest.status = str(quest_data["status"])
|
| 864 |
+
_QUEST_STATUS_CN = {
|
| 865 |
+
"active": "进行中", "in_progress": "进行中",
|
| 866 |
+
"IN_PROGRESS": "进行中", "ACTIVE": "进行中",
|
| 867 |
+
"completed": "已完成", "COMPLETED": "已完成",
|
| 868 |
+
"failed": "已失败", "FAILED": "已失败",
|
| 869 |
+
"expired": "已过期", "EXPIRED": "已过期",
|
| 870 |
+
}
|
| 871 |
+
status_cn = _QUEST_STATUS_CN.get(quest.status, quest.status)
|
| 872 |
+
change_log.append(f"任务【{quest.title}】进展至\"{status_cn}\"")
|
| 873 |
+
|
| 874 |
+
# --- 世界状态变更 ---
|
| 875 |
+
if "weather_change" in changes:
|
| 876 |
+
self.world.weather = str(changes["weather_change"])
|
| 877 |
+
change_log.append(f"天气变为: {self.world.weather}")
|
| 878 |
+
|
| 879 |
+
if "time_change" in changes:
|
| 880 |
+
valid_times = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
|
| 881 |
+
new_time = str(changes["time_change"])
|
| 882 |
+
if new_time in valid_times:
|
| 883 |
+
old_time = self.world.time_of_day
|
| 884 |
+
self.world.time_of_day = new_time
|
| 885 |
+
change_log.append(f"时间流逝: {old_time} → {self.world.time_of_day}")
|
| 886 |
+
else:
|
| 887 |
+
logger.warning(f"无效的 time_change 值 '{new_time}',已忽略。合法值: {valid_times}")
|
| 888 |
+
|
| 889 |
+
if "global_flags_set" in changes:
|
| 890 |
+
for flag, value in changes["global_flags_set"].items():
|
| 891 |
+
self.world.global_flags[flag] = bool(value)
|
| 892 |
+
# 全局标记仅内部使用,不展示给用户
|
| 893 |
+
logger.info(f"全局标记设置: {flag} = {value}")
|
| 894 |
+
|
| 895 |
+
if "world_event" in changes:
|
| 896 |
+
self.world.world_events.append(str(changes["world_event"]))
|
| 897 |
+
change_log.append(f"世界事件: {changes['world_event']}")
|
| 898 |
+
|
| 899 |
+
# --- 装备变更 ---
|
| 900 |
+
if "equip" in changes:
|
| 901 |
+
for slot, item_name in changes["equip"].items():
|
| 902 |
+
if slot in self.player.equipment:
|
| 903 |
+
old_item = self.player.equipment[slot]
|
| 904 |
+
self.player.equipment[slot] = item_name
|
| 905 |
+
change_log.append(f"装备 [{slot}]: {old_item} → {item_name}")
|
| 906 |
+
|
| 907 |
+
# --- 玩家称号变更 ---
|
| 908 |
+
if "title_change" in changes:
|
| 909 |
+
old_title = self.player.title
|
| 910 |
+
self.player.title = str(changes["title_change"])
|
| 911 |
+
change_log.append(f"称号: {old_title} → {self.player.title}")
|
| 912 |
+
|
| 913 |
+
if change_log:
|
| 914 |
+
logger.info(f"状态变更: {change_log}")
|
| 915 |
+
|
| 916 |
+
return change_log
|
| 917 |
+
|
| 918 |
+
def validate(self) -> tuple[bool, list[str]]:
|
| 919 |
+
"""
|
| 920 |
+
校验当前状态的合法性。
|
| 921 |
+
|
| 922 |
+
设计思路:
|
| 923 |
+
- 检查所有数值是否在合法范围内
|
| 924 |
+
- HP <= 0 时标记游戏结束
|
| 925 |
+
- 理智过低时施加特殊效果
|
| 926 |
+
- 返回 (是否合法, 问题列表)
|
| 927 |
+
|
| 928 |
+
Returns:
|
| 929 |
+
(is_valid, issues): 合法性标志和问题描述列表
|
| 930 |
+
"""
|
| 931 |
+
issues: list[str] = []
|
| 932 |
+
|
| 933 |
+
# HP 校验 —— 核心逻辑:HP <= 0 触发死亡
|
| 934 |
+
if self.player.hp <= 0:
|
| 935 |
+
self.player.hp = 0
|
| 936 |
+
self.game_mode = "game_over"
|
| 937 |
+
self.player.death_count += 1
|
| 938 |
+
issues.append("玩家生命值归零,触发死亡结局!")
|
| 939 |
+
|
| 940 |
+
# MP 范围校验
|
| 941 |
+
self.player.mp = clamp(self.player.mp, 0, self.player.max_mp)
|
| 942 |
+
|
| 943 |
+
# 饱食度惩罚
|
| 944 |
+
if self.player.hunger <= 0:
|
| 945 |
+
self.player.hunger = 0
|
| 946 |
+
issues.append("玩家极度饥饿,攻击力和防御力下降!")
|
| 947 |
+
|
| 948 |
+
# 理智值校验
|
| 949 |
+
if self.player.sanity <= 0:
|
| 950 |
+
self.player.sanity = 0
|
| 951 |
+
self.game_mode = "game_over"
|
| 952 |
+
issues.append("玩家理智归零,陷入疯狂!触发疯狂结局!")
|
| 953 |
+
|
| 954 |
+
# 士气校验
|
| 955 |
+
if self.player.morale <= 10:
|
| 956 |
+
issues.append("玩家士气极低,行动效率降低。")
|
| 957 |
+
|
| 958 |
+
# 金币不能为负
|
| 959 |
+
if self.player.gold < 0:
|
| 960 |
+
self.player.gold = 0
|
| 961 |
+
issues.append("金币不足。")
|
| 962 |
+
|
| 963 |
+
is_valid = self.game_mode != "game_over"
|
| 964 |
+
return is_valid, issues
|
| 965 |
+
|
| 966 |
+
def to_prompt(self) -> str:
|
| 967 |
+
"""
|
| 968 |
+
将当前完整状态序列化为自然语言描述,注入 System Prompt。
|
| 969 |
+
|
| 970 |
+
设计思路(需求文档核心要求):
|
| 971 |
+
- System Prompt 必须包含当前状态描述
|
| 972 |
+
- 描述要全面但简洁,避免 token 浪费
|
| 973 |
+
- 包括:场景、玩家状态、已发生的重要事件、NPC 信息
|
| 974 |
+
- 加入一致性约束指令,提醒 LLM 不要产生矛盾
|
| 975 |
+
"""
|
| 976 |
+
# 1. 场景与环境
|
| 977 |
+
scene_desc = (
|
| 978 |
+
f"【当前场景】{self.world.current_scene}\n"
|
| 979 |
+
f"【时间】第{self.world.day_count}天 {self.world.time_of_day}\n"
|
| 980 |
+
f"【天气】{self.world.weather}\n"
|
| 981 |
+
f"【季节】{self.world.season}"
|
| 982 |
+
)
|
| 983 |
+
|
| 984 |
+
# 2. 玩家状态
|
| 985 |
+
effects_str = "、".join(e.name for e in self.player.status_effects) if self.player.status_effects else "无"
|
| 986 |
+
equipped = {k: (v or "无") for k, v in self.player.equipment.items()}
|
| 987 |
+
equip_str = "、".join(f"{k}={v}" for k, v in equipped.items())
|
| 988 |
+
# 背包物品标注消耗品/可重复使用
|
| 989 |
+
if self.player.inventory:
|
| 990 |
+
inv_items = []
|
| 991 |
+
for item_name in self.player.inventory:
|
| 992 |
+
if self.is_item_consumable(item_name):
|
| 993 |
+
inv_items.append(f"{item_name}[消耗品]")
|
| 994 |
+
else:
|
| 995 |
+
inv_items.append(f"{item_name}[可重复使用]")
|
| 996 |
+
inventory_str = "、".join(inv_items)
|
| 997 |
+
else:
|
| 998 |
+
inventory_str = "空"
|
| 999 |
+
skills_str = "、".join(self.player.skills) if self.player.skills else "无"
|
| 1000 |
+
|
| 1001 |
+
player_desc = (
|
| 1002 |
+
f"【玩家】{self.player.name}({self.player.title})\n"
|
| 1003 |
+
f" 等级: {self.player.level} | 经验: {self.player.experience}/{self.player.exp_to_next_level}\n"
|
| 1004 |
+
f" HP: {self.player.hp}/{self.player.max_hp} | MP: {self.player.mp}/{self.player.max_mp}\n"
|
| 1005 |
+
f" 攻击: {self.player.attack} | 防御: {self.player.defense} | 速度: {self.player.speed} | 幸运: {self.player.luck} | 感知: {self.player.perception}\n"
|
| 1006 |
+
f" 金币: {self.player.gold} | 善恶值: {self.player.karma}\n"
|
| 1007 |
+
f" 士气: {self.player.morale} | 理智: {self.player.sanity} | 饱食度: {self.player.hunger}\n"
|
| 1008 |
+
f" 装备: {equip_str}\n"
|
| 1009 |
+
f" 背包: {inventory_str}\n"
|
| 1010 |
+
f" 技能: {skills_str}\n"
|
| 1011 |
+
f" 状态效果: {effects_str}\n"
|
| 1012 |
+
f" 所在位置: {self.player.location}"
|
| 1013 |
+
)
|
| 1014 |
+
|
| 1015 |
+
# 3. 当前场景中的 NPC
|
| 1016 |
+
current_npcs = [
|
| 1017 |
+
npc for npc in self.world.npcs.values()
|
| 1018 |
+
if npc.location == self.player.location and npc.is_alive
|
| 1019 |
+
]
|
| 1020 |
+
if current_npcs:
|
| 1021 |
+
npc_lines = []
|
| 1022 |
+
for npc in current_npcs:
|
| 1023 |
+
mem = ";".join(npc.memory[-3:]) if npc.memory else "无记忆"
|
| 1024 |
+
npc_lines.append(
|
| 1025 |
+
f" - {npc.name}({npc.occupation}, {npc.race}, 态度: {npc.attitude}, "
|
| 1026 |
+
f"好感度: {npc.relationship_level}, 记忆: {mem})"
|
| 1027 |
+
)
|
| 1028 |
+
npc_desc = "【场景中的NPC】\n" + "\n".join(npc_lines)
|
| 1029 |
+
else:
|
| 1030 |
+
npc_desc = "【场景中的NPC】无"
|
| 1031 |
+
|
| 1032 |
+
# 4. 当前活跃任务
|
| 1033 |
+
active_quests = [q for q in self.world.quests.values() if q.status == "active"]
|
| 1034 |
+
if active_quests:
|
| 1035 |
+
quest_lines = []
|
| 1036 |
+
for q in active_quests:
|
| 1037 |
+
objectives = ";".join(
|
| 1038 |
+
f"{'✅' if done else '❌'}{obj}" for obj, done in q.objectives.items()
|
| 1039 |
+
)
|
| 1040 |
+
time_info = f"(剩余 {q.turns_remaining} 回合)" if q.turns_remaining > 0 else ""
|
| 1041 |
+
quest_lines.append(f" - [{q.quest_type.upper()}] {q.title}: {objectives}{time_info}")
|
| 1042 |
+
quest_desc = "【活跃任务】\n" + "\n".join(quest_lines)
|
| 1043 |
+
else:
|
| 1044 |
+
quest_desc = "【活跃任务】无"
|
| 1045 |
+
|
| 1046 |
+
# 5. 已发现地点的连接关系
|
| 1047 |
+
loc_info = self.world.locations.get(self.player.location)
|
| 1048 |
+
if loc_info:
|
| 1049 |
+
accessible = [
|
| 1050 |
+
name for name in loc_info.connected_to
|
| 1051 |
+
if name in self.world.locations
|
| 1052 |
+
and self.world.locations[name].is_accessible
|
| 1053 |
+
]
|
| 1054 |
+
blocked = [
|
| 1055 |
+
f"{name}(需要: {self.world.locations[name].required_item})"
|
| 1056 |
+
for name in loc_info.connected_to
|
| 1057 |
+
if name in self.world.locations
|
| 1058 |
+
and not self.world.locations[name].is_accessible
|
| 1059 |
+
]
|
| 1060 |
+
move_desc = f"【可前往的地点】{'、'.join(accessible) if accessible else '无'}"
|
| 1061 |
+
if blocked:
|
| 1062 |
+
move_desc += f"\n【被阻挡的地点】{'、'.join(blocked)}"
|
| 1063 |
+
else:
|
| 1064 |
+
move_desc = "【可前往的地点】未知"
|
| 1065 |
+
|
| 1066 |
+
# 6. 近期事件(最近 5 条)
|
| 1067 |
+
if self.event_log:
|
| 1068 |
+
recent = self.event_log[-5:]
|
| 1069 |
+
event_lines = [f" - [回合{e.turn}] {e.description}" for e in recent]
|
| 1070 |
+
event_desc = "【近期事件】\n" + "\n".join(event_lines)
|
| 1071 |
+
else:
|
| 1072 |
+
event_desc = "【近期事件】无"
|
| 1073 |
+
|
| 1074 |
+
# 7. 传闻
|
| 1075 |
+
rumors_desc = ""
|
| 1076 |
+
if self.world.rumors:
|
| 1077 |
+
rumors_desc = "\n【流传的传闻】\n" + "\n".join(f" - {r}" for r in self.world.rumors[-3:])
|
| 1078 |
+
|
| 1079 |
+
# 8. 一致性约束指令
|
| 1080 |
+
consistency_rules = (
|
| 1081 |
+
"\n【一致性约束 —— 你必须严格遵守】\n"
|
| 1082 |
+
"1. 已死亡的NPC不可再出现或对话(除非有特殊复活剧情)。\n"
|
| 1083 |
+
"2. 玩家背包中没有的物品不可使用或赠送。\n"
|
| 1084 |
+
"3. 玩家不可到达未连接的地点,被阻挡的地点需要对应物品才能进入。\n"
|
| 1085 |
+
"4. 时间线不可回退,已发生的事件不可矛盾。\n"
|
| 1086 |
+
"5. NPC的态度和记忆应与历史事件一致。\n"
|
| 1087 |
+
"6. 战斗伤害应考虑攻击力和防御力的差值,��果要合理。\n"
|
| 1088 |
+
"7. 所有状态变更必须在 state_changes 字段中明确输出。\n"
|
| 1089 |
+
"8. 每次生成的文本描写必须使用全新的比喻和意象,严禁重复之前回合用过的修辞和句式。\n"
|
| 1090 |
+
"9. 【物品消耗规则】只有消耗品(药水、食物等一次性物品)在使用后才会消失,应放入 items_lost。"
|
| 1091 |
+
"非消耗品(哨子、武器、工具、乐器、钥匙等可重复使用的物品)使用后仍然保留在背包中,"
|
| 1092 |
+
"绝对不要将它们放入 items_lost。例如:吹响哨子后哨子仍在背包中;使用火把照明后火把仍在。\n"
|
| 1093 |
+
"10. 【选项物品约束】生成的选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。"
|
| 1094 |
+
"不要生成使用玩家不拥有的物品的选项。"
|
| 1095 |
+
)
|
| 1096 |
+
|
| 1097 |
+
# 组合完整 Prompt
|
| 1098 |
+
full_prompt = "\n\n".join([
|
| 1099 |
+
scene_desc,
|
| 1100 |
+
player_desc,
|
| 1101 |
+
npc_desc,
|
| 1102 |
+
quest_desc,
|
| 1103 |
+
move_desc,
|
| 1104 |
+
event_desc,
|
| 1105 |
+
rumors_desc,
|
| 1106 |
+
consistency_rules,
|
| 1107 |
+
])
|
| 1108 |
+
|
| 1109 |
+
return full_prompt
|
| 1110 |
+
|
| 1111 |
+
def log_event(
|
| 1112 |
+
self,
|
| 1113 |
+
event_type: str,
|
| 1114 |
+
description: str,
|
| 1115 |
+
player_action: str = "",
|
| 1116 |
+
involved_npcs: list[str] | None = None,
|
| 1117 |
+
state_changes: dict | None = None,
|
| 1118 |
+
consequence_tags: list[str] | None = None,
|
| 1119 |
+
is_reversible: bool = True,
|
| 1120 |
+
):
|
| 1121 |
+
"""
|
| 1122 |
+
记录一条事件到 event_log。
|
| 1123 |
+
|
| 1124 |
+
每次状态变更都应该调用此方法,确保完整的历史记录。
|
| 1125 |
+
事件日志是一致性维护的基石。
|
| 1126 |
+
"""
|
| 1127 |
+
event = GameEvent(
|
| 1128 |
+
turn=self.turn,
|
| 1129 |
+
day=self.world.day_count,
|
| 1130 |
+
time_of_day=self.world.time_of_day,
|
| 1131 |
+
event_type=event_type,
|
| 1132 |
+
description=description,
|
| 1133 |
+
location=self.player.location,
|
| 1134 |
+
involved_npcs=involved_npcs or [],
|
| 1135 |
+
state_changes=state_changes or {},
|
| 1136 |
+
player_action=player_action,
|
| 1137 |
+
consequence_tags=consequence_tags or [],
|
| 1138 |
+
is_reversible=is_reversible,
|
| 1139 |
+
)
|
| 1140 |
+
self.event_log.append(event)
|
| 1141 |
+
logger.info(f"事件记录: [{event_type}] {description}")
|
| 1142 |
+
|
| 1143 |
+
def check_consistency(self, proposed_changes: dict) -> list[str]:
|
| 1144 |
+
"""
|
| 1145 |
+
对比事件日志和当前状态,检测拟议变更中的矛盾。
|
| 1146 |
+
|
| 1147 |
+
设计思路:
|
| 1148 |
+
- 在 apply_changes 之前调用,预防性检测
|
| 1149 |
+
- 返回所有发现的矛盾描述列表
|
| 1150 |
+
- 空列表 = 无矛盾,可以安全应用
|
| 1151 |
+
|
| 1152 |
+
检测维度:
|
| 1153 |
+
1. 已死亡 NPC 是否被重新引用
|
| 1154 |
+
2. 不存在的物品是否被消耗
|
| 1155 |
+
3. 不可达的地点是否被移动到
|
| 1156 |
+
4. 任务目标是否已经跳跃完成
|
| 1157 |
+
"""
|
| 1158 |
+
contradictions: list[str] = []
|
| 1159 |
+
|
| 1160 |
+
# 检测1: 已死亡NPC是否被引用
|
| 1161 |
+
if "npc_changes" in proposed_changes:
|
| 1162 |
+
for npc_name in proposed_changes["npc_changes"]:
|
| 1163 |
+
if npc_name in self.world.npcs and not self.world.npcs[npc_name].is_alive:
|
| 1164 |
+
contradictions.append(
|
| 1165 |
+
f"矛盾: 试图与已死亡的NPC '{npc_name}' 交互"
|
| 1166 |
+
)
|
| 1167 |
+
|
| 1168 |
+
# 检测2: 不存在的物品是否被消耗
|
| 1169 |
+
if "items_lost" in proposed_changes:
|
| 1170 |
+
for item in proposed_changes["items_lost"]:
|
| 1171 |
+
if str(item) not in self.player.inventory:
|
| 1172 |
+
contradictions.append(
|
| 1173 |
+
f"矛盾: 试图消耗不在背包中的物品 '{item}'"
|
| 1174 |
+
)
|
| 1175 |
+
elif not self.is_item_consumable(str(item)):
|
| 1176 |
+
# 非消耗品不应因使用而消失(交易/丢弃除外,由引擎层判断)
|
| 1177 |
+
contradictions.append(
|
| 1178 |
+
f"矛盾: 物品 '{item}' 不是消耗品,使用后不应消失。请将其从 items_lost 中移除。"
|
| 1179 |
+
)
|
| 1180 |
+
|
| 1181 |
+
# 检测3: 位置移动是否合法
|
| 1182 |
+
if "new_location" in proposed_changes:
|
| 1183 |
+
target = str(proposed_changes["new_location"])
|
| 1184 |
+
current_loc = self.world.locations.get(self.player.location)
|
| 1185 |
+
if current_loc and target not in current_loc.connected_to:
|
| 1186 |
+
contradictions.append(
|
| 1187 |
+
f"矛盾: 试图移动到不相邻的地点 '{target}'(当前位置: {self.player.location})"
|
| 1188 |
+
)
|
| 1189 |
+
target_loc = self.world.locations.get(target)
|
| 1190 |
+
if target_loc and not target_loc.is_accessible:
|
| 1191 |
+
if target_loc.required_item and target_loc.required_item not in self.player.inventory:
|
| 1192 |
+
contradictions.append(
|
| 1193 |
+
f"矛盾: 地点 '{target}' 被锁定,需要 '{target_loc.required_item}'"
|
| 1194 |
+
)
|
| 1195 |
+
|
| 1196 |
+
# 检测4: 金币��否足够(如果是消费操作)
|
| 1197 |
+
if "gold_change" in proposed_changes:
|
| 1198 |
+
change = int(proposed_changes["gold_change"])
|
| 1199 |
+
if change < 0 and self.player.gold + change < 0:
|
| 1200 |
+
contradictions.append(
|
| 1201 |
+
f"矛盾: 金币不足(当前: {self.player.gold},需要: {abs(change)})"
|
| 1202 |
+
)
|
| 1203 |
+
|
| 1204 |
+
if contradictions:
|
| 1205 |
+
logger.warning(f"一致性检查发现矛盾: {contradictions}")
|
| 1206 |
+
|
| 1207 |
+
return contradictions
|
| 1208 |
+
|
| 1209 |
+
def is_game_over(self) -> bool:
|
| 1210 |
+
"""
|
| 1211 |
+
判断游戏是否结束。
|
| 1212 |
+
|
| 1213 |
+
结束条件:
|
| 1214 |
+
1. HP <= 0(死亡)
|
| 1215 |
+
2. 理智 <= 0(疯狂)
|
| 1216 |
+
3. 触发终局标记
|
| 1217 |
+
"""
|
| 1218 |
+
if self.player.hp <= 0:
|
| 1219 |
+
return True
|
| 1220 |
+
if self.player.sanity <= 0:
|
| 1221 |
+
return True
|
| 1222 |
+
if self.game_mode == "game_over":
|
| 1223 |
+
return True
|
| 1224 |
+
# 检查终局标记
|
| 1225 |
+
if self.ending_flags.get("game_complete", False):
|
| 1226 |
+
return True
|
| 1227 |
+
return False
|
| 1228 |
+
|
| 1229 |
+
def tick_time(self) -> list[str]:
|
| 1230 |
+
"""
|
| 1231 |
+
推进游戏时间(每回合调用)。
|
| 1232 |
+
|
| 1233 |
+
设计思路:
|
| 1234 |
+
- 回合数递增
|
| 1235 |
+
- 时间段按固定顺序轮转
|
| 1236 |
+
- 每过一个完整日夜循环,天数+1
|
| 1237 |
+
- 自动减少饱食度,模拟饥饿机制
|
| 1238 |
+
- 结算状态效果持续时间
|
| 1239 |
+
- 检查限时任务
|
| 1240 |
+
|
| 1241 |
+
Returns:
|
| 1242 |
+
tick_log: 本回合时间流逝引起的状态变化描述列表
|
| 1243 |
+
"""
|
| 1244 |
+
tick_log: list[str] = []
|
| 1245 |
+
self.turn += 1
|
| 1246 |
+
|
| 1247 |
+
# 时间推进
|
| 1248 |
+
time_sequence = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
|
| 1249 |
+
current_idx = time_sequence.index(self.world.time_of_day) if self.world.time_of_day in time_sequence else 0
|
| 1250 |
+
next_idx = (current_idx + 1) % len(time_sequence)
|
| 1251 |
+
old_time = self.world.time_of_day
|
| 1252 |
+
self.world.time_of_day = time_sequence[next_idx]
|
| 1253 |
+
tick_log.append(f"时间流逝: {old_time} → {self.world.time_of_day}")
|
| 1254 |
+
|
| 1255 |
+
# 新的一天
|
| 1256 |
+
if next_idx == 0:
|
| 1257 |
+
self.world.day_count += 1
|
| 1258 |
+
tick_log.append(f"新的一天!第 {self.world.day_count} 天")
|
| 1259 |
+
logger.info(f"新的一天开始了!第 {self.world.day_count} 天")
|
| 1260 |
+
|
| 1261 |
+
# 饱食度衰减(每回合 -3)
|
| 1262 |
+
old_hunger = self.player.hunger
|
| 1263 |
+
self.player.hunger = max(0, self.player.hunger - 3)
|
| 1264 |
+
if old_hunger != self.player.hunger:
|
| 1265 |
+
tick_log.append(f"饱食度自然衰减: {old_hunger} → {self.player.hunger}")
|
| 1266 |
+
if self.player.hunger <= 0:
|
| 1267 |
+
tick_log.append("极度饥饿!属性受到惩罚")
|
| 1268 |
+
logger.info("玩家非常饥饿,属性受到惩罚")
|
| 1269 |
+
|
| 1270 |
+
# 结算状态效果
|
| 1271 |
+
effect_log = self._apply_status_effects()
|
| 1272 |
+
tick_log.extend(effect_log)
|
| 1273 |
+
|
| 1274 |
+
# 检查限时任务
|
| 1275 |
+
self._check_quest_deadlines()
|
| 1276 |
+
|
| 1277 |
+
# 更新 NPC 位置(根据时间表)
|
| 1278 |
+
self._update_npc_schedules()
|
| 1279 |
+
|
| 1280 |
+
return tick_log
|
| 1281 |
+
|
| 1282 |
+
def _apply_status_effects(self) -> list[str]:
|
| 1283 |
+
"""每回合结算状态效果:应用修正、递减持续时间、移除过期效果
|
| 1284 |
+
|
| 1285 |
+
Returns:
|
| 1286 |
+
effect_log: 状态效果结算引起的变化描述列表
|
| 1287 |
+
"""
|
| 1288 |
+
effect_log: list[str] = []
|
| 1289 |
+
expired = []
|
| 1290 |
+
for effect in self.player.status_effects:
|
| 1291 |
+
# 应用属性修正(每回合)
|
| 1292 |
+
if "hp" in effect.stat_modifiers:
|
| 1293 |
+
old_hp = self.player.hp
|
| 1294 |
+
self.player.hp = clamp(
|
| 1295 |
+
self.player.hp + effect.stat_modifiers["hp"],
|
| 1296 |
+
0, self.player.max_hp,
|
| 1297 |
+
)
|
| 1298 |
+
if old_hp != self.player.hp:
|
| 1299 |
+
effect_log.append(f"{effect.name}: HP {old_hp} → {self.player.hp}")
|
| 1300 |
+
if "mp" in effect.stat_modifiers:
|
| 1301 |
+
old_mp = self.player.mp
|
| 1302 |
+
self.player.mp = clamp(
|
| 1303 |
+
self.player.mp + effect.stat_modifiers["mp"],
|
| 1304 |
+
0, self.player.max_mp,
|
| 1305 |
+
)
|
| 1306 |
+
if old_mp != self.player.mp:
|
| 1307 |
+
effect_log.append(f"{effect.name}: MP {old_mp} → {self.player.mp}")
|
| 1308 |
+
if "sanity" in effect.stat_modifiers:
|
| 1309 |
+
old_sanity = self.player.sanity
|
| 1310 |
+
self.player.sanity = clamp(
|
| 1311 |
+
self.player.sanity + effect.stat_modifiers["sanity"],
|
| 1312 |
+
0, 100,
|
| 1313 |
+
)
|
| 1314 |
+
if old_sanity != self.player.sanity:
|
| 1315 |
+
effect_log.append(f"{effect.name}: 理智 {old_sanity} → {self.player.sanity}")
|
| 1316 |
+
# 感知、攻击、防御、速度、幸运、士气、饱食度等直接加减属性
|
| 1317 |
+
for stat_key, stat_cn in [("perception", "感知"), ("attack", "攻击力"),
|
| 1318 |
+
("defense", "防御力"), ("speed", "速度"),
|
| 1319 |
+
("luck", "幸运"), ("morale", "士气"),
|
| 1320 |
+
("hunger", "饱食度")]:
|
| 1321 |
+
if stat_key in effect.stat_modifiers:
|
| 1322 |
+
old_val = getattr(self.player, stat_key)
|
| 1323 |
+
max_val = 100 if stat_key in ("morale", "hunger") else None
|
| 1324 |
+
new_val = old_val + effect.stat_modifiers[stat_key]
|
| 1325 |
+
if max_val is not None:
|
| 1326 |
+
new_val = clamp(new_val, 0, max_val)
|
| 1327 |
+
setattr(self.player, stat_key, new_val)
|
| 1328 |
+
if old_val != new_val:
|
| 1329 |
+
effect_log.append(f"{effect.name}: {stat_cn} {old_val} → {new_val}")
|
| 1330 |
+
|
| 1331 |
+
# 递减持续时间
|
| 1332 |
+
if effect.duration > 0:
|
| 1333 |
+
effect.duration -= 1
|
| 1334 |
+
if effect.duration <= 0:
|
| 1335 |
+
expired.append(effect)
|
| 1336 |
+
# duration == -1 表示永久效果,不递减
|
| 1337 |
+
|
| 1338 |
+
# 移除过期效果
|
| 1339 |
+
for effect in expired:
|
| 1340 |
+
self.player.status_effects.remove(effect)
|
| 1341 |
+
effect_log.append(f"状态效果 '{effect.name}' 已过期并移除")
|
| 1342 |
+
logger.info(f"状态效果 '{effect.name}' 已过期")
|
| 1343 |
+
|
| 1344 |
+
return effect_log
|
| 1345 |
+
|
| 1346 |
+
def _check_quest_deadlines(self):
|
| 1347 |
+
"""检查限时任务是否过期"""
|
| 1348 |
+
for quest in self.world.quests.values():
|
| 1349 |
+
if quest.status == "active" and quest.turns_remaining > 0:
|
| 1350 |
+
quest.turns_remaining -= 1
|
| 1351 |
+
if quest.turns_remaining <= 0:
|
| 1352 |
+
quest.status = "failed"
|
| 1353 |
+
logger.info(f"任务 '{quest.title}' 已超时失败!")
|
| 1354 |
+
|
| 1355 |
+
def _update_npc_schedules(self):
|
| 1356 |
+
"""根据当前时间段更新 NPC 位置"""
|
| 1357 |
+
for npc in self.world.npcs.values():
|
| 1358 |
+
if not npc.is_alive:
|
| 1359 |
+
continue
|
| 1360 |
+
if self.world.time_of_day in npc.schedule:
|
| 1361 |
+
old_loc = npc.location
|
| 1362 |
+
new_loc = npc.schedule[self.world.time_of_day]
|
| 1363 |
+
if old_loc != new_loc:
|
| 1364 |
+
npc.location = new_loc
|
| 1365 |
+
# 更新地点的 NPC 列表
|
| 1366 |
+
if old_loc in self.world.locations:
|
| 1367 |
+
loc = self.world.locations[old_loc]
|
| 1368 |
+
if npc.name in loc.npcs_present:
|
| 1369 |
+
loc.npcs_present.remove(npc.name)
|
| 1370 |
+
if new_loc in self.world.locations:
|
| 1371 |
+
loc = self.world.locations[new_loc]
|
| 1372 |
+
if npc.name not in loc.npcs_present:
|
| 1373 |
+
loc.npcs_present.append(npc.name)
|
| 1374 |
+
|
| 1375 |
+
def _level_up(self):
|
| 1376 |
+
"""
|
| 1377 |
+
角色升级逻辑。
|
| 1378 |
+
|
| 1379 |
+
每次升级:
|
| 1380 |
+
- 等级+1
|
| 1381 |
+
- 扣除当前升级所需经验
|
| 1382 |
+
- 下次升级所需经验提升 50%
|
| 1383 |
+
- 属性随机增长
|
| 1384 |
+
- HP/MP 完全恢复
|
| 1385 |
+
"""
|
| 1386 |
+
self.player.experience -= self.player.exp_to_next_level
|
| 1387 |
+
self.player.level += 1
|
| 1388 |
+
self.player.exp_to_next_level = int(self.player.exp_to_next_level * 1.5)
|
| 1389 |
+
|
| 1390 |
+
# 属性提升
|
| 1391 |
+
self.player.max_hp += 10
|
| 1392 |
+
self.player.max_mp += 5
|
| 1393 |
+
self.player.attack += 2
|
| 1394 |
+
self.player.defense += 1
|
| 1395 |
+
self.player.speed += 1
|
| 1396 |
+
self.player.perception += 1
|
| 1397 |
+
|
| 1398 |
+
# 升级后满血满蓝
|
| 1399 |
+
self.player.hp = self.player.max_hp
|
| 1400 |
+
self.player.mp = self.player.max_mp
|
| 1401 |
+
|
| 1402 |
+
logger.info(
|
| 1403 |
+
f"升级!等级: {self.player.level}, "
|
| 1404 |
+
f"HP: {self.player.max_hp}, MP: {self.player.max_mp}, "
|
| 1405 |
+
f"ATK: {self.player.attack}, DEF: {self.player.defense}"
|
| 1406 |
+
)
|
| 1407 |
+
|
| 1408 |
+
def get_death_narrative_context(self) -> str:
|
| 1409 |
+
"""生成死亡结局的上下文信息(供 story_engine 使用)"""
|
| 1410 |
+
cause = "生命值归零" if self.player.hp <= 0 else "理智崩溃"
|
| 1411 |
+
last_event = self.event_log[-1].description if self.event_log else "未知"
|
| 1412 |
+
return (
|
| 1413 |
+
f"玩家 {self.player.name} 因{cause}而倒下。\n"
|
| 1414 |
+
f"最后发生的事件: {last_event}\n"
|
| 1415 |
+
f"死亡次数: {self.player.death_count}\n"
|
| 1416 |
+
f"存活天数: {self.world.day_count}\n"
|
| 1417 |
+
f"最终善恶值: {self.player.karma}"
|
| 1418 |
+
)
|
| 1419 |
+
|
| 1420 |
+
def is_item_consumable(self, item_name: str) -> bool:
|
| 1421 |
+
"""
|
| 1422 |
+
判断物品是否为消耗品(使用后会消失)。
|
| 1423 |
+
|
| 1424 |
+
规则:
|
| 1425 |
+
- item_registry 中 item_type == "consumable" 的物品是消耗品
|
| 1426 |
+
- item_type == "material" 的物品也视为消耗品(合成材料,用完即消失)
|
| 1427 |
+
- 其他类型(weapon, armor, key, quest_item, misc 等)为可重复使用物品
|
| 1428 |
+
- 未注册物品默认为非消耗品(更安全,避免误删)
|
| 1429 |
+
"""
|
| 1430 |
+
if item_name in self.world.item_registry:
|
| 1431 |
+
item_info = self.world.item_registry[item_name]
|
| 1432 |
+
return item_info.item_type in ("consumable", "material")
|
| 1433 |
+
# 对未注册物品,用关键词启发式判断
|
| 1434 |
+
consumable_keywords = ["药水", "药剂", "食物", "面包", "烤肉", "麦酒",
|
| 1435 |
+
"草药", "卷轴", "炸弹", "手雷", "箭矢", "弹药",
|
| 1436 |
+
"丹药", "果实", "干粮", "肉干", "饮料", "汤",
|
| 1437 |
+
"符咒", "一次性"]
|
| 1438 |
+
for keyword in consumable_keywords:
|
| 1439 |
+
if keyword in item_name:
|
| 1440 |
+
return True
|
| 1441 |
+
return False # 默认为非消耗品
|
| 1442 |
+
|
| 1443 |
+
def get_available_actions(self) -> list[str]:
|
| 1444 |
+
"""根据当前场景和状态返回可用的行动类型"""
|
| 1445 |
+
actions = ["观察", "对话", "移动"]
|
| 1446 |
+
|
| 1447 |
+
# 当前场景信息
|
| 1448 |
+
loc = self.world.locations.get(self.player.location)
|
| 1449 |
+
if loc:
|
| 1450 |
+
if loc.rest_available:
|
| 1451 |
+
actions.append("休息")
|
| 1452 |
+
if loc.shop_available:
|
| 1453 |
+
actions.append("交易")
|
| 1454 |
+
if loc.enemies:
|
| 1455 |
+
actions.append("战斗")
|
| 1456 |
+
if loc.available_items:
|
| 1457 |
+
actions.append("搜索")
|
| 1458 |
+
|
| 1459 |
+
# 背包中有可用物品
|
| 1460 |
+
if self.player.inventory:
|
| 1461 |
+
actions.append("使用物品")
|
| 1462 |
+
|
| 1463 |
+
# 有技能可用
|
| 1464 |
+
if self.player.skills:
|
| 1465 |
+
actions.append("使用技能")
|
| 1466 |
+
|
| 1467 |
+
return actions
|
| 1468 |
+
|
| 1469 |
+
def get_scene_summary(self) -> str:
|
| 1470 |
+
"""获取当前场景的简短摘要(用于 UI 展示)"""
|
| 1471 |
+
loc = self.world.locations.get(self.player.location)
|
| 1472 |
+
desc = loc.description if loc else "未知区域"
|
| 1473 |
+
ambient = loc.ambient_description if loc else ""
|
| 1474 |
+
|
| 1475 |
+
npcs = [
|
| 1476 |
+
npc.name for npc in self.world.npcs.values()
|
| 1477 |
+
if npc.location == self.player.location and npc.is_alive
|
| 1478 |
+
]
|
| 1479 |
+
npc_str = f"可见NPC: {'、'.join(npcs)}" if npcs else ""
|
| 1480 |
+
|
| 1481 |
+
return f"{desc}\n{ambient}\n{npc_str}".strip()
|
story_engine.py
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
story_engine.py - StoryWeaver 叙事引擎
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 封装故事生成的核心逻辑(两阶段生成策略)
|
| 6 |
+
2. 生成连贯、有文学色彩的剧情段落
|
| 7 |
+
3. 生成 3 个后续选项供玩家选择
|
| 8 |
+
4. 输出结构化的状态变更数据
|
| 9 |
+
|
| 10 |
+
两阶段生成策略 (Chain of Thought):
|
| 11 |
+
- 第一阶段:让 Qwen 生成 JSON 格式的剧情大纲(事件、地点变化、NPC反应、状态变更)
|
| 12 |
+
- 第二阶段:基于大纲生成具体的描写文本 + 3 个选项
|
| 13 |
+
|
| 14 |
+
目的:
|
| 15 |
+
- 第一阶段便于程序解析状态变化,保证数据准确
|
| 16 |
+
- 第二阶段保证文本质量和文学色彩
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
import re
|
| 21 |
+
from typing import Optional
|
| 22 |
+
|
| 23 |
+
from utils import call_qwen, safe_json_call, extract_json_from_text, DEFAULT_MODEL
|
| 24 |
+
from state_manager import GameState
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger("StoryWeaver")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ============================================================
|
| 30 |
+
# 变更日志合并工具
|
| 31 |
+
# ============================================================
|
| 32 |
+
|
| 33 |
+
# 用于从 "饱食度自然衰减: 100 → 97" 或 "饱食度: 97 → 95" 中提取属性名和数值
|
| 34 |
+
_CHANGE_LOG_PATTERN = re.compile(
|
| 35 |
+
r"^(.+?)(?:自然衰减)?:\s*(\S+)\s*→\s*(\S+)$"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _merge_change_logs(tick_log: list[str], action_log: list[str]) -> list[str]:
|
| 40 |
+
"""
|
| 41 |
+
合并时间流逝日志与行动变更日志,避免同一属性出现两行。
|
| 42 |
+
|
| 43 |
+
例如:
|
| 44 |
+
tick_log: ["饱食度自然衰减: 100 → 97"]
|
| 45 |
+
action_log: ["饱食度: 97 → 95"]
|
| 46 |
+
合并为: ["饱食度: 100 → 95"]
|
| 47 |
+
|
| 48 |
+
原理:按属性名匹配,如果 tick_log 中某属性的终值 == action_log 中同属性的起始值,
|
| 49 |
+
则合并为一条:属性名: tick起始值 → action终值。
|
| 50 |
+
"""
|
| 51 |
+
# 从 tick_log 中提取 {属性名: (起始值, 终值, 原始索引)}
|
| 52 |
+
tick_attrs: dict[str, tuple[str, str, int]] = {}
|
| 53 |
+
for i, line in enumerate(tick_log):
|
| 54 |
+
m = _CHANGE_LOG_PATTERN.match(line.strip())
|
| 55 |
+
if m:
|
| 56 |
+
attr_name = m.group(1).strip()
|
| 57 |
+
tick_attrs[attr_name] = (m.group(2), m.group(3), i)
|
| 58 |
+
|
| 59 |
+
# 标记哪些 tick_log 条目被合并掉了
|
| 60 |
+
tick_merged: set[int] = set()
|
| 61 |
+
merged_results: list[str] = []
|
| 62 |
+
|
| 63 |
+
for line in action_log:
|
| 64 |
+
m = _CHANGE_LOG_PATTERN.match(line.strip())
|
| 65 |
+
if m:
|
| 66 |
+
attr_name = m.group(1).strip()
|
| 67 |
+
action_start = m.group(2)
|
| 68 |
+
action_end = m.group(3)
|
| 69 |
+
if attr_name in tick_attrs:
|
| 70 |
+
tick_start, tick_end, tick_idx = tick_attrs[attr_name]
|
| 71 |
+
if tick_end == action_start:
|
| 72 |
+
# 可以合并:使用 tick 的起始值 + action 的终值
|
| 73 |
+
if tick_start != action_end:
|
| 74 |
+
merged_results.append(f"{attr_name}: {tick_start} → {action_end}")
|
| 75 |
+
# 如果起始值==终值(变了又变回来),直接省略此条
|
| 76 |
+
tick_merged.add(tick_idx)
|
| 77 |
+
continue
|
| 78 |
+
merged_results.append(line)
|
| 79 |
+
|
| 80 |
+
# 输出:未被合并的 tick_log 条目 + 合并后的 action_log 条目
|
| 81 |
+
remaining_tick = [
|
| 82 |
+
line for i, line in enumerate(tick_log) if i not in tick_merged
|
| 83 |
+
]
|
| 84 |
+
return remaining_tick + merged_results
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ============================================================
|
| 88 |
+
# Prompt 模板设计
|
| 89 |
+
# ============================================================
|
| 90 |
+
|
| 91 |
+
# ------------------------------------------------------------
|
| 92 |
+
# 第一阶段 Prompt:生成剧情大纲(结构化 JSON)
|
| 93 |
+
#
|
| 94 |
+
# 设计思路:
|
| 95 |
+
# - System Prompt 注入当前完整状态(来自 state_manager.to_prompt())
|
| 96 |
+
# - 要求 LLM 严格输出 JSON,包含:事件描述、状态变更、NPC 反应
|
| 97 |
+
# - 低温度 (0.3) 确保 JSON 结构稳定
|
| 98 |
+
# - 明确指定每个字段的含义和格式
|
| 99 |
+
# ------------------------------------------------------------
|
| 100 |
+
OUTLINE_SYSTEM_PROMPT_TEMPLATE = """你是一个专业的 RPG 叙事引擎的规划模块。你的任务是根据玩家的行动和当前世界状态,生成剧情大纲。
|
| 101 |
+
|
| 102 |
+
{world_state}
|
| 103 |
+
|
| 104 |
+
【你的任务】
|
| 105 |
+
根据玩家的行动意图,生成一个 JSON 格式的剧情大纲。你必须:
|
| 106 |
+
1. 考虑当前所有状态(HP、位置、NPC态度、背包物品等)
|
| 107 |
+
2. 确保剧情发展合理,不违反一致性约束
|
| 108 |
+
3. 生成合理的状态变更数据
|
| 109 |
+
4. 考虑剧情的趣味性和戏剧张力
|
| 110 |
+
|
| 111 |
+
请严格按以下 JSON 格式输出(不要输出任何其他文字):
|
| 112 |
+
{{
|
| 113 |
+
"event_summary": "简短描述发生了什么事(一句话)",
|
| 114 |
+
"event_type": "事件类型(COMBAT/DIALOGUE/MOVE/ITEM/QUEST/TRADE/REST/DISCOVERY)",
|
| 115 |
+
"involved_npcs": ["涉及的NPC名称列表"],
|
| 116 |
+
"state_changes": {{
|
| 117 |
+
"hp_change": 0,
|
| 118 |
+
"mp_change": 0,
|
| 119 |
+
"gold_change": 0,
|
| 120 |
+
"exp_change": 0,
|
| 121 |
+
"morale_change": 0,
|
| 122 |
+
"sanity_change": 0,
|
| 123 |
+
"hunger_change": 0,
|
| 124 |
+
"karma_change": 0,
|
| 125 |
+
"new_location": null,
|
| 126 |
+
"items_gained": [],
|
| 127 |
+
"items_lost": [],
|
| 128 |
+
"skills_gained": [],
|
| 129 |
+
"status_effects_added": [],
|
| 130 |
+
"status_effects_removed": [],
|
| 131 |
+
"npc_changes": {{}},
|
| 132 |
+
"quest_updates": {{}},
|
| 133 |
+
"weather_change": null,
|
| 134 |
+
"time_change": null,
|
| 135 |
+
"global_flags_set": {{}},
|
| 136 |
+
"world_event": null,
|
| 137 |
+
"equip": {{}},
|
| 138 |
+
"title_change": null
|
| 139 |
+
}},
|
| 140 |
+
"npc_reactions": {{
|
| 141 |
+
"NPC名称": "NPC的反应描述"
|
| 142 |
+
}},
|
| 143 |
+
"scene_atmosphere": "场景氛围描述(天气、光线、声音等)",
|
| 144 |
+
"consequence_tags": ["后果标签列表,如 angered_dragon"],
|
| 145 |
+
"is_reversible": true,
|
| 146 |
+
"danger_hint": "如果有潜在危险,在这里提示"
|
| 147 |
+
}}
|
| 148 |
+
|
| 149 |
+
注意:
|
| 150 |
+
- state_changes 中只填写确实发生变化的字段,未变化的字段可以省略或设为 null/0/空
|
| 151 |
+
- npc_changes 格式: {{"NPC名称": {{"attitude": "新态度", "relationship_change": 数值, "memory_add": "新记忆", "hp_change": 数值}}}}
|
| 152 |
+
- quest_updates 格式: {{"任务ID": {{"objectives_completed": ["完成的目标"], "status": "新状态"}}}}
|
| 153 |
+
- 确保所有数值变更合理(例如战斗伤害应考虑攻防差值)
|
| 154 |
+
- 【关键】所有数值变更必须精确,禁止使用“恢复一些”“大幅降低”等模糊描述,必须给出精确数字(如 hunger_change: 14)。
|
| 155 |
+
- 【关键】time_change 字段只允许以下值之一:"清晨""上午""正午""下午""黄昏""夜晚""深夜",不要填写其他格式(如"30分钟""两小时后"等都是非法值)。如果本回合没有发生时间跳跃(例如休息、等待、长途旅行等),请设为 null(系统会自动推进一个时段)。只有当剧情需要跳跃多个时段时才设置此字段。
|
| 156 |
+
- 【关键】游戏内的货币单位是"金币",对应 gold_change 字段。不要使用"银币""铜币""银两"等其他货币名称,所有交易都以"金币"结算。
|
| 157 |
+
- 【关键】status_effects_added 中每个效果必须是一个对象,且必须包含以下字段:
|
| 158 |
+
- "name": 中文效果名称(如"中毒""祝福""饱腹"),必须是中文,不要使用英文字段名
|
| 159 |
+
- "description": 获得原因的简短说明(如"食用面包后精力充沛""被毒蛇咬伤")
|
| 160 |
+
- "effect_type": "buff" 或 "debuff" 或 "neutral"
|
| 161 |
+
- "stat_modifiers": 具体属性影响的字典(如 {{"hp": 5}} 表示每回合恢复5HP)
|
| 162 |
+
- "duration": 持续回合数(正整数,-1表示永久)
|
| 163 |
+
示例: {{"name": "饱腹", "description": "吃了面包后恢复了体力", "effect_type": "buff", "stat_modifiers": {{"hp": 2}}, "duration": 3}}
|
| 164 |
+
- 【关键】state_changes 是程序更新游戏状态的唯一数据源,你输出的 JSON 将被直接用于更新游戏状态。请先在心中做算术验证,确保变更后的数值不超出合法范围(HP ∈ [0, max_hp],饱食度/士气/理智 ∈ [0, 100])。
|
| 165 |
+
- 【关键】请在生成状态变更 JSON 时,确保数值的精确性和一致性。不要在 JSON 中使用模糊或近似的数值。
|
| 166 |
+
- 【关键】如果某个字段没有发生变化,请完全省略该字段或将其设为 null / 0 / 空。绝对不要将实际值写成字符串 "None"。特别注意:new_location 仅在玩家移动到新地点时填写(必须是具体的地点名称),weather_change 仅在天气真正改变时填写(必须是具体天气名称),title_change 仅在称号真正变更时填写(必须是具体称号名称),world_event 仅在发生世界级大事件时填写。如果没有变化,这些字段统一设为 null 或省略。
|
| 167 |
+
- 【关键】quest_updates 中的 status 字段只允许以下值:"active"(进行中)、"completed"(已完成)、"failed"(已失败)。不要使用英文大写或其他变体如 "IN_PROGRESS" "ACTIVE" 等。
|
| 168 |
+
- 【关键】status_effects_added 不是每回合都必须添加的。只有在剧情确实发生了影响角色身心状态的重要事件时才添加状态效果(例如中毒、受到祝福、吃了特殊食物等)。普通的对话、行走、观察等日常行为不应产生状态效果。大约每 3-5 个回合才可能自然产生一次状态效果。同时,状态效果应正向(buff)和负向(debuff)均衡分布,不要总是给予负面效果。
|
| 169 |
+
- 【极其重要 —— 物品消耗规则】items_lost 字段仅用于记录真正消失的物品。判断标准如下:
|
| 170 |
+
- 消耗品(药水、食物、卷轴、弹药等一次性物品):使用后会消失,应放入 items_lost。
|
| 171 |
+
- 非消耗品(哨子、武器、护甲、工具、乐器、钥匙、火把、绳索等可重复使用的物品):使用后仍然保留在背包中,绝对不要放入 items_lost。
|
| 172 |
+
- 举例:吹响哨子 → 哨子仍在背包中(不放入 items_lost);吃了面包 → 面包消失(放入 items_lost);使用火把照明 → 火把仍在(不放入 items_lost)。
|
| 173 |
+
- 在背包列表中,标注了[消耗品]的物品才是消耗品,标注[可重复使用]的物���绝对不能因使用而消失。
|
| 174 |
+
- 如果不确定某个物品是否为消耗品,就不要将它放入 items_lost。
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
# ------------------------------------------------------------
|
| 178 |
+
# 第二阶段 Prompt:基于大纲生成文本 + 选项
|
| 179 |
+
#
|
| 180 |
+
# 设计思路:
|
| 181 |
+
# - 将第一阶段的大纲作为硬性约束注入
|
| 182 |
+
# - 要求生成有文学色彩的描写段落
|
| 183 |
+
# - 额外生成 3 个选项,每个选项有标签和描述
|
| 184 |
+
# - 选项应覆盖不同策略(如激进/保守/探索)
|
| 185 |
+
# - 中等温度 (0.8) 增加文学创意
|
| 186 |
+
# ------------------------------------------------------------
|
| 187 |
+
NARRATIVE_SYSTEM_PROMPT_TEMPLATE = """你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写剧情文本。
|
| 188 |
+
|
| 189 |
+
{world_state}
|
| 190 |
+
|
| 191 |
+
【剧情大纲(必须严格遵守,不可偏离)】
|
| 192 |
+
{outline}
|
| 193 |
+
|
| 194 |
+
【实际状态变更记录(数值必须以此为准,不可偏离)】
|
| 195 |
+
{actual_changes}
|
| 196 |
+
当你在叙事中提及任何属性数值变化(如 HP、饱食度、金币等)时,必须与上方「实际状态变更记录」完全一致,不要自行推算或编造数值。
|
| 197 |
+
|
| 198 |
+
【你的任务】
|
| 199 |
+
1. 基于以上大纲,写一段剧情描写(200-400字,使用中文)。
|
| 200 |
+
- 使用第二人称(“你”)叙述
|
| 201 |
+
- 【最重要】文本开头必须直接回应玩家的行动或选择,让玩家明确感受到自己的操作产生了效果。例如玩家选择“和铁匠交谈”,开头就应写玩家走向铁匠并开始对话,而不是先写一大段环境描写。
|
| 202 |
+
- 文风要求:简洁、自然、有画面感。像写小说而不是写诗歌。少用比喻和修辞,多用具体的动作和对话。不要堆砌华丽辞藻,不要每句都加形容词。
|
| 203 |
+
- 描写要具体:与其说“空气中弥漫着岁月的沉淀”,不如说“柜台上摆着一壶凉透的茶”。用具体的事物代替抽象的感受。
|
| 204 |
+
- 禁止使用以下老套表达:“阳光洒下”“微风拂过”“空气中弥漫着”“XX与 XX 交织在一起”“如同XX般”。如果你压根想不到新鲜的表达,就用最普通的叙述即可。
|
| 205 |
+
- 【反重复】严禁在不同回合反复使用相同或极度相似的动作描写和身体反应描写(如“喉结上下一滚”“握紧了拳头”“深吸一口气”等)。每段描写中的动作细节必须是全新的,绝不能与之前出现过的动作描写雷同。如果你不确定是否用过某个表达,就换一种完全不同的描写方式。
|
| 206 |
+
- 如果大纲中涉及 NPC,要呈现 NPC 的性格特点
|
| 207 |
+
- NPC 姓名规则:当某个 NPC 在本次冒险中第一次出场时,必须先通过外貌、职业等特征描写他(如“铁匠铺里一个肩宽体壮的矮人”),然后通过自然的方式引出名字(如自我介绍、别人称呼、招牌上写着等)。绝对不要在没有铺垫的情况下直接使用名字。
|
| 208 |
+
- 如果是战斗场景,描写要紧张刺激
|
| 209 |
+
- 游戏内的货币统一称为"金币",不要使用"银币""铜币""银两"等其他名称
|
| 210 |
+
|
| 211 |
+
2. 在文本之后,生成恰好 3 个后续选项供玩家选择。选项应:
|
| 212 |
+
- 覆盖不同策略方向(如:激进/谨慎/探索/社交 等)
|
| 213 |
+
- 每个选项都可能导向不同的剧情分支
|
| 214 |
+
- 简洁明了,让玩家一眼就能理解
|
| 215 |
+
- 【关键约束】选项中提及的所有人物、物品和地点,必须已经在本回合的剧情文本中出现过,或在之前的冒险历史中铺垫过。绝对禁止在选项中凭空引入未经铺垫的新 NPC、新地点或新物品。
|
| 216 |
+
- 【关键约束】如果本回合玩家获得了新物品(武器、食物、药水、装备等),至少一个选项应涉及使用、装备或查看该物品。例如:获得武器→选项之一为"装备XX";获得食物→选项之一为"食用XX恢复体力"。 - 【关键约束】选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。不要生成使用玩家不拥有的物品的选项。请仔细检查上方的背包列表,确保选项中提及的物品确实在背包中。
|
| 217 |
+
请严格按以下格式输出(先是剧情文本,然后是分隔符,最后是JSON选项):
|
| 218 |
+
|
| 219 |
+
---STORY_TEXT---
|
| 220 |
+
(在这里写剧情描写文本)
|
| 221 |
+
---OPTIONS_JSON---
|
| 222 |
+
[
|
| 223 |
+
{{"id": 1, "text": "选项描述", "action_type": "动作类型(如ATTACK/TALK/MOVE/EXPLORE/USE_ITEM等)"}},
|
| 224 |
+
{{"id": 2, "text": "选项描述", "action_type": "动作类型"}},
|
| 225 |
+
{{"id": 3, "text": "选项描述", "action_type": "动作类型"}}
|
| 226 |
+
]
|
| 227 |
+
"""
|
| 228 |
+
|
| 229 |
+
# ------------------------------------------------------------
|
| 230 |
+
# 死亡结局 Prompt
|
| 231 |
+
# ------------------------------------------------------------
|
| 232 |
+
DEATH_NARRATIVE_PROMPT = """你是一个才华横溢的奇幻小说家。玩家的角色已经死亡,请为他写一段庄严、有诗意的死亡结局描写。
|
| 233 |
+
|
| 234 |
+
{death_context}
|
| 235 |
+
|
| 236 |
+
{world_state}
|
| 237 |
+
|
| 238 |
+
请写一段 150-250 字的死亡结局描写(使用第二人称"你"叙述),包含:
|
| 239 |
+
1. 角色倒下的场��描写
|
| 240 |
+
2. 回顾此次冒险的点滴
|
| 241 |
+
3. 一句意味深长的结语
|
| 242 |
+
|
| 243 |
+
然后生成 2 个选项:
|
| 244 |
+
---STORY_TEXT---
|
| 245 |
+
(死亡结局文本)
|
| 246 |
+
---OPTIONS_JSON---
|
| 247 |
+
[
|
| 248 |
+
{{"id": 1, "text": "重新开始冒险", "action_type": "RESTART"}},
|
| 249 |
+
{{"id": 2, "text": "接受命运,结束游戏", "action_type": "QUIT"}}
|
| 250 |
+
]
|
| 251 |
+
"""
|
| 252 |
+
|
| 253 |
+
# ------------------------------------------------------------
|
| 254 |
+
# 开场叙事 Prompt
|
| 255 |
+
# ------------------------------------------------------------
|
| 256 |
+
OPENING_NARRATIVE_PROMPT = """你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写开场白。
|
| 257 |
+
|
| 258 |
+
{world_state}
|
| 259 |
+
|
| 260 |
+
【你的任务】
|
| 261 |
+
写一段游戏开场叙事(200-400字,中文,第二人称“你”叙述):
|
| 262 |
+
1. 介绍主角({player_name})抵达这个村庄的背景
|
| 263 |
+
2. 用具体的场景和细节描写村庄(而不是笼统的氛围形容词)
|
| 264 |
+
3. 暗示冒险即将开始(可以提到村民们的忧虑、远处森林的阴影等)
|
| 265 |
+
4. 以一种引人入胜的方式结束,激发玩家的探索欲望
|
| 266 |
+
|
| 267 |
+
文风要求:简洁自然,像写小说而不是写诗歌。少用比喻、少用形容词,多用具体的动作和场景细节。禁止使用“阳光洒下”“微风拂过”“空气中弥漫着”等老套表达。
|
| 268 |
+
|
| 269 |
+
然后生成 3 个初始选项:
|
| 270 |
+
---STORY_TEXT---
|
| 271 |
+
(开场叙事文本)
|
| 272 |
+
---OPTIONS_JSON---
|
| 273 |
+
[
|
| 274 |
+
{{"id": 1, "text": "前往村庄广场,与村长交谈", "action_type": "TALK"}},
|
| 275 |
+
{{"id": 2, "text": "先去旅店休息一下,顺便打听消息", "action_type": "MOVE"}},
|
| 276 |
+
{{"id": 3, "text": "在村子里四处走走,观察环境", "action_type": "EXPLORE"}}
|
| 277 |
+
]
|
| 278 |
+
"""
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class StoryEngine:
|
| 282 |
+
"""
|
| 283 |
+
叙事引擎 —— 负责故事内容的生成
|
| 284 |
+
|
| 285 |
+
核心工作流程:
|
| 286 |
+
1. 接收玩家意图(来自 nlu_engine 解析)
|
| 287 |
+
2. 第一阶段:调用 Qwen 生成剧情大纲 (JSON)
|
| 288 |
+
3. 一致性检查(通过 state_manager)
|
| 289 |
+
4. 第二阶段:调用 Qwen 基于大纲生成文学文本 + 选项
|
| 290 |
+
5. 返回完整结果(文本 + 选项 + 状态变更)
|
| 291 |
+
"""
|
| 292 |
+
|
| 293 |
+
def __init__(self, game_state: GameState, model: str = DEFAULT_MODEL):
|
| 294 |
+
self.game_state = game_state
|
| 295 |
+
self.model = model
|
| 296 |
+
|
| 297 |
+
def generate_opening(self) -> dict:
|
| 298 |
+
"""
|
| 299 |
+
生成游戏开场叙事。
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
{
|
| 303 |
+
"story_text": "开场叙事文本",
|
| 304 |
+
"options": [{"id": 1, "text": "...", "action_type": "..."}],
|
| 305 |
+
"state_changes": {}
|
| 306 |
+
}
|
| 307 |
+
"""
|
| 308 |
+
logger.info("生成游戏开场叙事...")
|
| 309 |
+
|
| 310 |
+
prompt = OPENING_NARRATIVE_PROMPT.format(
|
| 311 |
+
world_state=self.game_state.to_prompt(),
|
| 312 |
+
player_name=self.game_state.player.name,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
messages = [
|
| 316 |
+
{"role": "system", "content": prompt},
|
| 317 |
+
{"role": "user", "content": "请开始讲述故事的开场。"},
|
| 318 |
+
]
|
| 319 |
+
|
| 320 |
+
raw_text = call_qwen(messages, model=self.model, temperature=0.9, max_tokens=2000)
|
| 321 |
+
story_text, options = self._parse_story_response(raw_text)
|
| 322 |
+
|
| 323 |
+
# 开场没有状态变更
|
| 324 |
+
return {
|
| 325 |
+
"story_text": story_text,
|
| 326 |
+
"options": options,
|
| 327 |
+
"state_changes": {},
|
| 328 |
+
"change_log": [],
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
def generate_story(self, player_intent: dict) -> dict:
|
| 332 |
+
"""
|
| 333 |
+
核心方法:根据玩家意图生成完整的故事响应。
|
| 334 |
+
|
| 335 |
+
两阶段生成流程:
|
| 336 |
+
1. 生成剧情大纲(结构化 JSON)
|
| 337 |
+
2. 一致性检查
|
| 338 |
+
3. 基于大纲生成文学文本 + 选项
|
| 339 |
+
4. 应用状态变更
|
| 340 |
+
|
| 341 |
+
Args:
|
| 342 |
+
player_intent: NLU 引擎解析出的意图
|
| 343 |
+
{
|
| 344 |
+
"intent": "ATTACK",
|
| 345 |
+
"target": "哥布林",
|
| 346 |
+
"details": "用剑攻击",
|
| 347 |
+
"raw_input": "我想攻击那个哥布林"
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
Returns:
|
| 351 |
+
{
|
| 352 |
+
"story_text": "剧情文本",
|
| 353 |
+
"options": [选项列表],
|
| 354 |
+
"state_changes": {状态变更},
|
| 355 |
+
"change_log": ["变更日志"],
|
| 356 |
+
"outline": {大纲},
|
| 357 |
+
"consistency_issues": ["一致性问题"],
|
| 358 |
+
}
|
| 359 |
+
"""
|
| 360 |
+
logger.info(f"生成故事响应,玩家意图: {player_intent}")
|
| 361 |
+
|
| 362 |
+
# ============================================
|
| 363 |
+
# 推进时间(行动前,时间自然流逝)
|
| 364 |
+
# 设计思路:将 tick_time 放在大纲生成之前,确保:
|
| 365 |
+
# 1. LLM 看到的是 tick 后的真实状态
|
| 366 |
+
# 2. apply_changes 的 change_log 与状态栏显示一致
|
| 367 |
+
# 3. 不会出现“文本说100、状态栏显示97”的同步Bug
|
| 368 |
+
# ============================================
|
| 369 |
+
tick_log = self.game_state.tick_time()
|
| 370 |
+
|
| 371 |
+
# ============================================
|
| 372 |
+
# 第一阶段:生成剧情大纲
|
| 373 |
+
# ============================================
|
| 374 |
+
outline = self._generate_outline(player_intent)
|
| 375 |
+
|
| 376 |
+
if outline is None:
|
| 377 |
+
# 大纲生成失败 —— 降级处理
|
| 378 |
+
logger.error("大纲生成失败,使用降级叙事")
|
| 379 |
+
return self._fallback_response(player_intent, tick_log)
|
| 380 |
+
|
| 381 |
+
# ============================================
|
| 382 |
+
# 处理时间冲突:如果大纲指定了 time_change(时间跳跃),
|
| 383 |
+
# 则移除 tick_time 自动推进的"时间流逝"日志,
|
| 384 |
+
# 避免出现"时间流逝: 上午→正午"和"时段变为: 下午"两条冲突记录。
|
| 385 |
+
# ============================================
|
| 386 |
+
state_changes = outline.get("state_changes", {})
|
| 387 |
+
if state_changes.get("time_change"):
|
| 388 |
+
tick_log = [line for line in tick_log if not line.startswith("时间流逝:")]
|
| 389 |
+
|
| 390 |
+
# ============================================
|
| 391 |
+
# 一致性检查
|
| 392 |
+
# ============================================
|
| 393 |
+
consistency_issues = self.game_state.check_consistency(state_changes)
|
| 394 |
+
|
| 395 |
+
if consistency_issues:
|
| 396 |
+
logger.warning(f"发现一致性问题: {consistency_issues}")
|
| 397 |
+
# 尝试修复:重新生成大纲,附带一致性约束
|
| 398 |
+
outline = self._regenerate_outline_with_fixes(player_intent, consistency_issues)
|
| 399 |
+
if outline is None:
|
| 400 |
+
return self._fallback_response(player_intent, tick_log)
|
| 401 |
+
state_changes = outline.get("state_changes", {})
|
| 402 |
+
# 再次检查
|
| 403 |
+
consistency_issues = self.game_state.check_consistency(state_changes)
|
| 404 |
+
|
| 405 |
+
# ============================================
|
| 406 |
+
# 清理状态变更:阻止非消耗品被错误移除
|
| 407 |
+
# ============================================
|
| 408 |
+
event_type = outline.get("event_type", "")
|
| 409 |
+
state_changes, sanitize_warnings = self._sanitize_state_changes(state_changes, event_type)
|
| 410 |
+
if sanitize_warnings:
|
| 411 |
+
logger.info(f"状态变更清理: {sanitize_warnings}")
|
| 412 |
+
|
| 413 |
+
# ============================================
|
| 414 |
+
# 应用状态变更
|
| 415 |
+
# ============================================
|
| 416 |
+
change_log = self.game_state.apply_changes(state_changes)
|
| 417 |
+
|
| 418 |
+
# 校验状态合法性
|
| 419 |
+
is_valid, validation_issues = self.game_state.validate()
|
| 420 |
+
|
| 421 |
+
# 记录事件
|
| 422 |
+
self.game_state.log_event(
|
| 423 |
+
event_type=outline.get("event_type", "UNKNOWN"),
|
| 424 |
+
description=outline.get("event_summary", ""),
|
| 425 |
+
player_action=player_intent.get("raw_input", ""),
|
| 426 |
+
involved_npcs=outline.get("involved_npcs", []),
|
| 427 |
+
state_changes=state_changes,
|
| 428 |
+
consequence_tags=outline.get("consequence_tags", []),
|
| 429 |
+
is_reversible=outline.get("is_reversible", True),
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
# ============================================
|
| 433 |
+
# 检查是否游戏结束
|
| 434 |
+
# ============================================
|
| 435 |
+
if self.game_state.is_game_over():
|
| 436 |
+
return self._generate_death_narrative()
|
| 437 |
+
|
| 438 |
+
# ============================================
|
| 439 |
+
# 第二阶段:生成文学文本 + 选项
|
| 440 |
+
# (将实际 change_log 注入叙事 Prompt,确保文本数值与状态一致)
|
| 441 |
+
# ============================================
|
| 442 |
+
story_text, options = self._generate_narrative(outline, change_log)
|
| 443 |
+
|
| 444 |
+
# 验证生成的选项:确保不引用玩家没有的物品
|
| 445 |
+
options = self._validate_options(options)
|
| 446 |
+
|
| 447 |
+
# 合并 tick_log 和 change_log 中的重复属性条目
|
| 448 |
+
merged_log = _merge_change_logs(tick_log, change_log + validation_issues)
|
| 449 |
+
|
| 450 |
+
return {
|
| 451 |
+
"story_text": story_text,
|
| 452 |
+
"options": options,
|
| 453 |
+
"state_changes": state_changes,
|
| 454 |
+
"change_log": merged_log,
|
| 455 |
+
"outline": outline,
|
| 456 |
+
"consistency_issues": consistency_issues,
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
def _generate_outline(self, player_intent: dict) -> Optional[dict]:
|
| 460 |
+
"""
|
| 461 |
+
第一阶段:生成剧情大纲。
|
| 462 |
+
|
| 463 |
+
使用低温度 (0.3) 确保 JSON 结构稳定,
|
| 464 |
+
将完整世界状态注入 System Prompt。
|
| 465 |
+
"""
|
| 466 |
+
system_prompt = OUTLINE_SYSTEM_PROMPT_TEMPLATE.format(
|
| 467 |
+
world_state=self.game_state.to_prompt(),
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
# 构造用户消息:包含意图的完整描述
|
| 471 |
+
user_message = (
|
| 472 |
+
f"玩家行动: {player_intent.get('raw_input', '未知行动')}\n"
|
| 473 |
+
f"解析意图: {player_intent.get('intent', 'UNKNOWN')}\n"
|
| 474 |
+
f"目标: {player_intent.get('target', '无')}\n"
|
| 475 |
+
f"细节: {player_intent.get('details', '无')}"
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
messages = [
|
| 479 |
+
{"role": "system", "content": system_prompt},
|
| 480 |
+
{"role": "user", "content": user_message},
|
| 481 |
+
]
|
| 482 |
+
|
| 483 |
+
result = safe_json_call(messages, model=self.model, temperature=0.3, max_tokens=1500)
|
| 484 |
+
|
| 485 |
+
if result and isinstance(result, dict):
|
| 486 |
+
logger.info(f"大纲生成成功: {result.get('event_summary', 'N/A')}")
|
| 487 |
+
return result
|
| 488 |
+
|
| 489 |
+
logger.error("大纲生成失败,未能获取有效 JSON")
|
| 490 |
+
return None
|
| 491 |
+
|
| 492 |
+
def _regenerate_outline_with_fixes(
|
| 493 |
+
self, player_intent: dict, issues: list[str]
|
| 494 |
+
) -> Optional[dict]:
|
| 495 |
+
"""
|
| 496 |
+
当一致性检查发现问题时,重新生成大纲。
|
| 497 |
+
将发现的矛盾作为额外约束注入 Prompt。
|
| 498 |
+
"""
|
| 499 |
+
logger.info("重新生成大纲(附带一致性修正)...")
|
| 500 |
+
|
| 501 |
+
system_prompt = OUTLINE_SYSTEM_PROMPT_TEMPLATE.format(
|
| 502 |
+
world_state=self.game_state.to_prompt(),
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
issues_text = "\n".join(f"- {issue}" for issue in issues)
|
| 506 |
+
user_message = (
|
| 507 |
+
f"玩家行动: {player_intent.get('raw_input', '未知行动')}\n"
|
| 508 |
+
f"解析意图: {player_intent.get('intent', 'UNKNOWN')}\n"
|
| 509 |
+
f"目标: {player_intent.get('target', '无')}\n"
|
| 510 |
+
f"细节: {player_intent.get('details', '无')}\n\n"
|
| 511 |
+
f"【⚠️ 注意:上一次生成存在以下矛盾,请修正】\n{issues_text}\n"
|
| 512 |
+
f"请重新生成符合逻辑的剧情大纲。"
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
messages = [
|
| 516 |
+
{"role": "system", "content": system_prompt},
|
| 517 |
+
{"role": "user", "content": user_message},
|
| 518 |
+
]
|
| 519 |
+
|
| 520 |
+
result = safe_json_call(messages, model=self.model, temperature=0.2, max_tokens=1500)
|
| 521 |
+
|
| 522 |
+
if result and isinstance(result, dict):
|
| 523 |
+
logger.info(f"修正后大纲生成成功: {result.get('event_summary', 'N/A')}")
|
| 524 |
+
return result
|
| 525 |
+
|
| 526 |
+
return None
|
| 527 |
+
|
| 528 |
+
def _generate_narrative(self, outline: dict, change_log: list[str] | None = None) -> tuple[str, list[dict]]:
|
| 529 |
+
"""
|
| 530 |
+
第二阶段:基于大纲生成文学文本 + 3 个选项。
|
| 531 |
+
|
| 532 |
+
使用中等温度 (0.8) 增加文学创意。
|
| 533 |
+
|
| 534 |
+
Args:
|
| 535 |
+
outline: 第一阶段生成的剧情大纲
|
| 536 |
+
change_log: apply_changes 返回的实际状态变更日志,用于约束叙事中的数值描写
|
| 537 |
+
"""
|
| 538 |
+
import json
|
| 539 |
+
|
| 540 |
+
actual_changes_text = "\n".join(change_log) if change_log else "无状态变化"
|
| 541 |
+
system_prompt = NARRATIVE_SYSTEM_PROMPT_TEMPLATE.format(
|
| 542 |
+
world_state=self.game_state.to_prompt(),
|
| 543 |
+
outline=json.dumps(outline, ensure_ascii=False, indent=2),
|
| 544 |
+
actual_changes=actual_changes_text,
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
messages = [
|
| 548 |
+
{"role": "system", "content": system_prompt},
|
| 549 |
+
{"role": "user", "content": "请基于以上大纲,生成剧情描写和选项。"},
|
| 550 |
+
]
|
| 551 |
+
|
| 552 |
+
raw_text = call_qwen(
|
| 553 |
+
messages, model=self.model, temperature=0.8, max_tokens=2000
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
story_text, options = self._parse_story_response(raw_text)
|
| 557 |
+
return story_text, options
|
| 558 |
+
|
| 559 |
+
def _generate_death_narrative(self) -> dict:
|
| 560 |
+
"""生成死亡结局叙事"""
|
| 561 |
+
logger.info("生成死亡结局...")
|
| 562 |
+
|
| 563 |
+
death_context = self.game_state.get_death_narrative_context()
|
| 564 |
+
prompt = DEATH_NARRATIVE_PROMPT.format(
|
| 565 |
+
death_context=death_context,
|
| 566 |
+
world_state=self.game_state.to_prompt(),
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
messages = [
|
| 570 |
+
{"role": "system", "content": prompt},
|
| 571 |
+
{"role": "user", "content": "请为这位冒险者写一段死亡结局。"},
|
| 572 |
+
]
|
| 573 |
+
|
| 574 |
+
raw_text = call_qwen(messages, model=self.model, temperature=0.9, max_tokens=1500)
|
| 575 |
+
story_text, options = self._parse_story_response(raw_text)
|
| 576 |
+
|
| 577 |
+
return {
|
| 578 |
+
"story_text": story_text,
|
| 579 |
+
"options": options,
|
| 580 |
+
"state_changes": {},
|
| 581 |
+
"change_log": ["游戏结束"],
|
| 582 |
+
"outline": None,
|
| 583 |
+
"consistency_issues": [],
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
def _parse_story_response(self, raw_text: str) -> tuple[str, list[dict]]:
|
| 587 |
+
"""
|
| 588 |
+
解析 LLM 返回的故事响应,分离文本和选项。
|
| 589 |
+
|
| 590 |
+
预期格式:
|
| 591 |
+
---STORY_TEXT---
|
| 592 |
+
(故事文本)
|
| 593 |
+
---OPTIONS_JSON---
|
| 594 |
+
[JSON 选项数组]
|
| 595 |
+
|
| 596 |
+
降级策略:如果格式不符,尝试直接提取 JSON 数组。
|
| 597 |
+
"""
|
| 598 |
+
story_text = ""
|
| 599 |
+
options = []
|
| 600 |
+
|
| 601 |
+
if "---STORY_TEXT---" in raw_text and "---OPTIONS_JSON---" in raw_text:
|
| 602 |
+
# 标准格式解析:严格取两个标记之间的内容,避免LLM在标记前重复输出导致文本重复
|
| 603 |
+
story_start = raw_text.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 604 |
+
options_start = raw_text.index("---OPTIONS_JSON---")
|
| 605 |
+
story_part = raw_text[story_start:options_start].strip()
|
| 606 |
+
options_part = raw_text[options_start + len("---OPTIONS_JSON---"):].strip()
|
| 607 |
+
|
| 608 |
+
story_text = story_part
|
| 609 |
+
parsed_options = extract_json_from_text(options_part)
|
| 610 |
+
if isinstance(parsed_options, list):
|
| 611 |
+
options = parsed_options
|
| 612 |
+
else:
|
| 613 |
+
# 降级:尝试找到 JSON 数组作为选项
|
| 614 |
+
logger.warning("响应格式不标准,尝试降级解析")
|
| 615 |
+
# 查找最后出现的 JSON 数组
|
| 616 |
+
import re
|
| 617 |
+
array_matches = list(re.finditer(r"\[[\s\S]*?\]", raw_text))
|
| 618 |
+
if array_matches:
|
| 619 |
+
last_match = array_matches[-1]
|
| 620 |
+
options_text = last_match.group()
|
| 621 |
+
parsed = extract_json_from_text(options_text)
|
| 622 |
+
if isinstance(parsed, list):
|
| 623 |
+
options = parsed
|
| 624 |
+
story_text = raw_text[:last_match.start()].strip()
|
| 625 |
+
else:
|
| 626 |
+
story_text = raw_text
|
| 627 |
+
else:
|
| 628 |
+
story_text = raw_text
|
| 629 |
+
|
| 630 |
+
# 确保至少有默认选项
|
| 631 |
+
if not options:
|
| 632 |
+
options = self._generate_default_options()
|
| 633 |
+
|
| 634 |
+
return story_text, options
|
| 635 |
+
|
| 636 |
+
def _generate_default_options(self) -> list[dict]:
|
| 637 |
+
"""生成默认选项(当 LLM 未能提供有效选项时的降级方案)"""
|
| 638 |
+
available = self.game_state.get_available_actions()
|
| 639 |
+
default_options = []
|
| 640 |
+
|
| 641 |
+
action_map = {
|
| 642 |
+
"观察": {"text": "仔细观察周围的环境", "action_type": "EXPLORE"},
|
| 643 |
+
"对话": {"text": "与附近的人交谈", "action_type": "TALK"},
|
| 644 |
+
"移动": {"text": "前往其他地方", "action_type": "MOVE"},
|
| 645 |
+
"休息": {"text": "在此处休息一会儿", "action_type": "REST"},
|
| 646 |
+
"战斗": {"text": "准备战斗", "action_type": "ATTACK"},
|
| 647 |
+
"搜索": {"text": "搜索这个区域", "action_type": "EXPLORE"},
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
for i, action in enumerate(available[:3], 1):
|
| 651 |
+
if action in action_map:
|
| 652 |
+
opt = action_map[action].copy()
|
| 653 |
+
opt["id"] = i
|
| 654 |
+
default_options.append(opt)
|
| 655 |
+
|
| 656 |
+
# 确保恰好 3 个选项
|
| 657 |
+
while len(default_options) < 3:
|
| 658 |
+
default_options.append({
|
| 659 |
+
"id": len(default_options) + 1,
|
| 660 |
+
"text": "继续探索",
|
| 661 |
+
"action_type": "EXPLORE",
|
| 662 |
+
})
|
| 663 |
+
|
| 664 |
+
return default_options[:3]
|
| 665 |
+
|
| 666 |
+
def _fallback_response(self, player_intent: dict, tick_log: list[str] | None = None) -> dict:
|
| 667 |
+
"""
|
| 668 |
+
降级响应:当大纲生成完全失败时,提供基本响应。
|
| 669 |
+
|
| 670 |
+
设计思路:
|
| 671 |
+
- 直接用单次调用生成简短叙事 + 选项
|
| 672 |
+
- 不涉及状态变更(安全保守策略)
|
| 673 |
+
- 确保游戏不会卡死
|
| 674 |
+
|
| 675 |
+
Args:
|
| 676 |
+
player_intent: 玩家意图
|
| 677 |
+
tick_log: tick_time 返回的时间流逝日志(可能为 None)
|
| 678 |
+
"""
|
| 679 |
+
logger.warning("使用降级响应模式")
|
| 680 |
+
|
| 681 |
+
fallback_prompt = (
|
| 682 |
+
f"你是一个 RPG 游戏的叙事引擎。\n"
|
| 683 |
+
f"当前场景: {self.game_state.world.current_scene}\n"
|
| 684 |
+
f"玩家说: {player_intent.get('raw_input', '...')}\n\n"
|
| 685 |
+
f"请写一段简短的过渡叙事(100字以内),并给出 3 个后续选项。\n"
|
| 686 |
+
f"格式:\n"
|
| 687 |
+
f"---STORY_TEXT---\n(叙事文本)\n---OPTIONS_JSON---\n"
|
| 688 |
+
f'[{{"id":1,"text":"选项1","action_type":"EXPLORE"}},'
|
| 689 |
+
f'{{"id":2,"text":"选项2","action_type":"TALK"}},'
|
| 690 |
+
f'{{"id":3,"text":"选项3","action_type":"MOVE"}}]'
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
messages = [
|
| 694 |
+
{"role": "system", "content": fallback_prompt},
|
| 695 |
+
{"role": "user", "content": "请继续。"},
|
| 696 |
+
]
|
| 697 |
+
|
| 698 |
+
try:
|
| 699 |
+
raw_text = call_qwen(messages, model=self.model, temperature=0.7, max_tokens=800)
|
| 700 |
+
story_text, options = self._parse_story_response(raw_text)
|
| 701 |
+
except Exception:
|
| 702 |
+
story_text = "你沉思片刻,思考着下一步该怎么做..."
|
| 703 |
+
options = self._generate_default_options()
|
| 704 |
+
|
| 705 |
+
fallback_change_log = (tick_log or []) + ["(系统提示:本回合使用了降级响应)"]
|
| 706 |
+
return {
|
| 707 |
+
"story_text": story_text,
|
| 708 |
+
"options": options,
|
| 709 |
+
"state_changes": {},
|
| 710 |
+
"change_log": fallback_change_log,
|
| 711 |
+
"outline": None,
|
| 712 |
+
"consistency_issues": [],
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
def _sanitize_state_changes(self, changes: dict, event_type: str = "") -> tuple[dict, list[str]]:
|
| 716 |
+
"""
|
| 717 |
+
清理状态变更:阻止非消耗品因使用而被移除。
|
| 718 |
+
|
| 719 |
+
规则:
|
| 720 |
+
- 交易(TRADE)、赠送、丢弃等行为可以移除任何物品
|
| 721 |
+
- 其他行为(使用、探索、对话等)只能移除消耗品
|
| 722 |
+
|
| 723 |
+
Args:
|
| 724 |
+
changes: 状态变更字典
|
| 725 |
+
event_type: 事件类型(来自大纲)
|
| 726 |
+
|
| 727 |
+
Returns:
|
| 728 |
+
(清理���的changes, 警告列表)
|
| 729 |
+
"""
|
| 730 |
+
warnings = []
|
| 731 |
+
if "items_lost" not in changes:
|
| 732 |
+
return changes, warnings
|
| 733 |
+
|
| 734 |
+
# 交易/赠送行为可以移除任何物品
|
| 735 |
+
trade_events = {"TRADE", "GIVE", "DROP"}
|
| 736 |
+
if event_type.upper() in trade_events:
|
| 737 |
+
return changes, warnings
|
| 738 |
+
|
| 739 |
+
# 其他行为:只允许移除消耗品
|
| 740 |
+
changes = dict(changes) # 浅拷贝,避免修改原始数据
|
| 741 |
+
sanitized_items_lost = []
|
| 742 |
+
for item_name in changes["items_lost"]:
|
| 743 |
+
item_str = str(item_name)
|
| 744 |
+
if self.game_state.is_item_consumable(item_str):
|
| 745 |
+
sanitized_items_lost.append(item_name)
|
| 746 |
+
else:
|
| 747 |
+
warnings.append(
|
| 748 |
+
f"物品 '{item_str}' 不是消耗品,使用后仍保留在背包中"
|
| 749 |
+
)
|
| 750 |
+
logger.warning(f"阻止移除非消耗品: {item_str}")
|
| 751 |
+
|
| 752 |
+
if sanitized_items_lost:
|
| 753 |
+
changes["items_lost"] = sanitized_items_lost
|
| 754 |
+
else:
|
| 755 |
+
changes.pop("items_lost", None)
|
| 756 |
+
|
| 757 |
+
return changes, warnings
|
| 758 |
+
|
| 759 |
+
def _validate_options(self, options: list[dict]) -> list[dict]:
|
| 760 |
+
"""
|
| 761 |
+
验证生成的选项:移除引用了玩家不拥有的物品的选项。
|
| 762 |
+
|
| 763 |
+
检查逻辑:
|
| 764 |
+
- 收集所有已知物品名(来自 item_registry 和事件日志)
|
| 765 |
+
- 如果选项文本提及了某个已知物品,但该物品不在当前背包中,
|
| 766 |
+
则该选项无效,替换为默认选项
|
| 767 |
+
"""
|
| 768 |
+
inventory = set(self.game_state.player.inventory)
|
| 769 |
+
|
| 770 |
+
# 构建所有已知物品名称集合
|
| 771 |
+
known_items: set[str] = set(self.game_state.world.item_registry.keys())
|
| 772 |
+
for event in self.game_state.event_log:
|
| 773 |
+
sc = event.state_changes
|
| 774 |
+
if isinstance(sc, dict):
|
| 775 |
+
for item in sc.get("items_gained", []):
|
| 776 |
+
known_items.add(str(item))
|
| 777 |
+
for item in sc.get("items_lost", []):
|
| 778 |
+
known_items.add(str(item))
|
| 779 |
+
|
| 780 |
+
# 玩家不拥有的已知物品
|
| 781 |
+
unavailable_items = known_items - inventory
|
| 782 |
+
|
| 783 |
+
validated = []
|
| 784 |
+
for opt in options:
|
| 785 |
+
text = opt.get("text", "")
|
| 786 |
+
action_type = opt.get("action_type", "")
|
| 787 |
+
is_valid = True
|
| 788 |
+
|
| 789 |
+
# 检查选项是否引用了玩家不拥有的物品
|
| 790 |
+
for item_name in unavailable_items:
|
| 791 |
+
# 只检查名称足够长的物品(避免单字误匹配)
|
| 792 |
+
if len(item_name) >= 2 and item_name in text:
|
| 793 |
+
# 额外确认:如果选项是使用类动作,更可能是无效的
|
| 794 |
+
# 对于移动/对话类选项,提及物品可能只是描述性的
|
| 795 |
+
if action_type in ("USE_ITEM", "EQUIP", "SKILL"):
|
| 796 |
+
logger.warning(
|
| 797 |
+
f"移除无效选项(引用了不在背包中的物品 '{item_name}'): {text}"
|
| 798 |
+
)
|
| 799 |
+
is_valid = False
|
| 800 |
+
break
|
| 801 |
+
# 对于其他动作类型,用更严格的判断:是否是“使用XX”“吹XX”“吃XX”等模式
|
| 802 |
+
use_patterns = [
|
| 803 |
+
f"使用{item_name}", f"吹响{item_name}", f"吹{item_name}",
|
| 804 |
+
f"吃{item_name}", f"喝{item_name}", f"装备{item_name}",
|
| 805 |
+
f"拿出{item_name}", f"打开{item_name}",
|
| 806 |
+
]
|
| 807 |
+
if any(pattern in text for pattern in use_patterns):
|
| 808 |
+
logger.warning(
|
| 809 |
+
f"移除无效选项(涉及使用不在背包中的物品 '{item_name}'): {text}"
|
| 810 |
+
)
|
| 811 |
+
is_valid = False
|
| 812 |
+
break
|
| 813 |
+
|
| 814 |
+
if is_valid:
|
| 815 |
+
validated.append(opt)
|
| 816 |
+
|
| 817 |
+
# 确保仍然有 3 个选项
|
| 818 |
+
if len(validated) < 3:
|
| 819 |
+
defaults = self._generate_default_options()
|
| 820 |
+
for d in defaults:
|
| 821 |
+
if len(validated) >= 3:
|
| 822 |
+
break
|
| 823 |
+
# 避免重复选项
|
| 824 |
+
if not any(v.get("text") == d["text"] for v in validated):
|
| 825 |
+
d["id"] = len(validated) + 1
|
| 826 |
+
validated.append(d)
|
| 827 |
+
|
| 828 |
+
# 重新编号
|
| 829 |
+
for i, opt in enumerate(validated[:3], 1):
|
| 830 |
+
opt["id"] = i
|
| 831 |
+
|
| 832 |
+
return validated[:3]
|
| 833 |
+
|
| 834 |
+
def process_option_selection(self, option: dict) -> dict:
|
| 835 |
+
"""
|
| 836 |
+
处理玩家点击选项的操作。
|
| 837 |
+
|
| 838 |
+
将选项转化为意图格式,然后调用 generate_story。
|
| 839 |
+
|
| 840 |
+
Args:
|
| 841 |
+
option: 玩家选择的选项 {"id": 1, "text": "...", "action_type": "..."}
|
| 842 |
+
|
| 843 |
+
Returns:
|
| 844 |
+
generate_story 的返回结果
|
| 845 |
+
"""
|
| 846 |
+
intent = {
|
| 847 |
+
"intent": option.get("action_type", "EXPLORE"),
|
| 848 |
+
"target": None,
|
| 849 |
+
"details": option.get("text", ""),
|
| 850 |
+
"raw_input": option.get("text", ""),
|
| 851 |
+
}
|
| 852 |
+
return self.generate_story(intent)
|
utils.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils.py - StoryWeaver 工具函数模块
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 加载环境变量,初始化 OpenAI 兼容客户端 (Qwen API)
|
| 6 |
+
2. 提供通用的 API 调用封装函数(带重试机制)
|
| 7 |
+
3. 提供 JSON 安全解析工具(从 LLM 输出中提取结构化数据)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import json
|
| 13 |
+
import time
|
| 14 |
+
import logging
|
| 15 |
+
from typing import Any, Optional
|
| 16 |
+
from dotenv import load_dotenv
|
| 17 |
+
from openai import OpenAI
|
| 18 |
+
|
| 19 |
+
# ============================================================
|
| 20 |
+
# 日志配置
|
| 21 |
+
# ============================================================
|
| 22 |
+
logging.basicConfig(
|
| 23 |
+
level=logging.INFO,
|
| 24 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger("StoryWeaver")
|
| 27 |
+
|
| 28 |
+
# ============================================================
|
| 29 |
+
# 环境变量加载 & API 客户端初始化
|
| 30 |
+
# ============================================================
|
| 31 |
+
|
| 32 |
+
# 从项目根目录的 .env 文件加载环境变量
|
| 33 |
+
load_dotenv()
|
| 34 |
+
|
| 35 |
+
# 严禁硬编码 API Key —— 仅通过环境变量读取
|
| 36 |
+
QWEN_API_KEY: str = os.getenv("QWEN_API_KEY", "")
|
| 37 |
+
|
| 38 |
+
if not QWEN_API_KEY or QWEN_API_KEY == "sk-xxxxxx":
|
| 39 |
+
logger.warning(
|
| 40 |
+
"⚠️ QWEN_API_KEY 未设置或仍为模板值!"
|
| 41 |
+
"请在 .env 文件中填写有效的 API Key。"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# 使用 OpenAI 兼容格式连接 Qwen API
|
| 45 |
+
# base_url 指向通义千问的 OpenAI 兼容端点
|
| 46 |
+
_client: Optional[OpenAI] = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_client() -> OpenAI:
|
| 50 |
+
"""
|
| 51 |
+
获取全局 OpenAI 客户端(懒加载单例)。
|
| 52 |
+
使用兼容格式调用 Qwen API。
|
| 53 |
+
"""
|
| 54 |
+
global _client
|
| 55 |
+
if _client is None:
|
| 56 |
+
_client = OpenAI(
|
| 57 |
+
api_key=QWEN_API_KEY,
|
| 58 |
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
| 59 |
+
)
|
| 60 |
+
return _client
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ============================================================
|
| 64 |
+
# 默认模型配置
|
| 65 |
+
# ============================================================
|
| 66 |
+
# 推荐使用 qwen-plus 平衡速度和效果
|
| 67 |
+
DEFAULT_MODEL: str = "qwen-plus"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ============================================================
|
| 71 |
+
# 通用 API 调用封装(带重试 & 错误处理)
|
| 72 |
+
# ============================================================
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def call_qwen(
|
| 76 |
+
messages: list[dict[str, str]],
|
| 77 |
+
model: str = DEFAULT_MODEL,
|
| 78 |
+
temperature: float = 0.8,
|
| 79 |
+
max_tokens: int = 2000,
|
| 80 |
+
max_retries: int = 3,
|
| 81 |
+
retry_delay: float = 1.0,
|
| 82 |
+
) -> str:
|
| 83 |
+
"""
|
| 84 |
+
调用 Qwen API 的通用封装函数。
|
| 85 |
+
|
| 86 |
+
设计思路:
|
| 87 |
+
- 使用 OpenAI 兼容格式,方便后续切换模型
|
| 88 |
+
- 内置指数退避重试机制,应对网络波动和限流
|
| 89 |
+
- 返回纯文本内容,JSON 解析交给调用方处理
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
messages: OpenAI 格式的消息列表 [{"role": "system", "content": "..."}, ...]
|
| 93 |
+
model: 模型名称,默认 qwen-plus
|
| 94 |
+
temperature: 生成温度,越高越有创意(0.0-2.0)
|
| 95 |
+
max_tokens: 最大生成 token 数
|
| 96 |
+
max_retries: 最大重试次数
|
| 97 |
+
retry_delay: 初始重试间隔(秒),每次翻倍
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
模型生成的文本内容
|
| 101 |
+
|
| 102 |
+
Raises:
|
| 103 |
+
Exception: 重试耗尽后抛出最后一次异常
|
| 104 |
+
"""
|
| 105 |
+
client = get_client()
|
| 106 |
+
last_exception: Optional[Exception] = None
|
| 107 |
+
|
| 108 |
+
for attempt in range(1, max_retries + 1):
|
| 109 |
+
try:
|
| 110 |
+
logger.info(f"调用 Qwen API (尝试 {attempt}/{max_retries}),模型: {model}")
|
| 111 |
+
response = client.chat.completions.create(
|
| 112 |
+
model=model,
|
| 113 |
+
messages=messages,
|
| 114 |
+
temperature=temperature,
|
| 115 |
+
max_tokens=max_tokens,
|
| 116 |
+
)
|
| 117 |
+
content = response.choices[0].message.content.strip()
|
| 118 |
+
logger.info(f"API 调用成功,响应长度: {len(content)} 字符")
|
| 119 |
+
return content
|
| 120 |
+
|
| 121 |
+
except Exception as e:
|
| 122 |
+
last_exception = e
|
| 123 |
+
logger.warning(f"API 调用失败 (尝试 {attempt}/{max_retries}): {e}")
|
| 124 |
+
if attempt < max_retries:
|
| 125 |
+
sleep_time = retry_delay * (2 ** (attempt - 1))
|
| 126 |
+
logger.info(f"等待 {sleep_time:.1f} 秒后重试...")
|
| 127 |
+
time.sleep(sleep_time)
|
| 128 |
+
|
| 129 |
+
# 重试耗尽,抛出异常
|
| 130 |
+
raise RuntimeError(
|
| 131 |
+
f"Qwen API 调用在 {max_retries} 次尝试后仍然失败: {last_exception}"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ============================================================
|
| 136 |
+
# JSON 安全解析工具
|
| 137 |
+
# ============================================================
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def extract_json_from_text(text: str) -> Optional[dict | list]:
|
| 141 |
+
"""
|
| 142 |
+
从 LLM 输出的文本中提取 JSON 数据。
|
| 143 |
+
|
| 144 |
+
设计思路:
|
| 145 |
+
LLM 有时会在 JSON 前后附加说明文字,或使用 ```json 代码块包裹。
|
| 146 |
+
此函数通过多种策略尝试提取有效 JSON:
|
| 147 |
+
1. 先尝试直接解析整段文本
|
| 148 |
+
2. 再尝试提取 ```json ... ``` 代码块
|
| 149 |
+
3. 最���尝试匹配第一个 { ... } 或 [ ... ] 结构
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
text: LLM 返回的原始文本
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
解析后的 dict/list,解析失败返回 None
|
| 156 |
+
"""
|
| 157 |
+
if not text:
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
# 策略1: 直接解析(LLM 可能返回纯 JSON)
|
| 161 |
+
try:
|
| 162 |
+
return json.loads(text.strip())
|
| 163 |
+
except json.JSONDecodeError:
|
| 164 |
+
pass
|
| 165 |
+
|
| 166 |
+
# 策略2: 提取 ```json ... ``` 代码块
|
| 167 |
+
code_block_pattern = r"```(?:json)?\s*\n?(.*?)\n?\s*```"
|
| 168 |
+
matches = re.findall(code_block_pattern, text, re.DOTALL)
|
| 169 |
+
for match in matches:
|
| 170 |
+
try:
|
| 171 |
+
return json.loads(match.strip())
|
| 172 |
+
except json.JSONDecodeError:
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
# 策略3: 匹配第一个完整的 JSON 对象 { ... }
|
| 176 |
+
brace_pattern = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}"
|
| 177 |
+
brace_matches = re.findall(brace_pattern, text, re.DOTALL)
|
| 178 |
+
for match in brace_matches:
|
| 179 |
+
try:
|
| 180 |
+
return json.loads(match)
|
| 181 |
+
except json.JSONDecodeError:
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
# 策略4: 匹配嵌套更深的 JSON(贪婪匹配从第一个 { 到最后一个 })
|
| 185 |
+
deep_match = re.search(r"\{.*\}", text, re.DOTALL)
|
| 186 |
+
if deep_match:
|
| 187 |
+
try:
|
| 188 |
+
return json.loads(deep_match.group())
|
| 189 |
+
except json.JSONDecodeError:
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
# 策略5: 匹配 JSON 数组 [ ... ]
|
| 193 |
+
array_match = re.search(r"\[.*\]", text, re.DOTALL)
|
| 194 |
+
if array_match:
|
| 195 |
+
try:
|
| 196 |
+
return json.loads(array_match.group())
|
| 197 |
+
except json.JSONDecodeError:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
logger.warning(f"无法从文本中提取 JSON: {text[:200]}...")
|
| 201 |
+
return None
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def safe_json_call(
|
| 205 |
+
messages: list[dict[str, str]],
|
| 206 |
+
model: str = DEFAULT_MODEL,
|
| 207 |
+
temperature: float = 0.3,
|
| 208 |
+
max_tokens: int = 2000,
|
| 209 |
+
max_retries: int = 3,
|
| 210 |
+
) -> Optional[dict | list]:
|
| 211 |
+
"""
|
| 212 |
+
调用 Qwen API 并安全地解析返回的 JSON。
|
| 213 |
+
|
| 214 |
+
设计思路:
|
| 215 |
+
- 将 API 调用与 JSON 解析合为一步
|
| 216 |
+
- 如果第一次解析失败,会额外重试(重新调用 API)
|
| 217 |
+
- temperature 默认较低 (0.3),让 JSON 输出更稳定
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
messages: 消息列表
|
| 221 |
+
model: 模型名称
|
| 222 |
+
temperature: 生成温度(JSON 输出建议低温)
|
| 223 |
+
max_tokens: 最大 token 数
|
| 224 |
+
max_retries: JSON 解析失败时的额外重试次数
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
解析后的 dict/list,全部失败返回 None
|
| 228 |
+
"""
|
| 229 |
+
for attempt in range(1, max_retries + 1):
|
| 230 |
+
try:
|
| 231 |
+
raw_text = call_qwen(
|
| 232 |
+
messages=messages,
|
| 233 |
+
model=model,
|
| 234 |
+
temperature=temperature,
|
| 235 |
+
max_tokens=max_tokens,
|
| 236 |
+
)
|
| 237 |
+
result = extract_json_from_text(raw_text)
|
| 238 |
+
if result is not None:
|
| 239 |
+
return result
|
| 240 |
+
logger.warning(
|
| 241 |
+
f"JSON 解析失败 (尝试 {attempt}/{max_retries}),原始文本: {raw_text[:300]}..."
|
| 242 |
+
)
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logger.error(f"safe_json_call 异常 (尝试 {attempt}/{max_retries}): {e}")
|
| 245 |
+
|
| 246 |
+
logger.error(f"safe_json_call 在 {max_retries} 次尝试后仍无法获取有效 JSON")
|
| 247 |
+
return None
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ============================================================
|
| 251 |
+
# 辅助工具函数
|
| 252 |
+
# ============================================================
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def clamp(value: int, min_val: int, max_val: int) -> int:
|
| 256 |
+
"""将数值限制在 [min_val, max_val] 范围内"""
|
| 257 |
+
return max(min_val, min(max_val, value))
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def format_dict_for_prompt(data: dict, indent: int = 0) -> str:
|
| 261 |
+
"""
|
| 262 |
+
将字典格式化为易读的 Prompt 文本。
|
| 263 |
+
用于将状态数据注入 System Prompt。
|
| 264 |
+
"""
|
| 265 |
+
lines = []
|
| 266 |
+
prefix = " " * indent
|
| 267 |
+
for key, value in data.items():
|
| 268 |
+
if isinstance(value, dict):
|
| 269 |
+
lines.append(f"{prefix}{key}:")
|
| 270 |
+
lines.append(format_dict_for_prompt(value, indent + 1))
|
| 271 |
+
elif isinstance(value, list):
|
| 272 |
+
if value:
|
| 273 |
+
lines.append(f"{prefix}{key}: {', '.join(str(v) for v in value)}")
|
| 274 |
+
else:
|
| 275 |
+
lines.append(f"{prefix}{key}: 无")
|
| 276 |
+
else:
|
| 277 |
+
lines.append(f"{prefix}{key}: {value}")
|
| 278 |
+
return "\n".join(lines)
|