wzh0617 commited on
Commit
4998893
·
verified ·
1 Parent(s): 1b980f7

Upload 7 files

Browse files
Files changed (7) hide show
  1. app.py +1394 -990
  2. combat_engine.py +97 -0
  3. demo_rules.py +0 -0
  4. nlu_engine.py +163 -74
  5. state_manager.py +319 -184
  6. story_engine.py +0 -0
  7. utils.py +27 -27
app.py CHANGED
@@ -13,62 +13,79 @@ app.py - StoryWeaver Gradio 交互界面
13
  Gradio UI ← 状态管理器(校验 + 更新) ← 叙事引擎(文本 + 选项)
14
  """
15
 
16
- import copy
17
- import json
18
- import logging
19
- from time import perf_counter
20
- import gradio as gr
21
-
22
- from state_manager import GameState
23
- from nlu_engine import NLUEngine
24
- from scene_assets import get_scene_image_path
25
- from story_engine import StoryEngine
26
- from telemetry import append_turn_log, create_session_metadata
27
- from utils import logger
28
-
29
- APP_UI_CSS = """
30
- .story-chat {min-height: 500px;}
31
- .status-panel {
32
- font-family: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
33
- font-size: 0.88em;
34
- line-height: 1.6;
35
- }
36
- .option-btn {min-height: 50px !important;}
37
- .scene-sidebar {gap: 14px;}
38
- .scene-card {
39
- border: 1px solid rgba(161, 125, 83, 0.14) !important;
40
- border-radius: 18px !important;
41
- background:
42
- linear-gradient(180deg, rgba(255, 249, 241, 0.98) 0%,
43
- rgba(246, 238, 226, 0.96) 100%) !important;
44
- box-shadow: 0 14px 28px rgba(110, 81, 49, 0.08) !important;
45
- }
46
- .scene-image {
47
- min-height: 280px;
48
- padding: 14px !important;
49
- }
50
- .scene-image > div,
51
- .scene-image img,
52
- .scene-image button,
53
- .scene-image [class*="image"],
54
- .scene-image [class*="wrap"],
55
- .scene-image [class*="frame"],
56
- .scene-image [class*="preview"] {
57
- border: none !important;
58
- box-shadow: none !important;
59
- background: transparent !important;
60
- }
61
- .scene-image img {
62
- width: 100%;
63
- height: 100%;
64
- object-fit: contain !important;
65
- border-radius: 14px;
66
- padding: 8px;
67
- background:
68
- radial-gradient(circle at top, rgba(255, 255, 255, 0.95) 0%,
69
- rgba(247, 239, 227, 0.92) 100%) !important;
70
- }
71
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  # ============================================================
74
  # 全局游戏实例(每个会话独立)
@@ -78,158 +95,458 @@ APP_UI_CSS = """
78
  # 这里先定义工厂函数
79
 
80
 
81
- def create_new_game(player_name: str = "旅人") -> dict:
82
- """创建新游戏实例,返回包含所有引擎的字典"""
83
- game_state = GameState(player_name=player_name)
84
- nlu = NLUEngine(game_state)
85
- story = StoryEngine(game_state, enable_rule_text_polish=True)
86
- return {
87
- "game_state": game_state,
88
- "nlu": nlu,
89
- "story": story,
90
- "current_options": [],
91
- "started": False,
92
- **create_session_metadata(),
93
- }
94
-
95
-
96
- def _json_safe(value):
97
- """Convert nested values into JSON-serializable data for logs."""
98
- if value is None or isinstance(value, (str, int, float, bool)):
99
- return value
100
- if isinstance(value, dict):
101
- return {str(key): _json_safe(val) for key, val in value.items()}
102
- if isinstance(value, (list, tuple, set)):
103
- return [_json_safe(item) for item in value]
104
- if hasattr(value, "model_dump"):
105
- return _json_safe(value.model_dump())
106
- return str(value)
107
-
108
-
109
- def _build_state_snapshot(gs: GameState) -> dict:
110
- """Build a compact state snapshot for reproducible evaluation logs."""
111
- active_quests = []
112
- effective_stats = gs.get_effective_player_stats()
113
- equipment_bonuses = gs.get_equipment_stat_bonuses()
114
- environment_snapshot = gs.get_environment_snapshot(limit=3)
115
- for quest in gs.world.quests.values():
116
- if quest.status == "active":
117
- active_quests.append(
118
- {
119
- "quest_id": quest.quest_id,
120
- "title": quest.title,
121
- "status": quest.status,
122
- "objectives": _json_safe(quest.objectives),
123
- }
124
- )
125
-
126
- return {
127
- "turn": gs.turn,
128
- "game_mode": gs.game_mode,
129
- "location": gs.player.location,
130
- "scene": gs.world.current_scene,
131
- "day": gs.world.day_count,
132
- "time_of_day": gs.world.time_of_day,
133
- "weather": gs.world.weather,
134
- "light_level": gs.world.light_level,
135
- "environment": _json_safe(environment_snapshot),
136
- "player": {
137
- "name": gs.player.name,
138
- "level": gs.player.level,
139
- "hp": gs.player.hp,
140
- "max_hp": gs.player.max_hp,
141
- "mp": gs.player.mp,
142
- "max_mp": gs.player.max_mp,
143
- "attack": gs.player.attack,
144
- "defense": gs.player.defense,
145
- "speed": gs.player.speed,
146
- "luck": gs.player.luck,
147
- "perception": gs.player.perception,
148
- "gold": gs.player.gold,
149
- "morale": gs.player.morale,
150
- "sanity": gs.player.sanity,
151
- "hunger": gs.player.hunger,
152
- "karma": gs.player.karma,
153
- "effective_stats": _json_safe(effective_stats),
154
- "equipment_bonuses": _json_safe(equipment_bonuses),
155
- "inventory": list(gs.player.inventory),
156
- "equipment": copy.deepcopy(gs.player.equipment),
157
- "skills": list(gs.player.skills),
158
- "status_effects": [effect.name for effect in gs.player.status_effects],
159
- },
160
- "active_quests": active_quests,
161
- "event_log_size": len(gs.event_log),
162
- }
163
-
164
-
165
- def _record_interaction_log(
166
- game_session: dict,
167
- *,
168
- input_source: str,
169
- user_input: str,
170
- intent_result: dict | None,
171
- output_text: str,
172
- latency_ms: float,
173
- nlu_latency_ms: float | None = None,
174
- generation_latency_ms: float | None = None,
175
- final_result: dict | None = None,
176
- selected_option: dict | None = None,
177
- ):
178
- """Append a structured interaction log without affecting gameplay."""
179
- if not game_session or "game_state" not in game_session:
180
- return
181
-
182
- final_result = final_result or {}
183
- telemetry = _json_safe(final_result.get("telemetry", {})) or {}
184
- record = {
185
- "input_source": input_source,
186
- "user_input": user_input,
187
- "selected_option": _json_safe(selected_option),
188
- "nlu_result": _json_safe(intent_result),
189
- "latency_ms": round(latency_ms, 2),
190
- "nlu_latency_ms": None if nlu_latency_ms is None else round(nlu_latency_ms, 2),
191
- "generation_latency_ms": None if generation_latency_ms is None else round(generation_latency_ms, 2),
192
- "used_fallback": bool(telemetry.get("used_fallback", False)),
193
- "fallback_reason": telemetry.get("fallback_reason"),
194
- "engine_mode": telemetry.get("engine_mode"),
195
- "state_changes": _json_safe(final_result.get("state_changes", {})),
196
- "change_log": _json_safe(final_result.get("change_log", [])),
197
- "consistency_issues": _json_safe(final_result.get("consistency_issues", [])),
198
- "output_text": output_text,
199
- "story_text": final_result.get("story_text"),
200
- "options": _json_safe(final_result.get("options", game_session.get("current_options", []))),
201
- "post_turn_snapshot": _build_state_snapshot(game_session["game_state"]),
202
- }
203
-
204
- try:
205
- append_turn_log(game_session, record)
206
- except Exception as exc:
207
- logger.warning(f"Failed to append interaction log: {exc}")
208
-
209
-
210
- def _build_option_intent(selected_option: dict) -> dict:
211
- """Represent button clicks in the same schema as free-text NLU output."""
212
- option_text = selected_option.get("text", "")
213
- return {
214
- "intent": selected_option.get("action_type", "EXPLORE"),
215
- "target": selected_option.get("target"),
216
- "details": option_text,
217
- "raw_input": option_text,
218
- "parser_source": "option_click",
219
- }
220
-
221
-
222
- def _get_scene_image_value(gs: GameState) -> str | None:
223
- focus_npc = getattr(gs, "last_interacted_npc", None)
224
- return get_scene_image_path(gs, focus_npc=focus_npc)
225
-
226
-
227
- def _get_scene_image_update(gs: GameState):
228
- image_value = _get_scene_image_value(gs)
229
- return gr.update(value=image_value, visible=bool(image_value))
230
-
231
-
232
- def restart_game() -> tuple:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  """
234
  重启冒险:清空所有数据,回到初始输入名称阶段。
235
 
@@ -238,15 +555,17 @@ def restart_game() -> tuple:
238
  禁用文本输入框, 重置角色名称)
239
  """
240
  loading = _get_loading_button_updates()
241
- return (
242
- [], # 清空聊天历史
243
- "## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
244
- gr.update(value=None, visible=False), # 清空场景图片
245
- *loading, # 占位选项按钮
246
- {}, # 清空游戏会话
247
- gr.update(value="", interactive=False), # 禁用并清空文本输入
248
- gr.update(value="旅人"), # 重置角色名称
249
- )
 
 
250
 
251
 
252
  # ============================================================
@@ -269,35 +588,44 @@ def start_game(player_name: str, game_session: dict):
269
 
270
  # 初始 yield:显示加载状态,按钮保持可见但禁用
271
  chat_history = [{"role": "assistant", "content": "⏳ 正在生成开场..."}]
 
272
  status_text = _format_status_panel(game_session["game_state"])
273
  loading = _get_loading_button_updates()
274
 
275
- yield (
276
- chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
277
- *loading,
278
- game_session,
279
- gr.update(interactive=False),
280
- )
 
 
 
 
281
 
282
  # 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
283
- turn_started = perf_counter()
284
- story_text = ""
285
  final_result = None
286
 
287
  for update in game_session["story"].generate_opening_stream():
288
  if update["type"] == "story_chunk":
289
  story_text = update["text"]
290
  chat_history[-1]["content"] = story_text
291
- yield (
292
- chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
293
- *loading,
294
- game_session,
295
- gr.update(interactive=False),
296
- )
 
 
 
 
297
  elif update["type"] == "final":
298
- final_result = update
299
-
300
- generation_latency_ms = (perf_counter() - turn_started) * 1000
301
 
302
  # ★ 只在数据流完全结束后,从 final_result 中提取选项
303
  if final_result:
@@ -307,46 +635,50 @@ def start_game(player_name: str, game_session: dict):
307
  options = []
308
 
309
  # ★ 安全兜底:强制确保恰好 3 个选项
310
- options = _finalize_session_options(options)
311
 
312
  # 最终 yield:显示完整文本 + 选项 + 启用按钮
313
- game_session["current_options"] = options
314
- options_text = _format_options(options)
315
- full_message = f"{story_text}\n\n{options_text}"
316
- if not final_result:
317
- final_result = {
318
- "story_text": story_text,
319
- "options": options,
320
- "state_changes": {},
321
- "change_log": [],
322
- "consistency_issues": [],
323
- "telemetry": {
324
- "engine_mode": "opening_app",
325
- "used_fallback": True,
326
- "fallback_reason": "missing_final_event",
327
- },
328
- }
329
-
330
- chat_history[-1]["content"] = full_message
331
- status_text = _format_status_panel(game_session["game_state"])
332
- btn_updates = _get_button_updates(options)
333
- _record_interaction_log(
334
- game_session,
335
- input_source="system_opening",
336
- user_input="",
337
- intent_result=None,
338
- output_text=full_message,
339
- latency_ms=generation_latency_ms,
340
- generation_latency_ms=generation_latency_ms,
341
- final_result=final_result,
342
- )
343
-
344
- yield (
345
- chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
346
- *btn_updates,
347
- game_session,
348
- gr.update(interactive=True),
349
- )
 
 
 
 
350
 
351
 
352
  def process_user_input(user_input: str, chat_history: list, game_session: dict):
@@ -358,138 +690,153 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
358
  2. 叙事引擎流式生成故事
359
  3. 逐步更新 UI
360
  """
361
- if not game_session or not game_session.get("started"):
362
- chat_history = chat_history or []
363
- chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
364
- loading = _get_loading_button_updates()
365
- yield (
366
- chat_history, "", gr.update(value=None, visible=False),
367
- *loading,
368
- game_session,
369
- )
370
- return
371
-
372
- if not user_input.strip():
373
- btn_updates = _get_button_updates(game_session.get("current_options", []))
374
- yield (
375
- chat_history, _format_status_panel(game_session["game_state"]), _get_scene_image_update(game_session["game_state"]),
376
- *btn_updates,
377
- game_session,
378
- )
379
- return
380
-
381
- gs: GameState = game_session["game_state"]
382
- nlu: NLUEngine = game_session["nlu"]
383
- story: StoryEngine = game_session["story"]
384
- turn_started = perf_counter()
 
 
 
 
 
 
 
 
385
 
386
  # 检查游戏是否已结束
387
- if gs.is_game_over():
388
- chat_history.append({"role": "user", "content": user_input})
389
- chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
390
- restart_buttons = _get_button_updates(
391
- [
392
- {"id": 1, "text": "重新开始", "action_type": "RESTART"},
393
- ]
394
- )
395
- yield (
396
- chat_history,
397
- _format_status_panel(gs),
398
- _get_scene_image_update(gs),
399
- *restart_buttons,
400
- game_session,
401
- )
402
- return
403
-
404
- # 1. NLU 解析
405
- nlu_started = perf_counter()
406
- intent = nlu.parse_intent(user_input)
407
- nlu_latency_ms = (perf_counter() - nlu_started) * 1000
 
 
408
 
409
  # 1.5 预校验:立即驳回违反一致性的操作(不调用 LLM,不消耗回合)
410
  is_valid, rejection_msg = gs.pre_validate_action(intent)
411
  if not is_valid:
