Spaces:
Runtime error
Runtime error
Upload 6 files
Browse files- app.py +363 -119
- state_manager.py +46 -4
- story_engine.py +664 -51
- utils.py +41 -2
app.py
CHANGED
|
@@ -52,12 +52,13 @@ def restart_game() -> tuple:
|
|
| 52 |
(空聊天历史, 初始状态面板, 隐藏选项按钮×3, 空游戏会话,
|
| 53 |
禁用文本输入框, 重置角色名称)
|
| 54 |
"""
|
|
|
|
| 55 |
return (
|
| 56 |
[], # 清空聊天历史
|
| 57 |
"## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
{}, # 清空游戏会话
|
| 62 |
gr.update(value="", interactive=False), # 禁用并清空文本输入
|
| 63 |
gr.update(value="旅人"), # 重置角色名称
|
|
@@ -69,12 +70,11 @@ def restart_game() -> tuple:
|
|
| 69 |
# ============================================================
|
| 70 |
|
| 71 |
|
| 72 |
-
def start_game(player_name: str, game_session: dict)
|
| 73 |
"""
|
| 74 |
-
开始新游戏:生成开场叙事。
|
| 75 |
|
| 76 |
-
|
| 77 |
-
(聊天历史, 状态面板文本, 选项按钮文本, 游戏会话)
|
| 78 |
"""
|
| 79 |
if not player_name.strip():
|
| 80 |
player_name = "旅人"
|
|
@@ -83,58 +83,89 @@ def start_game(player_name: str, game_session: dict) -> tuple:
|
|
| 83 |
game_session = create_new_game(player_name)
|
| 84 |
game_session["started"] = True
|
| 85 |
|
| 86 |
-
#
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
#
|
| 93 |
-
story_text =
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
-
#
|
| 100 |
-
|
|
|
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
|
|
|
| 104 |
|
| 105 |
-
|
| 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)
|
| 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 |
-
|
|
|
|
| 127 |
chat_history, "",
|
| 128 |
-
|
| 129 |
game_session,
|
| 130 |
)
|
|
|
|
| 131 |
|
| 132 |
if not user_input.strip():
|
| 133 |
-
|
| 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"]
|
|
@@ -144,61 +175,103 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict)
|
|
| 144 |
if gs.is_game_over():
|
| 145 |
chat_history.append({"role": "user", "content": user_input})
|
| 146 |
chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
|
| 147 |
-
|
| 148 |
chat_history,
|
| 149 |
_format_status_panel(gs),
|
| 150 |
gr.update(value="重新开始", visible=True, interactive=True),
|
| 151 |
-
gr.update(
|
|
|
|
| 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 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
|
|
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
btn_updates = _get_button_updates(result.get("options", []))
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
-
def process_option_click(option_idx: int, chat_history: list, game_session: dict)
|
| 200 |
"""
|
| 201 |
-
处理玩家点击选项按钮。
|
| 202 |
|
| 203 |
Args:
|
| 204 |
option_idx: 选项索引 (0, 1, 2)
|
|
@@ -206,20 +279,23 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 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 |
-
|
|
|
|
| 210 |
chat_history, "",
|
| 211 |
-
|
| 212 |
game_session,
|
| 213 |
)
|
|
|
|
| 214 |
|
| 215 |
options = game_session.get("current_options", [])
|
| 216 |
if option_idx >= len(options):
|
| 217 |
-
|
| 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"]
|
|
@@ -227,76 +303,234 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 227 |
|
| 228 |
# 检查特殊选项:重新开始
|
| 229 |
if selected_option.get("action_type") == "RESTART":
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 239 |
chat_history,
|
| 240 |
_format_status_panel(gs),
|
| 241 |
gr.update(value="重新开始", visible=True, interactive=True),
|
| 242 |
-
gr.update(
|
|
|
|
| 243 |
game_session,
|
| 244 |
)
|
|
|
|
| 245 |
|
| 246 |
-
# 正常选项处理
|
| 247 |
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
|
|
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
| 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 |
-
|
| 269 |
-
|
| 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 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
else:
|
| 299 |
-
|
|
|
|
| 300 |
return updates
|
| 301 |
|
| 302 |
|
|
@@ -464,7 +698,7 @@ def build_app() -> gr.Blocks:
|
|
| 464 |
# ==================
|
| 465 |
# 左侧:聊天区域
|
| 466 |
# ==================
|
| 467 |
-
with gr.Column(scale=
|
| 468 |
# 玩家姓名输入 + 开始按钮
|
| 469 |
with gr.Row():
|
| 470 |
player_name_input = gr.Textbox(
|
|
@@ -476,12 +710,12 @@ def build_app() -> gr.Blocks:
|
|
| 476 |
start_btn = gr.Button(
|
| 477 |
"开始冒险",
|
| 478 |
variant="primary",
|
| 479 |
-
scale=
|
| 480 |
)
|
| 481 |
restart_btn = gr.Button(
|
| 482 |
"重启冒险",
|
| 483 |
variant="stop",
|
| 484 |
-
scale=
|
| 485 |
)
|
| 486 |
|
| 487 |
# 聊天窗口
|
|
@@ -490,16 +724,16 @@ def build_app() -> gr.Blocks:
|
|
| 490 |
height=480,
|
| 491 |
)
|
| 492 |
|
| 493 |
-
# 选项按钮
|
| 494 |
with gr.Row():
|
| 495 |
option_btn_1 = gr.Button(
|
| 496 |
-
"
|
| 497 |
)
|
| 498 |
option_btn_2 = gr.Button(
|
| 499 |
-
"
|
| 500 |
)
|
| 501 |
option_btn_3 = gr.Button(
|
| 502 |
-
"
|
| 503 |
)
|
| 504 |
|
| 505 |
# 自由输入
|
|
@@ -507,7 +741,7 @@ def build_app() -> gr.Blocks:
|
|
| 507 |
user_input = gr.Textbox(
|
| 508 |
label="自由输入(也可以直接点击上方选项)",
|
| 509 |
placeholder="输入你想做的事情,例如:和村长说话、攻击哥布林、搜索这个区域...",
|
| 510 |
-
scale=
|
| 511 |
interactive=False,
|
| 512 |
)
|
| 513 |
send_btn = gr.Button("发送", variant="primary", scale=1)
|
|
@@ -515,7 +749,7 @@ def build_app() -> gr.Blocks:
|
|
| 515 |
# ==================
|
| 516 |
# 右侧:状态面板
|
| 517 |
# ==================
|
| 518 |
-
with gr.Column(scale=
|
| 519 |
status_panel = gr.Markdown(
|
| 520 |
value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
|
| 521 |
label="角色状态",
|
|
@@ -575,9 +809,19 @@ def build_app() -> gr.Blocks:
|
|
| 575 |
outputs=[user_input],
|
| 576 |
)
|
| 577 |
|
| 578 |
-
# 选项按钮点击
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
option_btn_1.click(
|
| 580 |
-
fn=
|
| 581 |
inputs=[chatbot, game_session],
|
| 582 |
outputs=[
|
| 583 |
chatbot, status_panel,
|
|
@@ -587,7 +831,7 @@ def build_app() -> gr.Blocks:
|
|
| 587 |
)
|
| 588 |
|
| 589 |
option_btn_2.click(
|
| 590 |
-
fn=
|
| 591 |
inputs=[chatbot, game_session],
|
| 592 |
outputs=[
|
| 593 |
chatbot, status_panel,
|
|
@@ -597,7 +841,7 @@ def build_app() -> gr.Blocks:
|
|
| 597 |
)
|
| 598 |
|
| 599 |
option_btn_3.click(
|
| 600 |
-
fn=
|
| 601 |
inputs=[chatbot, game_session],
|
| 602 |
outputs=[
|
| 603 |
chatbot, status_panel,
|
|
|
|
| 52 |
(空聊天历史, 初始状态面板, 隐藏选项按钮×3, 空游戏会话,
|
| 53 |
禁用文本输入框, 重置角色名称)
|
| 54 |
"""
|
| 55 |
+
loading = _get_loading_button_updates()
|
| 56 |
return (
|
| 57 |
[], # 清空聊天历史
|
| 58 |
"## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
|
| 59 |
+
loading[0], # 占位选项按钮1
|
| 60 |
+
loading[1], # 占位选项按钮2
|
| 61 |
+
loading[2], # 占位选项按钮3
|
| 62 |
{}, # 清空游戏会话
|
| 63 |
gr.update(value="", interactive=False), # 禁用并清空文本输入
|
| 64 |
gr.update(value="旅人"), # 重置角色名称
|
|
|
|
| 70 |
# ============================================================
|
| 71 |
|
| 72 |
|
| 73 |
+
def start_game(player_name: str, game_session: dict):
|
| 74 |
"""
|
| 75 |
+
开始新游戏:流式生成开场叙事。
|
| 76 |
|
| 77 |
+
使用生成器 yield 实现流式输出,让用户看到文字逐步出现。
|
|
|
|
| 78 |
"""
|
| 79 |
if not player_name.strip():
|
| 80 |
player_name = "旅人"
|
|
|
|
| 83 |
game_session = create_new_game(player_name)
|
| 84 |
game_session["started"] = True
|
| 85 |
|
| 86 |
+
# 初始 yield:显示加载状态,按钮保持可见但禁用
|
| 87 |
+
chat_history = [{"role": "assistant", "content": "⏳ 正在生成开场..."}]
|
| 88 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 89 |
+
loading = _get_loading_button_updates()
|
| 90 |
|
| 91 |
+
yield (
|
| 92 |
+
chat_history, status_text,
|
| 93 |
+
loading[0], loading[1], loading[2],
|
| 94 |
+
game_session,
|
| 95 |
+
gr.update(interactive=False),
|
| 96 |
+
)
|
| 97 |
|
| 98 |
+
# 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
|
| 99 |
+
story_text = ""
|
| 100 |
+
final_result = None
|
| 101 |
+
|
| 102 |
+
for update in game_session["story"].generate_opening_stream():
|
| 103 |
+
if update["type"] == "story_chunk":
|
| 104 |
+
story_text = update["text"]
|
| 105 |
+
chat_history[-1]["content"] = story_text
|
| 106 |
+
yield (
|
| 107 |
+
chat_history, status_text,
|
| 108 |
+
loading[0], loading[1], loading[2],
|
| 109 |
+
game_session,
|
| 110 |
+
gr.update(interactive=False),
|
| 111 |
+
)
|
| 112 |
+
elif update["type"] == "final":
|
| 113 |
+
final_result = update
|
| 114 |
+
|
| 115 |
+
# ★ 只在数据流完全结束后,从 final_result 中提取选项
|
| 116 |
+
if final_result:
|
| 117 |
+
story_text = final_result.get("story_text", story_text)
|
| 118 |
+
options = final_result.get("options", [])
|
| 119 |
+
else:
|
| 120 |
+
options = []
|
| 121 |
|
| 122 |
+
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 123 |
+
options = _ensure_min_options(options, 3)
|
| 124 |
|
| 125 |
+
# 最终 yield:显示完整文本 + 选项 + 启用按钮
|
| 126 |
+
game_session["current_options"] = options
|
| 127 |
+
options_text = _format_options(options)
|
| 128 |
+
full_message = f"{story_text}\n\n{options_text}"
|
| 129 |
|
| 130 |
+
chat_history[-1]["content"] = full_message
|
| 131 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 132 |
+
btn_updates = _get_button_updates(options)
|
| 133 |
|
| 134 |
+
yield (
|
| 135 |
+
chat_history, status_text,
|
|
|
|
| 136 |
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 137 |
game_session,
|
| 138 |
+
gr.update(interactive=True),
|
| 139 |
)
|
| 140 |
|
| 141 |
|
| 142 |
+
def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
| 143 |
"""
|
| 144 |
+
处理用户文本输入(流式版本)。
|
| 145 |
|
| 146 |
流程:
|
| 147 |
1. NLU 引擎解析意图
|
| 148 |
+
2. 叙事引擎流式生成故事
|
| 149 |
+
3. 逐步更新 UI
|
| 150 |
"""
|
| 151 |
if not game_session or not game_session.get("started"):
|
| 152 |
chat_history = chat_history or []
|
| 153 |
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 154 |
+
loading = _get_loading_button_updates()
|
| 155 |
+
yield (
|
| 156 |
chat_history, "",
|
| 157 |
+
loading[0], loading[1], loading[2],
|
| 158 |
game_session,
|
| 159 |
)
|
| 160 |
+
return
|
| 161 |
|
| 162 |
if not user_input.strip():
|
| 163 |
+
yield (
|
| 164 |
chat_history, _format_status_panel(game_session["game_state"]),
|
| 165 |
gr.update(), gr.update(), gr.update(),
|
| 166 |
game_session,
|
| 167 |
)
|
| 168 |
+
return
|
| 169 |
|
| 170 |
gs: GameState = game_session["game_state"]
|
| 171 |
nlu: NLUEngine = game_session["nlu"]
|
|
|
|
| 175 |
if gs.is_game_over():
|
| 176 |
chat_history.append({"role": "user", "content": user_input})
|
| 177 |
chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
|
| 178 |
+
yield (
|
| 179 |
chat_history,
|
| 180 |
_format_status_panel(gs),
|
| 181 |
gr.update(value="重新开始", visible=True, interactive=True),
|
| 182 |
+
gr.update(value="...", visible=True, interactive=False),
|
| 183 |
+
gr.update(value="...", visible=True, interactive=False),
|
| 184 |
game_session,
|
| 185 |
)
|
| 186 |
+
return
|
| 187 |
|
| 188 |
# 1. NLU 解析
|
| 189 |
intent = nlu.parse_intent(user_input)
|
| 190 |
|
| 191 |
+
# 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
chat_history.append({"role": "user", "content": user_input})
|
| 193 |
+
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 194 |
|
| 195 |
+
# 按钮保持可见但禁用,防止流式期间点击
|
| 196 |
+
loading = _get_loading_button_updates()
|
| 197 |
+
yield (
|
| 198 |
+
chat_history,
|
| 199 |
+
_format_status_panel(gs),
|
| 200 |
+
loading[0], loading[1], loading[2],
|
| 201 |
+
game_session,
|
| 202 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
+
# 3. 流式生成故事
|
| 205 |
+
final_result = None
|
| 206 |
+
for update in story.generate_story_stream(intent):
|
| 207 |
+
if update["type"] == "story_chunk":
|
| 208 |
+
chat_history[-1]["content"] = update["text"]
|
| 209 |
+
yield (
|
| 210 |
+
chat_history,
|
| 211 |
+
_format_status_panel(gs),
|
| 212 |
+
loading[0], loading[1], loading[2],
|
| 213 |
+
game_session,
|
| 214 |
+
)
|
| 215 |
+
elif update["type"] == "final":
|
| 216 |
+
final_result = update
|
| 217 |
+
|
| 218 |
+
# 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
|
| 219 |
+
if final_result:
|
| 220 |
+
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 221 |
+
options = _ensure_min_options(final_result.get("options", []), 3)
|
| 222 |
+
game_session["current_options"] = options
|
| 223 |
+
|
| 224 |
+
change_log = final_result.get("change_log", [])
|
| 225 |
+
log_text = ""
|
| 226 |
+
if change_log:
|
| 227 |
+
log_text = "\n".join(f" {c}" for c in change_log)
|
| 228 |
+
log_text = f"\n\n**状态变化:**\n{log_text}"
|
| 229 |
+
|
| 230 |
+
issues = final_result.get("consistency_issues", [])
|
| 231 |
+
issues_text = ""
|
| 232 |
+
if issues:
|
| 233 |
+
issues_text = "\n".join(f" {i}" for i in issues)
|
| 234 |
+
issues_text = f"\n\n**一致性提示:**\n{issues_text}"
|
| 235 |
+
|
| 236 |
+
options_text = _format_options(options)
|
| 237 |
+
full_message = f"{final_result['story_text']}{log_text}{issues_text}\n\n{options_text}"
|
| 238 |
+
chat_history[-1]["content"] = full_message
|
| 239 |
+
|
| 240 |
+
status_text = _format_status_panel(gs)
|
| 241 |
+
btn_updates = _get_button_updates(options)
|
| 242 |
+
|
| 243 |
+
yield (
|
| 244 |
+
chat_history,
|
| 245 |
+
status_text,
|
| 246 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 247 |
+
game_session,
|
| 248 |
+
)
|
| 249 |
+
else:
|
| 250 |
+
# ★ 兜底:final_result 为空,说明流式生成未产生 final ��件
|
| 251 |
+
logger.warning("流式生成未产生 final 事件,使用兜底文本")
|
| 252 |
+
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 253 |
+
fallback_options = _ensure_min_options([], 3)
|
| 254 |
+
game_session["current_options"] = fallback_options
|
| 255 |
|
| 256 |
+
options_text = _format_options(fallback_options)
|
| 257 |
+
full_message = f"{fallback_text}\n\n{options_text}"
|
| 258 |
+
chat_history[-1]["content"] = full_message
|
| 259 |
|
| 260 |
+
status_text = _format_status_panel(gs)
|
| 261 |
+
btn_updates = _get_button_updates(fallback_options)
|
|
|
|
| 262 |
|
| 263 |
+
yield (
|
| 264 |
+
chat_history,
|
| 265 |
+
status_text,
|
| 266 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 267 |
+
game_session,
|
| 268 |
+
)
|
| 269 |
+
return
|
| 270 |
|
| 271 |
|
| 272 |
+
def process_option_click(option_idx: int, chat_history: list, game_session: dict):
|
| 273 |
"""
|
| 274 |
+
处理玩家点击选项按钮(流式版本)。
|
| 275 |
|
| 276 |
Args:
|
| 277 |
option_idx: 选项索引 (0, 1, 2)
|
|
|
|
| 279 |
if not game_session or not game_session.get("started"):
|
| 280 |
chat_history = chat_history or []
|
| 281 |
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 282 |
+
loading = _get_loading_button_updates()
|
| 283 |
+
yield (
|
| 284 |
chat_history, "",
|
| 285 |
+
loading[0], loading[1], loading[2],
|
| 286 |
game_session,
|
| 287 |
)
|
| 288 |
+
return
|
| 289 |
|
| 290 |
options = game_session.get("current_options", [])
|
| 291 |
if option_idx >= len(options):
|
| 292 |
+
yield (
|
| 293 |
chat_history,
|
| 294 |
_format_status_panel(game_session["game_state"]),
|
| 295 |
gr.update(), gr.update(), gr.update(),
|
| 296 |
game_session,
|
| 297 |
)
|
| 298 |
+
return
|
| 299 |
|
| 300 |
selected_option = options[option_idx]
|
| 301 |
gs: GameState = game_session["game_state"]
|
|
|
|
| 303 |
|
| 304 |
# 检查特殊选项:重新开始
|
| 305 |
if selected_option.get("action_type") == "RESTART":
|
| 306 |
+
# 重新开始时使用流式开场
|
| 307 |
+
game_session = create_new_game(gs.player.name)
|
| 308 |
+
game_session["started"] = True
|
| 309 |
+
|
| 310 |
+
chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
|
| 311 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 312 |
+
loading = _get_loading_button_updates()
|
| 313 |
+
|
| 314 |
+
yield (
|
| 315 |
+
chat_history, status_text,
|
| 316 |
+
loading[0], loading[1], loading[2],
|
| 317 |
+
game_session,
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
story_text = ""
|
| 321 |
+
restart_final = None
|
| 322 |
+
|
| 323 |
+
for update in game_session["story"].generate_opening_stream():
|
| 324 |
+
if update["type"] == "story_chunk":
|
| 325 |
+
story_text = update["text"]
|
| 326 |
+
chat_history[-1]["content"] = story_text
|
| 327 |
+
yield (
|
| 328 |
+
chat_history, status_text,
|
| 329 |
+
loading[0], loading[1], loading[2],
|
| 330 |
+
game_session,
|
| 331 |
+
)
|
| 332 |
+
elif update["type"] == "final":
|
| 333 |
+
restart_final = update
|
| 334 |
+
|
| 335 |
+
# ★ 只在流完全结束后提取选项
|
| 336 |
+
if restart_final:
|
| 337 |
+
story_text = restart_final.get("story_text", story_text)
|
| 338 |
+
restart_options = restart_final.get("options", [])
|
| 339 |
+
else:
|
| 340 |
+
restart_options = []
|
| 341 |
+
|
| 342 |
+
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 343 |
+
restart_options = _ensure_min_options(restart_options, 3)
|
| 344 |
+
game_session["current_options"] = restart_options
|
| 345 |
+
options_text = _format_options(restart_options)
|
| 346 |
+
full_message = f"{story_text}\n\n{options_text}"
|
| 347 |
+
chat_history[-1]["content"] = full_message
|
| 348 |
+
|
| 349 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 350 |
+
btn_updates = _get_button_updates(restart_options)
|
| 351 |
+
|
| 352 |
+
yield (
|
| 353 |
+
chat_history, status_text,
|
| 354 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 355 |
+
game_session,
|
| 356 |
+
)
|
| 357 |
+
return
|
| 358 |
|
| 359 |
# 检查特殊选项:退出
|
| 360 |
if selected_option.get("action_type") == "QUIT":
|
| 361 |
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 362 |
chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
|
| 363 |
+
yield (
|
| 364 |
chat_history,
|
| 365 |
_format_status_panel(gs),
|
| 366 |
gr.update(value="重新开始", visible=True, interactive=True),
|
| 367 |
+
gr.update(value="...", visible=True, interactive=False),
|
| 368 |
+
gr.update(value="...", visible=True, interactive=False),
|
| 369 |
game_session,
|
| 370 |
)
|
| 371 |
+
return
|
| 372 |
|
| 373 |
+
# 正常选项处理:流式生成
|
| 374 |
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 375 |
+
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 376 |
|
| 377 |
+
# 按钮保持可见但禁用
|
| 378 |
+
loading = _get_loading_button_updates()
|
| 379 |
+
yield (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
chat_history,
|
| 381 |
+
_format_status_panel(gs),
|
| 382 |
+
loading[0], loading[1], loading[2],
|
| 383 |
game_session,
|
| 384 |
)
|
| 385 |
|
| 386 |
+
final_result = None
|
| 387 |
+
for update in story.process_option_selection_stream(selected_option):
|
| 388 |
+
if update["type"] == "story_chunk":
|
| 389 |
+
chat_history[-1]["content"] = update["text"]
|
| 390 |
+
yield (
|
| 391 |
+
chat_history,
|
| 392 |
+
_format_status_panel(gs),
|
| 393 |
+
loading[0], loading[1], loading[2],
|
| 394 |
+
game_session,
|
| 395 |
+
)
|
| 396 |
+
elif update["type"] == "final":
|
| 397 |
+
final_result = update
|
| 398 |
+
|
| 399 |
+
if final_result:
|
| 400 |
+
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 401 |
+
options = _ensure_min_options(final_result.get("options", []), 3)
|
| 402 |
+
game_session["current_options"] = options
|
| 403 |
+
|
| 404 |
+
change_log = final_result.get("change_log", [])
|
| 405 |
+
log_text = ""
|
| 406 |
+
if change_log:
|
| 407 |
+
log_text = "\n".join(f" {c}" for c in change_log)
|
| 408 |
+
log_text = f"\n\n**状态变化:**\n{log_text}"
|
| 409 |
+
|
| 410 |
+
options_text = _format_options(options)
|
| 411 |
+
full_message = f"{final_result['story_text']}{log_text}\n\n{options_text}"
|
| 412 |
+
chat_history[-1]["content"] = full_message
|
| 413 |
+
|
| 414 |
+
status_text = _format_status_panel(gs)
|
| 415 |
+
btn_updates = _get_button_updates(options)
|
| 416 |
+
|
| 417 |
+
yield (
|
| 418 |
+
chat_history, status_text,
|
| 419 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 420 |
+
game_session,
|
| 421 |
+
)
|
| 422 |
+
else:
|
| 423 |
+
# ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
|
| 424 |
+
logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
|
| 425 |
+
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 426 |
+
fallback_options = _ensure_min_options([], 3)
|
| 427 |
+
game_session["current_options"] = fallback_options
|
| 428 |
+
|
| 429 |
+
options_text = _format_options(fallback_options)
|
| 430 |
+
full_message = f"{fallback_text}\n\n{options_text}"
|
| 431 |
+
chat_history[-1]["content"] = full_message
|
| 432 |
+
|
| 433 |
+
status_text = _format_status_panel(gs)
|
| 434 |
+
btn_updates = _get_button_updates(fallback_options)
|
| 435 |
+
|
| 436 |
+
yield (
|
| 437 |
+
chat_history, status_text,
|
| 438 |
+
btn_updates[0], btn_updates[1], btn_updates[2],
|
| 439 |
+
game_session,
|
| 440 |
+
)
|
| 441 |
+
return
|
| 442 |
+
|
| 443 |
|
| 444 |
# ============================================================
|
| 445 |
# UI 辅助函数
|
| 446 |
# ============================================================
|
| 447 |
|
| 448 |
|
| 449 |
+
# 兜底默认选项(当解析出的选项不足 3 个时使用)
|
| 450 |
+
_FALLBACK_BUTTON_OPTIONS = [
|
| 451 |
+
{"id": 1, "text": "查看周围", "action_type": "EXPLORE"},
|
| 452 |
+
{"id": 2, "text": "等待一会", "action_type": "REST"},
|
| 453 |
+
{"id": 3, "text": "检查状态", "action_type": "EXPLORE"},
|
| 454 |
+
]
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def _ensure_min_options(options: list[dict], min_count: int = 3) -> list[dict]:
|
| 458 |
+
"""
|
| 459 |
+
强制确保选项列表至少有 min_count 个选项。
|
| 460 |
+
不足时用通用兜底选项补全,作为 UI 层的最终安全网。
|
| 461 |
+
"""
|
| 462 |
+
if not isinstance(options, list):
|
| 463 |
+
options = []
|
| 464 |
+
|
| 465 |
+
if len(options) >= min_count:
|
| 466 |
+
return options[:min_count]
|
| 467 |
+
|
| 468 |
+
# 用兜底选项补全
|
| 469 |
+
for fb in _FALLBACK_BUTTON_OPTIONS:
|
| 470 |
+
if len(options) >= min_count:
|
| 471 |
+
break
|
| 472 |
+
if not any(o.get("text") == fb["text"] for o in options):
|
| 473 |
+
options.append(fb.copy())
|
| 474 |
+
|
| 475 |
+
# 极端兜底:仍不足则用"继续探索"填充
|
| 476 |
+
while len(options) < min_count:
|
| 477 |
+
options.append({
|
| 478 |
+
"id": len(options) + 1,
|
| 479 |
+
"text": "继续探索",
|
| 480 |
+
"action_type": "EXPLORE",
|
| 481 |
+
})
|
| 482 |
+
|
| 483 |
+
# 重新编号
|
| 484 |
+
for i, opt in enumerate(options[:min_count], 1):
|
| 485 |
+
if isinstance(opt, dict):
|
| 486 |
+
opt["id"] = i
|
| 487 |
+
|
| 488 |
+
return options[:min_count]
|
| 489 |
+
|
| 490 |
+
|
| 491 |
def _format_options(options: list[dict]) -> str:
|
| 492 |
+
"""将选项列表格式化为可读的文本(纯文字,绝不显示 JSON)"""
|
| 493 |
if not options:
|
| 494 |
return ""
|
| 495 |
lines = ["---", "**你的选择:**"]
|
| 496 |
+
for i, opt in enumerate(options):
|
| 497 |
+
# 安全提取:兼容 dict 和异常情况
|
| 498 |
+
if isinstance(opt, dict):
|
| 499 |
+
idx = opt.get("id", i + 1)
|
| 500 |
+
text = opt.get("text", "未知选项")
|
| 501 |
+
else:
|
| 502 |
+
idx = i + 1
|
| 503 |
+
text = str(opt)
|
| 504 |
lines.append(f" **[{idx}]** {text}")
|
| 505 |
return "\n".join(lines)
|
| 506 |
|
| 507 |
|
| 508 |
+
def _get_loading_button_updates() -> list:
|
| 509 |
+
"""返回 3 个加载中占位按钮更新(始终可见,但禁用交互)"""
|
| 510 |
+
return [
|
| 511 |
+
gr.update(value="...", visible=True, interactive=False),
|
| 512 |
+
gr.update(value="...", visible=True, interactive=False),
|
| 513 |
+
gr.update(value="...", visible=True, interactive=False),
|
| 514 |
+
]
|
| 515 |
+
|
| 516 |
+
|
| 517 |
def _get_button_updates(options: list[dict]) -> list:
|
| 518 |
+
"""从选项列表生成按钮的 gr.update() 对象,始终返回恰好 3 个更新"""
|
| 519 |
+
# 确保 options 是列表
|
| 520 |
+
if not isinstance(options, list):
|
| 521 |
+
options = []
|
| 522 |
+
|
| 523 |
+
# ★ 安全兜底:强制补全到 3 个选项,确保按钮永远是 3 个
|
| 524 |
+
options = _ensure_min_options(options, 3)
|
| 525 |
+
|
| 526 |
updates = []
|
| 527 |
for i in range(3):
|
| 528 |
+
opt = options[i]
|
| 529 |
+
if isinstance(opt, dict):
|
| 530 |
+
text = opt.get("text", "...")
|
| 531 |
else:
|
| 532 |
+
text = str(opt) if opt else "..."
|
| 533 |
+
updates.append(gr.update(value=text, visible=True, interactive=True))
|
| 534 |
return updates
|
| 535 |
|
| 536 |
|
|
|
|
| 698 |
# ==================
|
| 699 |
# 左侧:聊天区域
|
| 700 |
# ==================
|
| 701 |
+
with gr.Column(scale=10):
|
| 702 |
# 玩家姓名输入 + 开始按钮
|
| 703 |
with gr.Row():
|
| 704 |
player_name_input = gr.Textbox(
|
|
|
|
| 710 |
start_btn = gr.Button(
|
| 711 |
"开始冒险",
|
| 712 |
variant="primary",
|
| 713 |
+
scale=2,
|
| 714 |
)
|
| 715 |
restart_btn = gr.Button(
|
| 716 |
"重启冒险",
|
| 717 |
variant="stop",
|
| 718 |
+
scale=2,
|
| 719 |
)
|
| 720 |
|
| 721 |
# 聊天窗口
|
|
|
|
| 724 |
height=480,
|
| 725 |
)
|
| 726 |
|
| 727 |
+
# 选项按钮(始终可见,加载时显示占位符)
|
| 728 |
with gr.Row():
|
| 729 |
option_btn_1 = gr.Button(
|
| 730 |
+
"...", visible=True, interactive=False
|
| 731 |
)
|
| 732 |
option_btn_2 = gr.Button(
|
| 733 |
+
"...", visible=True, interactive=False
|
| 734 |
)
|
| 735 |
option_btn_3 = gr.Button(
|
| 736 |
+
"...", visible=True, interactive=False
|
| 737 |
)
|
| 738 |
|
| 739 |
# 自由输入
|
|
|
|
| 741 |
user_input = gr.Textbox(
|
| 742 |
label="自由输入(也可以直接点击上方选项)",
|
| 743 |
placeholder="输入你想做的事情,例如:和村长说话、攻击哥布林、搜索这个区域...",
|
| 744 |
+
scale=5,
|
| 745 |
interactive=False,
|
| 746 |
)
|
| 747 |
send_btn = gr.Button("发送", variant="primary", scale=1)
|
|
|
|
| 749 |
# ==================
|
| 750 |
# 右侧:状态面板
|
| 751 |
# ==================
|
| 752 |
+
with gr.Column(scale=2, min_width=250):
|
| 753 |
status_panel = gr.Markdown(
|
| 754 |
value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
|
| 755 |
label="角色状态",
|
|
|
|
| 809 |
outputs=[user_input],
|
| 810 |
)
|
| 811 |
|
| 812 |
+
# 选项按钮点击(需要使用 yield from 的生成器包装函数,
|
| 813 |
+
# 使 Gradio 能正确识别为流式输出)
|
| 814 |
+
def _option_click_0(ch, gs):
|
| 815 |
+
yield from process_option_click(0, ch, gs)
|
| 816 |
+
|
| 817 |
+
def _option_click_1(ch, gs):
|
| 818 |
+
yield from process_option_click(1, ch, gs)
|
| 819 |
+
|
| 820 |
+
def _option_click_2(ch, gs):
|
| 821 |
+
yield from process_option_click(2, ch, gs)
|
| 822 |
+
|
| 823 |
option_btn_1.click(
|
| 824 |
+
fn=_option_click_0,
|
| 825 |
inputs=[chatbot, game_session],
|
| 826 |
outputs=[
|
| 827 |
chatbot, status_panel,
|
|
|
|
| 831 |
)
|
| 832 |
|
| 833 |
option_btn_2.click(
|
| 834 |
+
fn=_option_click_1,
|
| 835 |
inputs=[chatbot, game_session],
|
| 836 |
outputs=[
|
| 837 |
chatbot, status_panel,
|
|
|
|
| 841 |
)
|
| 842 |
|
| 843 |
option_btn_3.click(
|
| 844 |
+
fn=_option_click_2,
|
| 845 |
inputs=[chatbot, game_session],
|
| 846 |
outputs=[
|
| 847 |
chatbot, status_panel,
|
state_manager.py
CHANGED
|
@@ -757,14 +757,32 @@ class GameState:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}")
|
|
@@ -901,8 +919,25 @@ class GameState:
|
|
| 901 |
for slot, item_name in changes["equip"].items():
|
| 902 |
if slot in self.player.equipment:
|
| 903 |
old_item = self.player.equipment[slot]
|
| 904 |
-
|
| 905 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
|
| 907 |
# --- 玩家称号变更 ---
|
| 908 |
if "title_change" in changes:
|
|
@@ -1091,7 +1126,14 @@ class GameState:
|
|
| 1091 |
"非消耗品(哨子、武器、工具、乐器、钥匙等可重复使用的物品)使用后仍然保留在背包中,"
|
| 1092 |
"绝对不要将它们放入 items_lost。例如:吹响哨子后哨子仍在背包中;使用火把照明后火把仍在。\n"
|
| 1093 |
"10. 【选项物品约束】生成的选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。"
|
| 1094 |
-
"不要生成使用玩家不拥有的物品的选项。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1095 |
)
|
| 1096 |
|
| 1097 |
# 组合完整 Prompt
|
|
|
|
| 757 |
self.world.locations[new_loc].is_discovered = True
|
| 758 |
|
| 759 |
# --- 物品变更 ---
|
| 760 |
+
# 货币关键词列表:这些物品不进背包,而是直接转换为金币
|
| 761 |
+
_CURRENCY_KEYWORDS = ["铜币", "银币", "铜钱", "银两", "金币", "货币", "钱袋", "钱币", "硬币"]
|
| 762 |
+
|
| 763 |
if "items_gained" in changes:
|
| 764 |
for item in changes["items_gained"]:
|
| 765 |
+
item_str = str(item)
|
| 766 |
+
# 检查是否为货币类物品 —— 如果是,跳过入背包(金币已通过 gold_change 处理)
|
| 767 |
+
is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS)
|
| 768 |
+
if is_currency:
|
| 769 |
+
# 如果 gold_change 没有设置,尝试自动补偿少量金币
|
| 770 |
+
if "gold_change" not in changes:
|
| 771 |
+
old_gold = self.player.gold
|
| 772 |
+
self.player.gold += 3 # 默认少量金币
|
| 773 |
+
change_log.append(f"金币: {old_gold} → {self.player.gold}")
|
| 774 |
+
logger.info(f"货币物品 '{item_str}' 已转换为金币,不放入背包")
|
| 775 |
+
continue
|
| 776 |
+
self.player.inventory.append(item_str)
|
| 777 |
change_log.append(f"获得物品: {item}")
|
| 778 |
|
| 779 |
if "items_lost" in changes:
|
| 780 |
for item in changes["items_lost"]:
|
| 781 |
item_str = str(item)
|
| 782 |
+
# 货币类物品也不需要从背包移除
|
| 783 |
+
is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS)
|
| 784 |
+
if is_currency:
|
| 785 |
+
continue
|
| 786 |
if item_str in self.player.inventory:
|
| 787 |
self.player.inventory.remove(item_str)
|
| 788 |
change_log.append(f"失去物品: {item}")
|
|
|
|
| 919 |
for slot, item_name in changes["equip"].items():
|
| 920 |
if slot in self.player.equipment:
|
| 921 |
old_item = self.player.equipment[slot]
|
| 922 |
+
new_item = item_name if item_name and str(item_name).lower() not in ("none", "null", "") else None
|
| 923 |
+
|
| 924 |
+
# 1. 如果旧装备栏有物品,卸下时放回背包
|
| 925 |
+
if old_item and old_item != "无":
|
| 926 |
+
if old_item not in self.player.inventory:
|
| 927 |
+
self.player.inventory.append(old_item)
|
| 928 |
+
logger.info(f"卸下装备 '{old_item}' 放回背包")
|
| 929 |
+
|
| 930 |
+
# 2. 如果要装备新物品,从背包中移除
|
| 931 |
+
if new_item:
|
| 932 |
+
new_item_str = str(new_item)
|
| 933 |
+
if new_item_str in self.player.inventory:
|
| 934 |
+
self.player.inventory.remove(new_item_str)
|
| 935 |
+
logger.info(f"从背包取出 '{new_item_str}' 装备到 [{slot}]")
|
| 936 |
+
|
| 937 |
+
self.player.equipment[slot] = new_item
|
| 938 |
+
display_old = old_item or "无"
|
| 939 |
+
display_new = new_item or "无"
|
| 940 |
+
change_log.append(f"装备 [{slot}]: {display_old} → {display_new}")
|
| 941 |
|
| 942 |
# --- 玩家称号变更 ---
|
| 943 |
if "title_change" in changes:
|
|
|
|
| 1126 |
"非消耗品(哨子、武器、工具、乐器、钥匙等可重复使用的物品)使用后仍然保留在背包中,"
|
| 1127 |
"绝对不要将它们放入 items_lost。例如:吹响哨子后哨子仍在背包中;使用火把照明后火把仍在。\n"
|
| 1128 |
"10. 【选项物品约束】生成的选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。"
|
| 1129 |
+
"不要生成使用玩家不拥有的物品的选项。\n"
|
| 1130 |
+
'11. 【货币规则】游戏内货币统一为"金币",对应 gold_change 字段。严禁使用"铜币""银币""银两"等名称。'
|
| 1131 |
+
"任何钱财/财物类收获(如击败怪物掉落的钱币、交易获得的货款等)必须通过 gold_change 表达,"
|
| 1132 |
+
"严禁将任何种类的钱币放入 items_gained。\n"
|
| 1133 |
+
'12. 【装备规则】装备物品时必须使用 equip 字段指定槽位和物品名称(如 "weapon": "小刀")。'
|
| 1134 |
+
"系统会自动将装备的物品从背包移到装备栏,并将旧装备放回背包。"
|
| 1135 |
+
"因此装备操作时不要在 items_lost/items_gained 中重复处理该物品。"
|
| 1136 |
+
"合法槽位:weapon / armor / accessory / helmet / boots。卸下装备时将对应槽位设为 null。"
|
| 1137 |
)
|
| 1138 |
|
| 1139 |
# 组合完整 Prompt
|
story_engine.py
CHANGED
|
@@ -20,7 +20,7 @@ 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")
|
|
@@ -84,6 +84,24 @@ def _merge_change_logs(tick_log: list[str], action_log: list[str]) -> list[str]:
|
|
| 84 |
return remaining_tick + merged_results
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
# ============================================================
|
| 88 |
# Prompt 模板设计
|
| 89 |
# ============================================================
|
|
@@ -153,7 +171,8 @@ OUTLINE_SYSTEM_PROMPT_TEMPLATE = """你是一个专业的 RPG 叙事引擎的规
|
|
| 153 |
- 确保所有数值变更合理(例如战斗伤害应考虑攻防差值)
|
| 154 |
- 【关键】所有数值变更必须精确,禁止使用“恢复一些”“大幅降低”等模糊描述,必须给出精确数字(如 hunger_change: 14)。
|
| 155 |
- 【关键】time_change 字段只允许以下值之一:"清晨""上午""正午""下午""黄昏""夜晚""深夜",不要填写其他格式(如"30分钟""两小时后"等都是非法值)。如果本回合没有发生时间跳跃(例如休息、等待、长途旅行等),请设为 null(系统会自动推进一个时段)。只有当剧情需要跳跃多个时段时才设置此字段。
|
| 156 |
-
- 【关键】游戏内的货币单位
|
|
|
|
| 157 |
- 【关键】status_effects_added 中每个效果必须是一个对象,且必须包含以下字段:
|
| 158 |
- "name": 中文效果名称(如"中毒""祝福""饱腹"),必须是中文,不要使用英文字段名
|
| 159 |
- "description": 获得原因的简短说明(如"食用面包后精力充沛""被毒蛇咬伤")
|
|
@@ -184,7 +203,15 @@ OUTLINE_SYSTEM_PROMPT_TEMPLATE = """你是一个专业的 RPG 叙事引擎的规
|
|
| 184 |
# - 选项应覆盖不同策略(如激进/保守/探索)
|
| 185 |
# - 中等温度 (0.8) 增加文学创意
|
| 186 |
# ------------------------------------------------------------
|
| 187 |
-
NARRATIVE_SYSTEM_PROMPT_TEMPLATE = """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
{world_state}
|
| 190 |
|
|
@@ -253,7 +280,15 @@ DEATH_NARRATIVE_PROMPT = """你是一个才华横溢的奇幻小说家。玩家
|
|
| 253 |
# ------------------------------------------------------------
|
| 254 |
# 开场叙事 Prompt
|
| 255 |
# ------------------------------------------------------------
|
| 256 |
-
OPENING_NARRATIVE_PROMPT = """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
{world_state}
|
| 259 |
|
|
@@ -278,6 +313,101 @@ OPENING_NARRATIVE_PROMPT = """你是一个经验丰富的奇幻小说家,正
|
|
| 278 |
"""
|
| 279 |
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
class StoryEngine:
|
| 282 |
"""
|
| 283 |
叙事引擎 —— 负责故事内容的生成
|
|
@@ -583,54 +713,147 @@ class StoryEngine:
|
|
| 583 |
"consistency_issues": [],
|
| 584 |
}
|
| 585 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
def _parse_story_response(self, raw_text: str) -> tuple[str, list[dict]]:
|
| 587 |
"""
|
| 588 |
解析 LLM 返回的故事响应,分离文本和选项。
|
| 589 |
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
降级策略:如果格式不符,尝试直接提取 JSON 数组。
|
| 597 |
"""
|
|
|
|
|
|
|
|
|
|
| 598 |
story_text = ""
|
| 599 |
options = []
|
| 600 |
|
| 601 |
-
if
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]:
|
|
@@ -663,6 +886,34 @@ class StoryEngine:
|
|
| 663 |
|
| 664 |
return default_options[:3]
|
| 665 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
def _fallback_response(self, player_intent: dict, tick_log: list[str] | None = None) -> dict:
|
| 667 |
"""
|
| 668 |
降级响应:当大纲生成完全失败时,提供基本响应。
|
|
@@ -814,22 +1065,8 @@ class StoryEngine:
|
|
| 814 |
if is_valid:
|
| 815 |
validated.append(opt)
|
| 816 |
|
| 817 |
-
# 确保
|
| 818 |
-
|
| 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 |
"""
|
|
@@ -850,3 +1087,379 @@ class StoryEngine:
|
|
| 850 |
"raw_input": option.get("text", ""),
|
| 851 |
}
|
| 852 |
return self.generate_story(intent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
import re
|
| 21 |
from typing import Optional
|
| 22 |
|
| 23 |
+
from utils import call_qwen, call_qwen_stream, safe_json_call, extract_json_from_text, DEFAULT_MODEL
|
| 24 |
from state_manager import GameState
|
| 25 |
|
| 26 |
logger = logging.getLogger("StoryWeaver")
|
|
|
|
| 84 |
return remaining_tick + merged_results
|
| 85 |
|
| 86 |
|
| 87 |
+
def _normalize_markers(text: str) -> str:
|
| 88 |
+
"""
|
| 89 |
+
标准化 LLM 输出中的分隔标记,处理常见变体格式。
|
| 90 |
+
|
| 91 |
+
LLM 有时会输出与预期略有不同的标记格式,例如:
|
| 92 |
+
- 多余的空格: "--- STORY_TEXT ---"
|
| 93 |
+
- 不同的连字符数量: "----STORY_TEXT----"
|
| 94 |
+
- 大小写变化: "---story_text---"
|
| 95 |
+
- 下划线变空格: "---STORY TEXT---"
|
| 96 |
+
此函数将这些变体统一为标准格式。
|
| 97 |
+
"""
|
| 98 |
+
text = re.sub(r'-{2,}\s*STORY[_ ]?TEXT\s*-{2,}', '---STORY_TEXT---', text, flags=re.IGNORECASE)
|
| 99 |
+
text = re.sub(r'-{2,}\s*OPTIONS[_ ]?JSON\s*-{2,}', '---OPTIONS_JSON---', text, flags=re.IGNORECASE)
|
| 100 |
+
text = re.sub(r'-{2,}\s*STATE[_ ]?JSON\s*-{2,}', '---STATE_JSON---', text, flags=re.IGNORECASE)
|
| 101 |
+
text = re.sub(r'-{2,}\s*THINKING\s*-{2,}', '---THINKING---', text, flags=re.IGNORECASE)
|
| 102 |
+
return text
|
| 103 |
+
|
| 104 |
+
|
| 105 |
# ============================================================
|
| 106 |
# Prompt 模板设计
|
| 107 |
# ============================================================
|
|
|
|
| 171 |
- 确保所有数值变更合理(例如战斗伤害应考虑攻防差值)
|
| 172 |
- 【关键】所有数值变更必须精确,禁止使用“恢复一些”“大幅降低”等模糊描述,必须给出精确数字(如 hunger_change: 14)。
|
| 173 |
- 【关键】time_change 字段只允许以下值之一:"清晨""上午""正午""下午""黄昏""夜晚""深夜",不要填写其他格式(如"30分钟""两小时后"等都是非法值)。如果本回合没有发生时间跳跃(例如休息、等待、长途旅行等),请设为 null(系统会自动推进一个时段)。只有当剧情需要跳跃多个时段时才设置此字段。
|
| 174 |
+
- 【关键】游戏内的货币单位统一为"金币",对应 gold_change 字段。严禁使用"银币""铜币""银两""钱币"等其他货币名称。任何财物/钱财类收获必须通过 gold_change 字段表达,严禁将钱币放入 items_gained。举例:击败怪物掉落 3 金币 → gold_change: 3,绝对不要在 items_gained 中放入"铜币""银币""金币"等。
|
| 175 |
+
- 【关键 —— 装备规则】当玩家装备某个物品时,必须在 equip 字段中指定对应槽位和物品名称(如 "weapon": "小刀")。系统会自动将装备的物品从背包移到装备栏,并将替换下来的旧装备放回背包。因此装备操作时不要在 items_lost/items_gained 中重复处理该物品。合法槽位:weapon / armor / accessory / helmet / boots。卸下装备时将对应槽位设为 null。
|
| 176 |
- 【关键】status_effects_added 中每个效果必须是一个对象,且必须包含以下字段:
|
| 177 |
- "name": 中文效果名称(如"中毒""祝福""饱腹"),必须是中文,不要使用英文字段名
|
| 178 |
- "description": 获得原因的简短说明(如"食用面包后精力充沛""被毒蛇咬伤")
|
|
|
|
| 203 |
# - 选项应覆盖不同策略(如激进/保守/探索)
|
| 204 |
# - 中等温度 (0.8) 增加文学创意
|
| 205 |
# ------------------------------------------------------------
|
| 206 |
+
NARRATIVE_SYSTEM_PROMPT_TEMPLATE = """【最高优先级指令 ── 输出格式】
|
| 207 |
+
你的输出必须严格遵守以下格式,这是最高优先级的要求,违反将导致系统崩溃:
|
| 208 |
+
1. 先输出 ---STORY_TEXT--- 标记(独占一行)
|
| 209 |
+
2. 然后直接写故事内容,不加任何前缀(禁止“好的”“以下是”等)
|
| 210 |
+
3. 故事写完后输出 ---OPTIONS_JSON--- 标记(独占一行)
|
| 211 |
+
4. 最后输出 JSON 格式的 3 个选项数组
|
| 212 |
+
绝对不要省略这两个标记。
|
| 213 |
+
|
| 214 |
+
你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写剧情文本。
|
| 215 |
|
| 216 |
{world_state}
|
| 217 |
|
|
|
|
| 280 |
# ------------------------------------------------------------
|
| 281 |
# 开场叙事 Prompt
|
| 282 |
# ------------------------------------------------------------
|
| 283 |
+
OPENING_NARRATIVE_PROMPT = """【��高优先级指令 ── 输出格式】
|
| 284 |
+
你的输出必须严格遵守以下格式,这是最高优先级的要求,违反将导致系统崩溃:
|
| 285 |
+
1. 先输出 ---STORY_TEXT--- 标记(独占一行)
|
| 286 |
+
2. 然后直接写故事内容(200-400字),不加任何前缀(禁止“好的”“以下是”等)
|
| 287 |
+
3. 故事写完后输出 ---OPTIONS_JSON--- 标记(独占一行)
|
| 288 |
+
4. 最后输出 JSON 格式的 3 个选项数组
|
| 289 |
+
绝对不要省略这两个标记。
|
| 290 |
+
|
| 291 |
+
你是一个经验丰富的奇幻小说家,正在为一个互动 RPG 游戏编写开场白。
|
| 292 |
|
| 293 |
{world_state}
|
| 294 |
|
|
|
|
| 313 |
"""
|
| 314 |
|
| 315 |
|
| 316 |
+
# ------------------------------------------------------------
|
| 317 |
+
# 合并式 Prompt:一次调用完成大纲 + 叙事 + 选项
|
| 318 |
+
#
|
| 319 |
+
# 设计思路:
|
| 320 |
+
# - 将两阶段生成合并为一次 API 调用,减少一半延迟
|
| 321 |
+
# - THINKING 区域供模型内部规划(不展示给玩家),确保一致性
|
| 322 |
+
# - STORY_TEXT 区域放在中间,支持流式输出(用户最先看到文字)
|
| 323 |
+
# - STATE_JSON 和 OPTIONS_JSON 放在末尾,供程序解析
|
| 324 |
+
# ------------------------------------------------------------
|
| 325 |
+
MERGED_SYSTEM_PROMPT_TEMPLATE = """【最高优先级指令 ── 输出格式】
|
| 326 |
+
你的输出必须严格遵守以下分隔标记格式。这是最高优先级的要求,违反将导致系统崩溃:
|
| 327 |
+
- ---THINKING--- (规划区域)
|
| 328 |
+
- ---STORY_TEXT--- (故事文本,200-400字,不加任何前缀)
|
| 329 |
+
- ---STATE_JSON--- (状态变更 JSON)
|
| 330 |
+
- ---OPTIONS_JSON--- (3 个选项的 JSON 数组)
|
| 331 |
+
每个区域必须以对应的 --- 标记开头(独占一行)。绝对不要省略任何标记。故事文本区域只放纯叙事文字,禁止混入 JSON。
|
| 332 |
+
|
| 333 |
+
你是一个专业的 RPG 叙事引擎,兼具剧情规划与文学描写能力。
|
| 334 |
+
|
| 335 |
+
{world_state}
|
| 336 |
+
|
| 337 |
+
【你的任务】
|
| 338 |
+
根据玩家的行动意图,在一次输出中完成以下所有工作:
|
| 339 |
+
1. 在 THINKING 标签内进行简短的剧情规划(不展示给玩家)
|
| 340 |
+
2. 写一段 200-400 字的剧情描写
|
| 341 |
+
3. 输出结构化的状态变更 JSON
|
| 342 |
+
4. 生成 3 个后续选项
|
| 343 |
+
|
| 344 |
+
【状态变更规则】
|
| 345 |
+
- 只填写确实发生变化的字段,未变化的设为 null/0/空或省略
|
| 346 |
+
- 所有数值变更必须精确,禁止模糊描述如"恢复一些"
|
| 347 |
+
- time_change 仅允许:"清晨""上午""正午""下午""黄昏""夜晚""深夜",无跳跃则 null
|
| 348 |
+
- 货币统一为"金币"(gold_change),严禁使用"铜币""银币""银两"等。任何钱财收获只通过 gold_change 表达,严禁将钱币放入 items_gained
|
| 349 |
+
- 装备规则:装备物品时用 equip 字段指定槽位和物品名(如 "weapon": "小刀"),系统自动处理背包⇌装备栏转移。装备时不要在 items_lost/items_gained 重复处理该物品。合法槽位:weapon/armor/accessory/helmet/boots。卸下装备设槽位为 null
|
| 350 |
+
- status_effects_added 中每个效果须含:name/description/effect_type/stat_modifiers/duration
|
| 351 |
+
- 数值不超合法范围(HP∈[0,max_hp],饱食度/士气/理智∈[0,100])
|
| 352 |
+
- new_location/weather_change/title_change/world_event 仅真正变化时填写,否则 null
|
| 353 |
+
- quest_updates.status 只允许 "active"/"completed"/"failed"
|
| 354 |
+
- 消耗品(药水、食物等一次性物品)使用后放入 items_lost;非消耗品(武器、工具等)使用后不放入 items_lost
|
| 355 |
+
- 仅重要事件才添加状态效果,普通日常行为不产生
|
| 356 |
+
- npc_changes 格式: {{"NPC名称": {{"attitude": "新态度", "relationship_change": 数值, "memory_add": "新记忆", "hp_change": 数值}}}}
|
| 357 |
+
- quest_updates 格式: {{"任务ID": {{"objectives_completed": ["完成的目标"], "status": "新状态"}}}}
|
| 358 |
+
|
| 359 |
+
【叙事写作规则】
|
| 360 |
+
- 使用第二人称"你"叙述
|
| 361 |
+
- 文本开头必须直接回应玩家的行动/选择
|
| 362 |
+
- 文风简洁自然有画面感,像写小说而非诗歌
|
| 363 |
+
- 多用具体动作和对话,少用比喻修辞
|
| 364 |
+
- 禁用:"阳光洒下""微风拂过""空气中弥漫着""如同XX般"
|
| 365 |
+
- 严禁重复相似的动作描写(如"握紧拳头""深吸一口气"等不可每回合重复)
|
| 366 |
+
- NPC 首次出场需先描写外貌/特征,再自然引出名字
|
| 367 |
+
- 战斗场景要紧张刺激
|
| 368 |
+
- 货币统一称"金币"
|
| 369 |
+
|
| 370 |
+
【选项规则】
|
| 371 |
+
- 恰好 3 个选项,覆盖不同策略方向(激进/谨慎/探索/社交等)
|
| 372 |
+
- 选项中的人物/物品/地点必须已在当前或之前剧情中出现过
|
| 373 |
+
- 获得新物品时至少一个选项涉及使用该物品
|
| 374 |
+
- 使用物品的选项中该物品必须在背包中
|
| 375 |
+
|
| 376 |
+
【输出格式(严格遵守,按以下顺序输出)】
|
| 377 |
+
|
| 378 |
+
---THINKING---
|
| 379 |
+
(简短剧情规划:事件类型、涉及NPC、关键状态变更概要,3-5句话即可)
|
| 380 |
+
|
| 381 |
+
---STORY_TEXT---
|
| 382 |
+
(200-400 字剧情描写)
|
| 383 |
+
|
| 384 |
+
---STATE_JSON---
|
| 385 |
+
{{
|
| 386 |
+
"event_summary": "一句话描述",
|
| 387 |
+
"event_type": "COMBAT/DIALOGUE/MOVE/ITEM/QUEST/TRADE/REST/DISCOVERY",
|
| 388 |
+
"involved_npcs": [],
|
| 389 |
+
"state_changes": {{
|
| 390 |
+
"hp_change": 0, "mp_change": 0, "gold_change": 0, "exp_change": 0,
|
| 391 |
+
"morale_change": 0, "sanity_change": 0, "hunger_change": 0, "karma_change": 0,
|
| 392 |
+
"new_location": null, "items_gained": [], "items_lost": [],
|
| 393 |
+
"skills_gained": [], "status_effects_added": [], "status_effects_removed": [],
|
| 394 |
+
"npc_changes": {{}}, "quest_updates": {{}},
|
| 395 |
+
"weather_change": null, "time_change": null, "global_flags_set": {{}},
|
| 396 |
+
"world_event": null, "equip": {{}}, "title_change": null
|
| 397 |
+
}},
|
| 398 |
+
"consequence_tags": [],
|
| 399 |
+
"is_reversible": true
|
| 400 |
+
}}
|
| 401 |
+
|
| 402 |
+
---OPTIONS_JSON---
|
| 403 |
+
[
|
| 404 |
+
{{"id": 1, "text": "选项描述", "action_type": "动作类型(ATTACK/TALK/MOVE/EXPLORE/USE_ITEM等)"}},
|
| 405 |
+
{{"id": 2, "text": "选项描述", "action_type": "动作类型"}},
|
| 406 |
+
{{"id": 3, "text": "选项描述", "action_type": "动作类型"}}
|
| 407 |
+
]
|
| 408 |
+
"""
|
| 409 |
+
|
| 410 |
+
|
| 411 |
class StoryEngine:
|
| 412 |
"""
|
| 413 |
叙事引擎 —— 负责故事内容的生成
|
|
|
|
| 713 |
"consistency_issues": [],
|
| 714 |
}
|
| 715 |
|
| 716 |
+
@staticmethod
|
| 717 |
+
def _clean_story_text(story_text: str) -> str:
|
| 718 |
+
"""
|
| 719 |
+
清理故事文本中残留的 JSON 选项数组和格式标记。
|
| 720 |
+
|
| 721 |
+
LLM 有时会将选项 JSON 嵌入到故事文本中(尤其在标记之间),
|
| 722 |
+
此方法移除这些 JSON 片段,仅保留纯叙事文本。
|
| 723 |
+
"""
|
| 724 |
+
# 移除看起来像选项 JSON 数组的内容: [{"id": ...}, ...]
|
| 725 |
+
cleaned = re.sub(
|
| 726 |
+
r'\[\s*\{\s*"id"\s*:.*?\]',
|
| 727 |
+
'',
|
| 728 |
+
story_text,
|
| 729 |
+
flags=re.DOTALL,
|
| 730 |
+
)
|
| 731 |
+
# 移除残留的标记
|
| 732 |
+
for marker in ["---STORY_TEXT---", "---OPTIONS_JSON---", "---STATE_JSON---", "---THINKING---"]:
|
| 733 |
+
cleaned = cleaned.replace(marker, "")
|
| 734 |
+
# 移除可能因清理产生的多余空行(保留最多一个空行)
|
| 735 |
+
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
| 736 |
+
return cleaned.strip()
|
| 737 |
+
|
| 738 |
def _parse_story_response(self, raw_text: str) -> tuple[str, list[dict]]:
|
| 739 |
"""
|
| 740 |
解析 LLM 返回的故事响应,分离文本和选项。
|
| 741 |
|
| 742 |
+
采用 **暴力提取策略**:
|
| 743 |
+
1. 优先尝试标准标记格式
|
| 744 |
+
2. 如都失败,默认将整段文本视为故事文本
|
| 745 |
+
3. 从末尾向前查找 JSON 块 ([...] 或 {...}),若找到则切除并解析为选项
|
| 746 |
+
4. 找不到任何 JSON 则整段当故事 + 硬性默认选项
|
|
|
|
|
|
|
| 747 |
"""
|
| 748 |
+
# ★ 调试日志
|
| 749 |
+
logger.debug(f"原始 API 返回内容: {raw_text}")
|
| 750 |
+
|
| 751 |
story_text = ""
|
| 752 |
options = []
|
| 753 |
|
| 754 |
+
if not raw_text or not raw_text.strip():
|
| 755 |
+
logger.warning("API 返回内容为空")
|
| 756 |
+
return "你环顾四周,思考着接下来该做什么...", self._generate_default_options()
|
| 757 |
+
|
| 758 |
+
# 标准化标记格式
|
| 759 |
+
normalized = _normalize_markers(raw_text)
|
| 760 |
+
|
| 761 |
+
# ========== 策略 1:标准标记格式 ==========
|
| 762 |
+
if "---STORY_TEXT---" in normalized and "---OPTIONS_JSON---" in normalized:
|
| 763 |
+
story_start = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 764 |
+
options_start = normalized.index("---OPTIONS_JSON---")
|
| 765 |
+
story_part = normalized[story_start:options_start].strip()
|
| 766 |
+
options_part = normalized[options_start + len("---OPTIONS_JSON---"):].strip()
|
| 767 |
|
| 768 |
story_text = story_part
|
| 769 |
parsed_options = extract_json_from_text(options_part)
|
| 770 |
+
if isinstance(parsed_options, list) and len(parsed_options) > 0:
|
| 771 |
options = parsed_options
|
| 772 |
+
logger.info("标准格式解析成功")
|
| 773 |
+
# ========== 策略 1b:只有 STORY_TEXT 没有 OPTIONS_JSON ==========
|
| 774 |
+
elif "---STORY_TEXT---" in normalized:
|
| 775 |
+
logger.warning("找到 STORY_TEXT 标记但缺少 OPTIONS_JSON,暴力提取")
|
| 776 |
+
story_start = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 777 |
+
remaining = normalized[story_start:].strip()
|
| 778 |
+
# 从 remaining 末尾查找 JSON
|
| 779 |
+
story_text, options = self._brute_force_extract(remaining)
|
| 780 |
+
|
| 781 |
+
# ========== 策略 2:完全没有标记 → 暴力提取 ==========
|
| 782 |
+
if not story_text.strip():
|
| 783 |
+
logger.warning("响应格式不标准,使用暴力提取策略")
|
| 784 |
+
story_text, options = self._brute_force_extract(normalized)
|
| 785 |
+
|
| 786 |
+
# 清理 story_text 中可能残留的 JSON 和标记
|
| 787 |
+
story_text = self._clean_story_text(story_text)
|
| 788 |
+
|
| 789 |
+
# 移除常见的 AI 前缀
|
| 790 |
+
story_text = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', story_text, flags=re.MULTILINE)
|
| 791 |
+
|
| 792 |
+
# 如果 story_text 仍为空,使用兜底文本
|
| 793 |
+
if not story_text.strip():
|
| 794 |
+
logger.warning("解析后 story_text 为空,使用兜底文本")
|
| 795 |
+
story_text = "你环顾四周,思考着接下来该做什么..."
|
| 796 |
|
| 797 |
# 确保至少有默认选项
|
| 798 |
if not options:
|
| 799 |
+
logger.info("未提取到有效选项,使用默认选项")
|
| 800 |
options = self._generate_default_options()
|
| 801 |
|
| 802 |
+
# 确保恰好 3 个选项
|
| 803 |
+
options = self._ensure_three_options(options)
|
| 804 |
+
|
| 805 |
+
return story_text, options
|
| 806 |
+
|
| 807 |
+
def _brute_force_extract(self, text: str) -> tuple[str, list[dict]]:
|
| 808 |
+
"""
|
| 809 |
+
暴力提取策略:默认整段文本都是故事,从末尾向前查找 JSON 块并切除。
|
| 810 |
+
|
| 811 |
+
Args:
|
| 812 |
+
text: 待解析的文本
|
| 813 |
+
|
| 814 |
+
Returns:
|
| 815 |
+
(story_text, options_list)
|
| 816 |
+
"""
|
| 817 |
+
options = []
|
| 818 |
+
story_text = text
|
| 819 |
+
|
| 820 |
+
# 第一步:尝试从末尾向前找 JSON 数组 [...]
|
| 821 |
+
json_array_matches = list(re.finditer(r'\[\s*\{.*?\}\s*\]', text, re.DOTALL))
|
| 822 |
+
if json_array_matches:
|
| 823 |
+
last_match = json_array_matches[-1]
|
| 824 |
+
try:
|
| 825 |
+
parsed = extract_json_from_text(last_match.group())
|
| 826 |
+
if isinstance(parsed, list) and len(parsed) > 0:
|
| 827 |
+
# 检查是否看起来像选项(至少一个元素有 text 或 id 字段)
|
| 828 |
+
if any(isinstance(item, dict) and ("text" in item or "id" in item) for item in parsed):
|
| 829 |
+
options = parsed
|
| 830 |
+
story_text = text[:last_match.start()].strip()
|
| 831 |
+
logger.info(f"暴力提取:从末尾找到 JSON 数组,提取了 {len(options)} 个选项")
|
| 832 |
+
except Exception:
|
| 833 |
+
pass
|
| 834 |
+
|
| 835 |
+
# 第二步:如果没找到数组,尝试找独立的 JSON 对象 {...}
|
| 836 |
+
if not options:
|
| 837 |
+
json_obj_matches = list(re.finditer(r'\{[^{}]*"(?:text|id|action_type)"[^{}]*\}', text, re.DOTALL))
|
| 838 |
+
if json_obj_matches and len(json_obj_matches) >= 2:
|
| 839 |
+
first_match_start = json_obj_matches[0].start()
|
| 840 |
+
last_match_end = json_obj_matches[-1].end()
|
| 841 |
+
json_block = text[first_match_start:last_match_end]
|
| 842 |
+
wrapped = "[" + json_block + "]"
|
| 843 |
+
try:
|
| 844 |
+
parsed = extract_json_from_text(wrapped)
|
| 845 |
+
if isinstance(parsed, list) and len(parsed) > 0:
|
| 846 |
+
options = parsed
|
| 847 |
+
story_text = text[:first_match_start].strip()
|
| 848 |
+
logger.info(f"暴力提取:通过独立 JSON 对象拼合,提取了 {len(options)} 个选项")
|
| 849 |
+
except Exception:
|
| 850 |
+
pass
|
| 851 |
+
|
| 852 |
+
# 第三步:完全没有 JSON 影子 → 整段当故事
|
| 853 |
+
if not options:
|
| 854 |
+
logger.info("暴力提取:未发现任何 JSON,整段文本作为故事,使用默认选项")
|
| 855 |
+
story_text = text
|
| 856 |
+
|
| 857 |
return story_text, options
|
| 858 |
|
| 859 |
def _generate_default_options(self) -> list[dict]:
|
|
|
|
| 886 |
|
| 887 |
return default_options[:3]
|
| 888 |
|
| 889 |
+
def _ensure_three_options(self, options: list[dict]) -> list[dict]:
|
| 890 |
+
"""
|
| 891 |
+
确保恰好有 3 个选项,不足时用默认选项补齐。
|
| 892 |
+
|
| 893 |
+
LLM 有时只生成 2 个甚至 1 个选项,此方法确保 UI 始终显示 3 个按钮。
|
| 894 |
+
"""
|
| 895 |
+
if len(options) >= 3:
|
| 896 |
+
options = options[:3]
|
| 897 |
+
else:
|
| 898 |
+
defaults = self._generate_default_options()
|
| 899 |
+
for d in defaults:
|
| 900 |
+
if len(options) >= 3:
|
| 901 |
+
break
|
| 902 |
+
# 避免重复选项
|
| 903 |
+
if not any(o.get("text") == d["text"] for o in options):
|
| 904 |
+
options.append(d)
|
| 905 |
+
# 兜底:如果仍不足 3 个,补充通用选项
|
| 906 |
+
while len(options) < 3:
|
| 907 |
+
options.append({
|
| 908 |
+
"id": len(options) + 1,
|
| 909 |
+
"text": "继续探索",
|
| 910 |
+
"action_type": "EXPLORE",
|
| 911 |
+
})
|
| 912 |
+
# 重新编号
|
| 913 |
+
for i, opt in enumerate(options[:3], 1):
|
| 914 |
+
opt["id"] = i
|
| 915 |
+
return options[:3]
|
| 916 |
+
|
| 917 |
def _fallback_response(self, player_intent: dict, tick_log: list[str] | None = None) -> dict:
|
| 918 |
"""
|
| 919 |
降级响应:当大纲生成完全失败时,提供基本响应。
|
|
|
|
| 1065 |
if is_valid:
|
| 1066 |
validated.append(opt)
|
| 1067 |
|
| 1068 |
+
# 确保恰好 3 个选项
|
| 1069 |
+
return self._ensure_three_options(validated)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1070 |
|
| 1071 |
def process_option_selection(self, option: dict) -> dict:
|
| 1072 |
"""
|
|
|
|
| 1087 |
"raw_input": option.get("text", ""),
|
| 1088 |
}
|
| 1089 |
return self.generate_story(intent)
|
| 1090 |
+
|
| 1091 |
+
# ============================================================
|
| 1092 |
+
# 流式输出 + 合并式生成(核心优化)
|
| 1093 |
+
# ============================================================
|
| 1094 |
+
|
| 1095 |
+
def generate_opening_stream(self):
|
| 1096 |
+
"""
|
| 1097 |
+
流式生成游戏开场叙事。
|
| 1098 |
+
|
| 1099 |
+
★ 重要设计:流式循环中仅做文本展示(story_chunk),
|
| 1100 |
+
选项解析逻辑严格在整个数据流结束后才执行一次。
|
| 1101 |
+
|
| 1102 |
+
Yields:
|
| 1103 |
+
{"type": "story_chunk", "text": "累积的故事文本"} — 流式文本更新
|
| 1104 |
+
{"type": "final", ...} — 最终完整结果(包含必定 3 个选项)
|
| 1105 |
+
"""
|
| 1106 |
+
logger.info("[流式] 生成游戏开场叙事...")
|
| 1107 |
+
|
| 1108 |
+
prompt = OPENING_NARRATIVE_PROMPT.format(
|
| 1109 |
+
world_state=self.game_state.to_prompt(),
|
| 1110 |
+
player_name=self.game_state.player.name,
|
| 1111 |
+
)
|
| 1112 |
+
|
| 1113 |
+
messages = [
|
| 1114 |
+
{"role": "system", "content": prompt},
|
| 1115 |
+
{"role": "user", "content": "请开始讲述故事的开场。"},
|
| 1116 |
+
]
|
| 1117 |
+
|
| 1118 |
+
full_text = ""
|
| 1119 |
+
story_started = False
|
| 1120 |
+
story_ended = False
|
| 1121 |
+
|
| 1122 |
+
try:
|
| 1123 |
+
for chunk in call_qwen_stream(messages, model=self.model, temperature=0.9, max_tokens=2000):
|
| 1124 |
+
full_text += chunk
|
| 1125 |
+
|
| 1126 |
+
# ★ 流式循环中只做展示用途的标记检测,绝不在此处解析选项
|
| 1127 |
+
normalized = _normalize_markers(full_text)
|
| 1128 |
+
|
| 1129 |
+
if not story_started:
|
| 1130 |
+
if "---STORY_TEXT---" in normalized:
|
| 1131 |
+
story_started = True
|
| 1132 |
+
idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 1133 |
+
current_story = normalized[idx:]
|
| 1134 |
+
if "---OPTIONS_JSON---" in current_story:
|
| 1135 |
+
story_ended = True
|
| 1136 |
+
current_story = current_story[:current_story.index("---OPTIONS_JSON---")]
|
| 1137 |
+
display_text = self._clean_story_text(current_story)
|
| 1138 |
+
if display_text.strip():
|
| 1139 |
+
yield {"type": "story_chunk", "text": display_text.strip()}
|
| 1140 |
+
elif not story_ended:
|
| 1141 |
+
idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 1142 |
+
current_story = normalized[idx:]
|
| 1143 |
+
if "---OPTIONS_JSON---" in current_story:
|
| 1144 |
+
story_ended = True
|
| 1145 |
+
current_story = current_story[:current_story.index("---OPTIONS_JSON---")]
|
| 1146 |
+
display_text = self._clean_story_text(current_story)
|
| 1147 |
+
if display_text.strip():
|
| 1148 |
+
yield {"type": "story_chunk", "text": display_text.strip()}
|
| 1149 |
+
|
| 1150 |
+
except Exception as e:
|
| 1151 |
+
logger.error(f"流式开场生成失败: {e},降级为非流式")
|
| 1152 |
+
try:
|
| 1153 |
+
result = self.generate_opening()
|
| 1154 |
+
# 降级结果也强制保证 3 个选项
|
| 1155 |
+
result["options"] = self._ensure_three_options(result.get("options", []))
|
| 1156 |
+
yield {"type": "final", **result}
|
| 1157 |
+
except Exception:
|
| 1158 |
+
yield {
|
| 1159 |
+
"type": "final",
|
| 1160 |
+
"story_text": "你踏上了一段新的旅程...",
|
| 1161 |
+
"options": self._generate_default_options(),
|
| 1162 |
+
"state_changes": {},
|
| 1163 |
+
"change_log": [],
|
| 1164 |
+
}
|
| 1165 |
+
return
|
| 1166 |
+
|
| 1167 |
+
# ★ 如果流式阶段未检测到标记但有累积文本,先 yield 给 UI 显示
|
| 1168 |
+
if not story_started and full_text.strip():
|
| 1169 |
+
logger.warning("流式阶段未检测到 STORY_TEXT 标记,直接使用累积文本")
|
| 1170 |
+
# 清理后先 yield 给 UI,确保用户能看到文本
|
| 1171 |
+
display = self._clean_story_text(full_text.strip())
|
| 1172 |
+
display = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', display, flags=re.MULTILINE)
|
| 1173 |
+
if display.strip():
|
| 1174 |
+
yield {"type": "story_chunk", "text": display.strip()}
|
| 1175 |
+
|
| 1176 |
+
# ★ 核心:只在数据流完全结束后,用完整 full_text 解析(暴力提取策略)
|
| 1177 |
+
story_text, options = self._parse_story_response(full_text)
|
| 1178 |
+
|
| 1179 |
+
# ★ 双重保险:确保恰好 3 个选项
|
| 1180 |
+
options = self._ensure_three_options(options)
|
| 1181 |
+
|
| 1182 |
+
logger.info(f"[流式开场] 最终 story_text 长度={len(story_text)}, 选项数={len(options)}")
|
| 1183 |
+
|
| 1184 |
+
yield {
|
| 1185 |
+
"type": "final",
|
| 1186 |
+
"story_text": story_text,
|
| 1187 |
+
"options": options,
|
| 1188 |
+
"state_changes": {},
|
| 1189 |
+
"change_log": [],
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
def generate_story_stream(self, player_intent: dict):
|
| 1193 |
+
"""
|
| 1194 |
+
流式生成故事响应(合并大纲+叙事为一次 API 调用)。
|
| 1195 |
+
|
| 1196 |
+
使用 MERGED_SYSTEM_PROMPT_TEMPLATE,一次调用完成:
|
| 1197 |
+
- 内部规划(THINKING 区域,不展示)
|
| 1198 |
+
- 故事文本(STORY_TEXT 区域,流式展示)
|
| 1199 |
+
- 状态变更(STATE_JSON 区域,程序解析)
|
| 1200 |
+
- 选项(OPTIONS_JSON 区域,程序解析)
|
| 1201 |
+
|
| 1202 |
+
Yields:
|
| 1203 |
+
{"type": "story_chunk", "text": "累积的故事文本"}
|
| 1204 |
+
{"type": "final", "story_text": ..., "options": ..., ...}
|
| 1205 |
+
"""
|
| 1206 |
+
logger.info(f"[流式/合并] 生成故事响应,玩家意图: {player_intent}")
|
| 1207 |
+
|
| 1208 |
+
# 推进时间
|
| 1209 |
+
tick_log = self.game_state.tick_time()
|
| 1210 |
+
|
| 1211 |
+
# 构建合并 Prompt
|
| 1212 |
+
system_prompt = MERGED_SYSTEM_PROMPT_TEMPLATE.format(
|
| 1213 |
+
world_state=self.game_state.to_prompt(),
|
| 1214 |
+
)
|
| 1215 |
+
|
| 1216 |
+
user_message = (
|
| 1217 |
+
f"玩家行动: {player_intent.get('raw_input', '未知行动')}\n"
|
| 1218 |
+
f"解析意图: {player_intent.get('intent', 'UNKNOWN')}\n"
|
| 1219 |
+
f"目标: {player_intent.get('target', '无')}\n"
|
| 1220 |
+
f"细节: {player_intent.get('details', '无')}"
|
| 1221 |
+
)
|
| 1222 |
+
|
| 1223 |
+
messages = [
|
| 1224 |
+
{"role": "system", "content": system_prompt},
|
| 1225 |
+
{"role": "user", "content": user_message},
|
| 1226 |
+
]
|
| 1227 |
+
|
| 1228 |
+
full_text = ""
|
| 1229 |
+
story_started = False
|
| 1230 |
+
story_ended = False
|
| 1231 |
+
|
| 1232 |
+
try:
|
| 1233 |
+
for chunk in call_qwen_stream(messages, model=self.model, temperature=0.7, max_tokens=3000):
|
| 1234 |
+
full_text += chunk
|
| 1235 |
+
|
| 1236 |
+
# 使用标准化标记检测,处理 LLM 输出的常见变体格式
|
| 1237 |
+
normalized = _normalize_markers(full_text)
|
| 1238 |
+
|
| 1239 |
+
# 跳过 THINKING 区域,从 STORY_TEXT 开始流式输出
|
| 1240 |
+
if not story_started:
|
| 1241 |
+
if "---STORY_TEXT---" in normalized:
|
| 1242 |
+
story_started = True
|
| 1243 |
+
idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 1244 |
+
current_story = normalized[idx:]
|
| 1245 |
+
# 检查是否已经到了 STATE_JSON
|
| 1246 |
+
if "---STATE_JSON---" in current_story:
|
| 1247 |
+
story_ended = True
|
| 1248 |
+
current_story = current_story[:current_story.index("---STATE_JSON---")]
|
| 1249 |
+
display_text = self._clean_story_text(current_story)
|
| 1250 |
+
if display_text.strip():
|
| 1251 |
+
yield {"type": "story_chunk", "text": display_text.strip()}
|
| 1252 |
+
elif not story_ended:
|
| 1253 |
+
idx = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 1254 |
+
current_story = normalized[idx:]
|
| 1255 |
+
if "---STATE_JSON---" in current_story:
|
| 1256 |
+
story_ended = True
|
| 1257 |
+
current_story = current_story[:current_story.index("---STATE_JSON---")]
|
| 1258 |
+
display_text = self._clean_story_text(current_story)
|
| 1259 |
+
if display_text.strip():
|
| 1260 |
+
yield {"type": "story_chunk", "text": display_text.strip()}
|
| 1261 |
+
|
| 1262 |
+
except Exception as e:
|
| 1263 |
+
logger.error(f"流式合并生成失败: {e},降级为非流式两阶段")
|
| 1264 |
+
try:
|
| 1265 |
+
result = self.generate_story(player_intent)
|
| 1266 |
+
# 降级结果也强制保证 3 个选项
|
| 1267 |
+
result["options"] = self._ensure_three_options(result.get("options", []))
|
| 1268 |
+
yield {"type": "final", **result}
|
| 1269 |
+
except Exception:
|
| 1270 |
+
fallback = self._fallback_response(player_intent, tick_log)
|
| 1271 |
+
fallback["options"] = self._ensure_three_options(fallback.get("options", []))
|
| 1272 |
+
yield {"type": "final", **fallback}
|
| 1273 |
+
return
|
| 1274 |
+
|
| 1275 |
+
# ★ 如果流式阶段未检测到标记但有累积文本,先 yield 给 UI 显示
|
| 1276 |
+
if not story_started and full_text.strip():
|
| 1277 |
+
logger.warning("[流式合并] 未检测到 STORY_TEXT 标记,直接使用累积文本")
|
| 1278 |
+
display = self._clean_story_text(full_text.strip())
|
| 1279 |
+
display = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', display, flags=re.MULTILINE)
|
| 1280 |
+
if display.strip():
|
| 1281 |
+
yield {"type": "story_chunk", "text": display.strip()}
|
| 1282 |
+
|
| 1283 |
+
# 解析合并响应
|
| 1284 |
+
outline, story_text, options = self._parse_merged_response(full_text)
|
| 1285 |
+
|
| 1286 |
+
if outline is None:
|
| 1287 |
+
# ★ outline 为空不代表 story_text 也为空!
|
| 1288 |
+
# 如果 story_text 已成功提取,继续走正常流程而非丢弃
|
| 1289 |
+
if story_text and story_text.strip():
|
| 1290 |
+
logger.warning("大纲(STATE_JSON)解析失败,但故事文本已提取,跳过状态更新继续")
|
| 1291 |
+
options = self._ensure_three_options(options)
|
| 1292 |
+
yield {
|
| 1293 |
+
"type": "final",
|
| 1294 |
+
"story_text": story_text,
|
| 1295 |
+
"options": options,
|
| 1296 |
+
"state_changes": {},
|
| 1297 |
+
"change_log": tick_log + ["(系统提示:本回合状态解析失败,未更新状态)"],
|
| 1298 |
+
"outline": None,
|
| 1299 |
+
"consistency_issues": [],
|
| 1300 |
+
}
|
| 1301 |
+
return
|
| 1302 |
+
else:
|
| 1303 |
+
logger.error("合并响应解析完全失败,使用降级")
|
| 1304 |
+
fallback = self._fallback_response(player_intent, tick_log)
|
| 1305 |
+
yield {"type": "final", **fallback}
|
| 1306 |
+
return
|
| 1307 |
+
|
| 1308 |
+
# 处理时间冲突
|
| 1309 |
+
state_changes = outline.get("state_changes", {})
|
| 1310 |
+
if state_changes.get("time_change"):
|
| 1311 |
+
tick_log = [line for line in tick_log if not line.startswith("时间流逝:")]
|
| 1312 |
+
|
| 1313 |
+
# 一致性检查
|
| 1314 |
+
consistency_issues = self.game_state.check_consistency(state_changes)
|
| 1315 |
+
|
| 1316 |
+
if consistency_issues:
|
| 1317 |
+
logger.warning(f"发现一致性问题: {consistency_issues}")
|
| 1318 |
+
|
| 1319 |
+
# 清理状态变更
|
| 1320 |
+
event_type = outline.get("event_type", "")
|
| 1321 |
+
state_changes, sanitize_warnings = self._sanitize_state_changes(state_changes, event_type)
|
| 1322 |
+
|
| 1323 |
+
# 应用状态变更
|
| 1324 |
+
change_log = self.game_state.apply_changes(state_changes)
|
| 1325 |
+
|
| 1326 |
+
# 校验状态合法性
|
| 1327 |
+
is_valid, validation_issues = self.game_state.validate()
|
| 1328 |
+
|
| 1329 |
+
# 记录事件
|
| 1330 |
+
self.game_state.log_event(
|
| 1331 |
+
event_type=outline.get("event_type", "UNKNOWN"),
|
| 1332 |
+
description=outline.get("event_summary", ""),
|
| 1333 |
+
player_action=player_intent.get("raw_input", ""),
|
| 1334 |
+
involved_npcs=outline.get("involved_npcs", []),
|
| 1335 |
+
state_changes=state_changes,
|
| 1336 |
+
consequence_tags=outline.get("consequence_tags", []),
|
| 1337 |
+
is_reversible=outline.get("is_reversible", True),
|
| 1338 |
+
)
|
| 1339 |
+
|
| 1340 |
+
# 检查游戏结束
|
| 1341 |
+
if self.game_state.is_game_over():
|
| 1342 |
+
death_result = self._generate_death_narrative()
|
| 1343 |
+
yield {"type": "final", **death_result}
|
| 1344 |
+
return
|
| 1345 |
+
|
| 1346 |
+
# 验证选项
|
| 1347 |
+
options = self._validate_options(options)
|
| 1348 |
+
|
| 1349 |
+
# ★ 双重保险:强制确保恰好 3 个选项
|
| 1350 |
+
options = self._ensure_three_options(options)
|
| 1351 |
+
|
| 1352 |
+
# 合并日志
|
| 1353 |
+
merged_log = _merge_change_logs(tick_log, change_log + validation_issues)
|
| 1354 |
+
|
| 1355 |
+
yield {
|
| 1356 |
+
"type": "final",
|
| 1357 |
+
"story_text": story_text,
|
| 1358 |
+
"options": options,
|
| 1359 |
+
"state_changes": state_changes,
|
| 1360 |
+
"change_log": merged_log,
|
| 1361 |
+
"outline": outline,
|
| 1362 |
+
"consistency_issues": consistency_issues,
|
| 1363 |
+
}
|
| 1364 |
+
|
| 1365 |
+
def process_option_selection_stream(self, option: dict):
|
| 1366 |
+
"""
|
| 1367 |
+
流式处理玩家点击选项。
|
| 1368 |
+
将选项转化为意图格式,然后调用 generate_story_stream。
|
| 1369 |
+
"""
|
| 1370 |
+
intent = {
|
| 1371 |
+
"intent": option.get("action_type", "EXPLORE"),
|
| 1372 |
+
"target": None,
|
| 1373 |
+
"details": option.get("text", ""),
|
| 1374 |
+
"raw_input": option.get("text", ""),
|
| 1375 |
+
}
|
| 1376 |
+
yield from self.generate_story_stream(intent)
|
| 1377 |
+
|
| 1378 |
+
def _parse_merged_response(self, raw_text: str) -> tuple:
|
| 1379 |
+
"""
|
| 1380 |
+
解析合并格式的响应(THINKING + STORY_TEXT + STATE_JSON + OPTIONS_JSON)。
|
| 1381 |
+
|
| 1382 |
+
Returns:
|
| 1383 |
+
(outline_dict, story_text, options_list)
|
| 1384 |
+
outline_dict 可能为 None(解析失败时)
|
| 1385 |
+
"""
|
| 1386 |
+
# ★ 调试日志:记录原始 API 返回内容
|
| 1387 |
+
logger.debug(f"原始 API 返回内容(合并模式): {raw_text}")
|
| 1388 |
+
|
| 1389 |
+
outline = None
|
| 1390 |
+
story_text = ""
|
| 1391 |
+
options = []
|
| 1392 |
+
|
| 1393 |
+
# 标准化标记格式
|
| 1394 |
+
normalized = _normalize_markers(raw_text)
|
| 1395 |
+
|
| 1396 |
+
# 解析 STORY_TEXT
|
| 1397 |
+
if "---STORY_TEXT---" in normalized:
|
| 1398 |
+
story_start = normalized.index("---STORY_TEXT---") + len("---STORY_TEXT---")
|
| 1399 |
+
# STORY_TEXT 的结尾可能是 STATE_JSON 或 OPTIONS_JSON
|
| 1400 |
+
story_end = len(normalized)
|
| 1401 |
+
if "---STATE_JSON---" in normalized:
|
| 1402 |
+
story_end = normalized.index("---STATE_JSON---")
|
| 1403 |
+
elif "---OPTIONS_JSON---" in normalized:
|
| 1404 |
+
story_end = normalized.index("---OPTIONS_JSON---")
|
| 1405 |
+
story_text = normalized[story_start:story_end].strip()
|
| 1406 |
+
|
| 1407 |
+
# 解析 STATE_JSON
|
| 1408 |
+
if "---STATE_JSON---" in normalized:
|
| 1409 |
+
state_start = normalized.index("---STATE_JSON---") + len("---STATE_JSON---")
|
| 1410 |
+
state_end = len(normalized)
|
| 1411 |
+
if "---OPTIONS_JSON---" in normalized:
|
| 1412 |
+
state_end = normalized.index("---OPTIONS_JSON---")
|
| 1413 |
+
state_part = normalized[state_start:state_end].strip()
|
| 1414 |
+
outline = extract_json_from_text(state_part)
|
| 1415 |
+
|
| 1416 |
+
# 解析 OPTIONS_JSON
|
| 1417 |
+
if "---OPTIONS_JSON---" in normalized:
|
| 1418 |
+
options_start = normalized.index("---OPTIONS_JSON---") + len("---OPTIONS_JSON---")
|
| 1419 |
+
options_part = normalized[options_start:].strip()
|
| 1420 |
+
parsed = extract_json_from_text(options_part)
|
| 1421 |
+
if isinstance(parsed, list):
|
| 1422 |
+
options = parsed
|
| 1423 |
+
|
| 1424 |
+
if not options:
|
| 1425 |
+
options = self._generate_default_options()
|
| 1426 |
+
|
| 1427 |
+
# 确保恰好 3 个选项
|
| 1428 |
+
options = self._ensure_three_options(options)
|
| 1429 |
+
|
| 1430 |
+
if not story_text and outline is None:
|
| 1431 |
+
# 完全解析失败 → 使用暴力提取策略
|
| 1432 |
+
logger.warning("合并格式解析完全失败,使用暴力提取策略")
|
| 1433 |
+
# 先移除 THINKING 区域
|
| 1434 |
+
cleaned = re.sub(r'---THINKING---.*?(?=---[A-Z]|$)', '', raw_text, flags=re.DOTALL | re.IGNORECASE).strip()
|
| 1435 |
+
if not cleaned:
|
| 1436 |
+
cleaned = raw_text
|
| 1437 |
+
story_text, options_fallback = self._brute_force_extract(cleaned)
|
| 1438 |
+
if options_fallback:
|
| 1439 |
+
options = self._ensure_three_options(options_fallback)
|
| 1440 |
+
elif not story_text.strip() and raw_text.strip():
|
| 1441 |
+
# story_text 为空但有原始文本 → 暴力提取
|
| 1442 |
+
logger.warning("story_text 为空,使用暴力提取")
|
| 1443 |
+
cleaned = re.sub(r'---THINKING---.*?(?=---[A-Z]|$)', '', raw_text, flags=re.DOTALL | re.IGNORECASE).strip()
|
| 1444 |
+
if not cleaned:
|
| 1445 |
+
cleaned = raw_text
|
| 1446 |
+
story_text, options_fallback = self._brute_force_extract(cleaned)
|
| 1447 |
+
if options_fallback:
|
| 1448 |
+
options = self._ensure_three_options(options_fallback)
|
| 1449 |
+
|
| 1450 |
+
# 清理 story_text 中可能残留的 JSON 和标记
|
| 1451 |
+
story_text = self._clean_story_text(story_text)
|
| 1452 |
+
|
| 1453 |
+
# 移除常见 AI 前缀
|
| 1454 |
+
story_text = re.sub(r'^(好的[,,]?\s*(这是|以下是|下面是).*?[::]\s*)', '', story_text, flags=re.MULTILINE)
|
| 1455 |
+
|
| 1456 |
+
# 确保 story_text 不为空
|
| 1457 |
+
if not story_text.strip():
|
| 1458 |
+
story_text = "你环顾四周,思考着接下来该做什么..."
|
| 1459 |
+
|
| 1460 |
+
# 确保选项
|
| 1461 |
+
if not options:
|
| 1462 |
+
options = self._generate_default_options()
|
| 1463 |
+
options = self._ensure_three_options(options)
|
| 1464 |
+
|
| 1465 |
+
return outline, story_text, options
|
utils.py
CHANGED
|
@@ -63,8 +63,8 @@ def get_client() -> OpenAI:
|
|
| 63 |
# ============================================================
|
| 64 |
# 默认模型配置
|
| 65 |
# ============================================================
|
| 66 |
-
#
|
| 67 |
-
DEFAULT_MODEL: str = "qwen-
|
| 68 |
|
| 69 |
|
| 70 |
# ============================================================
|
|
@@ -132,6 +132,45 @@ def call_qwen(
|
|
| 132 |
)
|
| 133 |
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
# ============================================================
|
| 136 |
# JSON 安全解析工具
|
| 137 |
# ============================================================
|
|
|
|
| 63 |
# ============================================================
|
| 64 |
# 默认模型配置
|
| 65 |
# ============================================================
|
| 66 |
+
# 使用 qwen-turbo 以获得最快的响应速度
|
| 67 |
+
DEFAULT_MODEL: str = "qwen-turbo"
|
| 68 |
|
| 69 |
|
| 70 |
# ============================================================
|
|
|
|
| 132 |
)
|
| 133 |
|
| 134 |
|
| 135 |
+
def call_qwen_stream(
|
| 136 |
+
messages: list[dict[str, str]],
|
| 137 |
+
model: str = DEFAULT_MODEL,
|
| 138 |
+
temperature: float = 0.8,
|
| 139 |
+
max_tokens: int = 2000,
|
| 140 |
+
):
|
| 141 |
+
"""
|
| 142 |
+
调用 Qwen API 的流式版本,逐块 yield 文本内容。
|
| 143 |
+
|
| 144 |
+
使用 stream=True,让用户在 AI 生成过程中就能看到文字逐步出现,
|
| 145 |
+
大幅改善感知延迟。
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
messages: OpenAI 格式的消息列表
|
| 149 |
+
model: 模型名称
|
| 150 |
+
temperature: 生成温度
|
| 151 |
+
max_tokens: 最大生成 token 数
|
| 152 |
+
|
| 153 |
+
Yields:
|
| 154 |
+
每次生成的文本片段(str)
|
| 155 |
+
"""
|
| 156 |
+
client = get_client()
|
| 157 |
+
logger.info(f"调用 Qwen 流式 API,模型: {model}")
|
| 158 |
+
try:
|
| 159 |
+
response = client.chat.completions.create(
|
| 160 |
+
model=model,
|
| 161 |
+
messages=messages,
|
| 162 |
+
temperature=temperature,
|
| 163 |
+
max_tokens=max_tokens,
|
| 164 |
+
stream=True,
|
| 165 |
+
)
|
| 166 |
+
for chunk in response:
|
| 167 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 168 |
+
yield chunk.choices[0].delta.content
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"流式 API 调用失败: {e}")
|
| 171 |
+
raise
|
| 172 |
+
|
| 173 |
+
|
| 174 |
# ============================================================
|
| 175 |
# JSON 安全解析工具
|
| 176 |
# ============================================================
|