412
- chat_history.append({"role": "user", "content": user_input})
413
- options = game_session.get("current_options", [])
414
- options = _finalize_session_options(options)
415
- options_text = _format_options(options)
416
  rejection_content = (
417
  f"⚠️ **行动被驳回**:{rejection_msg}\n\n"
418
- f"请重新选择行动,或输入其他指令。\n\n{options_text}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  )
420
- chat_history.append({"role": "assistant", "content": rejection_content})
421
- rejection_result = {
422
- "story_text": rejection_content,
423
- "options": options,
424
- "state_changes": {},
425
- "change_log": [],
426
- "consistency_issues": [],
427
- "telemetry": {
428
- "engine_mode": "pre_validation",
429
- "used_fallback": False,
430
- "fallback_reason": None,
431
- },
432
- }
433
- _record_interaction_log(
434
- game_session,
435
- input_source="text_input",
436
- user_input=user_input,
437
- intent_result=intent,
438
- output_text=rejection_content,
439
- latency_ms=(perf_counter() - turn_started) * 1000,
440
- nlu_latency_ms=nlu_latency_ms,
441
- generation_latency_ms=0.0,
442
- final_result=rejection_result,
443
- )
444
- btn_updates = _get_button_updates(options)
445
- yield (
446
- chat_history,
447
- _format_status_panel(gs),
448
- _get_scene_image_update(gs),
449
- *btn_updates,
450
- game_session,
451
- )
452
- return
453
 
454
  # 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
455
  chat_history.append({"role": "user", "content": user_input})
456
  chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
457
 
458
  # 按钮保持可见但禁用,防止流式期间点击
459
- loading = _get_loading_button_updates(
460
- max(len(game_session.get("current_options", [])), MIN_OPTION_BUTTONS)
461
- )
462
- yield (
463
- chat_history,
464
- _format_status_panel(gs),
465
- _get_scene_image_update(gs),
466
- *loading,
467
- game_session,
468
- )
 
 
469
 
470
  # 3. 流式生成故事
471
- generation_started = perf_counter()
472
- final_result = None
473
- for update in story.generate_story_stream(intent):
474
- if update["type"] == "story_chunk":
475
- chat_history[-1]["content"] = update["text"]
476
- yield (
477
- chat_history,
478
- _format_status_panel(gs),
479
- _get_scene_image_update(gs),
480
- *loading,
481
- game_session,
482
- )
483
- elif update["type"] == "final":
484
- final_result = update
485
-
486
- generation_latency_ms = (perf_counter() - generation_started) * 1000
 
 
487
 
488
  # 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
489
  if final_result:
490
  # ★ 安全兜底:强制确保恰好 3 个选项
491
- options = _finalize_session_options(final_result.get("options", []))
492
- game_session["current_options"] = options
493
 
494
  change_log = final_result.get("change_log", [])
495
  log_text = ""
@@ -503,113 +850,121 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
503
  issues_text = "\n".join(f" {i}" for i in issues)
504
  issues_text = f"\n\n**一致性提示:**\n{issues_text}"
505
 
506
- options_text = _format_options(options)
507
- full_message = f"{final_result['story_text']}{log_text}{issues_text}\n\n{options_text}"
508
- chat_history[-1]["content"] = full_message
509
-
510
- status_text = _format_status_panel(gs)
511
- btn_updates = _get_button_updates(options)
512
- _record_interaction_log(
513
- game_session,
514
- input_source="text_input",
515
- user_input=user_input,
516
- intent_result=intent,
517
- output_text=full_message,
518
- latency_ms=(perf_counter() - turn_started) * 1000,
519
- nlu_latency_ms=nlu_latency_ms,
520
- generation_latency_ms=generation_latency_ms,
521
- final_result=final_result,
522
- )
523
-
524
- yield (
525
- chat_history,
526
- status_text,
527
- _get_scene_image_update(gs),
528
- *btn_updates,
529
- game_session,
530
- )
 
531
  else:
532
  # ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
533
  logger.warning("流式生成未产生 final 事件,使用兜底文本")
534
  fallback_text = "你环顾四周,思考着接下来该做什么..."
535
- fallback_options = _finalize_session_options([])
536
- game_session["current_options"] = fallback_options
537
-
538
- options_text = _format_options(fallback_options)
539
- full_message = f"{fallback_text}\n\n{options_text}"
540
- fallback_result = {
541
- "story_text": fallback_text,
542
- "options": fallback_options,
543
- "state_changes": {},
544
- "change_log": [],
545
- "consistency_issues": [],
546
- "telemetry": {
547
- "engine_mode": "app_fallback",
548
- "used_fallback": True,
549
- "fallback_reason": "missing_final_event",
550
- },
551
- }
552
- chat_history[-1]["content"] = full_message
553
-
554
- status_text = _format_status_panel(gs)
555
- btn_updates = _get_button_updates(fallback_options)
556
- _record_interaction_log(
557
- game_session,
558
- input_source="text_input",
559
- user_input=user_input,
560
- intent_result=intent,
561
- output_text=full_message,
562
- latency_ms=(perf_counter() - turn_started) * 1000,
563
- nlu_latency_ms=nlu_latency_ms,
564
- generation_latency_ms=generation_latency_ms,
565
- final_result=fallback_result,
566
- )
567
-
568
- yield (
569
- chat_history,
570
- status_text,
571
- _get_scene_image_update(gs),
572
- *btn_updates,
573
- game_session,
574
- )
575
- return
576
-
577
-
578
- def process_option_click(option_idx: int, chat_history: list, game_session: dict):
 
579
  """
580
- 处理玩家点击选项按钮(流式版本)。
581
-
582
- Args:
583
- option_idx: 选项索引 (0-5)
584
- """
585
- if not game_session or not game_session.get("started"):
586
- chat_history = chat_history or []
587
- chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
588
- loading = _get_loading_button_updates()
589
- yield (
590
- chat_history, "", gr.update(value=None, visible=False),
591
- *loading,
592
- game_session,
593
- )
594
- return
595
-
596
- options = game_session.get("current_options", [])
597
- if option_idx >= len(options):
598
- btn_updates = _get_button_updates(options)
599
- yield (
600
- chat_history,
601
- _format_status_panel(game_session["game_state"]),
602
- _get_scene_image_update(game_session["game_state"]),
603
- *btn_updates,
604
- game_session,
605
- )
606
- return
607
-
608
- selected_option = options[option_idx]
609
- gs: GameState = game_session["game_state"]
610
- story: StoryEngine = game_session["story"]
611
- option_intent = _build_option_intent(selected_option)
612
- turn_started = perf_counter()
 
 
 
 
 
 
613
 
614
  # 检查特殊选项:重新开始
615
  if selected_option.get("action_type") == "RESTART":
@@ -617,15 +972,20 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
617
  game_session = create_new_game(gs.player.name)
618
  game_session["started"] = True
619
 
620
- chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
621
- status_text = _format_status_panel(game_session["game_state"])
622
- loading = _get_loading_button_updates()
623
-
624
- yield (
625
- chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
626
- *loading,
627
- game_session,
628
- )
 
 
 
 
 
629
 
630
  story_text = ""
631
  restart_final = None
@@ -634,11 +994,15 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
634
  if update["type"] == "story_chunk":
635
  story_text = update["text"]
636
  chat_history[-1]["content"] = story_text
637
- yield (
638
- chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
639
- *loading,
640
- game_session,
641
- )
 
 
 
 
642
  elif update["type"] == "final":
643
  restart_final = update
644
 
@@ -650,75 +1014,84 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
650
  restart_options = []
651
 
652
  # ★ 安全兜底:强制确保恰好 3 个选项
653
- restart_options = _finalize_session_options(restart_options)
654
- game_session["current_options"] = restart_options
655
- options_text = _format_options(restart_options)
656
- full_message = f"{story_text}\n\n{options_text}"
657
  chat_history[-1]["content"] = full_message
658
 
659
  status_text = _format_status_panel(game_session["game_state"])
660
  btn_updates = _get_button_updates(restart_options)
661
 
662
- yield (
663
- chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
664
- *btn_updates,
665
- game_session,
666
- )
667
- return
 
 
 
 
668
 
669
  # 检查特殊选项:退出
670
  if selected_option.get("action_type") == "QUIT":
671
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
672
- chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
673
- quit_buttons = _get_button_updates(
674
- [
675
- {"id": 1, "text": "重新开始", "action_type": "RESTART"},
676
- ]
677
- )
678
- yield (
679
- chat_history,
680
- _format_status_panel(gs),
681
- _get_scene_image_update(gs),
682
- *quit_buttons,
683
- game_session,
684
- )
685
- return
 
 
686
 
687
  # 正常选项处理:流式生成
688
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
689
  chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
690
 
691
  # 按钮保持可见但禁用
692
- loading = _get_loading_button_updates(max(len(options), MIN_OPTION_BUTTONS))
693
- yield (
694
- chat_history,
695
- _format_status_panel(gs),
696
- _get_scene_image_update(gs),
697
- *loading,
698
- game_session,
699
- )
700
-
701
- generation_started = perf_counter()
702
- final_result = None
703
- for update in story.process_option_selection_stream(selected_option):
704
- if update["type"] == "story_chunk":
705
- chat_history[-1]["content"] = update["text"]
706
- yield (
707
- chat_history,
708
- _format_status_panel(gs),
709
- _get_scene_image_update(gs),
710
- *loading,
711
- game_session,
712
- )
713
- elif update["type"] == "final":
714
- final_result = update
715
-
716
- generation_latency_ms = (perf_counter() - generation_started) * 1000
 
 
 
 
717
 
718
  if final_result:
719
  # ★ 安全兜底:强制确保恰好 3 个选项
720
- options = _finalize_session_options(final_result.get("options", []))
721
- game_session["current_options"] = options
722
 
723
  change_log = final_result.get("change_log", [])
724
  log_text = ""
@@ -726,130 +1099,136 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
726
  log_text = "\n".join(f" {c}" for c in change_log)
727
  log_text = f"\n\n**状态变化:**\n{log_text}"
728
 
729
- options_text = _format_options(options)
730
- full_message = f"{final_result['story_text']}{log_text}\n\n{options_text}"
731
- chat_history[-1]["content"] = full_message
732
-
733
- status_text = _format_status_panel(gs)
734
- btn_updates = _get_button_updates(options)
735
- _record_interaction_log(
736
- game_session,
737
- input_source="option_click",
738
- user_input=selected_option.get("text", ""),
739
- intent_result=option_intent,
740
- output_text=full_message,
741
- latency_ms=(perf_counter() - turn_started) * 1000,
742
- generation_latency_ms=generation_latency_ms,
743
- final_result=final_result,
744
- selected_option=selected_option,
745
- )
746
-
747
- yield (
748
- chat_history, status_text, _get_scene_image_update(gs),
749
- *btn_updates,
750
- game_session,
751
- )
752
- else:
 
 
 
753
  # ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
754
  logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
755
  fallback_text = "你环顾四周,思考着接下来该做什么..."
756
- fallback_options = _finalize_session_options([])
757
- game_session["current_options"] = fallback_options
758
-
759
- options_text = _format_options(fallback_options)
760
- full_message = f"{fallback_text}\n\n{options_text}"
761
- fallback_result = {
762
- "story_text": fallback_text,
763
- "options": fallback_options,
764
- "state_changes": {},
765
- "change_log": [],
766
- "consistency_issues": [],
767
- "telemetry": {
768
- "engine_mode": "app_fallback",
769
- "used_fallback": True,
770
- "fallback_reason": "missing_final_event",
771
- },
772
- }
773
- chat_history[-1]["content"] = full_message
774
-
775
- status_text = _format_status_panel(gs)
776
- btn_updates = _get_button_updates(fallback_options)
777
- _record_interaction_log(
778
- game_session,
779
- input_source="option_click",
780
- user_input=selected_option.get("text", ""),
781
- intent_result=option_intent,
782
- output_text=full_message,
783
- latency_ms=(perf_counter() - turn_started) * 1000,
784
- generation_latency_ms=generation_latency_ms,
785
- final_result=fallback_result,
786
- selected_option=selected_option,
787
- )
788
-
789
- yield (
790
- chat_history, status_text, _get_scene_image_update(gs),
791
- *btn_updates,
792
- game_session,
793
- )
 
 
 
794
  return
795
 
796
 
797
- # ============================================================
798
- # UI 辅助函数
799
- # ============================================================
800
-
801
-
802
- MIN_OPTION_BUTTONS = 3
803
- MAX_OPTION_BUTTONS = 6
804
-
805
- # 兜底默认选项(当解析出的选项为空时使用)
806
- _FALLBACK_BUTTON_OPTIONS = [
807
- {"id": 1, "text": "查看周围", "action_type": "EXPLORE"},
808
- {"id": 2, "text": "等待一会", "action_type": "REST"},
809
- {"id": 3, "text": "检查状态", "action_type": "EXPLORE"},
810
- ]
811
-
812
-
813
- def _normalize_options(
814
- options: list[dict],
815
- *,
816
- minimum: int = 0,
817
- maximum: int = MAX_OPTION_BUTTONS,
818
- ) -> list[dict]:
819
- """
820
- 规范化选项列表:
821
- - 至多保留 maximum 个选项
822
- - 仅当 minimum > 0 时补充兜底项
823
- - 始终重新编号
824
- """
825
- if not isinstance(options, list):
826
- options = []
827
-
828
- normalized = [opt for opt in options if isinstance(opt, dict)][:maximum]
829
-
830
- for fb in _FALLBACK_BUTTON_OPTIONS:
831
- if len(normalized) >= minimum:
832
- break
833
- if not any(o.get("text") == fb["text"] for o in normalized):
834
- normalized.append(fb.copy())
835
-
836
- while len(normalized) < minimum:
837
- normalized.append({
838
- "id": len(normalized) + 1,
839
- "text": "继续探索",
840
- "action_type": "EXPLORE",
841
- })
842
-
843
- for i, opt in enumerate(normalized[:maximum], 1):
844
- if isinstance(opt, dict):
845
- opt["id"] = i
846
-
847
- return normalized[:maximum]
848
-
849
-
850
- def _finalize_session_options(options: list[dict]) -> list[dict]:
851
- minimum = MIN_OPTION_BUTTONS if not options else 0
852
- return _normalize_options(options, minimum=minimum)
853
 
854
 
855
  def _format_options(options: list[dict]) -> str:
@@ -869,56 +1248,56 @@ def _format_options(options: list[dict]) -> str:
869
  return "\n".join(lines)
870
 
871
 
872
- def _get_loading_button_updates(visible_count: int = MIN_OPTION_BUTTONS) -> list:
873
- """返回加载中占位按钮更新,支持最多 6 个选项槽位。"""
874
- visible_count = max(0, min(int(visible_count or 0), MAX_OPTION_BUTTONS))
875
- updates = []
876
- for index in range(MAX_OPTION_BUTTONS):
877
- updates.append(
878
- gr.update(
879
- value="...",
880
- visible=index < visible_count,
881
- interactive=False,
882
- )
883
- )
884
- return updates
885
-
886
-
887
- def _get_button_updates(options: list[dict]) -> list:
888
- """从选项列表生成按钮更新,始终返回 6 个槽位。"""
889
- options = _normalize_options(options, minimum=0)
890
-
891
- updates = []
892
- for i in range(MAX_OPTION_BUTTONS):
893
- opt = options[i] if i < len(options) else None
894
- if isinstance(opt, dict):
895
- text = opt.get("text", "...")
896
- visible = True
897
- else:
898
- text = "..."
899
- visible = False
900
- updates.append(gr.update(value=text, visible=visible, interactive=visible))
901
- return updates
902
-
903
-
904
- def _format_status_panel(gs: GameState) -> str:
905
- """格式化状态面板文本(双列 HTML 布局,减少滚动)"""
906
- p = gs.player
907
- w = gs.world
908
- effective_stats = gs.get_effective_player_stats()
909
- equipment_bonuses = gs.get_equipment_stat_bonuses()
910
- env_snapshot = gs.get_environment_snapshot(limit=3)
911
- survival_snapshot = gs.get_survival_state_snapshot()
912
- scene_summary = gs.get_scene_summary().replace("\n", "<br>")
913
- clock_display = gs.get_clock_display()
914
-
915
- # 属性进度条
916
- hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
917
- mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
918
- stamina_bar = _progress_bar(p.stamina, p.max_stamina, "体力")
919
- hunger_bar = _progress_bar(p.hunger, 100, "饱食")
920
- sanity_bar = _progress_bar(p.sanity, 100, "理智")
921
- morale_bar = _progress_bar(p.morale, 100, "士气")
922
 
923
  # 装备
924
  slot_names = {
@@ -926,26 +1305,26 @@ def _format_status_panel(gs: GameState) -> str:
926
  "helmet": "头盔", "boots": "靴子",
927
  }
928
  equip_lines = []
929
- for slot, item in p.equipment.items():
930
- equip_lines.append(f"{slot_names.get(slot, slot)}: {item or '无'}")
931
- equip_text = "<br>".join(equip_lines)
932
-
933
- def render_stat(stat_key: str, label: str) -> str:
934
- base_value = int(getattr(p, stat_key))
935
- bonus_value = int(equipment_bonuses.get(stat_key, 0))
936
- effective_value = int(effective_stats.get(stat_key, base_value))
937
- if bonus_value > 0:
938
- return f"{label}: {effective_value} <span style='color:#4a6;'>(+{bonus_value} 装备)</span>"
939
- if bonus_value < 0:
940
- return f"{label}: {effective_value} <span style='color:#b44;'>({bonus_value} 装备)</span>"
941
- return f"{label}: {base_value}"
942
-
943
- def badge(text: str, bg: str, fg: str = "#1f2937") -> str:
944
- return (
945
- f"<span style='display:inline-block;margin:0 6px 6px 0;padding:3px 10px;"
946
- f"border-radius:999px;background:{bg};color:{fg};font-size:0.8em;"
947
- f"font-weight:600;'>{text}</span>"
948
- )
949
 
950
  # 状态效果
951
  if p.status_effects:
@@ -958,65 +1337,65 @@ def _format_status_panel(gs: GameState) -> str:
958
  # 背包
959
  if p.inventory:
960
  inventory_text = "<br>".join(p.inventory)
961
- else:
962
- inventory_text = "空"
963
-
964
- weather_colors = {
965
- "晴朗": "#fef3c7",
966
- "多云": "#e5e7eb",
967
- "小雨": "#dbeafe",
968
- "浓雾": "#e0e7ff",
969
- "暴风雨": "#c7d2fe",
970
- "大雪": "#f3f4f6",
971
- }
972
- light_colors = {
973
- "明亮": "#fde68a",
974
- "柔和": "#fcd34d",
975
- "昏暗": "#cbd5e1",
976
- "幽暗": "#94a3b8",
977
- "漆黑": "#334155",
978
- }
979
- danger_level = int(env_snapshot.get("danger_level", 0))
980
- if danger_level >= 7:
981
- danger_badge = badge(f"危险 {danger_level}/10", "#fecaca", "#7f1d1d")
982
- elif danger_level >= 4:
983
- danger_badge = badge(f"危险 {danger_level}/10", "#fed7aa", "#9a3412")
984
- else:
985
- danger_badge = badge(f"危险 {danger_level}/10", "#dcfce7", "#166534")
986
-
987
- env_badges = "".join(
988
- [
989
- badge(f"天气 {w.weather}", weather_colors.get(w.weather, "#e5e7eb")),
990
- badge(
991
- f"光照 {w.light_level}",
992
- light_colors.get(w.light_level, "#e5e7eb"),
993
- "#0f172a" if w.light_level not in {"幽暗", "漆黑"} else "#f8fafc",
994
- ),
995
- danger_badge,
996
- badge(f"场景 {env_snapshot.get('location_type', 'unknown')}", "#ede9fe", "#4c1d95"),
997
- ]
998
- )
999
-
1000
- recent_env_events = env_snapshot.get("recent_events", [])
1001
- if recent_env_events:
1002
- latest_event = recent_env_events[-1]
1003
- latest_event_html = (
1004
- f"<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
1005
- f"border:1px solid #dbeafe;margin-bottom:6px;'>"
1006
- f"<b>{latest_event.get('title', '环境事件')}</b>"
1007
- f"<br><span style='font-size:0.82em;color:#475569;'>{latest_event.get('description', '')}</span>"
1008
- f"</div>"
1009
- )
1010
- recent_event_lines = "<br>".join(
1011
- f"- {event.get('title', '环境事件')}"
1012
- for event in reversed(recent_env_events[-3:])
1013
- )
1014
- else:
1015
- latest_event_html = (
1016
- "<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
1017
- "border:1px dashed #cbd5e1;color:#64748b;'>本回合暂无显式环境事件</div>"
1018
- )
1019
- recent_event_lines = "无"
1020
 
1021
  # 活跃任务(完整展示:描述、子目标、奖励、来源)
1022
  active_quests = [q for q in w.quests.values() if q.status == "active"]
@@ -1047,10 +1426,10 @@ def _format_status_panel(gs: GameState) -> str:
1047
 
1048
  block = (
1049
  f"<details open><summary><b>{tag} {q.title}</b>({done}/{total})</summary>"
1050
- f"<span style='font-size:0.82em;color:#888;'>来源: {q.giver_npc or '未知'}</span><br>"
1051
- f"<span style='font-size:0.82em;'>{q.description}</span>"
1052
  f"{obj_lines}"
1053
- f"<br><span style='font-size:0.82em;color:#4a6;'>奖励: {reward_str}</span>"
1054
  f"</details>"
1055
  )
1056
  quest_blocks.append(block)
@@ -1068,31 +1447,30 @@ def _format_status_panel(gs: GameState) -> str:
1068
  <div>
1069
  <h4 style="margin:4px 0 2px 0;">🩸 生命与状态</h4>
1070
  <span style="font-size:0.85em;">
1071
- {hp_bar}<br>
1072
- {mp_bar}<br>
1073
- {stamina_bar}<br>
1074
- {hunger_bar}<br>
1075
- {sanity_bar}<br>
1076
- {morale_bar}
1077
- </span>
1078
- </div>
1079
-
1080
- <div>
1081
- <h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
1082
- <span style="font-size:0.85em;">
1083
- {render_stat("attack", "攻击")}<br>
1084
- {render_stat("defense", "防御")}<br>
1085
- {render_stat("speed", "速度")}<br>
1086
- {render_stat("luck", "幸运")}<br>
1087
- {render_stat("perception", "感知")}
1088
- </span>
1089
- </div>
1090
 
1091
  <div>
1092
- <h4 style="margin:4px 0 2px 0;">💰 资源</h4>
1093
  <span style="font-size:0.85em;">
1094
- 金币: {p.gold}<br>
1095
- 善恶值: {p.karma}
 
 
 
 
 
 
 
 
 
 
1096
  </span>
1097
  </div>
1098
 
@@ -1104,56 +1482,72 @@ def _format_status_panel(gs: GameState) -> str:
1104
  </div>
1105
 
1106
  <div>
1107
- <h4 style="margin:4px 0 2px 0;"> 状态效果</h4>
1108
  <span style="font-size:0.85em;">
1109
- {effect_lines}
 
1110
  </span>
1111
  </div>
1112
 
1113
- <div>
1114
- <h4 style="margin:4px 0 2px 0;">🎒 背包</h4>
1115
- <span style="font-size:0.85em;">
1116
- {inventory_text}
1117
- </span>
1118
- </div>
1119
-
1120
- <div style="grid-column: 1 / -1;">
1121
- <h4 style="margin:4px 0 2px 0;">🧭 当前场景信息</h4>
1122
- <div style="font-size:0.85em;line-height:1.5;padding:8px 10px;border-radius:12px;background:#fff7ed;border:1px solid #fed7aa;">
1123
- {env_badges}
1124
- <div style="margin:6px 0 8px 0;color:#475569;">
1125
- 时间 {clock_display} | 场景 {w.current_scene} | 状态系数 {survival_snapshot.get('combined_multiplier', 1.0)}
1126
- </div>
1127
- {latest_event_html}
1128
- <div style="margin-top:8px;">{scene_summary}</div>
1129
- <div style="margin-top:6px;color:#475569;">最近环境事件: {recent_event_lines}</div>
1130
- </div>
1131
- </div>
1132
-
1133
- <div style="grid-column: 1 / -1;">
1134
- <h4 style="margin:4px 0 2px 0;">📜 任务</h4>
1135
- <span style="font-size:0.85em;">
1136
- {quest_text}
1137
- </span>
1138
- </div>
1139
-
1140
  <div>
1141
- <h4 style="margin:4px 0 2px 0;">🌍 世界信息</h4>
1142
  <span style="font-size:0.85em;">
1143
- 位置: {w.current_scene}<br>
1144
- 时间: 第{w.day_count}天 {w.time_of_day} ({clock_display})<br>
1145
- 天气: {w.weather}<br>
1146
- 光照: {w.light_level}<br>
1147
- 季节: {w.season}<br>
1148
- 回合: {gs.turn}
1149
- </span>
1150
- </div>
 
 
1151
 
 
 
 
 
 
 
 
 
 
 
 
 
1152
  </div>
1153
  </div>"""
1154
  return status
1155
 
1156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1157
  def _progress_bar(current: int, maximum: int, label: str, length: int = 10) -> str:
1158
  """生成文本进度条"""
1159
  ratio = current / maximum if maximum > 0 else 0
@@ -1168,13 +1562,13 @@ def _progress_bar(current: int, maximum: int, label: str, length: int = 10) -> s
1168
  # ============================================================
1169
 
1170
 
1171
- def build_app() -> gr.Blocks:
1172
- """构建 Gradio 界面"""
1173
-
1174
- with gr.Blocks(
1175
- title="StoryWeaver - 交互式叙事系统",
1176
- ) as app:
1177
- app.css = APP_UI_CSS
1178
 
1179
  gr.Markdown(
1180
  """
@@ -1210,60 +1604,70 @@ def build_app() -> gr.Blocks:
1210
  scale=2,
1211
  )
1212
 
 
 
 
 
1213
  # 聊天窗口
1214
  chatbot = gr.Chatbot(
1215
  label="故事",
1216
  height=480,
1217
  )
1218
 
1219
- # 选项按钮(最多 6 个,分两行显示)
1220
- with gr.Column():
1221
- with gr.Row():
1222
- option_btn_1 = gr.Button(
1223
- "...",
1224
- visible=True,
1225
- interactive=False,
1226
- elem_classes=["option-btn"],
1227
- )
1228
- option_btn_2 = gr.Button(
1229
- "...",
1230
- visible=True,
1231
- interactive=False,
1232
- elem_classes=["option-btn"],
1233
- )
1234
- option_btn_3 = gr.Button(
1235
- "...",
1236
- visible=True,
1237
- interactive=False,
1238
- elem_classes=["option-btn"],
1239
- )
1240
- with gr.Row():
1241
- option_btn_4 = gr.Button(
1242
- "...",
1243
- visible=False,
1244
- interactive=False,
1245
- elem_classes=["option-btn"],
1246
- )
1247
- option_btn_5 = gr.Button(
1248
- "...",
1249
- visible=False,
1250
- interactive=False,
1251
- elem_classes=["option-btn"],
1252
- )
1253
- option_btn_6 = gr.Button(
1254
- "...",
1255
- visible=False,
1256
- interactive=False,
1257
- elem_classes=["option-btn"],
1258
- )
1259
- option_buttons = [
1260
- option_btn_1,
1261
- option_btn_2,
1262
- option_btn_3,
1263
- option_btn_4,
1264
- option_btn_5,
1265
- option_btn_6,
1266
- ]
 
 
 
 
 
 
1267
 
1268
  # 自由输入
1269
  with gr.Row():
@@ -1275,100 +1679,100 @@ def build_app() -> gr.Blocks:
1275
  )
1276
  send_btn = gr.Button("发送", variant="primary", scale=1)
1277
 
1278
- # ==================
1279
- # 右侧:状态面板
1280
- # ==================
1281
- with gr.Column(scale=2, min_width=250, elem_classes=["scene-sidebar"]):
1282
- scene_image = gr.Image(
1283
- value=None,
1284
- type="filepath",
1285
- label="场景画面",
1286
- show_label=False,
1287
- container=False,
1288
- interactive=False,
1289
- height=260,
1290
- buttons=[],
1291
- visible=False,
1292
- elem_classes=["scene-card", "scene-image"],
1293
- )
1294
- status_panel = gr.Markdown(
1295
- elem_classes=["scene-card", "status-panel"],
1296
- value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
1297
- label="角色状态",
1298
- )
1299
 
1300
  # ============================================================
1301
  # 事件绑定
1302
  # ============================================================
1303
 
1304
  # 开始游戏
1305
- start_btn.click(
1306
- fn=start_game,
1307
- inputs=[player_name_input, game_session],
1308
- outputs=[
1309
- chatbot, status_panel, scene_image,
1310
- *option_buttons,
1311
- game_session, user_input,
1312
- ],
1313
- )
1314
 
1315
  # 重启冒险
1316
- restart_btn.click(
1317
- fn=restart_game,
1318
- inputs=[],
1319
- outputs=[
1320
- chatbot, status_panel, scene_image,
1321
- *option_buttons,
1322
- game_session, user_input, player_name_input,
1323
- ],
1324
- )
1325
 
1326
  # 文本输入发送
1327
- send_btn.click(
1328
- fn=process_user_input,
1329
- inputs=[user_input, chatbot, game_session],
1330
- outputs=[
1331
- chatbot, status_panel, scene_image,
1332
- *option_buttons,
1333
- game_session,
1334
- ],
1335
- ).then(
1336
  fn=lambda: "",
1337
  outputs=[user_input],
1338
  )
1339
 
1340
  # 回车发送
1341
- user_input.submit(
1342
- fn=process_user_input,
1343
- inputs=[user_input, chatbot, game_session],
1344
- outputs=[
1345
- chatbot, status_panel, scene_image,
1346
- *option_buttons,
1347
- game_session,
1348
- ],
1349
- ).then(
1350
  fn=lambda: "",
1351
  outputs=[user_input],
1352
  )
1353
 
1354
  # 选项按钮点击(需要使用 yield from 的生成器包装函数,
1355
  # 使 Gradio 能正确识别为流式输出)
1356
- def _make_option_click_handler(index: int):
1357
- def _handler(ch, gs):
1358
- yield from process_option_click(index, ch, gs)
1359
-
1360
- return _handler
1361
-
1362
- for index, option_button in enumerate(option_buttons):
1363
- option_button.click(
1364
- fn=_make_option_click_handler(index),
1365
- inputs=[chatbot, game_session],
1366
- outputs=[
1367
- chatbot, status_panel, scene_image,
1368
- *option_buttons,
1369
- game_session,
1370
- ],
1371
- )
1372
 
1373
  return app
1374
 
@@ -1389,5 +1793,5 @@ if __name__ == "__main__":
1389
  primary_hue="emerald",
1390
  secondary_hue="blue",
1391
  ),
1392
- css=APP_UI_CSS,
1393
- )
 
13
  Gradio UI ← 状态管理器(校验 + 更新) ← 叙事引擎(文本 + 选项)
14
  """
15
 
16
+ import copy
17
+ import html
18
+ import json
19
+ import logging
20
+ from time import perf_counter
21
+ import gradio as gr
22
+
23
+ from state_manager import GameState
24
+ from nlu_engine import NLUEngine
25
+ from scene_assets import get_scene_image_path
26
+ from story_engine import StoryEngine
27
+ from telemetry import append_turn_log, create_session_metadata
28
+ from utils import logger
29
+
30
+ APP_UI_CSS = """
31
+ .story-chat {min-height: 500px;}
32
+ .status-panel {
33
+ font-family: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
34
+ font-size: 0.9em;
35
+ line-height: 1.5;
36
+ background: transparent !important;
37
+ border: none !important;
38
+ border-radius: 0 !important;
39
+ padding: 10px 12px !important;
40
+ box-shadow: none !important;
41
+ overflow: visible !important;
42
+ }
43
+ .status-panel > div,
44
+ .status-panel [class*="prose"],
45
+ .status-panel .markdown-body,
46
+ .status-panel [class*="wrap"] {
47
+ background: transparent !important;
48
+ border: none !important;
49
+ box-shadow: none !important;
50
+ padding: 0 !important;
51
+ overflow: visible !important;
52
+ }
53
+ .status-panel * {
54
+ word-break: break-word;
55
+ overflow-wrap: anywhere;
56
+ }
57
+ .option-btn {min-height: 50px !important;}
58
+ .scene-sidebar {gap: 12px;}
59
+ .scene-card {
60
+ border: 1px solid #e5e7eb !important;
61
+ border-radius: 12px !important;
62
+ background: #fcfcfd !important;
63
+ box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04) !important;
64
+ }
65
+ .scene-image {
66
+ min-height: 260px;
67
+ padding: 10px !important;
68
+ }
69
+ .scene-image > div,
70
+ .scene-image img,
71
+ .scene-image button,
72
+ .scene-image [class*="image"],
73
+ .scene-image [class*="wrap"],
74
+ .scene-image [class*="frame"],
75
+ .scene-image [class*="preview"] {
76
+ border: none !important;
77
+ box-shadow: none !important;
78
+ background: transparent !important;
79
+ }
80
+ .scene-image img {
81
+ width: 100%;
82
+ height: 100%;
83
+ object-fit: contain !important;
84
+ border-radius: 10px;
85
+ padding: 4px;
86
+ background: #ffffff !important;
87
+ }
88
+ """
89
 
90
  # ============================================================
91
  # 全局游戏实例(每个会话独立)
 
95
  # 这里先定义工厂函数
96
 
97
 
98
+ def create_new_game(player_name: str = "旅人") -> dict:
99
+ """创建新游戏实例,返回包含所有引擎的字典"""
100
+ game_state = GameState(player_name=player_name)
101
+ nlu = NLUEngine(game_state)
102
+ story = StoryEngine(game_state, enable_rule_text_polish=True)
103
+ return {
104
+ "game_state": game_state,
105
+ "nlu": nlu,
106
+ "story": story,
107
+ "current_options": [],
108
+ "started": False,
109
+ **create_session_metadata(),
110
+ }
111
+
112
+
113
+ def _json_safe(value):
114
+ """Convert nested values into JSON-serializable data for logs."""
115
+ if value is None or isinstance(value, (str, int, float, bool)):
116
+ return value
117
+ if isinstance(value, dict):
118
+ return {str(key): _json_safe(val) for key, val in value.items()}
119
+ if isinstance(value, (list, tuple, set)):
120
+ return [_json_safe(item) for item in value]
121
+ if hasattr(value, "model_dump"):
122
+ return _json_safe(value.model_dump())
123
+ return str(value)
124
+
125
+
126
+ def _build_state_snapshot(gs: GameState) -> dict:
127
+ """Build a compact state snapshot for reproducible evaluation logs."""
128
+ active_quests = []
129
+ effective_stats = gs.get_effective_player_stats()
130
+ equipment_bonuses = gs.get_equipment_stat_bonuses()
131
+ environment_snapshot = gs.get_environment_snapshot(limit=3)
132
+ for quest in gs.world.quests.values():
133
+ if quest.status == "active":
134
+ active_quests.append(
135
+ {
136
+ "quest_id": quest.quest_id,
137
+ "title": quest.title,
138
+ "status": quest.status,
139
+ "objectives": _json_safe(quest.objectives),
140
+ }
141
+ )
142
+
143
+ return {
144
+ "turn": gs.turn,
145
+ "game_mode": gs.game_mode,
146
+ "location": gs.player.location,
147
+ "scene": gs.world.current_scene,
148
+ "day": gs.world.day_count,
149
+ "time_of_day": gs.world.time_of_day,
150
+ "weather": gs.world.weather,
151
+ "light_level": gs.world.light_level,
152
+ "environment": _json_safe(environment_snapshot),
153
+ "player": {
154
+ "name": gs.player.name,
155
+ "level": gs.player.level,
156
+ "hp": gs.player.hp,
157
+ "max_hp": gs.player.max_hp,
158
+ "mp": gs.player.mp,
159
+ "max_mp": gs.player.max_mp,
160
+ "attack": gs.player.attack,
161
+ "defense": gs.player.defense,
162
+ "speed": gs.player.speed,
163
+ "luck": gs.player.luck,
164
+ "perception": gs.player.perception,
165
+ "gold": gs.player.gold,
166
+ "morale": gs.player.morale,
167
+ "sanity": gs.player.sanity,
168
+ "hunger": gs.player.hunger,
169
+ "karma": gs.player.karma,
170
+ "effective_stats": _json_safe(effective_stats),
171
+ "equipment_bonuses": _json_safe(equipment_bonuses),
172
+ "inventory": list(gs.player.inventory),
173
+ "equipment": copy.deepcopy(gs.player.equipment),
174
+ "skills": list(gs.player.skills),
175
+ "status_effects": [effect.name for effect in gs.player.status_effects],
176
+ },
177
+ "active_quests": active_quests,
178
+ "event_log_size": len(gs.event_log),
179
+ }
180
+
181
+
182
+ def _record_interaction_log(
183
+ game_session: dict,
184
+ *,
185
+ input_source: str,
186
+ user_input: str,
187
+ intent_result: dict | None,
188
+ output_text: str,
189
+ latency_ms: float,
190
+ nlu_latency_ms: float | None = None,
191
+ generation_latency_ms: float | None = None,
192
+ final_result: dict | None = None,
193
+ selected_option: dict | None = None,
194
+ ):
195
+ """Append a structured interaction log without affecting gameplay."""
196
+ if not game_session or "game_state" not in game_session:
197
+ return
198
+
199
+ final_result = final_result or {}
200
+ telemetry = _json_safe(final_result.get("telemetry", {})) or {}
201
+ record = {
202
+ "input_source": input_source,
203
+ "user_input": user_input,
204
+ "selected_option": _json_safe(selected_option),
205
+ "nlu_result": _json_safe(intent_result),
206
+ "latency_ms": round(latency_ms, 2),
207
+ "nlu_latency_ms": None if nlu_latency_ms is None else round(nlu_latency_ms, 2),
208
+ "generation_latency_ms": None if generation_latency_ms is None else round(generation_latency_ms, 2),
209
+ "used_fallback": bool(telemetry.get("used_fallback", False)),
210
+ "fallback_reason": telemetry.get("fallback_reason"),
211
+ "engine_mode": telemetry.get("engine_mode"),
212
+ "state_changes": _json_safe(final_result.get("state_changes", {})),
213
+ "change_log": _json_safe(final_result.get("change_log", [])),
214
+ "consistency_issues": _json_safe(final_result.get("consistency_issues", [])),
215
+ "output_text": output_text,
216
+ "story_text": final_result.get("story_text"),
217
+ "options": _json_safe(final_result.get("options", game_session.get("current_options", []))),
218
+ "post_turn_snapshot": _build_state_snapshot(game_session["game_state"]),
219
+ }
220
+
221
+ try:
222
+ append_turn_log(game_session, record)
223
+ except Exception as exc:
224
+ logger.warning(f"Failed to append interaction log: {exc}")
225
+
226
+
227
+ def _build_option_intent(selected_option: dict) -> dict:
228
+ """Represent button clicks in the same schema as free-text NLU output."""
229
+ option_text = selected_option.get("text", "")
230
+ return {
231
+ "intent": selected_option.get("action_type", "EXPLORE"),
232
+ "target": selected_option.get("target"),
233
+ "details": option_text,
234
+ "raw_input": option_text,
235
+ "parser_source": "option_click",
236
+ }
237
+
238
+
239
+ def _get_scene_image_value(gs: GameState) -> str | None:
240
+ focus_npc = getattr(gs, "last_interacted_npc", None)
241
+ return get_scene_image_path(gs, focus_npc=focus_npc)
242
+
243
+
244
+ def _get_scene_image_update(gs: GameState):
245
+ image_value = _get_scene_image_value(gs)
246
+ return gr.update(value=image_value, visible=bool(image_value))
247
+
248
+
249
+ def _build_map_graph_data(gs: GameState) -> dict:
250
+ """基于已发现地点与连接关系构建地图拓扑数据。"""
251
+ world_locations = getattr(getattr(gs, "world", None), "locations", {}) or {}
252
+ discovered = list(getattr(getattr(gs, "world", None), "discovered_locations", []) or [])
253
+ history = list(getattr(gs, "location_history", []) or [])
254
+
255
+ current_location = str(getattr(gs, "current_location", None) or "").strip()
256
+ if not current_location:
257
+ current_location = str(getattr(getattr(gs, "player", None), "location", None) or "未知之地")
258
+
259
+ visible_set: set[str] = set(discovered) | set(history)
260
+ if current_location:
261
+ visible_set.add(current_location)
262
+
263
+ # 使用世界注册顺序保证地图输出稳定,便于玩家快速扫描。
264
+ ordered_nodes: list[str] = [name for name in world_locations.keys() if name in visible_set]
265
+ for name in discovered + history + [current_location]:
266
+ if name and name in visible_set and name not in ordered_nodes:
267
+ ordered_nodes.append(name)
268
+
269
+ visited_set = set(history)
270
+ if current_location:
271
+ visited_set.add(current_location)
272
+
273
+ adjacency: dict[str, list[str]] = {}
274
+ for node in ordered_nodes:
275
+ loc_info = world_locations.get(node)
276
+ if not loc_info:
277
+ adjacency[node] = []
278
+ continue
279
+ neighbors = []
280
+ for neighbor in list(getattr(loc_info, "connected_to", []) or []):
281
+ if neighbor in visible_set and neighbor != node:
282
+ neighbors.append(neighbor)
283
+ adjacency[node] = neighbors
284
+
285
+ node_state: dict[str, str] = {}
286
+ for node in ordered_nodes:
287
+ if node == current_location:
288
+ node_state[node] = "current"
289
+ elif node in visited_set:
290
+ node_state[node] = "visited"
291
+ else:
292
+ node_state[node] = "known"
293
+
294
+ return {
295
+ "current_location": current_location,
296
+ "nodes": ordered_nodes,
297
+ "adjacency": adjacency,
298
+ "node_state": node_state,
299
+ }
300
+
301
+
302
+ def _build_location_hover_text(gs: GameState, location_name: str) -> str:
303
+ """构造地点 hover 提示:展示 NPC 与怪物。"""
304
+ world = getattr(gs, "world", None)
305
+ locations = getattr(world, "locations", {}) or {}
306
+ npcs = getattr(world, "npcs", {}) or {}
307
+ loc = locations.get(location_name)
308
+ if not loc:
309
+ return f"{location_name}\nNPC: 无\n怪物: 无"
310
+
311
+ npc_names: set[str] = set(getattr(loc, "npcs_present", []) or [])
312
+ for npc in npcs.values():
313
+ if getattr(npc, "location", None) == location_name and getattr(npc, "is_alive", True):
314
+ npc_names.add(getattr(npc, "name", ""))
315
+ npc_names = {name for name in npc_names if name}
316
+
317
+ enemy_names = [str(name) for name in list(getattr(loc, "enemies", []) or []) if str(name)]
318
+ npc_text = "、".join(sorted(npc_names)) if npc_names else "无"
319
+ enemy_text = "、".join(enemy_names) if enemy_names else "无"
320
+ return f"{location_name}\nNPC: {npc_text}\n怪物: {enemy_text}"
321
+
322
+
323
+ def _truncate_map_label(name: str, max_len: int = 8) -> str:
324
+ text = str(name or "")
325
+ return text if len(text) <= max_len else f"{text[:max_len]}..."
326
+
327
+
328
+ def _build_fixed_branch_layout(nodes: list[str], adjacency: dict[str, list[str]]) -> dict[str, tuple[int, int]]:
329
+ """固定起点的分层布局:保持总体顺序稳定,同时展示分支。"""
330
+ if not nodes:
331
+ return {}
332
+
333
+ node_set = set(nodes)
334
+ layers: dict[str, int] = {}
335
+
336
+ def _bfs(seed: str, base_layer: int) -> None:
337
+ if seed not in node_set or seed in layers:
338
+ return
339
+ queue: list[str] = [seed]
340
+ layers[seed] = base_layer
341
+ cursor = 0
342
+ while cursor < len(queue):
343
+ node = queue[cursor]
344
+ cursor += 1
345
+ next_layer = layers[node] + 1
346
+ for nxt in adjacency.get(node, []):
347
+ if nxt in node_set and nxt not in layers:
348
+ layers[nxt] = next_layer
349
+ queue.append(nxt)
350
+
351
+ # 第一出现地点作为固定起点,避免因当前位置变化而重排。
352
+ _bfs(nodes[0], 0)
353
+ for name in nodes:
354
+ if name not in layers:
355
+ base = (max(layers.values()) + 1) if layers else 0
356
+ _bfs(name, base)
357
+
358
+ level_nodes: dict[int, list[str]] = {}
359
+ for name in nodes:
360
+ level = layers.get(name, 0)
361
+ level_nodes.setdefault(level, []).append(name)
362
+
363
+ positions: dict[str, tuple[int, int]] = {}
364
+ for col_idx, level in enumerate(sorted(level_nodes.keys())):
365
+ for row_idx, name in enumerate(level_nodes[level]):
366
+ positions[name] = (col_idx, row_idx)
367
+ return positions
368
+
369
+
370
+ def _render_text_map(gs: GameState | None) -> str:
371
+ """拓扑地图:从左到右显示地点关系图。"""
372
+ if gs is None:
373
+ return "地图关系图\n(未开始)"
374
+
375
+ graph = _build_map_graph_data(gs)
376
+ nodes = graph["nodes"]
377
+ adjacency = graph["adjacency"]
378
+ node_state = graph["node_state"]
379
+ current_location = graph["current_location"]
380
+
381
+ if not nodes:
382
+ current = current_location or "未知之地"
383
+ return f"地图关系图\n当前位置:{current}"
384
+
385
+ positions = _build_fixed_branch_layout(nodes, adjacency)
386
+ if not positions:
387
+ return "地图关系图\n(暂无可显示节点)"
388
+
389
+ node_width = 110
390
+ node_height = 34
391
+ col_gap = 140
392
+ row_gap = 50
393
+ x_margin = 16
394
+ y_margin = 16
395
+
396
+ max_col = max(col for col, _ in positions.values())
397
+ max_row = max(row for _, row in positions.values())
398
+ canvas_width = x_margin * 2 + max_col * col_gap + node_width
399
+ canvas_height = y_margin * 2 + max_row * row_gap + node_height
400
+ canvas_height = max(canvas_height, 74)
401
+
402
+ node_boxes: dict[str, tuple[int, int, int, int]] = {}
403
+ centers: dict[str, tuple[int, int]] = {}
404
+ for name, (col, row) in positions.items():
405
+ x = x_margin + col * col_gap
406
+ y = y_margin + row * row_gap
407
+ node_boxes[name] = (x, y, node_width, node_height)
408
+ centers[name] = (x + node_width // 2, y + node_height // 2)
409
+
410
+ edge_pairs: set[tuple[str, str]] = set()
411
+ for source, neighbors in adjacency.items():
412
+ for target in neighbors:
413
+ if source in positions and target in positions and source != target:
414
+ edge_pairs.add(tuple(sorted((source, target))))
415
+
416
+ edge_svg: list[str] = []
417
+
418
+ def _segment_hits_box_horizontal(y: float, x_start: float, x_end: float, box: tuple[int, int, int, int]) -> bool:
419
+ bx, by, bw, bh = box
420
+ left = min(x_start, x_end)
421
+ right = max(x_start, x_end)
422
+ return (by + 1) <= y <= (by + bh - 1) and not (right <= bx + 1 or left >= bx + bw - 1)
423
+
424
+ def _segment_hits_box_vertical(x: float, y_start: float, y_end: float, box: tuple[int, int, int, int]) -> bool:
425
+ bx, by, bw, bh = box
426
+ top = min(y_start, y_end)
427
+ bottom = max(y_start, y_end)
428
+ return (bx + 1) <= x <= (bx + bw - 1) and not (bottom <= by + 1 or top >= by + bh - 1)
429
+
430
+ for source, target in sorted(edge_pairs):
431
+ sx, sy, sw, sh = node_boxes[source]
432
+ tx, ty, tw, th = node_boxes[target]
433
+ source_exit_x = sx + sw
434
+ source_exit_y = sy + sh / 2
435
+ target_entry_x = tx
436
+ target_entry_y = ty + th / 2
437
+
438
+ mid_x = (source_exit_x + target_entry_x) / 2
439
+ needs_detour = False
440
+ for name, box in node_boxes.items():
441
+ if name in {source, target}:
442
+ continue
443
+ if (
444
+ _segment_hits_box_horizontal(source_exit_y, source_exit_x, mid_x, box)
445
+ or _segment_hits_box_vertical(mid_x, source_exit_y, target_entry_y, box)
446
+ or _segment_hits_box_horizontal(target_entry_y, mid_x, target_entry_x, box)
447
+ ):
448
+ needs_detour = True
449
+ break
450
+
451
+ if not needs_detour:
452
+ points = (
453
+ f"{source_exit_x},{source_exit_y} "
454
+ f"{mid_x},{source_exit_y} "
455
+ f"{mid_x},{target_entry_y} "
456
+ f"{target_entry_x},{target_entry_y}"
457
+ )
458
+ else:
459
+ # 局部上绕:仅在必要时走上方,减少杂乱感同时避免压到节点。
460
+ route_y = max(4, min(source_exit_y, target_entry_y) - node_height / 2 - 10)
461
+ route_left_x = source_exit_x + 6
462
+ route_right_x = target_entry_x - 6
463
+ points = (
464
+ f"{source_exit_x},{source_exit_y} "
465
+ f"{route_left_x},{source_exit_y} "
466
+ f"{route_left_x},{route_y} "
467
+ f"{route_right_x},{route_y} "
468
+ f"{route_right_x},{target_entry_y} "
469
+ f"{target_entry_x},{target_entry_y}"
470
+ )
471
+
472
+ edge_svg.append(
473
+ f"<polyline points='{points}' "
474
+ "fill='none' stroke='#94a3b8' stroke-width='1.7' "
475
+ "stroke-linecap='round' stroke-linejoin='round' />"
476
+ )
477
+
478
+ node_svg: list[str] = []
479
+ for name in nodes:
480
+ col, row = positions[name]
481
+ x = x_margin + col * col_gap
482
+ y = y_margin + row * row_gap
483
+ state = node_state.get(name, "known")
484
+ escaped_name = html.escape(_truncate_map_label(name))
485
+ hover_text = html.escape(_build_location_hover_text(gs, name))
486
+
487
+ if state == "current":
488
+ fill = "#fff7ed"
489
+ stroke = "#f97316"
490
+ text_color = "#9a3412"
491
+ stroke_width = 1.8
492
+ display_name = escaped_name
493
+ elif state == "visited":
494
+ fill = "#f1f5f9"
495
+ stroke = "#64748b"
496
+ text_color = "#334155"
497
+ stroke_width = 1.4
498
+ display_name = escaped_name
499
+ else:
500
+ fill = "#ffffff"
501
+ stroke = "#cbd5e1"
502
+ text_color = "#334155"
503
+ stroke_width = 1.2
504
+ display_name = escaped_name
505
+
506
+ rect_class_attr = " class='map-current-node'" if state == "current" else ""
507
+ node_svg.append(
508
+ "<g cursor='help'>"
509
+ f"<title>{hover_text}</title>"
510
+ f"<rect x='{x}' y='{y}' width='{node_width}' height='{node_height}' "
511
+ f"rx='8' ry='8' fill='{fill}' stroke='{stroke}' stroke-width='{stroke_width}'{rect_class_attr} />"
512
+ f"<text x='{x + node_width / 2}' y='{y + node_height / 2 + 5}' "
513
+ "text-anchor='middle' font-size='11.5' font-family='Microsoft YaHei UI, Noto Sans SC, sans-serif' "
514
+ f"fill='{text_color}'>{display_name}</text>"
515
+ "</g>"
516
+ )
517
+
518
+ svg = (
519
+ f"<svg width='{canvas_width}' height='{canvas_height}' viewBox='0 0 {canvas_width} {canvas_height}' "
520
+ "xmlns='http://www.w3.org/2000/svg'>"
521
+ "<style>"
522
+ "@keyframes mapNodePulse{"
523
+ "0%{fill:#fff7ed;stroke:#f97316;stroke-width:1.8;opacity:0.9;}"
524
+ "50%{fill:#fdba74;stroke:#c2410c;stroke-width:1.8;opacity:1;}"
525
+ "100%{fill:#fff7ed;stroke:#f97316;stroke-width:1.8;opacity:0.9;}"
526
+ "}"
527
+ ".map-current-node{animation:mapNodePulse 1.2s ease-in-out infinite;}"
528
+ "</style>"
529
+ + "".join(edge_svg)
530
+ + "".join(node_svg)
531
+ + "</svg>"
532
+ )
533
+
534
+ return (
535
+ "<div style='font-size:0.9em;'>"
536
+ "<details>"
537
+ "<summary style='cursor:pointer;font-weight:700;'>展开地图关系图</summary>"
538
+ "<div style='font-size:0.8em;color:#475569;margin:8px 0 6px 0;'>"
539
+ "鼠标悬停于地点格可查看NPC与怪物。"
540
+ "</div>"
541
+ "<div style='overflow-x:auto;padding-bottom:2px;'>"
542
+ + svg
543
+ + "</div>"
544
+ "</details>"
545
+ "</div>"
546
+ )
547
+
548
+
549
+ def restart_game() -> tuple:
550
  """
551
  重启冒险:清空所有数据,回到初始输入名称阶段。
552
 
 
555
  禁用文本输入框, 重置角色名称)
556
  """
557
  loading = _get_loading_button_updates()
558
+ return (
559
+ [], # 清空聊天历史
560
+ _format_world_info_panel(None), # 重置世界信息
561
+ "## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
562
+ "地图关系图\n(未开始)", # 清空地图
563
+ gr.update(value=None, visible=False), # 清空场景图片
564
+ *loading, # 占位选项按钮
565
+ {}, # 清空游戏会话
566
+ gr.update(value="", interactive=False), # 禁用并清空文本输入
567
+ gr.update(value="旅人"), # 重置角色名称
568
+ )
569
 
570
 
571
  # ============================================================
 
588
 
589
  # 初始 yield:显示加载状态,按钮保持可见但禁用
590
  chat_history = [{"role": "assistant", "content": "⏳ 正在生成开场..."}]
591
+ world_info_text = _format_world_info_panel(game_session["game_state"])
592
  status_text = _format_status_panel(game_session["game_state"])
593
  loading = _get_loading_button_updates()
594
 
595
+ yield (
596
+ chat_history,
597
+ world_info_text,
598
+ status_text,
599
+ _render_text_map(game_session["game_state"]),
600
+ _get_scene_image_update(game_session["game_state"]),
601
+ *loading,
602
+ game_session,
603
+ gr.update(interactive=False),
604
+ )
605
 
606
  # 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
607
+ turn_started = perf_counter()
608
+ story_text = ""
609
  final_result = None
610
 
611
  for update in game_session["story"].generate_opening_stream():
612
  if update["type"] == "story_chunk":
613
  story_text = update["text"]
614
  chat_history[-1]["content"] = story_text
615
+ yield (
616
+ chat_history,
617
+ world_info_text,
618
+ status_text,
619
+ _render_text_map(game_session["game_state"]),
620
+ _get_scene_image_update(game_session["game_state"]),
621
+ *loading,
622
+ game_session,
623
+ gr.update(interactive=False),
624
+ )
625
  elif update["type"] == "final":
626
+ final_result = update
627
+
628
+ generation_latency_ms = (perf_counter() - turn_started) * 1000
629
 
630
  # ★ 只在数据流完全结束后,从 final_result 中提取选项
631
  if final_result:
 
635
  options = []
636
 
637
  # ★ 安全兜底:强制确保恰好 3 个选项
638
+ options = _finalize_session_options(options)
639
 
640
  # 最终 yield:显示完整文本 + 选项 + 启用按钮
641
+ game_session["current_options"] = options
642
+ full_message = story_text
643
+ if not final_result:
644
+ final_result = {
645
+ "story_text": story_text,
646
+ "options": options,
647
+ "state_changes": {},
648
+ "change_log": [],
649
+ "consistency_issues": [],
650
+ "telemetry": {
651
+ "engine_mode": "opening_app",
652
+ "used_fallback": True,
653
+ "fallback_reason": "missing_final_event",
654
+ },
655
+ }
656
+
657
+ chat_history[-1]["content"] = full_message
658
+ world_info_text = _format_world_info_panel(game_session["game_state"])
659
+ status_text = _format_status_panel(game_session["game_state"])
660
+ btn_updates = _get_button_updates(options)
661
+ _record_interaction_log(
662
+ game_session,
663
+ input_source="system_opening",
664
+ user_input="",
665
+ intent_result=None,
666
+ output_text=full_message,
667
+ latency_ms=generation_latency_ms,
668
+ generation_latency_ms=generation_latency_ms,
669
+ final_result=final_result,
670
+ )
671
+
672
+ yield (
673
+ chat_history,
674
+ world_info_text,
675
+ status_text,
676
+ _render_text_map(game_session["game_state"]),
677
+ _get_scene_image_update(game_session["game_state"]),
678
+ *btn_updates,
679
+ game_session,
680
+ gr.update(interactive=True),
681
+ )
682
 
683
 
684
  def process_user_input(user_input: str, chat_history: list, game_session: dict):
 
690
  2. 叙事引擎流式生成故事
691
  3. 逐步更新 UI
692
  """
693
+ if not game_session or not game_session.get("started"):
694
+ chat_history = chat_history or []
695
+ chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
696
+ loading = _get_loading_button_updates()
697
+ yield (
698
+ chat_history,
699
+ _format_world_info_panel(None),
700
+ "",
701
+ "",
702
+ gr.update(value=None, visible=False),
703
+ *loading,
704
+ game_session,
705
+ )
706
+ return
707
+
708
+ if not user_input.strip():
709
+ btn_updates = _get_button_updates(game_session.get("current_options", []))
710
+ yield (
711
+ chat_history,
712
+ _format_world_info_panel(game_session["game_state"]),
713
+ _format_status_panel(game_session["game_state"]),
714
+ _render_text_map(game_session["game_state"]),
715
+ _get_scene_image_update(game_session["game_state"]),
716
+ *btn_updates,
717
+ game_session,
718
+ )
719
+ return
720
+
721
+ gs: GameState = game_session["game_state"]
722
+ nlu: NLUEngine = game_session["nlu"]
723
+ story: StoryEngine = game_session["story"]
724
+ turn_started = perf_counter()
725
 
726
  # 检查游戏是否已结束
727
+ if gs.is_game_over():
728
+ chat_history.append({"role": "user", "content": user_input})
729
+ chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
730
+ restart_buttons = _get_button_updates(
731
+ [
732
+ {"id": 1, "text": "重新开始", "action_type": "RESTART"},
733
+ ]
734
+ )
735
+ yield (
736
+ chat_history,
737
+ _format_world_info_panel(gs),
738
+ _format_status_panel(gs),
739
+ _render_text_map(gs),
740
+ _get_scene_image_update(gs),
741
+ *restart_buttons,
742
+ game_session,
743
+ )
744
+ return
745
+
746
+ # 1. NLU 解析
747
+ nlu_started = perf_counter()
748
+ intent = nlu.parse_intent(user_input)
749
+ nlu_latency_ms = (perf_counter() - nlu_started) * 1000
750
 
751
  # 1.5 预校验:立即驳回违反一致性的操作(不调用 LLM,不消耗回合)
752
  is_valid, rejection_msg = gs.pre_validate_action(intent)
753
  if not is_valid:
754
+ chat_history.append({"role": "user", "content": user_input})
755
+ options = game_session.get("current_options", [])
756
+ options = _finalize_session_options(options)
 
757
  rejection_content = (
758
  f"⚠️ **行动被驳回**:{rejection_msg}\n\n"
759
+ f"请重新选择行动,或输入其他指令。"
760
+ )
761
+ chat_history.append({"role": "assistant", "content": rejection_content})
762
+ rejection_result = {
763
+ "story_text": rejection_content,
764
+ "options": options,
765
+ "state_changes": {},
766
+ "change_log": [],
767
+ "consistency_issues": [],
768
+ "telemetry": {
769
+ "engine_mode": "pre_validation",
770
+ "used_fallback": False,
771
+ "fallback_reason": None,
772
+ },
773
+ }
774
+ _record_interaction_log(
775
+ game_session,
776
+ input_source="text_input",
777
+ user_input=user_input,
778
+ intent_result=intent,
779
+ output_text=rejection_content,
780
+ latency_ms=(perf_counter() - turn_started) * 1000,
781
+ nlu_latency_ms=nlu_latency_ms,
782
+ generation_latency_ms=0.0,
783
+ final_result=rejection_result,
784
  )
785
+ btn_updates = _get_button_updates(options)
786
+ yield (
787
+ chat_history,
788
+ _format_world_info_panel(gs),
789
+ _format_status_panel(gs),
790
+ _render_text_map(gs),
791
+ _get_scene_image_update(gs),
792
+ *btn_updates,
793
+ game_session,
794
+ )
795
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
796
 
797
  # 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
798
  chat_history.append({"role": "user", "content": user_input})
799
  chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
800
 
801
  # 按钮保持可见但禁用,防止流式期间点击
802
+ loading = _get_loading_button_updates(
803
+ max(len(game_session.get("current_options", [])), MIN_OPTION_BUTTONS)
804
+ )
805
+ yield (
806
+ chat_history,
807
+ _format_world_info_panel(gs),
808
+ _format_status_panel(gs),
809
+ _render_text_map(gs),
810
+ _get_scene_image_update(gs),
811
+ *loading,
812
+ game_session,
813
+ )
814
 
815
  # 3. 流式生成故事
816
+ generation_started = perf_counter()
817
+ final_result = None
818
+ for update in story.generate_story_stream(intent):
819
+ if update["type"] == "story_chunk":
820
+ chat_history[-1]["content"] = update["text"]
821
+ yield (
822
+ chat_history,
823
+ _format_world_info_panel(gs),
824
+ _format_status_panel(gs),
825
+ _render_text_map(gs),
826
+ _get_scene_image_update(gs),
827
+ *loading,
828
+ game_session,
829
+ )
830
+ elif update["type"] == "final":
831
+ final_result = update
832
+
833
+ generation_latency_ms = (perf_counter() - generation_started) * 1000
834
 
835
  # 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
836
  if final_result:
837
  # ★ 安全兜底:强制确保恰好 3 个选项
838
+ options = _finalize_session_options(final_result.get("options", []))
839
+ game_session["current_options"] = options
840
 
841
  change_log = final_result.get("change_log", [])
842
  log_text = ""
 
850
  issues_text = "\n".join(f" {i}" for i in issues)
851
  issues_text = f"\n\n**一致性提示:**\n{issues_text}"
852
 
853
+ full_message = f"{final_result['story_text']}{log_text}{issues_text}"
854
+ chat_history[-1]["content"] = full_message
855
+
856
+ status_text = _format_status_panel(gs)
857
+ btn_updates = _get_button_updates(options)
858
+ _record_interaction_log(
859
+ game_session,
860
+ input_source="text_input",
861
+ user_input=user_input,
862
+ intent_result=intent,
863
+ output_text=full_message,
864
+ latency_ms=(perf_counter() - turn_started) * 1000,
865
+ nlu_latency_ms=nlu_latency_ms,
866
+ generation_latency_ms=generation_latency_ms,
867
+ final_result=final_result,
868
+ )
869
+
870
+ yield (
871
+ chat_history,
872
+ _format_world_info_panel(gs),
873
+ status_text,
874
+ _render_text_map(gs),
875
+ _get_scene_image_update(gs),
876
+ *btn_updates,
877
+ game_session,
878
+ )
879
  else:
880
  # ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
881
  logger.warning("流式生成未产生 final 事件,使用兜底文本")
882
  fallback_text = "你环顾四周,思考着接下来该做什么..."
883
+ fallback_options = _finalize_session_options([])
884
+ game_session["current_options"] = fallback_options
885
+
886
+ full_message = fallback_text
887
+ fallback_result = {
888
+ "story_text": fallback_text,
889
+ "options": fallback_options,
890
+ "state_changes": {},
891
+ "change_log": [],
892
+ "consistency_issues": [],
893
+ "telemetry": {
894
+ "engine_mode": "app_fallback",
895
+ "used_fallback": True,
896
+ "fallback_reason": "missing_final_event",
897
+ },
898
+ }
899
+ chat_history[-1]["content"] = full_message
900
+
901
+ status_text = _format_status_panel(gs)
902
+ btn_updates = _get_button_updates(fallback_options)
903
+ _record_interaction_log(
904
+ game_session,
905
+ input_source="text_input",
906
+ user_input=user_input,
907
+ intent_result=intent,
908
+ output_text=full_message,
909
+ latency_ms=(perf_counter() - turn_started) * 1000,
910
+ nlu_latency_ms=nlu_latency_ms,
911
+ generation_latency_ms=generation_latency_ms,
912
+ final_result=fallback_result,
913
+ )
914
+
915
+ yield (
916
+ chat_history,
917
+ _format_world_info_panel(gs),
918
+ status_text,
919
+ _render_text_map(gs),
920
+ _get_scene_image_update(gs),
921
+ *btn_updates,
922
+ game_session,
923
+ )
924
+ return
925
+
926
+
927
+ def process_option_click(option_idx: int, chat_history: list, game_session: dict):
928
  """
929
+ 处理玩家点击选项按钮(流式版本)。
930
+
931
+ Args:
932
+ option_idx: 选项索引 (0-5)
933
+ """
934
+ if not game_session or not game_session.get("started"):
935
+ chat_history = chat_history or []
936
+ chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
937
+ loading = _get_loading_button_updates()
938
+ yield (
939
+ chat_history,
940
+ _format_world_info_panel(None),
941
+ "",
942
+ "",
943
+ gr.update(value=None, visible=False),
944
+ *loading,
945
+ game_session,
946
+ )
947
+ return
948
+
949
+ options = game_session.get("current_options", [])
950
+ if option_idx >= len(options):
951
+ btn_updates = _get_button_updates(options)
952
+ yield (
953
+ chat_history,
954
+ _format_world_info_panel(game_session["game_state"]),
955
+ _format_status_panel(game_session["game_state"]),
956
+ _render_text_map(game_session["game_state"]),
957
+ _get_scene_image_update(game_session["game_state"]),
958
+ *btn_updates,
959
+ game_session,
960
+ )
961
+ return
962
+
963
+ selected_option = options[option_idx]
964
+ gs: GameState = game_session["game_state"]
965
+ story: StoryEngine = game_session["story"]
966
+ option_intent = _build_option_intent(selected_option)
967
+ turn_started = perf_counter()
968
 
969
  # 检查特殊选项:重新开始
970
  if selected_option.get("action_type") == "RESTART":
 
972
  game_session = create_new_game(gs.player.name)
973
  game_session["started"] = True
974
 
975
+ chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
976
+ world_info_text = _format_world_info_panel(game_session["game_state"])
977
+ status_text = _format_status_panel(game_session["game_state"])
978
+ loading = _get_loading_button_updates()
979
+
980
+ yield (
981
+ chat_history,
982
+ world_info_text,
983
+ status_text,
984
+ _render_text_map(game_session["game_state"]),
985
+ _get_scene_image_update(game_session["game_state"]),
986
+ *loading,
987
+ game_session,
988
+ )
989
 
990
  story_text = ""
991
  restart_final = None
 
994
  if update["type"] == "story_chunk":
995
  story_text = update["text"]
996
  chat_history[-1]["content"] = story_text
997
+ yield (
998
+ chat_history,
999
+ world_info_text,
1000
+ status_text,
1001
+ _render_text_map(game_session["game_state"]),
1002
+ _get_scene_image_update(game_session["game_state"]),
1003
+ *loading,
1004
+ game_session,
1005
+ )
1006
  elif update["type"] == "final":
1007
  restart_final = update
1008
 
 
1014
  restart_options = []
1015
 
1016
  # ★ 安全兜底:强制确保恰好 3 个选项
1017
+ restart_options = _finalize_session_options(restart_options)
1018
+ game_session["current_options"] = restart_options
1019
+ full_message = story_text
 
1020
  chat_history[-1]["content"] = full_message
1021
 
1022
  status_text = _format_status_panel(game_session["game_state"])
1023
  btn_updates = _get_button_updates(restart_options)
1024
 
1025
+ yield (
1026
+ chat_history,
1027
+ _format_world_info_panel(game_session["game_state"]),
1028
+ status_text,
1029
+ _render_text_map(game_session["game_state"]),
1030
+ _get_scene_image_update(game_session["game_state"]),
1031
+ *btn_updates,
1032
+ game_session,
1033
+ )
1034
+ return
1035
 
1036
  # 检查特殊选项:退出
1037
  if selected_option.get("action_type") == "QUIT":
1038
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
1039
+ chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
1040
+ quit_buttons = _get_button_updates(
1041
+ [
1042
+ {"id": 1, "text": "重新开始", "action_type": "RESTART"},
1043
+ ]
1044
+ )
1045
+ yield (
1046
+ chat_history,
1047
+ _format_world_info_panel(gs),
1048
+ _format_status_panel(gs),
1049
+ _render_text_map(gs),
1050
+ _get_scene_image_update(gs),
1051
+ *quit_buttons,
1052
+ game_session,
1053
+ )
1054
+ return
1055
 
1056
  # 正常选项处理:流式生成
1057
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
1058
  chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
1059
 
1060
  # 按钮保持可见但禁用
1061
+ loading = _get_loading_button_updates(max(len(options), MIN_OPTION_BUTTONS))
1062
+ yield (
1063
+ chat_history,
1064
+ _format_world_info_panel(gs),
1065
+ _format_status_panel(gs),
1066
+ _render_text_map(gs),
1067
+ _get_scene_image_update(gs),
1068
+ *loading,
1069
+ game_session,
1070
+ )
1071
+
1072
+ generation_started = perf_counter()
1073
+ final_result = None
1074
+ for update in story.process_option_selection_stream(selected_option):
1075
+ if update["type"] == "story_chunk":
1076
+ chat_history[-1]["content"] = update["text"]
1077
+ yield (
1078
+ chat_history,
1079
+ _format_world_info_panel(gs),
1080
+ _format_status_panel(gs),
1081
+ _render_text_map(gs),
1082
+ _get_scene_image_update(gs),
1083
+ *loading,
1084
+ game_session,
1085
+ )
1086
+ elif update["type"] == "final":
1087
+ final_result = update
1088
+
1089
+ generation_latency_ms = (perf_counter() - generation_started) * 1000
1090
 
1091
  if final_result:
1092
  # ★ 安全兜底:强制确保恰好 3 个选项
1093
+ options = _finalize_session_options(final_result.get("options", []))
1094
+ game_session["current_options"] = options
1095
 
1096
  change_log = final_result.get("change_log", [])
1097
  log_text = ""
 
1099
  log_text = "\n".join(f" {c}" for c in change_log)
1100
  log_text = f"\n\n**状态变化:**\n{log_text}"
1101
 
1102
+ full_message = f"{final_result['story_text']}{log_text}"
1103
+ chat_history[-1]["content"] = full_message
1104
+
1105
+ status_text = _format_status_panel(gs)
1106
+ btn_updates = _get_button_updates(options)
1107
+ _record_interaction_log(
1108
+ game_session,
1109
+ input_source="option_click",
1110
+ user_input=selected_option.get("text", ""),
1111
+ intent_result=option_intent,
1112
+ output_text=full_message,
1113
+ latency_ms=(perf_counter() - turn_started) * 1000,
1114
+ generation_latency_ms=generation_latency_ms,
1115
+ final_result=final_result,
1116
+ selected_option=selected_option,
1117
+ )
1118
+
1119
+ yield (
1120
+ chat_history,
1121
+ _format_world_info_panel(gs),
1122
+ status_text,
1123
+ _render_text_map(gs),
1124
+ _get_scene_image_update(gs),
1125
+ *btn_updates,
1126
+ game_session,
1127
+ )
1128
+ else:
1129
  # ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
1130
  logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
1131
  fallback_text = "你环顾四周,思考着接下来该做什么..."
1132
+ fallback_options = _finalize_session_options([])
1133
+ game_session["current_options"] = fallback_options
1134
+
1135
+ full_message = fallback_text
1136
+ fallback_result = {
1137
+ "story_text": fallback_text,
1138
+ "options": fallback_options,
1139
+ "state_changes": {},
1140
+ "change_log": [],
1141
+ "consistency_issues": [],
1142
+ "telemetry": {
1143
+ "engine_mode": "app_fallback",
1144
+ "used_fallback": True,
1145
+ "fallback_reason": "missing_final_event",
1146
+ },
1147
+ }
1148
+ chat_history[-1]["content"] = full_message
1149
+
1150
+ status_text = _format_status_panel(gs)
1151
+ btn_updates = _get_button_updates(fallback_options)
1152
+ _record_interaction_log(
1153
+ game_session,
1154
+ input_source="option_click",
1155
+ user_input=selected_option.get("text", ""),
1156
+ intent_result=option_intent,
1157
+ output_text=full_message,
1158
+ latency_ms=(perf_counter() - turn_started) * 1000,
1159
+ generation_latency_ms=generation_latency_ms,
1160
+ final_result=fallback_result,
1161
+ selected_option=selected_option,
1162
+ )
1163
+
1164
+ yield (
1165
+ chat_history,
1166
+ _format_world_info_panel(gs),
1167
+ status_text,
1168
+ _render_text_map(gs),
1169
+ _get_scene_image_update(gs),
1170
+ *btn_updates,
1171
+ game_session,
1172
+ )
1173
  return
1174
 
1175
 
1176
+ # ============================================================
1177
+ # UI 辅助函数
1178
+ # ============================================================
1179
+
1180
+
1181
+ MIN_OPTION_BUTTONS = 3
1182
+ MAX_OPTION_BUTTONS = 6
1183
+
1184
+ # 兜底默认选项(当解析出的选项为空时使用)
1185
+ _FALLBACK_BUTTON_OPTIONS = [
1186
+ {"id": 1, "text": "查看周围", "action_type": "EXPLORE"},
1187
+ {"id": 2, "text": "等待一会", "action_type": "REST"},
1188
+ {"id": 3, "text": "检查状态", "action_type": "EXPLORE"},
1189
+ ]
1190
+
1191
+
1192
+ def _normalize_options(
1193
+ options: list[dict],
1194
+ *,
1195
+ minimum: int = 0,
1196
+ maximum: int = MAX_OPTION_BUTTONS,
1197
+ ) -> list[dict]:
1198
+ """
1199
+ 规范化选项列表:
1200
+ - 至多保留 maximum 个选项
1201
+ - 仅当 minimum > 0 时补充兜底项
1202
+ - 始终重新编号
1203
+ """
1204
+ if not isinstance(options, list):
1205
+ options = []
1206
+
1207
+ normalized = [opt for opt in options if isinstance(opt, dict)][:maximum]
1208
+
1209
+ for fb in _FALLBACK_BUTTON_OPTIONS:
1210
+ if len(normalized) >= minimum:
1211
+ break
1212
+ if not any(o.get("text") == fb["text"] for o in normalized):
1213
+ normalized.append(fb.copy())
1214
+
1215
+ while len(normalized) < minimum:
1216
+ normalized.append({
1217
+ "id": len(normalized) + 1,
1218
+ "text": "继续探索",
1219
+ "action_type": "EXPLORE",
1220
+ })
1221
+
1222
+ for i, opt in enumerate(normalized[:maximum], 1):
1223
+ if isinstance(opt, dict):
1224
+ opt["id"] = i
1225
+
1226
+ return normalized[:maximum]
1227
+
1228
+
1229
+ def _finalize_session_options(options: list[dict]) -> list[dict]:
1230
+ minimum = MIN_OPTION_BUTTONS if not options else 0
1231
+ return _normalize_options(options, minimum=minimum)
1232
 
1233
 
1234
  def _format_options(options: list[dict]) -> str:
 
1248
  return "\n".join(lines)
1249
 
1250
 
1251
+ def _get_loading_button_updates(visible_count: int = MIN_OPTION_BUTTONS) -> list:
1252
+ """返回加载中占位按钮更新,支持最多 6 个选项槽位。"""
1253
+ visible_count = max(0, min(int(visible_count or 0), MAX_OPTION_BUTTONS))
1254
+ updates = []
1255
+ for index in range(MAX_OPTION_BUTTONS):
1256
+ updates.append(
1257
+ gr.update(
1258
+ value="...",
1259
+ visible=index < visible_count,
1260
+ interactive=False,
1261
+ )
1262
+ )
1263
+ return updates
1264
+
1265
+
1266
+ def _get_button_updates(options: list[dict]) -> list:
1267
+ """从选项列表生成按钮更新,始终返回 6 个槽位。"""
1268
+ options = _normalize_options(options, minimum=0)
1269
+
1270
+ updates = []
1271
+ for i in range(MAX_OPTION_BUTTONS):
1272
+ opt = options[i] if i < len(options) else None
1273
+ if isinstance(opt, dict):
1274
+ text = opt.get("text", "...")
1275
+ visible = True
1276
+ else:
1277
+ text = "..."
1278
+ visible = False
1279
+ updates.append(gr.update(value=text, visible=visible, interactive=visible))
1280
+ return updates
1281
+
1282
+
1283
+ def _format_status_panel(gs: GameState) -> str:
1284
+ """格式化状态面板文本(双列 HTML 布局,减少滚动)"""
1285
+ p = gs.player
1286
+ w = gs.world
1287
+ effective_stats = gs.get_effective_player_stats()
1288
+ equipment_bonuses = gs.get_equipment_stat_bonuses()
1289
+ env_snapshot = gs.get_environment_snapshot(limit=3)
1290
+ survival_snapshot = gs.get_survival_state_snapshot()
1291
+ scene_summary = gs.get_scene_summary().replace("\n", "<br>")
1292
+ clock_display = gs.get_clock_display()
1293
+
1294
+ # 属性进度条
1295
+ hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
1296
+ mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
1297
+ stamina_bar = _progress_bar(p.stamina, p.max_stamina, "体力")
1298
+ hunger_bar = _progress_bar(p.hunger, 100, "饱食")
1299
+ sanity_bar = _progress_bar(p.sanity, 100, "理智")
1300
+ morale_bar = _progress_bar(p.morale, 100, "士气")
1301
 
1302
  # 装备
1303
  slot_names = {
 
1305
  "helmet": "头盔", "boots": "靴子",
1306
  }
1307
  equip_lines = []
1308
+ for slot, item in p.equipment.items():
1309
+ equip_lines.append(f"{slot_names.get(slot, slot)}: {item or '无'}")
1310
+ equip_text = "<br>".join(equip_lines)
1311
+
1312
+ def render_stat(stat_key: str, label: str) -> str:
1313
+ base_value = int(getattr(p, stat_key))
1314
+ bonus_value = int(equipment_bonuses.get(stat_key, 0))
1315
+ effective_value = int(effective_stats.get(stat_key, base_value))
1316
+ if bonus_value > 0:
1317
+ return f"{label}: {effective_value} <span style='color:#4a6;'>(+{bonus_value} 装备)</span>"
1318
+ if bonus_value < 0:
1319
+ return f"{label}: {effective_value} <span style='color:#b44;'>({bonus_value} 装备)</span>"
1320
+ return f"{label}: {base_value}"
1321
+
1322
+ def badge(text: str, bg: str, fg: str = "#1f2937") -> str:
1323
+ return (
1324
+ f"<span style='display:inline-block;margin:0 6px 6px 0;padding:3px 10px;"
1325
+ f"border-radius:999px;background:{bg};color:{fg};font-size:0.8em;"
1326
+ f"font-weight:600;'>{text}</span>"
1327
+ )
1328
 
1329
  # 状态效果
1330
  if p.status_effects:
 
1337
  # 背包
1338
  if p.inventory:
1339
  inventory_text = "<br>".join(p.inventory)
1340
+ else:
1341
+ inventory_text = "空"
1342
+
1343
+ weather_colors = {
1344
+ "晴朗": "#fef3c7",
1345
+ "多云": "#e5e7eb",
1346
+ "小雨": "#dbeafe",
1347
+ "浓雾": "#e0e7ff",
1348
+ "暴风雨": "#c7d2fe",
1349
+ "大雪": "#f3f4f6",
1350
+ }
1351
+ light_colors = {
1352
+ "明亮": "#fde68a",
1353
+ "柔和": "#fcd34d",
1354
+ "昏暗": "#cbd5e1",
1355
+ "幽暗": "#94a3b8",
1356
+ "漆黑": "#334155",
1357
+ }
1358
+ danger_level = int(env_snapshot.get("danger_level", 0))
1359
+ if danger_level >= 7:
1360
+ danger_badge = badge(f"危险 {danger_level}/10", "#fecaca", "#7f1d1d")
1361
+ elif danger_level >= 4:
1362
+ danger_badge = badge(f"危险 {danger_level}/10", "#fed7aa", "#9a3412")
1363
+ else:
1364
+ danger_badge = badge(f"危险 {danger_level}/10", "#dcfce7", "#166534")
1365
+
1366
+ env_badges = "".join(
1367
+ [
1368
+ badge(f"天气 {w.weather}", weather_colors.get(w.weather, "#e5e7eb")),
1369
+ badge(
1370
+ f"光照 {w.light_level}",
1371
+ light_colors.get(w.light_level, "#e5e7eb"),
1372
+ "#0f172a" if w.light_level not in {"幽暗", "漆黑"} else "#f8fafc",
1373
+ ),
1374
+ danger_badge,
1375
+ badge(f"场景 {env_snapshot.get('location_type', 'unknown')}", "#ede9fe", "#4c1d95"),
1376
+ ]
1377
+ )
1378
+
1379
+ recent_env_events = env_snapshot.get("recent_events", [])
1380
+ if recent_env_events:
1381
+ latest_event = recent_env_events[-1]
1382
+ latest_event_html = (
1383
+ f"<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
1384
+ f"border:1px solid #dbeafe;margin-bottom:6px;'>"
1385
+ f"<b>{latest_event.get('title', '环境事件')}</b>"
1386
+ f"<br><span style='font-size:0.82em;color:#475569;'>{latest_event.get('description', '')}</span>"
1387
+ f"</div>"
1388
+ )
1389
+ recent_event_lines = "<br>".join(
1390
+ f"- {event.get('title', '环境事件')}"
1391
+ for event in reversed(recent_env_events[-3:])
1392
+ )
1393
+ else:
1394
+ latest_event_html = (
1395
+ "<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
1396
+ "border:1px dashed #cbd5e1;color:#64748b;'>本回合暂无显式环境事件</div>"
1397
+ )
1398
+ recent_event_lines = "无"
1399
 
1400
  # 活跃任务(完整展示:描述、子目标、奖励、来源)
1401
  active_quests = [q for q in w.quests.values() if q.status == "active"]
 
1426
 
1427
  block = (
1428
  f"<details open><summary><b>{tag} {q.title}</b>({done}/{total})</summary>"
1429
+ f"<span style='font-size:0.9em;color:#666;'>来源: {q.giver_npc or '未知'}</span><br>"
1430
+ f"<span style='font-size:0.9em;'>{q.description}</span>"
1431
  f"{obj_lines}"
1432
+ f"<br><span style='font-size:0.9em;color:#2f7a4a;'>奖励: {reward_str}</span>"
1433
  f"</details>"
1434
  )
1435
  quest_blocks.append(block)
 
1447
  <div>
1448
  <h4 style="margin:4px 0 2px 0;">🩸 生命与状态</h4>
1449
  <span style="font-size:0.85em;">
1450
+ {hp_bar}<br>
1451
+ {mp_bar}<br>
1452
+ {stamina_bar}<br>
1453
+ {hunger_bar}<br>
1454
+ {sanity_bar}<br>
1455
+ {morale_bar}
1456
+ </span>
1457
+ </div>
 
 
 
 
 
 
 
 
 
 
 
1458
 
1459
  <div>
1460
+ <h4 style="margin:4px 0 2px 0;">🎒 背包</h4>
1461
  <span style="font-size:0.85em;">
1462
+ {inventory_text}
1463
+ </span>
1464
+ </div>
1465
+
1466
+ <div>
1467
+ <h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
1468
+ <span style="font-size:0.85em;">
1469
+ {render_stat("attack", "攻击")}<br>
1470
+ {render_stat("defense", "防御")}<br>
1471
+ {render_stat("speed", "速度")}<br>
1472
+ {render_stat("luck", "幸运")}<br>
1473
+ {render_stat("perception", "感知")}
1474
  </span>
1475
  </div>
1476
 
 
1482
  </div>
1483
 
1484
  <div>
1485
+ <h4 style="margin:4px 0 2px 0;">💰 资源</h4>
1486
  <span style="font-size:0.85em;">
1487
+ 金币: {p.gold}<br>
1488
+ 善恶值: {p.karma}
1489
  </span>
1490
  </div>
1491
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1492
  <div>
1493
+ <h4 style="margin:4px 0 2px 0;"> 状态效果</h4>
1494
  <span style="font-size:0.85em;">
1495
+ {effect_lines}
1496
+ </span>
1497
+ </div>
1498
+
1499
+ <div style="grid-column: 1 / -1;">
1500
+ <h4 style="margin:4px 0 2px 0;">📜 任务</h4>
1501
+ <span style="font-size:0.9em;line-height:1.55;">
1502
+ {quest_text}
1503
+ </span>
1504
+ </div>
1505
 
1506
+ <div style="grid-column: 1 / -1;">
1507
+ <h4 style="margin:4px 0 2px 0;">🧭 当前场景信息</h4>
1508
+ <div style="font-size:0.85em;line-height:1.5;padding:8px 10px;border-radius:12px;background:#fff7ed;border:1px solid #fed7aa;">
1509
+ {env_badges}
1510
+ <div style="margin:6px 0 8px 0;color:#475569;">
1511
+ 时间 {clock_display} | 场景 {w.current_scene} | 状态系数 {survival_snapshot.get('combined_multiplier', 1.0)}
1512
+ </div>
1513
+ {latest_event_html}
1514
+ <div style="margin-top:8px;">{scene_summary}</div>
1515
+ <div style="margin-top:6px;color:#475569;">最近环境事件: {recent_event_lines}</div>
1516
+ </div>
1517
+ </div>
1518
  </div>
1519
  </div>"""
1520
  return status
1521
 
1522
 
1523
+ def _format_world_info_panel(gs: GameState | None) -> str:
1524
+ """格式化世界信息面板(放在故事框上方)。"""
1525
+ if gs is None:
1526
+ return "🌍 **世界信息**:未开始冒险"
1527
+
1528
+ w = gs.world
1529
+ if hasattr(gs, "get_clock_display"):
1530
+ clock_display = gs.get_clock_display()
1531
+ else:
1532
+ minute_of_day = int(getattr(w, "time_progress_units", 0)) * 10 % (24 * 60)
1533
+ clock_display = f"{minute_of_day // 60:02d}:{minute_of_day % 60:02d}"
1534
+
1535
+ current_scene = getattr(w, "current_scene", "未知地点")
1536
+ day_count = getattr(w, "day_count", 1)
1537
+ time_of_day = getattr(w, "time_of_day", "未知时段")
1538
+ weather = getattr(w, "weather", "未知")
1539
+ light_level = getattr(w, "light_level", "未知")
1540
+ season = getattr(w, "season", "未知")
1541
+
1542
+ return (
1543
+ "🌍 **世界���息**:"
1544
+ f"位置 {current_scene} | "
1545
+ f"第{day_count}天 {time_of_day}({clock_display}) | "
1546
+ f"天气 {weather} | 光照 {light_level} | "
1547
+ f"季节 {season} | 回合 {getattr(gs, 'turn', 0)}"
1548
+ )
1549
+
1550
+
1551
  def _progress_bar(current: int, maximum: int, label: str, length: int = 10) -> str:
1552
  """生成文本进度条"""
1553
  ratio = current / maximum if maximum > 0 else 0
 
1562
  # ============================================================
1563
 
1564
 
1565
+ def build_app() -> gr.Blocks:
1566
+ """构建 Gradio 界面"""
1567
+
1568
+ with gr.Blocks(
1569
+ title="StoryWeaver - 交互式叙事系统",
1570
+ ) as app:
1571
+ app.css = APP_UI_CSS
1572
 
1573
  gr.Markdown(
1574
  """
 
1604
  scale=2,
1605
  )
1606
 
1607
+ world_info_panel = gr.Markdown(
1608
+ value=_format_world_info_panel(None),
1609
+ )
1610
+
1611
  # 聊天窗口
1612
  chatbot = gr.Chatbot(
1613
  label="故事",
1614
  height=480,
1615
  )
1616
 
1617
+ location_map_panel = gr.Markdown(
1618
+ elem_classes=["scene-card", "status-panel"],
1619
+ value="地图关系图\n(未开始)",
1620
+ label="地图",
1621
+ )
1622
+
1623
+ # 选项按钮(最多 6 个,分两行显示)
1624
+ with gr.Column():
1625
+ with gr.Row():
1626
+ option_btn_1 = gr.Button(
1627
+ "...",
1628
+ visible=True,
1629
+ interactive=False,
1630
+ elem_classes=["option-btn"],
1631
+ )
1632
+ option_btn_2 = gr.Button(
1633
+ "...",
1634
+ visible=True,
1635
+ interactive=False,
1636
+ elem_classes=["option-btn"],
1637
+ )
1638
+ option_btn_3 = gr.Button(
1639
+ "...",
1640
+ visible=True,
1641
+ interactive=False,
1642
+ elem_classes=["option-btn"],
1643
+ )
1644
+ with gr.Row():
1645
+ option_btn_4 = gr.Button(
1646
+ "...",
1647
+ visible=False,
1648
+ interactive=False,
1649
+ elem_classes=["option-btn"],
1650
+ )
1651
+ option_btn_5 = gr.Button(
1652
+ "...",
1653
+ visible=False,
1654
+ interactive=False,
1655
+ elem_classes=["option-btn"],
1656
+ )
1657
+ option_btn_6 = gr.Button(
1658
+ "...",
1659
+ visible=False,
1660
+ interactive=False,
1661
+ elem_classes=["option-btn"],
1662
+ )
1663
+ option_buttons = [
1664
+ option_btn_1,
1665
+ option_btn_2,
1666
+ option_btn_3,
1667
+ option_btn_4,
1668
+ option_btn_5,
1669
+ option_btn_6,
1670
+ ]
1671
 
1672
  # 自由输入
1673
  with gr.Row():
 
1679
  )
1680
  send_btn = gr.Button("发送", variant="primary", scale=1)
1681
 
1682
+ # ==================
1683
+ # 右侧:状态面板
1684
+ # ==================
1685
+ with gr.Column(scale=2, min_width=320, elem_classes=["scene-sidebar"]):
1686
+ scene_image = gr.Image(
1687
+ value=None,
1688
+ type="filepath",
1689
+ label="场景画面",
1690
+ show_label=False,
1691
+ container=False,
1692
+ interactive=False,
1693
+ height=260,
1694
+ buttons=[],
1695
+ visible=False,
1696
+ elem_classes=["scene-card", "scene-image"],
1697
+ )
1698
+ status_panel = gr.Markdown(
1699
+ elem_classes=["scene-card", "status-panel"],
1700
+ value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
1701
+ label="角色状态",
1702
+ )
1703
 
1704
  # ============================================================
1705
  # 事件绑定
1706
  # ============================================================
1707
 
1708
  # 开始游戏
1709
+ start_btn.click(
1710
+ fn=start_game,
1711
+ inputs=[player_name_input, game_session],
1712
+ outputs=[
1713
+ chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
1714
+ *option_buttons,
1715
+ game_session, user_input,
1716
+ ],
1717
+ )
1718
 
1719
  # 重启冒险
1720
+ restart_btn.click(
1721
+ fn=restart_game,
1722
+ inputs=[],
1723
+ outputs=[
1724
+ chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
1725
+ *option_buttons,
1726
+ game_session, user_input, player_name_input,
1727
+ ],
1728
+ )
1729
 
1730
  # 文本输入发送
1731
+ send_btn.click(
1732
+ fn=process_user_input,
1733
+ inputs=[user_input, chatbot, game_session],
1734
+ outputs=[
1735
+ chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
1736
+ *option_buttons,
1737
+ game_session,
1738
+ ],
1739
+ ).then(
1740
  fn=lambda: "",
1741
  outputs=[user_input],
1742
  )
1743
 
1744
  # 回车发送
1745
+ user_input.submit(
1746
+ fn=process_user_input,
1747
+ inputs=[user_input, chatbot, game_session],
1748
+ outputs=[
1749
+ chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
1750
+ *option_buttons,
1751
+ game_session,
1752
+ ],
1753
+ ).then(
1754
  fn=lambda: "",
1755
  outputs=[user_input],
1756
  )
1757
 
1758
  # 选项按钮点击(需要使用 yield from 的生成器包装函数,
1759
  # 使 Gradio 能正确识别为流式输出)
1760
+ def _make_option_click_handler(index: int):
1761
+ def _handler(ch, gs):
1762
+ yield from process_option_click(index, ch, gs)
1763
+
1764
+ return _handler
1765
+
1766
+ for index, option_button in enumerate(option_buttons):
1767
+ option_button.click(
1768
+ fn=_make_option_click_handler(index),
1769
+ inputs=[chatbot, game_session],
1770
+ outputs=[
1771
+ chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
1772
+ *option_buttons,
1773
+ game_session,
1774
+ ],
1775
+ )
1776
 
1777
  return app
1778
 
 
1793
  primary_hue="emerald",
1794
  secondary_hue="blue",
1795
  ),
1796
+ css=APP_UI_CSS,
1797
+ )
combat_engine.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from typing import Any
5
+
6
+
7
+ MONSTER_DB: dict[str, dict[str, int]] = {
8
+ "哥布林": {"hp": 20, "attack": 5, "defense": 2, "difficulty": 1},
9
+ "森林狼": {"hp": 40, "attack": 15, "defense": 5, "difficulty": 2},
10
+ "远古巨龙": {"hp": 500, "attack": 100, "defense": 80, "difficulty": 10},
11
+ "default": {"hp": 30, "attack": 10, "defense": 5, "difficulty": 1},
12
+ }
13
+
14
+
15
+ def _difficulty_scale(game_state: Any | None) -> float:
16
+ if game_state is None:
17
+ return 1.0
18
+ diff_name = str(getattr(game_state, "difficulty", "normal")).lower()
19
+ return {"easy": 0.9, "normal": 1.0, "hard": 1.2}.get(diff_name, 1.0)
20
+
21
+
22
+ def _current_location_danger(game_state: Any | None) -> int:
23
+ if game_state is None:
24
+ return 1
25
+ player = getattr(game_state, "player", None)
26
+ world = getattr(game_state, "world", None)
27
+ if player is None or world is None:
28
+ return 1
29
+ location_name = str(getattr(player, "location", ""))
30
+ location = getattr(world, "locations", {}).get(location_name)
31
+ if location is None:
32
+ return 1
33
+ try:
34
+ return max(1, int(getattr(location, "danger_level", 1)))
35
+ except Exception:
36
+ return 1
37
+
38
+
39
+ def get_monster_profile(monster_name: str, game_state: Any | None = None) -> dict[str, int]:
40
+ normalized_name = str(monster_name or "").strip()
41
+ profile = MONSTER_DB.get(normalized_name)
42
+ if profile is not None:
43
+ return dict(profile)
44
+
45
+ base = dict(MONSTER_DB["default"])
46
+ location_danger = _current_location_danger(game_state)
47
+ diff_scale = _difficulty_scale(game_state)
48
+
49
+ generated_difficulty = max(1, int(round(base["difficulty"] + (location_danger - 1) * 0.6)))
50
+ generated_attack = max(1, int(round(base["attack"] * diff_scale + location_danger * 2)))
51
+ generated_defense = max(1, int(round(base["defense"] * diff_scale + location_danger)))
52
+ generated_hp = max(1, int(round(base["hp"] * diff_scale + location_danger * 10)))
53
+
54
+ return {
55
+ "hp": generated_hp,
56
+ "attack": generated_attack,
57
+ "defense": generated_defense,
58
+ "difficulty": generated_difficulty,
59
+ }
60
+
61
+
62
+ def resolve_combat(
63
+ player_state: Any,
64
+ monster_name: str,
65
+ *,
66
+ game_state: Any | None = None,
67
+ rng: random.Random | None = None,
68
+ ) -> dict[str, Any]:
69
+ active_rng = rng or random
70
+ monster = get_monster_profile(monster_name, game_state=game_state)
71
+
72
+ player_level = max(1, int(getattr(player_state, "level", 1)))
73
+ player_attack = max(1, int(getattr(player_state, "attack_power", getattr(player_state, "attack", 1))))
74
+ player_defense = max(0, int(getattr(player_state, "defense_power", getattr(player_state, "defense", 0))))
75
+
76
+ player_power = player_attack + player_level * 2
77
+ monster_power = int(monster["defense"]) + int(monster["difficulty"]) * 3
78
+ outcome = "win" if player_power >= monster_power else "lose"
79
+
80
+ random_float = float(active_rng.uniform(0.0, 3.0))
81
+ base_hp_loss = max(1, int(round(int(monster["attack"]) - player_defense - random_float)))
82
+ if outcome == "lose":
83
+ base_hp_loss = max(base_hp_loss + int(monster["difficulty"]) * 2, int(round(base_hp_loss * 1.4)))
84
+
85
+ if outcome == "win":
86
+ message = f"你击败了{monster_name},但仍受了些伤。"
87
+ else:
88
+ message = f"你不敌{monster_name},被迫败退。"
89
+
90
+ return {
91
+ "outcome": outcome,
92
+ "player_hp_loss": int(base_hp_loss),
93
+ "monster_name": str(monster_name),
94
+ "message": message,
95
+ "player_power": int(player_power),
96
+ "monster_power": int(monster_power),
97
+ }
demo_rules.py CHANGED
The diff for this file is too large to render. See raw diff
 
nlu_engine.py CHANGED
@@ -21,6 +21,7 @@ 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
 
@@ -117,14 +118,14 @@ class NLUEngine:
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
- "parser_source": "empty_input",
127
- }
128
 
129
  user_input = user_input.strip()
130
  logger.info(f"NLU 解析输入: '{user_input}'")
@@ -133,17 +134,17 @@ class NLUEngine:
133
  result = self._llm_parse(user_input)
134
 
135
  # 如果 LLM 解析失败,使用关键词降级
136
- if result is None:
137
- logger.warning("LLM 解析失败,使用关键词降级")
138
- result = self._keyword_fallback(user_input)
139
-
140
- result = self._apply_intent_postprocessing(result, user_input)
141
-
142
- # 附加原始输入
143
- result["raw_input"] = user_input
144
-
145
- logger.info(f"NLU 解析结果: {result}")
146
- return result
147
 
148
  def _llm_parse(self, user_input: str) -> Optional[dict]:
149
  """
@@ -171,17 +172,17 @@ class NLUEngine:
171
  max_retries=2,
172
  )
173
 
174
- if result and isinstance(result, dict) and "intent" in result:
175
- # 验证意图类型合法
176
- valid_intents = {
177
- "ATTACK", "TALK", "MOVE", "EXPLORE", "USE_ITEM",
178
- "TRADE", "EQUIP", "REST", "QUEST", "SKILL",
179
- "PICKUP", "FLEE", "CUSTOM",
180
- }
181
- if result["intent"] not in valid_intents:
182
- result["intent"] = "CUSTOM"
183
- result.setdefault("parser_source", "llm")
184
- return result
185
 
186
  return None
187
 
@@ -234,17 +235,17 @@ class NLUEngine:
234
  # 尝试提取目标
235
  target = self._extract_target_from_text(user_input)
236
 
237
- return {
238
- "intent": detected_intent,
239
- "target": target,
240
- "details": None,
241
- "parser_source": "keyword_fallback",
242
- }
243
-
244
- def _extract_target_from_text(self, text: str) -> Optional[str]:
245
- """
246
- 从文本中提取可能的目标对象。
247
- 尝试匹配当前场景中的 NPC、物品、地点名称。
248
  """
249
  # 检查 NPC 名称
250
  for npc_name in self.game_state.world.npcs:
@@ -267,39 +268,127 @@ class NLUEngine:
267
  for item_name in self.game_state.world.item_registry:
268
  if item_name in text:
269
  return item_name
270
-
271
- return None
272
-
273
- def _apply_intent_postprocessing(self, result: dict, user_input: str) -> dict:
274
- """Apply narrow intent corrections for high-confidence mixed phrases."""
275
- normalized = dict(result)
276
- intent = str(normalized.get("intent", "")).upper()
277
- if intent == "MOVE" and self._looks_like_trade_request(user_input, normalized.get("target")):
278
- normalized["intent"] = "TRADE"
279
- normalized["intent_correction"] = "move_to_trade_for_shop_request"
280
- return normalized
281
-
282
- def _looks_like_trade_request(self, user_input: str, target: Optional[str]) -> bool:
283
- trade_pattern = r"买|卖|交易|购买|出售|看看有什么卖的|买点"
284
- if not re.search(trade_pattern, user_input):
285
- return False
286
-
287
- target_text = str(target or "")
288
- if target_text:
289
- npc = self.game_state.world.npcs.get(target_text)
290
- if npc and npc.can_trade:
291
- return True
292
-
293
- location = self.game_state.world.locations.get(target_text)
294
- if location and location.shop_available:
295
- return True
296
-
297
- shop_hint_pattern = r"商店|杂货铺|旅店|铁匠铺"
298
- return bool(re.search(shop_hint_pattern, user_input))
299
-
300
- def _build_context(self) -> str:
301
- """构建当前场景的简要上下文描述"""
302
- gs = self.game_state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  return (
304
  f"场景: {gs.world.current_scene}\n"
305
  f"时间: 第{gs.world.day_count}天 {gs.world.time_of_day}\n"
 
21
  import logging
22
  from typing import Optional
23
 
24
+ from demo_rules import build_scene_actions
25
  from utils import safe_json_call, DEFAULT_MODEL
26
  from state_manager import GameState
27
 
 
118
  "raw_input": "我想用剑攻击那个哥布林"
119
  }
120
  """
121
+ if not user_input or not user_input.strip():
122
+ return {
123
+ "intent": "EXPLORE",
124
+ "target": None,
125
+ "details": "玩家沉默不语",
126
+ "raw_input": "",
127
+ "parser_source": "empty_input",
128
+ }
129
 
130
  user_input = user_input.strip()
131
  logger.info(f"NLU 解析输入: '{user_input}'")
 
134
  result = self._llm_parse(user_input)
135
 
136
  # 如果 LLM 解析失败,使用关键词降级
137
+ if result is None:
138
+ logger.warning("LLM 解析失败,使用关键词降级")
139
+ result = self._keyword_fallback(user_input)
140
+
141
+ result = self._apply_intent_postprocessing(result, user_input)
142
+
143
+ # 附加原始输入
144
+ result["raw_input"] = user_input
145
+
146
+ logger.info(f"NLU 解析结果: {result}")
147
+ return result
148
 
149
  def _llm_parse(self, user_input: str) -> Optional[dict]:
150
  """
 
172
  max_retries=2,
173
  )
174
 
175
+ if result and isinstance(result, dict) and "intent" in result:
176
+ # 验证意图类型合法
177
+ valid_intents = {
178
+ "ATTACK", "TALK", "MOVE", "EXPLORE", "USE_ITEM",
179
+ "TRADE", "EQUIP", "REST", "QUEST", "SKILL",
180
+ "PICKUP", "FLEE", "CUSTOM",
181
+ }
182
+ if result["intent"] not in valid_intents:
183
+ result["intent"] = "CUSTOM"
184
+ result.setdefault("parser_source", "llm")
185
+ return result
186
 
187
  return None
188
 
 
235
  # 尝试提取目标
236
  target = self._extract_target_from_text(user_input)
237
 
238
+ return {
239
+ "intent": detected_intent,
240
+ "target": target,
241
+ "details": None,
242
+ "parser_source": "keyword_fallback",
243
+ }
244
+
245
+ def _extract_target_from_text(self, text: str) -> Optional[str]:
246
+ """
247
+ 从文本中提取可能的目标对象。
248
+ 尝试匹配当前场景中的 NPC、物品、地点名称。
249
  """
250
  # 检查 NPC 名称
251
  for npc_name in self.game_state.world.npcs:
 
268
  for item_name in self.game_state.world.item_registry:
269
  if item_name in text:
270
  return item_name
271
+
272
+ return None
273
+
274
+ def _apply_intent_postprocessing(self, result: dict, user_input: str) -> dict:
275
+ """Apply narrow intent corrections for high-confidence mixed phrases."""
276
+ normalized = dict(result)
277
+ intent = str(normalized.get("intent", "")).upper()
278
+ if intent == "MOVE" and self._looks_like_trade_request(user_input, normalized.get("target")):
279
+ inferred_trade_target = self._infer_trade_target(user_input, normalized.get("target"))
280
+ target_text = str(normalized.get("target") or "")
281
+ target_location = self.game_state.world.locations.get(target_text)
282
+ # 目标是商店地点且玩家尚未到达时,优先保持 MOVE,避免生成“未到店先扣钱”的错误交易。
283
+ if (
284
+ target_location is not None
285
+ and target_location.shop_available
286
+ and target_text != self.game_state.player.location
287
+ ):
288
+ normalized["intent_correction"] = "preserve_move_for_shop_travel"
289
+ elif inferred_trade_target is not None:
290
+ normalized["intent"] = "TRADE"
291
+ normalized["target"] = inferred_trade_target
292
+ normalized["intent_correction"] = "move_to_trade_with_structured_target"
293
+ if intent == "TRADE" and not isinstance(normalized.get("target"), dict):
294
+ target_text = str(normalized.get("target") or "")
295
+ target_location = self.game_state.world.locations.get(target_text)
296
+ if (
297
+ target_location is not None
298
+ and target_location.shop_available
299
+ and target_text != self.game_state.player.location
300
+ ):
301
+ normalized["intent"] = "MOVE"
302
+ normalized["intent_correction"] = "trade_to_move_for_shop_travel"
303
+ return normalized
304
+ inferred_trade_target = self._infer_trade_target(user_input, normalized.get("target"))
305
+ if inferred_trade_target is not None:
306
+ normalized["target"] = inferred_trade_target
307
+ normalized["intent_correction"] = "trade_target_inferred_from_text"
308
+ if intent in {"ATTACK", "COMBAT"}:
309
+ target = normalized.get("target")
310
+ if not isinstance(target, str) or not target.strip() or target in {"怪物", "敌人", "它", "那个怪物"}:
311
+ inferred_target = self._infer_attack_target()
312
+ if inferred_target:
313
+ normalized["target"] = inferred_target
314
+ normalized["intent_correction"] = "attack_target_inferred_from_scene"
315
+ return normalized
316
+
317
+ def _looks_like_trade_request(self, user_input: str, target: Optional[str]) -> bool:
318
+ trade_pattern = r"买|卖|交易|购买|出售|看看有什么卖的|买点"
319
+ if not re.search(trade_pattern, user_input):
320
+ return False
321
+
322
+ target_text = str(target or "")
323
+ if target_text:
324
+ npc = self.game_state.world.npcs.get(target_text)
325
+ if npc and npc.can_trade:
326
+ return True
327
+
328
+ location = self.game_state.world.locations.get(target_text)
329
+ if location and location.shop_available:
330
+ return True
331
+
332
+ shop_hint_pattern = r"商店|杂货铺|旅店|铁匠铺"
333
+ return bool(re.search(shop_hint_pattern, user_input))
334
+
335
+ def _infer_attack_target(self) -> Optional[str]:
336
+ """Infer a concrete ATTACK target from deterministic scene actions first."""
337
+ try:
338
+ scene_actions = build_scene_actions(self.game_state, self.game_state.player.location)
339
+ except Exception:
340
+ scene_actions = []
341
+ for action in scene_actions:
342
+ if str(action.get("action_type", "")).upper() != "ATTACK":
343
+ continue
344
+ target = action.get("target")
345
+ if isinstance(target, str) and target.strip():
346
+ return target
347
+
348
+ current_loc = self.game_state.world.locations.get(self.game_state.player.location)
349
+ if current_loc and current_loc.enemies:
350
+ return str(current_loc.enemies[0])
351
+ return None
352
+
353
+ def _infer_trade_target(self, user_input: str, target: object) -> Optional[dict]:
354
+ """Infer structured trade target for rule-based TRADE handling."""
355
+ text_blob = f"{user_input} {target if isinstance(target, str) else ''}"
356
+
357
+ merchant_name: Optional[str] = None
358
+ for npc in self.game_state.world.npcs.values():
359
+ if not npc.can_trade or npc.location != self.game_state.player.location:
360
+ continue
361
+ if npc.name in text_blob or (npc.occupation and npc.occupation in text_blob):
362
+ merchant_name = npc.name
363
+ break
364
+
365
+ if merchant_name is None:
366
+ for npc in self.game_state.world.npcs.values():
367
+ if npc.can_trade and npc.location == self.game_state.player.location:
368
+ merchant_name = npc.name
369
+ break
370
+ if merchant_name is None:
371
+ return None
372
+
373
+ merchant = self.game_state.world.npcs.get(merchant_name)
374
+ if merchant is None:
375
+ return None
376
+
377
+ item_name: Optional[str] = None
378
+ for candidate in merchant.shop_inventory:
379
+ if candidate in text_blob:
380
+ item_name = candidate
381
+ break
382
+ if item_name is None and isinstance(target, str) and target in merchant.shop_inventory:
383
+ item_name = target
384
+ if item_name is None:
385
+ return None
386
+
387
+ return {"merchant": merchant_name, "item": item_name, "confirm": False}
388
+
389
+ def _build_context(self) -> str:
390
+ """构建当前场景的简要上下文描述"""
391
+ gs = self.game_state
392
  return (
393
  f"场景: {gs.world.current_scene}\n"
394
  f"时间: 第{gs.world.day_count}天 {gs.world.time_of_day}\n"
state_manager.py CHANGED
@@ -23,7 +23,7 @@ import re
23
  from typing import Any, Optional
24
  from pydantic import BaseModel, Field
25
 
26
- from demo_rules import OVERNIGHT_REST_LOCATIONS, action_time_cost_minutes
27
  from utils import clamp
28
 
29
  logger = logging.getLogger("StoryWeaver")
@@ -250,6 +250,8 @@ class PlayerState(BaseModel):
250
  max_mp: int = 50 # 最大魔力值
251
  attack: int = 10 # 攻击力
252
  defense: int = 5 # 防御力
 
 
253
  stamina: int = 100 # 体力值
254
  max_stamina: int = 100 # 最大体力值
255
  speed: int = 8 # 速度(影响行动顺序)
@@ -306,11 +308,11 @@ class WorldState(BaseModel):
306
  current_scene: str = "村庄广场" # 当前场景名称
307
  time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下午 / 黄昏 / 夜晚 / 深夜
308
  day_count: int = 1 # 当前天数
309
- weather: str = "晴朗" # 晴朗 / 多云 / 小雨 / 暴风雨 / 大雪 / 浓雾
310
- light_level: str = "明亮" # 明亮 / 柔和 / 昏暗 / 幽暗 / 漆黑
311
- time_progress_units: int = 0 # 当前时段内累积的动作耗时点数
312
- last_weather_change_minutes: int = -999999 # 上次天气变化时的累计分钟数
313
- season: str = "春" # 春 / 夏 / 秋 / 冬
314
 
315
  # --- 地图 ---
316
  locations: dict[str, LocationInfo] = Field(default_factory=dict)
@@ -427,6 +429,11 @@ class GameState:
427
 
428
  # 初始化起始世界
429
  self._init_starting_world()
 
 
 
 
 
430
  self.world.time_progress_units = 36
431
  self.pending_environment_event: EnvironmentEvent | None = None
432
  self._sync_world_clock()
@@ -705,58 +712,58 @@ class GameState:
705
 
706
  # --- 初始任务 ---
707
  self.world.quests = {
708
- "main_quest_01": QuestState(
709
- quest_id="main_quest_01",
710
- title="森林中的阴影",
711
- description="村长老伯告诉你,最近森林中频繁出现怪物袭击事件。他请求你前往调查。",
712
- quest_type="main",
713
  status="active",
714
  giver_npc="村长老伯",
715
- objectives={
716
- "与村长对话了解情况": False,
717
- "前往黑暗森林入口调查": False,
718
- "击败森林中的怪物": False,
719
- "调查怪物活动的原因": False,
720
- "与村长老伯对话汇报发现": False,
721
- },
722
  rewards=QuestRewards(
723
  gold=100,
724
  experience=50,
725
  items=["森林之钥"],
726
  reputation_changes={"村庄": 20},
727
- karma_change=5,
728
- ),
729
- ),
730
- "main_quest_02": QuestState(
731
- quest_id="main_quest_02",
732
- title="森林深处的咆哮",
733
- description="村长老伯确认森林巨魔已经苏醒,并命你持森林之钥深入黑暗森林,将这头怪物彻底斩杀。",
734
- quest_type="main",
735
- status="inactive",
736
- giver_npc="村长老伯",
737
- objectives={
738
- "前往森林深处": False,
739
- "击败森林巨魔": False,
740
- },
741
- rewards=QuestRewards(
742
- gold=0,
743
- experience=90,
744
- reputation_changes={"村庄": 30},
745
- karma_change=10,
746
- ),
747
- prerequisites=["main_quest_01"],
748
- ),
749
- "side_quest_01": QuestState(
750
- quest_id="side_quest_01",
751
- title="失落的传承",
752
- description="神秘旅人似乎在寻找一件古老的遗物。也许帮助他能得到意想不到的回报。",
753
- quest_type="side",
754
- status="inactive",
755
- giver_npc="神秘旅人",
756
- objectives={
757
- "与神秘旅人交谈": False,
758
- "找到古老遗物的线索": False,
759
- },
760
  rewards=QuestRewards(
761
  experience=30,
762
  items=["神秘卷轴"],
@@ -765,17 +772,17 @@ class GameState:
765
  prerequisites=[],
766
  ),
767
  # -------- 扩展任务 --------
768
- "side_quest_02": QuestState(
769
- quest_id="side_quest_02",
770
- title="河底的秘密",
771
- description="渡口老渔夫说他最近总在河里捞到奇怪的骨头,而且对岸矿洞方向夜里总是有光。他请你去查明真相。",
772
- quest_type="side",
773
- status="inactive",
774
- giver_npc="渡口老渔夫",
775
- objectives={
776
- "与渡口老渔夫交谈": False,
777
- "前往废弃矿洞调查": False,
778
- "找到矿洞异常的原因": False,
779
  },
780
  rewards=QuestRewards(
781
  gold=60,
@@ -785,17 +792,17 @@ class GameState:
785
  ),
786
  prerequisites=[],
787
  ),
788
- "side_quest_03": QuestState(
789
- quest_id="side_quest_03",
790
- title="守护者的试炼",
791
- description="精灵遗迹的守护者提出一个交换条件:通过她设下的试炼,就能获得古老的精灵祝福。",
792
- quest_type="side",
793
- status="inactive",
794
- giver_npc="遗守护者",
795
- objectives={
796
- "与遗迹守护者交谈": False,
797
- "通过守护者的试炼": False,
798
- },
799
  rewards=QuestRewards(
800
  experience=50,
801
  unlock_skill="精灵祝福",
@@ -918,6 +925,34 @@ class GameState:
918
  # 核心方法
919
  # ============================================================
920
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
  def apply_changes(self, changes: dict) -> list[str]:
922
  """
923
  接收 Qwen 返回的状态变更 JSON,校验并应用到当前状态。
@@ -1049,8 +1084,7 @@ class GameState:
1049
  ):
1050
  change_log.append(f"忽略未解锁地点: {new_loc}")
1051
  else:
1052
- self.player.location = new_loc
1053
- self.world.current_scene = new_loc
1054
  change_log.append(f"位置: {old_loc} → {new_loc}")
1055
  # 发现新地点
1056
  if new_loc not in self.world.discovered_locations:
@@ -1203,16 +1237,16 @@ class GameState:
1203
  change_log.append(f"任务【{quest.title}】进展至\"{status_cn}\"")
1204
 
1205
  # --- 世界状态变更 ---
1206
- if "weather_change" in changes:
1207
- valid_weathers = {"晴朗", "多云", "小雨", "暴风雨", "大雪", "浓雾"}
1208
- new_weather = str(changes["weather_change"])
1209
- if new_weather in valid_weathers:
1210
- if self.world.weather != new_weather:
1211
- self.world.weather = new_weather
1212
- self.world.last_weather_change_minutes = self.elapsed_minutes_total
1213
- change_log.append(f"天气变为: {self.world.weather}")
1214
- else:
1215
- logger.warning(f"无效的 weather_change 值 '{new_weather}',已忽略。")
1216
 
1217
  if "time_change" in changes:
1218
  valid_times = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
@@ -1274,6 +1308,9 @@ class GameState:
1274
  self.player.title = str(changes["title_change"])
1275
  change_log.append(f"称号: {old_title} → {self.player.title}")
1276
 
 
 
 
1277
  if change_log:
1278
  logger.info(f"状态变更: {change_log}")
1279
 
@@ -1341,6 +1378,12 @@ class GameState:
1341
  bonuses[stat_name] = bonuses.get(stat_name, 0) + int(amount)
1342
  return bonuses
1343
 
 
 
 
 
 
 
1344
  def _status_multiplier(self, value: int) -> float:
1345
  if value >= 90:
1346
  return 1.5
@@ -1412,17 +1455,17 @@ class GameState:
1412
  effective_stats[stat_name] = clamp(boosted_value, 1, max(cap, boosted_value))
1413
  return effective_stats
1414
 
1415
- def get_clock_minutes(self) -> int:
1416
- return int(self.world.time_progress_units) * 10
1417
-
1418
- def get_minute_of_day(self) -> int:
1419
- return self.get_clock_minutes() % (24 * 60)
1420
-
1421
- def get_clock_display(self) -> str:
1422
- total_minutes = self.get_clock_minutes() % (24 * 60)
1423
- hours = total_minutes // 60
1424
- minutes = total_minutes % 60
1425
- return f"{hours:02d}:{minutes:02d}"
1426
 
1427
  def _time_of_day_from_minutes(self, total_minutes: int) -> str:
1428
  minute_of_day = total_minutes % (24 * 60)
@@ -1440,63 +1483,63 @@ class GameState:
1440
  return "夜晚"
1441
  return "深夜"
1442
 
1443
- def _sync_world_clock(self):
1444
- self.world.time_of_day = self._time_of_day_from_minutes(self.get_clock_minutes())
1445
-
1446
- def can_overnight_rest(self) -> bool:
1447
- current_loc = self.world.locations.get(self.player.location)
1448
- if current_loc is None or not current_loc.rest_available:
1449
- return False
1450
- if self.player.location not in OVERNIGHT_REST_LOCATIONS:
1451
- return False
1452
- return self.get_minute_of_day() >= 19 * 60
1453
-
1454
- def prepare_overnight_rest(self) -> tuple[list[str], dict[str, int]]:
1455
- """Advance to next morning and return full-recovery deltas for overnight rest."""
1456
- if not self.can_overnight_rest():
1457
- return [], {}
1458
-
1459
- old_clock = self.get_clock_display()
1460
- old_time_of_day = self.world.time_of_day
1461
- old_day_count = self.world.day_count
1462
- old_light = self.world.light_level
1463
-
1464
- minute_of_day = self.get_minute_of_day()
1465
- minutes_until_midnight = (24 * 60) - minute_of_day
1466
- target_elapsed_minutes = self.elapsed_minutes_total + minutes_until_midnight + 6 * 60
1467
-
1468
- self.elapsed_minutes_total = target_elapsed_minutes
1469
- self.world.day_count = target_elapsed_minutes // (24 * 60) + 1
1470
- self.world.time_progress_units = (target_elapsed_minutes % (24 * 60)) // 10
1471
- self._sync_world_clock()
1472
- self.world.light_level = self._determine_light_level()
1473
-
1474
- tick_log: list[str] = []
1475
- if self.world.day_count != old_day_count:
1476
- tick_log.append(f"新的一天!第{self.world.day_count}天")
1477
-
1478
- new_clock = self.get_clock_display()
1479
- if new_clock != old_clock:
1480
- tick_log.append(f"时间流逝: {old_clock} → {new_clock}")
1481
- if self.world.time_of_day != old_time_of_day:
1482
- tick_log.append(f"时段变化: {old_time_of_day} → {self.world.time_of_day}")
1483
- if self.world.light_level != old_light:
1484
- tick_log.append(f"光照变化: {old_light} → {self.world.light_level}")
1485
-
1486
- hunger_cost = 20 if self.player.location == "村庄旅店" else 25
1487
- recovery_changes: dict[str, int] = {
1488
- "hp_change": self.player.max_hp - self.player.hp,
1489
- "mp_change": self.player.max_mp - self.player.mp,
1490
- "stamina_change": self.player.max_stamina - self.player.stamina,
1491
- "morale_change": 100 - self.player.morale,
1492
- "sanity_change": 100 - self.player.sanity,
1493
- "hunger_change": -hunger_cost,
1494
- }
1495
- return tick_log, {
1496
- key: value
1497
- for key, value in recovery_changes.items()
1498
- if int(value) != 0
1499
- }
1500
 
1501
  def to_prompt(self) -> str:
1502
  """
@@ -1536,8 +1579,10 @@ class GameState:
1536
  player_desc = (
1537
  f"【玩家】{self.player.name}({self.player.title})\n"
1538
  f" 等级: {self.player.level} | 经验: {self.player.experience}/{self.player.exp_to_next_level}\n"
1539
- f" HP: {self.player.hp}/{self.player.max_hp} | MP: {self.player.mp}/{self.player.max_mp}\n"
1540
- f" 攻击: {self.player.attack} | 防御: {self.player.defense} | 速度: {self.player.speed} | 幸运: {self.player.luck} | 感知: {self.player.perception}\n"
 
 
1541
  f" 金币: {self.player.gold} | 善恶值: {self.player.karma}\n"
1542
  f" 士气: {self.player.morale} | 理智: {self.player.sanity} | 饱食度: {self.player.hunger}\n"
1543
  f" 装备: {equip_str}\n"
@@ -1782,9 +1827,11 @@ class GameState:
1782
  (is_valid, rejection_reason): 合法返回 (True, ""), 否则返回 (False, "拒绝原因")
1783
  """
1784
  action = intent.get("intent", "")
 
1785
  target = intent.get("target", "") or ""
1786
  details = intent.get("details", "") or ""
1787
  raw_input = intent.get("raw_input", "") or ""
 
1788
 
1789
  inventory = list(self.player.inventory)
1790
  equipped_items = [v for v in self.player.equipment.values() if v]
@@ -1803,20 +1850,88 @@ class GameState:
1803
 
1804
  normalized_target = normalize_item_phrase(target)
1805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1806
  # --- 检测 1: USE_ITEM / EQUIP: target 必须在背包或装备中 ---
1807
- if action in ("USE_ITEM", "EQUIP") and target:
1808
  if target not in all_owned:
1809
  return False, f"你的背包中没有「{target}」,无法使用或装备。"
1810
- if action == "EQUIP" and target not in inventory:
1811
  if target in equipped_items:
1812
  return False, f"「{target}」已经装备在身上了。"
1813
  return False, f"你的背包中没有「{target}」,无法装备。"
1814
 
1815
  # 体力耗尽时禁止移动和战斗
1816
- if action in ("MOVE", "ATTACK", "COMBAT") and self.player.stamina <= 0:
1817
  return False, "你精疲力竭,体力耗尽,无法行动。需要先在旅店或营地休息恢复体力。"
1818
 
1819
- if action == "MOVE" and target:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1820
  current_loc = self.world.locations.get(self.player.location)
1821
  target_loc = self.world.locations.get(str(target))
1822
  if current_loc is None or target_loc is None:
@@ -1828,27 +1943,46 @@ class GameState:
1828
  return False, f"「{target}」尚未解锁,需要「{target_loc.required_item}」。"
1829
  return False, f"「{target}」当前无法进入。"
1830
 
1831
- if action == "VIEW_MAP":
1832
  if not any("地图" in item for item in all_owned):
1833
  return False, "你还没有获得可查看的地图。"
1834
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1835
  # --- 检测 2: SKILL: 必须已习得 ---
1836
- if action == "SKILL" and target:
1837
  if target not in self.player.skills:
1838
  return False, f"你尚未习得技能「{target}」。"
1839
 
1840
- wants_overnight_rest = str(action).upper() == "OVERNIGHT_REST" or (
1841
- str(action).upper() == "REST"
1842
- and any(keyword in f"{details} {raw_input}" for keyword in ("过夜", "睡一晚", "住一晚", "睡到天亮"))
1843
- )
1844
-
1845
- # --- 检测 3: REST/OVERNIGHT_REST: 当前位置必须允许休息 ---
1846
- if action in ("REST", "OVERNIGHT_REST"):
1847
- current_loc = self.world.locations.get(self.player.location)
1848
- if current_loc is None or not current_loc.rest_available:
1849
- return False, "这里不适合休息,试着前往旅店或营地。"
1850
- if wants_overnight_rest and not self.can_overnight_rest():
1851
- return False, "现在还不能在这里过夜。晚上七点后,且仅限旅店或溪边营地。"
1852
 
1853
  # --- 检测 4: 扫描 raw_input 中是否在使用上下文中引用了未拥有的已知物品 ---
1854
  known_items: set[str] = set(self.world.item_registry.keys())
@@ -2096,13 +2230,13 @@ class GameState:
2096
  inject_prompt=new_light in {"昏暗", "幽暗", "漆黑"},
2097
  )
2098
 
2099
- def _maybe_shift_weather(self, tick_log: list[str]):
2100
- """Occasionally shift weather to keep the environment dynamic."""
2101
- if self.elapsed_minutes_total - int(self.world.last_weather_change_minutes) < 180:
2102
- return
2103
- chance = 0.18 if self.world.time_of_day in {"清晨", "黄昏"} else 0.08
2104
- if random.random() >= chance:
2105
- return
2106
 
2107
  weather_transitions = {
2108
  "晴朗": ["多云", "小雨"],
@@ -2149,13 +2283,14 @@ class GameState:
2149
  if loc is None or not self.environment_event_pool:
2150
  return
2151
 
2152
- chance = 0.08
 
2153
  if loc.danger_level >= 3:
2154
- chance += 0.08
2155
  if self.world.light_level in {"幽暗", "漆黑"}:
2156
- chance += 0.06
2157
  if self.world.weather in {"暴风雨", "浓雾"}:
2158
- chance += 0.04
2159
  if random.random() >= chance:
2160
  return
2161
 
 
23
  from typing import Any, Optional
24
  from pydantic import BaseModel, Field
25
 
26
+ from demo_rules import OVERNIGHT_REST_LOCATIONS, action_time_cost_minutes, build_scene_actions
27
  from utils import clamp
28
 
29
  logger = logging.getLogger("StoryWeaver")
 
250
  max_mp: int = 50 # 最大魔力值
251
  attack: int = 10 # 攻击力
252
  defense: int = 5 # 防御力
253
+ attack_power: int = 10 # 实战攻击力(基础攻击+装备加成)
254
+ defense_power: int = 5 # 实战防御力(基础防御+装备加成)
255
  stamina: int = 100 # 体力值
256
  max_stamina: int = 100 # 最大体力值
257
  speed: int = 8 # 速度(影响行动顺序)
 
308
  current_scene: str = "村庄广场" # 当前场景名称
309
  time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下午 / 黄昏 / 夜晚 / 深夜
310
  day_count: int = 1 # 当前天数
311
+ weather: str = "晴朗" # 晴朗 / 多云 / 小雨 / 暴风雨 / 大雪 / 浓雾
312
+ light_level: str = "明亮" # 明亮 / 柔和 / 昏暗 / 幽暗 / 漆黑
313
+ time_progress_units: int = 0 # 当前时段内累积的动作耗时点数
314
+ last_weather_change_minutes: int = -999999 # 上次天气变化时的累计分钟数
315
+ season: str = "春" # 春 / 夏 / 秋 / 冬
316
 
317
  # --- 地图 ---
318
  locations: dict[str, LocationInfo] = Field(default_factory=dict)
 
429
 
430
  # 初始化起始世界
431
  self._init_starting_world()
432
+ self.refresh_combat_stats()
433
+ # 純文本地图渲染使用的“当前位置 + 足迹历史”
434
+ # current_location 必须始终与 self.player.location 保持一致。
435
+ self.current_location: str = str(self.player.location)
436
+ self.location_history: list[str] = []
437
  self.world.time_progress_units = 36
438
  self.pending_environment_event: EnvironmentEvent | None = None
439
  self._sync_world_clock()
 
712
 
713
  # --- 初始任务 ---
714
  self.world.quests = {
715
+ "main_quest_01": QuestState(
716
+ quest_id="main_quest_01",
717
+ title="森林中的阴影",
718
+ description="村长老伯告诉你,最近森林中频繁出现怪物袭击事件。他请求你前往调查。",
719
+ quest_type="main",
720
  status="active",
721
  giver_npc="村长老伯",
722
+ objectives={
723
+ "与村长对话了解情况": False,
724
+ "前往黑暗森林入口调查": False,
725
+ "击败森林中的怪物": False,
726
+ "调查怪物活动的原因": False,
727
+ "与村长老伯对话汇报发现": False,
728
+ },
729
  rewards=QuestRewards(
730
  gold=100,
731
  experience=50,
732
  items=["森林之钥"],
733
  reputation_changes={"村庄": 20},
734
+ karma_change=5,
735
+ ),
736
+ ),
737
+ "main_quest_02": QuestState(
738
+ quest_id="main_quest_02",
739
+ title="森林深处的咆哮",
740
+ description="村长老伯确认森林巨魔已经苏醒,并命你持森林之钥深入黑暗森林,将这头怪物彻底斩杀。",
741
+ quest_type="main",
742
+ status="inactive",
743
+ giver_npc="村长老伯",
744
+ objectives={
745
+ "前往森林深处": False,
746
+ "击败森林巨魔": False,
747
+ },
748
+ rewards=QuestRewards(
749
+ gold=0,
750
+ experience=90,
751
+ reputation_changes={"村庄": 30},
752
+ karma_change=10,
753
+ ),
754
+ prerequisites=["main_quest_01"],
755
+ ),
756
+ "side_quest_01": QuestState(
757
+ quest_id="side_quest_01",
758
+ title="失落的传承",
759
+ description="神秘旅人似乎在寻找一件古老的遗物。也许帮助他能得到意想不到的回报。",
760
+ quest_type="side",
761
+ status="inactive",
762
+ giver_npc="神秘旅人",
763
+ objectives={
764
+ "与神秘旅人交谈": False,
765
+ "找到古老遗物的线索": False,
766
+ },
767
  rewards=QuestRewards(
768
  experience=30,
769
  items=["神秘卷轴"],
 
772
  prerequisites=[],
773
  ),
774
  # -------- 扩展任务 --------
775
+ "side_quest_02": QuestState(
776
+ quest_id="side_quest_02",
777
+ title="河底的秘密",
778
+ description="渡口老渔夫说他最近总在河里捞到奇怪的骨头,而且对岸矿洞方向夜里总是有光。他请你去查明真相。",
779
+ quest_type="side",
780
+ status="inactive",
781
+ giver_npc="渡口老渔夫",
782
+ objectives={
783
+ "与渡口老渔夫交谈": False,
784
+ "前往废弃矿洞调查": False,
785
+ "找到矿洞异常的原因": False,
786
  },
787
  rewards=QuestRewards(
788
  gold=60,
 
792
  ),
793
  prerequisites=[],
794
  ),
795
+ "side_quest_03": QuestState(
796
+ quest_id="side_quest_03",
797
+ title="守护者的试炼",
798
+ description="精灵遗迹的守护者提出一个交换条件:通过她设下的试炼,就能获得古老的精灵祝福。",
799
+ quest_type="side",
800
+ status="inactive",
801
+ giver_npc="遗���守护者",
802
+ objectives={
803
+ "与遗迹守护者交谈": False,
804
+ "通过守护者的试炼": False,
805
+ },
806
  rewards=QuestRewards(
807
  experience=50,
808
  unlock_skill="精灵祝福",
 
925
  # 核心方法
926
  # ============================================================
927
 
928
+ def update_location(self, new_location: str) -> None:
929
+ """
930
+ 更新当前位置并维护足迹历史。
931
+
932
+ 规则:
933
+ - 当 new_location 与当前地点不同:把旧地点写入 location_history,然后更新 current_location
934
+ - 当 new_location 相同:不追加历史
935
+ - 同步更新 self.player.location 与 self.world.current_scene,确保一致性
936
+ """
937
+ target = str(new_location or "").strip()
938
+ if not target:
939
+ return
940
+ if target == self.current_location:
941
+ return
942
+
943
+ old_location = self.current_location
944
+ if old_location:
945
+ self.location_history.append(old_location)
946
+
947
+ # 让游戏状态和地图状态始终一致
948
+ self.current_location = target
949
+ self.player.location = target
950
+ self.world.current_scene = target
951
+
952
+ # ============================================================
953
+ # 状态变更应用
954
+ # ============================================================
955
+
956
  def apply_changes(self, changes: dict) -> list[str]:
957
  """
958
  接收 Qwen 返回的状态变更 JSON,校验并应用到当前状态。
 
1084
  ):
1085
  change_log.append(f"忽略未解锁地点: {new_loc}")
1086
  else:
1087
+ self.update_location(new_loc)
 
1088
  change_log.append(f"位置: {old_loc} → {new_loc}")
1089
  # 发现新地点
1090
  if new_loc not in self.world.discovered_locations:
 
1237
  change_log.append(f"任务【{quest.title}】进展至\"{status_cn}\"")
1238
 
1239
  # --- 世界状态变更 ---
1240
+ if "weather_change" in changes:
1241
+ valid_weathers = {"晴朗", "多云", "小雨", "暴风雨", "大雪", "浓雾"}
1242
+ new_weather = str(changes["weather_change"])
1243
+ if new_weather in valid_weathers:
1244
+ if self.world.weather != new_weather:
1245
+ self.world.weather = new_weather
1246
+ self.world.last_weather_change_minutes = self.elapsed_minutes_total
1247
+ change_log.append(f"天气变为: {self.world.weather}")
1248
+ else:
1249
+ logger.warning(f"无效的 weather_change 值 '{new_weather}',已忽略。")
1250
 
1251
  if "time_change" in changes:
1252
  valid_times = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
 
1308
  self.player.title = str(changes["title_change"])
1309
  change_log.append(f"称号: {old_title} → {self.player.title}")
1310
 
1311
+ # 战斗派生属性需要与装备和基础属性保持同步
1312
+ self.refresh_combat_stats()
1313
+
1314
  if change_log:
1315
  logger.info(f"状态变更: {change_log}")
1316
 
 
1378
  bonuses[stat_name] = bonuses.get(stat_name, 0) + int(amount)
1379
  return bonuses
1380
 
1381
+ def refresh_combat_stats(self) -> None:
1382
+ """Refresh deterministic combat stats from base values + equipment bonuses."""
1383
+ bonuses = self.get_equipment_stat_bonuses()
1384
+ self.player.attack_power = max(1, int(self.player.attack) + int(bonuses.get("attack", 0)))
1385
+ self.player.defense_power = max(0, int(self.player.defense) + int(bonuses.get("defense", 0)))
1386
+
1387
  def _status_multiplier(self, value: int) -> float:
1388
  if value >= 90:
1389
  return 1.5
 
1455
  effective_stats[stat_name] = clamp(boosted_value, 1, max(cap, boosted_value))
1456
  return effective_stats
1457
 
1458
+ def get_clock_minutes(self) -> int:
1459
+ return int(self.world.time_progress_units) * 10
1460
+
1461
+ def get_minute_of_day(self) -> int:
1462
+ return self.get_clock_minutes() % (24 * 60)
1463
+
1464
+ def get_clock_display(self) -> str:
1465
+ total_minutes = self.get_clock_minutes() % (24 * 60)
1466
+ hours = total_minutes // 60
1467
+ minutes = total_minutes % 60
1468
+ return f"{hours:02d}:{minutes:02d}"
1469
 
1470
  def _time_of_day_from_minutes(self, total_minutes: int) -> str:
1471
  minute_of_day = total_minutes % (24 * 60)
 
1483
  return "夜晚"
1484
  return "深夜"
1485
 
1486
+ def _sync_world_clock(self):
1487
+ self.world.time_of_day = self._time_of_day_from_minutes(self.get_clock_minutes())
1488
+
1489
+ def can_overnight_rest(self) -> bool:
1490
+ current_loc = self.world.locations.get(self.player.location)
1491
+ if current_loc is None or not current_loc.rest_available:
1492
+ return False
1493
+ if self.player.location not in OVERNIGHT_REST_LOCATIONS:
1494
+ return False
1495
+ return self.get_minute_of_day() >= 19 * 60
1496
+
1497
+ def prepare_overnight_rest(self) -> tuple[list[str], dict[str, int]]:
1498
+ """Advance to next morning and return full-recovery deltas for overnight rest."""
1499
+ if not self.can_overnight_rest():
1500
+ return [], {}
1501
+
1502
+ old_clock = self.get_clock_display()
1503
+ old_time_of_day = self.world.time_of_day
1504
+ old_day_count = self.world.day_count
1505
+ old_light = self.world.light_level
1506
+
1507
+ minute_of_day = self.get_minute_of_day()
1508
+ minutes_until_midnight = (24 * 60) - minute_of_day
1509
+ target_elapsed_minutes = self.elapsed_minutes_total + minutes_until_midnight + 6 * 60
1510
+
1511
+ self.elapsed_minutes_total = target_elapsed_minutes
1512
+ self.world.day_count = target_elapsed_minutes // (24 * 60) + 1
1513
+ self.world.time_progress_units = (target_elapsed_minutes % (24 * 60)) // 10
1514
+ self._sync_world_clock()
1515
+ self.world.light_level = self._determine_light_level()
1516
+
1517
+ tick_log: list[str] = []
1518
+ if self.world.day_count != old_day_count:
1519
+ tick_log.append(f"新的一天!第{self.world.day_count}天")
1520
+
1521
+ new_clock = self.get_clock_display()
1522
+ if new_clock != old_clock:
1523
+ tick_log.append(f"时间流逝: {old_clock} → {new_clock}")
1524
+ if self.world.time_of_day != old_time_of_day:
1525
+ tick_log.append(f"时段变化: {old_time_of_day} → {self.world.time_of_day}")
1526
+ if self.world.light_level != old_light:
1527
+ tick_log.append(f"光照变化: {old_light} → {self.world.light_level}")
1528
+
1529
+ hunger_cost = 20 if self.player.location == "村庄旅店" else 25
1530
+ recovery_changes: dict[str, int] = {
1531
+ "hp_change": self.player.max_hp - self.player.hp,
1532
+ "mp_change": self.player.max_mp - self.player.mp,
1533
+ "stamina_change": self.player.max_stamina - self.player.stamina,
1534
+ "morale_change": 100 - self.player.morale,
1535
+ "sanity_change": 100 - self.player.sanity,
1536
+ "hunger_change": -hunger_cost,
1537
+ }
1538
+ return tick_log, {
1539
+ key: value
1540
+ for key, value in recovery_changes.items()
1541
+ if int(value) != 0
1542
+ }
1543
 
1544
  def to_prompt(self) -> str:
1545
  """
 
1579
  player_desc = (
1580
  f"【玩家】{self.player.name}({self.player.title})\n"
1581
  f" 等级: {self.player.level} | 经验: {self.player.experience}/{self.player.exp_to_next_level}\n"
1582
+ f" HP: {self.player.hp}/{self.player.max_hp}\n"
1583
+ f" MP: {self.player.mp}/{self.player.max_mp}\n"
1584
+ f" 攻击: {self.player.attack} | 防御: {self.player.defense} | 实战攻击: {self.player.attack_power} | 实战防御: {self.player.defense_power}\n"
1585
+ f" 速度: {self.player.speed} | 幸运: {self.player.luck} | 感知: {self.player.perception}\n"
1586
  f" 金币: {self.player.gold} | 善恶值: {self.player.karma}\n"
1587
  f" 士气: {self.player.morale} | 理智: {self.player.sanity} | 饱食度: {self.player.hunger}\n"
1588
  f" 装备: {equip_str}\n"
 
1827
  (is_valid, rejection_reason): 合法返回 (True, ""), 否则返回 (False, "拒绝原因")
1828
  """
1829
  action = intent.get("intent", "")
1830
+ raw_target = intent.get("target")
1831
  target = intent.get("target", "") or ""
1832
  details = intent.get("details", "") or ""
1833
  raw_input = intent.get("raw_input", "") or ""
1834
+ action_upper = str(action or "").upper()
1835
 
1836
  inventory = list(self.player.inventory)
1837
  equipped_items = [v for v in self.player.equipment.values() if v]
 
1850
 
1851
  normalized_target = normalize_item_phrase(target)
1852
 
1853
+ def _resolve_dialogue_npc() -> NPCState | None:
1854
+ """Resolve TALK target from explicit target or alias mentions in free text."""
1855
+ text_blob = f"{target} {details} {raw_input}".strip()
1856
+ if not text_blob:
1857
+ return None
1858
+
1859
+ # 1) Exact NPC name match first.
1860
+ explicit_target = str(target or "").strip()
1861
+ if explicit_target and explicit_target in self.world.npcs:
1862
+ npc = self.world.npcs.get(explicit_target)
1863
+ if npc and npc.is_alive:
1864
+ return npc
1865
+
1866
+ # 2) Name / occupation fuzzy match from free text (e.g. "和村长聊天").
1867
+ alive_npcs = [npc for npc in self.world.npcs.values() if npc.is_alive]
1868
+ ranked_candidates: list[tuple[int, NPCState]] = []
1869
+ for npc in alive_npcs:
1870
+ score = 0
1871
+ if npc.name and npc.name in text_blob:
1872
+ score += 3
1873
+ if explicit_target and (
1874
+ (npc.name and explicit_target in npc.name)
1875
+ or (npc.name and npc.name in explicit_target)
1876
+ ):
1877
+ score += 2
1878
+ if npc.occupation and npc.occupation in text_blob:
1879
+ score += 2
1880
+ if explicit_target and npc.occupation and (
1881
+ explicit_target in npc.occupation or npc.occupation in explicit_target
1882
+ ):
1883
+ score += 1
1884
+ if score > 0:
1885
+ ranked_candidates.append((score, npc))
1886
+
1887
+ if ranked_candidates:
1888
+ ranked_candidates.sort(key=lambda item: item[0], reverse=True)
1889
+ return ranked_candidates[0][1]
1890
+
1891
+ return None
1892
+
1893
  # --- 检测 1: USE_ITEM / EQUIP: target 必须在背包或装备中 ---
1894
+ if action_upper in ("USE_ITEM", "EQUIP") and target:
1895
  if target not in all_owned:
1896
  return False, f"你的背包中没有「{target}」,无法使用或装备。"
1897
+ if action_upper == "EQUIP" and target not in inventory:
1898
  if target in equipped_items:
1899
  return False, f"「{target}」已经装备在身上了。"
1900
  return False, f"你的背包中没有「{target}」,无法装备。"
1901
 
1902
  # 体力耗尽时禁止移动和战斗
1903
+ if action_upper in ("MOVE", "ATTACK", "COMBAT") and self.player.stamina <= 0:
1904
  return False, "你精疲力竭,体力耗尽,无法行动。需要先在旅店或营地休息恢复体力。"
1905
 
1906
+ if action_upper == "TRADE":
1907
+ if not isinstance(raw_target, dict):
1908
+ return False, "交易指令缺少商品信息,请从商店列表中选择要购买的物品。"
1909
+ merchant_name = str(raw_target.get("merchant") or "")
1910
+ item_name = str(raw_target.get("item") or raw_target.get("item_name") or "")
1911
+ if not merchant_name or not item_name:
1912
+ return False, "交易信息不完整,请重新从商店列表选择商品。"
1913
+
1914
+ if action_upper in ("ATTACK", "COMBAT"):
1915
+ scene_actions = build_scene_actions(self, self.player.location)
1916
+ attack_targets = [
1917
+ str(option.get("target"))
1918
+ for option in scene_actions
1919
+ if str(option.get("action_type", "")).upper() == "ATTACK"
1920
+ and isinstance(option.get("target"), str)
1921
+ and str(option.get("target")).strip()
1922
+ ]
1923
+ if target:
1924
+ if target not in attack_targets:
1925
+ if attack_targets:
1926
+ return (
1927
+ False,
1928
+ f"当前无法攻击「{target}」。你现在可攻击的目标只有:{'、'.join(attack_targets)}。",
1929
+ )
1930
+ return False, "当前场景没有可攻击目标,先观察环境或移动到有敌人的地点。"
1931
+ elif not attack_targets:
1932
+ return False, "当前场景没有可攻击目标,先观察环境或移动到有敌人的地点。"
1933
+
1934
+ if action_upper == "MOVE" and target:
1935
  current_loc = self.world.locations.get(self.player.location)
1936
  target_loc = self.world.locations.get(str(target))
1937
  if current_loc is None or target_loc is None:
 
1943
  return False, f"「{target}」尚未解锁,需要「{target_loc.required_item}」。"
1944
  return False, f"「{target}」当前无法进入。"
1945
 
1946
+ if action_upper == "VIEW_MAP":
1947
  if not any("地图" in item for item in all_owned):
1948
  return False, "你还没有获得可查看的地图。"
1949
 
1950
+ # --- 检测 2: TALK: 对话对象必须在当前地点 ---
1951
+ if action_upper == "TALK":
1952
+ dialogue_npc = _resolve_dialogue_npc()
1953
+ if dialogue_npc is not None:
1954
+ if dialogue_npc.location != self.player.location:
1955
+ return (
1956
+ False,
1957
+ f"「{dialogue_npc.name}」目前在「{dialogue_npc.location}」,你现在在「{self.player.location}」。"
1958
+ f"无法隔空对话,请先前往对方所在地点。"
1959
+ )
1960
+ else:
1961
+ local_alive_npcs = [
1962
+ npc.name
1963
+ for npc in self.world.npcs.values()
1964
+ if npc.is_alive and npc.location == self.player.location
1965
+ ]
1966
+ if not local_alive_npcs:
1967
+ return False, "这里没有可对话的角色,无法进行聊天。"
1968
+
1969
  # --- 检测 2: SKILL: 必须已习得 ---
1970
+ if action_upper == "SKILL" and target:
1971
  if target not in self.player.skills:
1972
  return False, f"你尚未习得技能「{target}」。"
1973
 
1974
+ wants_overnight_rest = action_upper == "OVERNIGHT_REST" or (
1975
+ action_upper == "REST"
1976
+ and any(keyword in f"{details} {raw_input}" for keyword in ("过夜", "睡一晚", "住一晚", "睡到天亮"))
1977
+ )
1978
+
1979
+ # --- 检测 3: REST/OVERNIGHT_REST: 当前位置必须允许休息 ---
1980
+ if action_upper in ("REST", "OVERNIGHT_REST"):
1981
+ current_loc = self.world.locations.get(self.player.location)
1982
+ if current_loc is None or not current_loc.rest_available:
1983
+ return False, "这里不适合休息,试着前往旅店或营地。"
1984
+ if wants_overnight_rest and not self.can_overnight_rest():
1985
+ return False, "现在还不能在这里过夜。晚上七点后,且仅限旅店或溪边营地。"
1986
 
1987
  # --- 检测 4: 扫描 raw_input 中是否在使用上下文中引用了未拥有的已知物品 ---
1988
  known_items: set[str] = set(self.world.item_registry.keys())
 
2230
  inject_prompt=new_light in {"昏暗", "幽暗", "漆黑"},
2231
  )
2232
 
2233
+ def _maybe_shift_weather(self, tick_log: list[str]):
2234
+ """Occasionally shift weather to keep the environment dynamic."""
2235
+ if self.elapsed_minutes_total - int(self.world.last_weather_change_minutes) < 180:
2236
+ return
2237
+ chance = 0.18 if self.world.time_of_day in {"清晨", "黄昏"} else 0.08
2238
+ if random.random() >= chance:
2239
+ return
2240
 
2241
  weather_transitions = {
2242
  "晴朗": ["多云", "小雨"],
 
2283
  if loc is None or not self.environment_event_pool:
2284
  return
2285
 
2286
+ # 提高基础触发率,让环境事件更常进入叙事与决策反馈
2287
+ chance = 0.15
2288
  if loc.danger_level >= 3:
2289
+ chance += 0.1
2290
  if self.world.light_level in {"幽暗", "漆黑"}:
2291
+ chance += 0.08
2292
  if self.world.weather in {"暴风雨", "浓雾"}:
2293
+ chance += 0.06
2294
  if random.random() >= chance:
2295
  return
2296
 
story_engine.py CHANGED
The diff for this file is too large to render. See raw diff
 
utils.py CHANGED
@@ -10,17 +10,17 @@ utils.py - StoryWeaver 工具函数模块
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
-
18
- try:
19
- from openai import OpenAI
20
- _OPENAI_IMPORT_ERROR: Optional[Exception] = None
21
- except ImportError as exc: # pragma: no cover - depends on local env
22
- OpenAI = None # type: ignore[assignment]
23
- _OPENAI_IMPORT_ERROR = exc
24
 
25
  # ============================================================
26
  # 日志配置
@@ -49,22 +49,22 @@ if not QWEN_API_KEY or QWEN_API_KEY == "sk-xxxxxx":
49
 
50
  # 使用 OpenAI 兼容格式连接 Qwen API
51
  # base_url 指向通义千问的 OpenAI 兼容端点
52
- _client: Optional[Any] = None
53
-
54
-
55
- def get_client() -> Any:
56
  """
57
  获取全局 OpenAI 客户端(懒加载单例)。
58
- 使用兼容格式调用 Qwen API。
59
- """
60
- global _client
61
- if OpenAI is None:
62
- raise RuntimeError(
63
- "未安装 openai 依赖,无法初始化 Qwen 客户端。"
64
- "请先执行 `pip install -r requirements.txt`。"
65
- ) from _OPENAI_IMPORT_ERROR
66
- if _client is None:
67
- _client = OpenAI(
68
  api_key=QWEN_API_KEY,
69
  base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
70
  )
@@ -74,8 +74,8 @@ def get_client() -> Any:
74
  # ============================================================
75
  # 默认模型配置
76
  # ============================================================
77
- # 使用 qwen-turbo 以获得最快的响应速度
78
- DEFAULT_MODEL: str = "qwen-turbo"
79
 
80
 
81
  # ============================================================
 
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
+
18
+ try:
19
+ from openai import OpenAI
20
+ _OPENAI_IMPORT_ERROR: Optional[Exception] = None
21
+ except ImportError as exc: # pragma: no cover - depends on local env
22
+ OpenAI = None # type: ignore[assignment]
23
+ _OPENAI_IMPORT_ERROR = exc
24
 
25
  # ============================================================
26
  # 日志配置
 
49
 
50
  # 使用 OpenAI 兼容格式连接 Qwen API
51
  # base_url 指向通义千问的 OpenAI 兼容端点
52
+ _client: Optional[Any] = None
53
+
54
+
55
+ def get_client() -> Any:
56
  """
57
  获取全局 OpenAI 客户端(懒加载单例)。
58
+ 使用兼容格式调用 Qwen API。
59
+ """
60
+ global _client
61
+ if OpenAI is None:
62
+ raise RuntimeError(
63
+ "未安装 openai 依赖,无法初始化 Qwen 客户端。"
64
+ "请先执行 `pip install -r requirements.txt`。"
65
+ ) from _OPENAI_IMPORT_ERROR
66
+ if _client is None:
67
+ _client = OpenAI(
68
  api_key=QWEN_API_KEY,
69
  base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
70
  )
 
74
  # ============================================================
75
  # 默认模型配置
76
  # ============================================================
77
+ # 使用 qwen2.5-14b-instruct 以获得最快的响应速度
78
+ DEFAULT_MODEL: str = "qwen2.5-14b-instruct"
79
 
80
 
81
  # ============================================================