PPP commited on
Commit
1b980f7
·
1 Parent(s): 82f3f72

上传完整代码

Browse files
Files changed (6) hide show
  1. .gitattributes +0 -21
  2. app.py +495 -381
  3. demo_rules.py +1402 -0
  4. scene_assets.py +31 -0
  5. state_manager.py +0 -0
  6. story_engine.py +0 -0
.gitattributes CHANGED
@@ -33,24 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- image/村口小路.png filter=lfs diff=lfs merge=lfs -text
37
- image/村长老伯.png filter=lfs diff=lfs merge=lfs -text
38
- image/村庄广场.png filter=lfs diff=lfs merge=lfs -text
39
- image/村庄旅店.png filter=lfs diff=lfs merge=lfs -text
40
- image/村庄铁匠铺.png filter=lfs diff=lfs merge=lfs -text
41
- image/村庄杂货铺.png filter=lfs diff=lfs merge=lfs -text
42
- image/渡口老渔夫.png filter=lfs diff=lfs merge=lfs -text
43
- image/废弃矿洞入口.png filter=lfs diff=lfs merge=lfs -text
44
- image/古塔废墟.png filter=lfs diff=lfs merge=lfs -text
45
- image/河边渡口.png filter=lfs diff=lfs merge=lfs -text
46
- image/黑暗森林入口.png filter=lfs diff=lfs merge=lfs -text
47
- image/精灵遗迹.png filter=lfs diff=lfs merge=lfs -text
48
- image/矿洞深层.png filter=lfs diff=lfs merge=lfs -text
49
- image/旅店老板娘莉娜.png filter=lfs diff=lfs merge=lfs -text
50
- image/森林深处.png filter=lfs diff=lfs merge=lfs -text
51
- image/山麓盗贼营.png filter=lfs diff=lfs merge=lfs -text
52
- image/神秘旅人.png filter=lfs diff=lfs merge=lfs -text
53
- image/铁匠格林.png filter=lfs diff=lfs merge=lfs -text
54
- image/溪边营地.png filter=lfs diff=lfs merge=lfs -text
55
- image/遗迹守护者.png filter=lfs diff=lfs merge=lfs -text
56
- image/杂货商人阿尔.png filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -21,9 +21,54 @@ import gradio as gr
21
 
22
  from state_manager import GameState
23
  from nlu_engine import NLUEngine
 
24
  from story_engine import StoryEngine
25
  from telemetry import append_turn_log, create_session_metadata
26
  from utils import logger
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  # ============================================================
29
  # 全局游戏实例(每个会话独立)
@@ -37,8 +82,8 @@ def create_new_game(player_name: str = "旅人") -> dict:
37
  """创建新游戏实例,返回包含所有引擎的字典"""
38
  game_state = GameState(player_name=player_name)
39
  nlu = NLUEngine(game_state)
40
- story = StoryEngine(game_state)
41
- return {
42
  "game_state": game_state,
43
  "nlu": nlu,
44
  "story": story,
@@ -167,14 +212,24 @@ def _build_option_intent(selected_option: dict) -> dict:
167
  option_text = selected_option.get("text", "")
168
  return {
169
  "intent": selected_option.get("action_type", "EXPLORE"),
170
- "target": None,
171
  "details": option_text,
172
  "raw_input": option_text,
173
  "parser_source": "option_click",
174
  }
 
 
 
 
 
 
 
 
 
 
175
 
176
 
177
- def restart_game() -> tuple:
178
  """
179
  重启冒险:清空所有数据,回到初始输入名称阶段。
180
 
@@ -183,16 +238,15 @@ def restart_game() -> tuple:
183
  禁用文本输入框, 重置角色名称)
184
  """
185
  loading = _get_loading_button_updates()
186
- return (
187
- [], # 清空聊天历史
188
- "## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
189
- loading[0], # 占位选项按钮1
190
- loading[1], # 占位选项按钮2
191
- loading[2], # 占位选项按钮3
192
- {}, # 清空游戏会话
193
- gr.update(value="", interactive=False), # 禁用并清空文本输入
194
- gr.update(value="旅人"), # 重置角色名称
195
- )
196
 
197
 
198
  # ============================================================
@@ -218,12 +272,12 @@ def start_game(player_name: str, game_session: dict):
218
  status_text = _format_status_panel(game_session["game_state"])
219
  loading = _get_loading_button_updates()
220
 
221
- yield (
222
- chat_history, status_text,
223
- loading[0], loading[1], loading[2],
224
- game_session,
225
- gr.update(interactive=False),
226
- )
227
 
228
  # 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
229
  turn_started = perf_counter()
@@ -234,12 +288,12 @@ def start_game(player_name: str, game_session: dict):
234
  if update["type"] == "story_chunk":
235
  story_text = update["text"]
236
  chat_history[-1]["content"] = story_text
237
- yield (
238
- chat_history, status_text,
239
- loading[0], loading[1], loading[2],
240
- game_session,
241
- gr.update(interactive=False),
242
- )
243
  elif update["type"] == "final":
244
  final_result = update
245
 
@@ -253,7 +307,7 @@ def start_game(player_name: str, game_session: dict):
253
  options = []
254
 
255
  # ★ 安全兜底:强制确保恰好 3 个选项
256
- options = _ensure_min_options(options, 3)
257
 
258
  # 最终 yield:显示完整文本 + 选项 + 启用按钮
259
  game_session["current_options"] = options
@@ -287,12 +341,12 @@ def start_game(player_name: str, game_session: dict):
287
  final_result=final_result,
288
  )
289
 
290
- yield (
291
- chat_history, status_text,
292
- btn_updates[0], btn_updates[1], btn_updates[2],
293
- game_session,
294
- gr.update(interactive=True),
295
- )
296
 
297
 
298
  def process_user_input(user_input: str, chat_history: list, game_session: dict):
@@ -304,24 +358,25 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
304
  2. 叙事引擎流式生成故事
305
  3. 逐步更新 UI
306
  """
307
- if not game_session or not game_session.get("started"):
308
- chat_history = chat_history or []
309
- chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
310
- loading = _get_loading_button_updates()
311
- yield (
312
- chat_history, "",
313
- loading[0], loading[1], loading[2],
314
- game_session,
315
- )
316
- return
317
-
318
- if not user_input.strip():
319
- yield (
320
- chat_history, _format_status_panel(game_session["game_state"]),
321
- gr.update(), gr.update(), gr.update(),
322
- game_session,
323
- )
324
- return
 
325
 
326
  gs: GameState = game_session["game_state"]
327
  nlu: NLUEngine = game_session["nlu"]
@@ -329,18 +384,22 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
329
  turn_started = perf_counter()
330
 
331
  # 检查游戏是否已结束
332
- if gs.is_game_over():
333
- chat_history.append({"role": "user", "content": user_input})
334
- chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
335
- yield (
336
- chat_history,
337
- _format_status_panel(gs),
338
- gr.update(value="重新开始", visible=True, interactive=True),
339
- gr.update(value="...", visible=True, interactive=False),
340
- gr.update(value="...", visible=True, interactive=False),
341
- game_session,
342
- )
343
- return
 
 
 
 
344
 
345
  # 1. NLU 解析
346
  nlu_started = perf_counter()
@@ -350,10 +409,10 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
350
  # 1.5 预校验:立即驳回违反一致性的操作(不调用 LLM,不消耗回合)
351
  is_valid, rejection_msg = gs.pre_validate_action(intent)
352
  if not is_valid:
353
- chat_history.append({"role": "user", "content": user_input})
354
- options = game_session.get("current_options", [])
355
- options = _ensure_min_options(options, 3)
356
- options_text = _format_options(options)
357
  rejection_content = (
358
  f"⚠️ **行动被驳回**:{rejection_msg}\n\n"
359
  f"请重新选择行动,或输入其他指令。\n\n{options_text}"
@@ -383,26 +442,30 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
383
  final_result=rejection_result,
384
  )
385
  btn_updates = _get_button_updates(options)
386
- yield (
387
- chat_history,
388
- _format_status_panel(gs),
389
- btn_updates[0], btn_updates[1], btn_updates[2],
390
- game_session,
391
- )
392
- return
 
393
 
394
  # 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
395
  chat_history.append({"role": "user", "content": user_input})
396
  chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
397
 
398
  # 按钮保持可见但禁用,防止流式期间点击
399
- loading = _get_loading_button_updates()
400
- yield (
401
- chat_history,
402
- _format_status_panel(gs),
403
- loading[0], loading[1], loading[2],
404
- game_session,
405
- )
 
 
 
406
 
407
  # 3. 流式生成故事
408
  generation_started = perf_counter()
@@ -412,10 +475,11 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
412
  chat_history[-1]["content"] = update["text"]
413
  yield (
414
  chat_history,
415
- _format_status_panel(gs),
416
- loading[0], loading[1], loading[2],
417
- game_session,
418
- )
 
419
  elif update["type"] == "final":
420
  final_result = update
421
 
@@ -424,8 +488,8 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
424
  # 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
425
  if final_result:
426
  # ★ 安全兜底:强制确保恰好 3 个选项
427
- options = _ensure_min_options(final_result.get("options", []), 3)
428
- game_session["current_options"] = options
429
 
430
  change_log = final_result.get("change_log", [])
431
  log_text = ""
@@ -459,15 +523,16 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
459
 
460
  yield (
461
  chat_history,
462
- status_text,
463
- btn_updates[0], btn_updates[1], btn_updates[2],
464
- game_session,
465
- )
 
466
  else:
467
  # ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
468
  logger.warning("流式生成未产生 final 事件,使用兜底文本")
469
  fallback_text = "你环顾四周,思考着接下来该做什么..."
470
- fallback_options = _ensure_min_options([], 3)
471
  game_session["current_options"] = fallback_options
472
 
473
  options_text = _format_options(fallback_options)
@@ -500,42 +565,45 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
500
  final_result=fallback_result,
501
  )
502
 
503
- yield (
504
- chat_history,
505
- status_text,
506
- btn_updates[0], btn_updates[1], btn_updates[2],
507
- game_session,
508
- )
509
- return
510
-
511
 
512
- def process_option_click(option_idx: int, chat_history: list, game_session: dict):
513
- """
514
- 处理玩家点击选项按钮(流式版本)。
515
 
516
- Args:
517
- option_idx: 选项索引 (0, 1, 2)
518
  """
519
- if not game_session or not game_session.get("started"):
520
- chat_history = chat_history or []
521
- chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
522
- loading = _get_loading_button_updates()
523
- yield (
524
- chat_history, "",
525
- loading[0], loading[1], loading[2],
526
- game_session,
527
- )
528
- return
529
-
530
- options = game_session.get("current_options", [])
531
- if option_idx >= len(options):
532
- yield (
533
- chat_history,
534
- _format_status_panel(game_session["game_state"]),
535
- gr.update(), gr.update(), gr.update(),
536
- game_session,
537
- )
538
- return
 
 
 
 
 
 
 
539
 
540
  selected_option = options[option_idx]
541
  gs: GameState = game_session["game_state"]
@@ -549,15 +617,15 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
549
  game_session = create_new_game(gs.player.name)
550
  game_session["started"] = True
551
 
552
- chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
553
- status_text = _format_status_panel(game_session["game_state"])
554
- loading = _get_loading_button_updates()
555
-
556
- yield (
557
- chat_history, status_text,
558
- loading[0], loading[1], loading[2],
559
- game_session,
560
- )
561
 
562
  story_text = ""
563
  restart_final = None
@@ -566,11 +634,11 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
566
  if update["type"] == "story_chunk":
567
  story_text = update["text"]
568
  chat_history[-1]["content"] = story_text
569
- yield (
570
- chat_history, status_text,
571
- loading[0], loading[1], loading[2],
572
- game_session,
573
- )
574
  elif update["type"] == "final":
575
  restart_final = update
576
 
@@ -582,8 +650,8 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
582
  restart_options = []
583
 
584
  # ★ 安全兜底:强制确保恰好 3 个选项
585
- restart_options = _ensure_min_options(restart_options, 3)
586
- game_session["current_options"] = restart_options
587
  options_text = _format_options(restart_options)
588
  full_message = f"{story_text}\n\n{options_text}"
589
  chat_history[-1]["content"] = full_message
@@ -591,39 +659,44 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
591
  status_text = _format_status_panel(game_session["game_state"])
592
  btn_updates = _get_button_updates(restart_options)
593
 
594
- yield (
595
- chat_history, status_text,
596
- btn_updates[0], btn_updates[1], btn_updates[2],
597
- game_session,
598
- )
599
- return
600
 
601
  # 检查特殊选项:退出
602
  if selected_option.get("action_type") == "QUIT":
603
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
604
- chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
605
- yield (
606
- chat_history,
607
- _format_status_panel(gs),
608
- gr.update(value="重新开始", visible=True, interactive=True),
609
- gr.update(value="...", visible=True, interactive=False),
610
- gr.update(value="...", visible=True, interactive=False),
611
- game_session,
612
- )
613
- return
 
 
 
 
614
 
615
  # 正常选项处理:流式生成
616
  chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
617
  chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
618
 
619
  # 按钮保持可见但禁用
620
- loading = _get_loading_button_updates()
621
- yield (
622
- chat_history,
623
- _format_status_panel(gs),
624
- loading[0], loading[1], loading[2],
625
- game_session,
626
- )
 
627
 
628
  generation_started = perf_counter()
629
  final_result = None
@@ -632,10 +705,11 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
632
  chat_history[-1]["content"] = update["text"]
633
  yield (
634
  chat_history,
635
- _format_status_panel(gs),
636
- loading[0], loading[1], loading[2],
637
- game_session,
638
- )
 
639
  elif update["type"] == "final":
640
  final_result = update
641
 
@@ -643,8 +717,8 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
643
 
644
  if final_result:
645
  # ★ 安全兜底:强制确保恰好 3 个选项
646
- options = _ensure_min_options(final_result.get("options", []), 3)
647
- game_session["current_options"] = options
648
 
649
  change_log = final_result.get("change_log", [])
650
  log_text = ""
@@ -671,15 +745,15 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
671
  )
672
 
673
  yield (
674
- chat_history, status_text,
675
- btn_updates[0], btn_updates[1], btn_updates[2],
676
- game_session,
677
- )
678
- else:
679
  # ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
680
  logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
681
  fallback_text = "你环顾四周,思考着接下来该做什么..."
682
- fallback_options = _ensure_min_options([], 3)
683
  game_session["current_options"] = fallback_options
684
 
685
  options_text = _format_options(fallback_options)
@@ -712,59 +786,70 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
712
  selected_option=selected_option,
713
  )
714
 
715
- yield (
716
- chat_history, status_text,
717
- btn_updates[0], btn_updates[1], btn_updates[2],
718
- game_session,
719
- )
720
  return
721
 
722
 
723
- # ============================================================
724
- # UI 辅助函数
725
- # ============================================================
726
-
727
-
728
- # 兜底默认选项(当解析出的选项不足 3 个时使用)
729
- _FALLBACK_BUTTON_OPTIONS = [
730
- {"id": 1, "text": "查看周围", "action_type": "EXPLORE"},
731
- {"id": 2, "text": "等待一会", "action_type": "REST"},
732
- {"id": 3, "text": "检查状态", "action_type": "EXPLORE"},
733
- ]
734
-
735
-
736
- def _ensure_min_options(options: list[dict], min_count: int = 3) -> list[dict]:
737
- """
738
- 强制确保选项列表至少有 min_count 个选项。
739
- 不足时用通用兜底选项补全,作为 UI 层的最终安全网。
740
- """
741
- if not isinstance(options, list):
742
- options = []
743
-
744
- if len(options) >= min_count:
745
- return options[:min_count]
746
-
747
- # 用兜底选项补全
748
- for fb in _FALLBACK_BUTTON_OPTIONS:
749
- if len(options) >= min_count:
750
- break
751
- if not any(o.get("text") == fb["text"] for o in options):
752
- options.append(fb.copy())
753
-
754
- # 极端兜底:仍不足则用"继续探索"填充
755
- while len(options) < min_count:
756
- options.append({
757
- "id": len(options) + 1,
758
- "text": "继续探索",
759
- "action_type": "EXPLORE",
760
- })
761
-
762
- # 重新编号
763
- for i, opt in enumerate(options[:min_count], 1):
764
- if isinstance(opt, dict):
765
- opt["id"] = i
766
-
767
- return options[:min_count]
 
 
 
 
 
 
 
 
 
 
 
768
 
769
 
770
  def _format_options(options: list[dict]) -> str:
@@ -784,33 +869,36 @@ def _format_options(options: list[dict]) -> str:
784
  return "\n".join(lines)
785
 
786
 
787
- def _get_loading_button_updates() -> list:
788
- """返回 3 个加载中占位按钮更新(始终可见但禁用交互)"""
789
- return [
790
- gr.update(value="...", visible=True, interactive=False),
791
- gr.update(value="...", visible=True, interactive=False),
792
- gr.update(value="...", visible=True, interactive=False),
793
- ]
794
-
795
-
796
- def _get_button_updates(options: list[dict]) -> list:
797
- """从选项列表生成按钮的 gr.update() 对象,始终返回恰好 3 个更新"""
798
- # 确保 options 是列表
799
- if not isinstance(options, list):
800
- options = []
801
-
802
- # 安全兜底:强制补全到 3 个选项,确保按钮永远是 3 个
803
- options = _ensure_min_options(options, 3)
804
-
805
- updates = []
806
- for i in range(3):
807
- opt = options[i]
808
- if isinstance(opt, dict):
809
- text = opt.get("text", "...")
810
- else:
811
- text = str(opt) if opt else "..."
812
- updates.append(gr.update(value=text, visible=True, interactive=True))
813
- return updates
 
 
 
814
 
815
 
816
  def _format_status_panel(gs: GameState) -> str:
@@ -820,14 +908,17 @@ def _format_status_panel(gs: GameState) -> str:
820
  effective_stats = gs.get_effective_player_stats()
821
  equipment_bonuses = gs.get_equipment_stat_bonuses()
822
  env_snapshot = gs.get_environment_snapshot(limit=3)
 
823
  scene_summary = gs.get_scene_summary().replace("\n", "<br>")
 
824
 
825
  # 属性进度条
826
  hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
827
- mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
828
- hunger_bar = _progress_bar(p.hunger, 100, "饱食")
829
- sanity_bar = _progress_bar(p.sanity, 100, "理智")
830
- morale_bar = _progress_bar(p.morale, 100, "士气")
 
831
 
832
  # 装备
833
  slot_names = {
@@ -977,13 +1068,14 @@ def _format_status_panel(gs: GameState) -> str:
977
  <div>
978
  <h4 style="margin:4px 0 2px 0;">🩸 生命与状态</h4>
979
  <span style="font-size:0.85em;">
980
- {hp_bar}<br>
981
- {mp_bar}<br>
982
- {hunger_bar}<br>
983
- {sanity_bar}<br>
984
- {morale_bar}
985
- </span>
986
- </div>
 
987
 
988
  <div>
989
  <h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
@@ -1026,18 +1118,15 @@ def _format_status_panel(gs: GameState) -> str:
1026
  </div>
1027
 
1028
  <div style="grid-column: 1 / -1;">
1029
- <h4 style="margin:4px 0 2px 0;">🌦️ 环境态势</h4>
1030
- <div style="font-size:0.85em;">
1031
  {env_badges}
1032
- {latest_event_html}
1033
- <span style="color:#475569;">最近环境事件: {recent_event_lines}</span>
1034
- </div>
1035
  </div>
1036
-
1037
- <div style="grid-column: 1 / -1;">
1038
- <h4 style="margin:4px 0 2px 0;">🧭 场景摘要</h4>
1039
- <div style="font-size:0.85em;line-height:1.5;padding:8px 10px;border-radius:10px;background:#fff7ed;border:1px solid #fed7aa;">
1040
- {scene_summary}
1041
  </div>
1042
  </div>
1043
 
@@ -1051,8 +1140,8 @@ def _format_status_panel(gs: GameState) -> str:
1051
  <div>
1052
  <h4 style="margin:4px 0 2px 0;">🌍 世界信息</h4>
1053
  <span style="font-size:0.85em;">
1054
- 位置: {w.current_scene}<br>
1055
- 时间: 第{w.day_count}天 {w.time_of_day}<br>
1056
  天气: {w.weather}<br>
1057
  光照: {w.light_level}<br>
1058
  季节: {w.season}<br>
@@ -1079,12 +1168,13 @@ def _progress_bar(current: int, maximum: int, label: str, length: int = 10) -> s
1079
  # ============================================================
1080
 
1081
 
1082
- def build_app() -> gr.Blocks:
1083
- """构建 Gradio 界面"""
1084
-
1085
- with gr.Blocks(
1086
- title="StoryWeaver - 交互式叙事系统",
1087
- ) as app:
 
1088
 
1089
  gr.Markdown(
1090
  """
@@ -1126,17 +1216,54 @@ def build_app() -> gr.Blocks:
1126
  height=480,
1127
  )
1128
 
1129
- # 选项按钮(始终可见加载时显示占位符
1130
- with gr.Row():
1131
- option_btn_1 = gr.Button(
1132
- "...", visible=True, interactive=False
1133
- )
1134
- option_btn_2 = gr.Button(
1135
- "...", visible=True, interactive=False
1136
- )
1137
- option_btn_3 = gr.Button(
1138
- "...", visible=True, interactive=False
1139
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
 
1141
  # 自由输入
1142
  with gr.Row():
@@ -1148,109 +1275,100 @@ def build_app() -> gr.Blocks:
1148
  )
1149
  send_btn = gr.Button("发送", variant="primary", scale=1)
1150
 
1151
- # ==================
1152
- # 右侧:状态面板
1153
- # ==================
1154
- with gr.Column(scale=2, min_width=250):
1155
- status_panel = gr.Markdown(
1156
- value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
1157
- label="角色状态",
1158
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
1159
 
1160
  # ============================================================
1161
  # 事件绑定
1162
  # ============================================================
1163
 
1164
  # 开始游戏
1165
- start_btn.click(
1166
- fn=start_game,
1167
- inputs=[player_name_input, game_session],
1168
- outputs=[
1169
- chatbot, status_panel,
1170
- option_btn_1, option_btn_2, option_btn_3,
1171
- game_session, user_input,
1172
- ],
1173
- )
1174
 
1175
  # 重启冒险
1176
- restart_btn.click(
1177
- fn=restart_game,
1178
- inputs=[],
1179
- outputs=[
1180
- chatbot, status_panel,
1181
- option_btn_1, option_btn_2, option_btn_3,
1182
- game_session, user_input, player_name_input,
1183
- ],
1184
- )
1185
 
1186
  # 文本输入发送
1187
- send_btn.click(
1188
- fn=process_user_input,
1189
- inputs=[user_input, chatbot, game_session],
1190
- outputs=[
1191
- chatbot, status_panel,
1192
- option_btn_1, option_btn_2, option_btn_3,
1193
- game_session,
1194
- ],
1195
- ).then(
1196
  fn=lambda: "",
1197
  outputs=[user_input],
1198
  )
1199
 
1200
  # 回车发送
1201
- user_input.submit(
1202
- fn=process_user_input,
1203
- inputs=[user_input, chatbot, game_session],
1204
- outputs=[
1205
- chatbot, status_panel,
1206
- option_btn_1, option_btn_2, option_btn_3,
1207
- game_session,
1208
- ],
1209
- ).then(
1210
  fn=lambda: "",
1211
  outputs=[user_input],
1212
  )
1213
 
1214
  # 选项按钮点击(需要使用 yield from 的生成器包装函数,
1215
  # 使 Gradio 能正确识别为流式输出)
1216
- def _option_click_0(ch, gs):
1217
- yield from process_option_click(0, ch, gs)
1218
-
1219
- def _option_click_1(ch, gs):
1220
- yield from process_option_click(1, ch, gs)
1221
-
1222
- def _option_click_2(ch, gs):
1223
- yield from process_option_click(2, ch, gs)
1224
-
1225
- option_btn_1.click(
1226
- fn=_option_click_0,
1227
- inputs=[chatbot, game_session],
1228
- outputs=[
1229
- chatbot, status_panel,
1230
- option_btn_1, option_btn_2, option_btn_3,
1231
- game_session,
1232
- ],
1233
- )
1234
-
1235
- option_btn_2.click(
1236
- fn=_option_click_1,
1237
- inputs=[chatbot, game_session],
1238
- outputs=[
1239
- chatbot, status_panel,
1240
- option_btn_1, option_btn_2, option_btn_3,
1241
- game_session,
1242
- ],
1243
- )
1244
-
1245
- option_btn_3.click(
1246
- fn=_option_click_2,
1247
- inputs=[chatbot, game_session],
1248
- outputs=[
1249
- chatbot, status_panel,
1250
- option_btn_1, option_btn_2, option_btn_3,
1251
- game_session,
1252
- ],
1253
- )
1254
 
1255
  return app
1256
 
@@ -1271,9 +1389,5 @@ if __name__ == "__main__":
1271
  primary_hue="emerald",
1272
  secondary_hue="blue",
1273
  ),
1274
- css="""
1275
- .story-chat {min-height: 500px;}
1276
- .status-panel {font-family: monospace; font-size: 0.85em;}
1277
- .option-btn {min-height: 50px !important;}
1278
- """,
1279
- )
 
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
  # 全局游戏实例(每个会话独立)
 
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,
 
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
  禁用文本输入框, 重置角色名称)
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
  # ============================================================
 
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()
 
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
 
 
307
  options = []
308
 
309
  # ★ 安全兜底:强制确保恰好 3 个选项
310
+ options = _finalize_session_options(options)
311
 
312
  # 最终 yield:显示完整文本 + 选项 + 启用按钮
313
  game_session["current_options"] = options
 
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
  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"]
 
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()
 
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}"
 
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()
 
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
 
 
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 = ""
 
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)
 
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"]
 
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
  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
  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
 
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
 
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
 
 
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 = ""
 
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)
 
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
  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:
 
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 = {
 
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>
 
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
 
 
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>
 
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
  """
 
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
  )
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
  primary_hue="emerald",
1390
  secondary_hue="blue",
1391
  ),
1392
+ css=APP_UI_CSS,
1393
+ )
 
 
 
 
demo_rules.py ADDED
@@ -0,0 +1,1402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ ACTION_TIME_COSTS: dict[str, int] = {
9
+ "MOVE": 30,
10
+ "ATTACK": 30,
11
+ "COMBAT": 30,
12
+ "CLAIM_REWARD": 10,
13
+ "REST": 30,
14
+ "OVERNIGHT_REST": 30,
15
+ "TALK": 10,
16
+ "SHOP_MENU": 10,
17
+ "SCENE_OPTIONS": 10,
18
+ "TRADE": 10,
19
+ "EQUIP": 10,
20
+ "USE_ITEM": 10,
21
+ "VIEW_MAP": 10,
22
+ "MAP": 10,
23
+ "QUEST": 10,
24
+ }
25
+
26
+ MAX_OPTION_COUNT = 6
27
+ DEFAULT_OPTION_COUNT = 3
28
+ OVERNIGHT_REST_LOCATIONS = {"村庄旅店", "溪边营地"}
29
+ MAIN_QUEST_ID = "main_quest_01"
30
+ MAIN_QUEST_TROLL_ID = "main_quest_02"
31
+ FOREST_GOBLIN_DEFEATED_FLAG = "encounter::dark_forest_gate_goblin_defeated"
32
+ FOREST_TROLL_TRACKS_FOUND_FLAG = "clue::forest_troll_tracks_found"
33
+ FOREST_TROLL_DEFEATED_FLAG = "encounter::forest_troll_defeated"
34
+ FOREST_TROLL_INTRO_SEEN_FLAG = "scene::forest_troll_intro_seen"
35
+ FOREST_TROLL_HOARD_PENDING_FLAG = "reward::forest_troll_hoard_pending"
36
+ FOREST_TROLL_HOARD_CLAIMED_FLAG = "reward::forest_troll_hoard_claimed"
37
+ DEEP_FOREST_BARRIER_SEEN_FLAG = "clue::deep_forest_barrier_seen"
38
+ FOREST_CAUSE_OBJECTIVE = "调查怪物活动的原因"
39
+ REPORT_TO_CHIEF_OBJECTIVE = "与村长老伯对话汇报发现"
40
+ FOREST_TROLL_TRAVEL_OBJECTIVE = "前往森林深处"
41
+ FOREST_TROLL_BOSS_OBJECTIVE = "击败森林巨魔"
42
+ SIDE_QUEST_TRAVELER_ID = "side_quest_01"
43
+ SIDE_QUEST_FERRY_ID = "side_quest_02"
44
+ SIDE_QUEST_GUARDIAN_ID = "side_quest_03"
45
+ TRAVELER_RUMOR_HEARD_FLAG = "rumor::traveler_lead_heard"
46
+ MINE_RUMOR_HEARD_FLAG = "rumor::mine_ghost_heard"
47
+ FERRY_ROUTE_UNLOCKED_FLAG = "rumor::ferry_route_unlocked"
48
+ TRAVELER_ENCOUNTERED_FLAG = "scene::mysterious_traveler_encountered"
49
+ GUARDIAN_INTRO_SEEN_FLAG = "scene::elf_guardian_introduced"
50
+
51
+ LOCATION_MAP_REQUIREMENTS: dict[str, str] = {
52
+ "村庄广场": "村庄地图",
53
+ "村庄铁匠铺": "村庄地图",
54
+ "村庄旅店": "村庄地图",
55
+ "村庄杂货铺": "村庄地图",
56
+ "村口小路": "村庄地图",
57
+ # 黑暗森林入口 可以用村庄地图到达;击败哥布林后获得黑暗森林地图
58
+ "黑暗森林入口": "村庄地图",
59
+ # 溪边营地/森林深处 需要黑暗森林地图(森林入口战斗奖励)
60
+ "溪边营地": "黑暗森林地图",
61
+ "森林深处": "黑暗森林地图",
62
+ # 河边渡口 用村庄地图可到达;与老渔夫对话后获得山麓地图
63
+ "河边渡口": "村庄地图",
64
+ # 以下地点需要山麓地图(老渔夫给予)
65
+ "废弃矿洞入口": "山麓地图",
66
+ "山麓盗贼营": "山麓地图",
67
+ "精灵遗迹": "山麓地图",
68
+ # 古塔废墟 从村口小路可直接发现,村庄地图即可
69
+ "古塔废墟": "村庄地图",
70
+ }
71
+
72
+ SHOP_LOCATION_TO_MERCHANT: dict[str, str] = {
73
+ "村庄铁匠铺": "铁匠格林",
74
+ "村庄旅店": "旅店老板娘莉娜",
75
+ "村庄杂货铺": "杂货商人阿尔",
76
+ }
77
+
78
+ ARRIVAL_EVENT_CONFIG: dict[str, dict[str, str]] = {
79
+ "村庄铁匠铺": {
80
+ "event_key": "arrival::village_blacksmith",
81
+ "story_text": (
82
+ "一间热气腾腾的铁匠铺,炉火正旺。墙上挂满了各式武器和护甲。\n"
83
+ "炉火映红了铁匠粗犷的脸庞,空气中弥漫着金属和碳的气味。"
84
+ ),
85
+ },
86
+ "村庄旅店": {
87
+ "event_key": "arrival::village_inn",
88
+ "story_text": (
89
+ "一家温馨的小旅店,空气中弥漫着烤肉和麦酒的香气。壁炉里的火焰跳动着。\n"
90
+ "壁炉噼啪作响,几位旅客正低声交谈,空气温暖而舒适。"
91
+ ),
92
+ },
93
+ "村庄杂货铺": {
94
+ "event_key": "arrival::village_general_store",
95
+ "story_text": (
96
+ "一家琳琅满目的杂货铺,从草药到绳索应有尽有。老板是个精明的商人。\n"
97
+ "货架上摆满了稀奇古怪的商品,柜台后的商人正用算盘噼里啪啦地算账。"
98
+ ),
99
+ },
100
+ "黑暗森林入口": {
101
+ "event_key": "arrival::dark_forest_gate",
102
+ "story_text": (
103
+ "黑暗森林入口的树冠压低了天色,落叶间散着新鲜爪痕和被拖行过的泥印。\n"
104
+ "一只拎着弯刀的哥布林正伏在断木后张望,时不时发出刺耳怪叫,像是在替林中的东西守门。"
105
+ ),
106
+ },
107
+ "河边渡口": {
108
+ "event_key": "arrival::river_ferry",
109
+ "story_text": (
110
+ "破旧的渡口被河水拍得吱呀作响,湿冷水汽贴着木桩往上爬。\n"
111
+ "披着蓑衣的老渔夫正盯着对岸,矿洞与山麓营地的路径都从这里分开。"
112
+ ),
113
+ },
114
+ "山麓盗贼营": {
115
+ "event_key": "arrival::bandit_camp",
116
+ "story_text": (
117
+ "山麓盗贼营的篝火还留着余温,翻倒的酒桶和散落的干粮说明有人刚撤离不久。\n"
118
+ "营帐阴影里有个盗贼斥候正贴着木桩窥探四周,显然不打算让外来者轻易通过。"
119
+ ),
120
+ },
121
+ "古塔废墟": {
122
+ "event_key": "arrival::ancient_tower",
123
+ "story_text": (
124
+ "半坍塌的古塔在风里发出低鸣,残破石阶和裂墙间还能看到新鲜抓痕。\n"
125
+ "塔门内飘着一团幽蓝冷火,游荡幽灵在尘灰间若隐若现,像是在警告你别再向前。"
126
+ ),
127
+ },
128
+ "废弃矿洞入口": {
129
+ "event_key": "arrival::mine_entrance",
130
+ "story_text": (
131
+ "矿洞入口被枯枝和碎石半堵着,腐朽的矿车轨道向黑暗深处延伸,铁锈和硫磺气味扑面而来。\n"
132
+ "一具骷髅兵从废弃矿车后方立起,锈迹斑斑的武器指向你——矿洞里的东西不欢迎活人。"
133
+ ),
134
+ },
135
+ "溪边营地": {
136
+ "event_key": "arrival::creek_camp",
137
+ "story_text": (
138
+ "森林中一处难得的开阔地带,清澈的溪水从旁流过,树冠间的光在水面打出零碎金片。\n"
139
+ "篝火余烬和被压平的草丛说明有人不久前在此扎营——这里适合短暂休息和搜寻遗留物资。"
140
+ ),
141
+ },
142
+ "精灵遗迹": {
143
+ "event_key": "arrival::elf_ruins",
144
+ "story_text": (
145
+ "石柱在藤蔓间若隐若现,精灵文字的刻痕随你的步伐明灭,像是感应到了来者的意图。\n"
146
+ "一个穿着褪色绿袍的消瘦身影从石柱阴影里转出,用警惕的眼神审视着你——遗迹有它的守护者。"
147
+ ),
148
+ },
149
+ "森林深处": {
150
+ "event_key": "arrival::deep_forest",
151
+ "story_text": (
152
+ "古树盘根错节,荧光苔藓将深处映成幽蓝,腐朽与魔力的气息混杂难辨。\n"
153
+ "远处传来低沉咆哮,树影间有成双眼睛移动——有什么东西已经感知到了你的闯入。"
154
+ ),
155
+ },
156
+ }
157
+
158
+
159
+ @dataclass(slots=True)
160
+ class BattleSnapshot:
161
+ hp: int
162
+ attack: int
163
+ defense: int
164
+ stamina: int
165
+ hit_rate: float
166
+ dodge_rate: float
167
+ live_state_multiplier: float = 1.0
168
+
169
+ @property
170
+ def power(self) -> float:
171
+ return (
172
+ (self.attack * 0.6 + self.defense * 0.3 + self.stamina * 0.1)
173
+ * self.live_state_multiplier
174
+ )
175
+
176
+
177
+ BATTLE_ENCOUNTER_CONFIG: dict[tuple[str, str], dict[str, Any]] = {
178
+ ("黑暗森林入口", "哥布林"): {
179
+ "enemy_snapshot": BattleSnapshot(
180
+ hp=45,
181
+ attack=8,
182
+ defense=3,
183
+ stamina=28,
184
+ hit_rate=0.82,
185
+ dodge_rate=0.08,
186
+ live_state_multiplier=1.0,
187
+ ),
188
+ "defeated_flag": "encounter::dark_forest_gate_goblin_defeated",
189
+ "reward_items": ["黑暗森林地图"],
190
+ "quest_objectives": ["击败森林中的怪物"],
191
+ },
192
+ ("山麓盗贼营", "盗贼斥候"): {
193
+ "enemy_snapshot": BattleSnapshot(
194
+ hp=52,
195
+ attack=9,
196
+ defense=4,
197
+ stamina=30,
198
+ hit_rate=0.84,
199
+ dodge_rate=0.12,
200
+ live_state_multiplier=1.0,
201
+ ),
202
+ "defeated_flag": "encounter::bandit_scout_defeated",
203
+ "reward_items": ["山麓地图"],
204
+ "quest_objectives": [],
205
+ },
206
+ ("古塔废墟", "游荡幽灵"): {
207
+ "enemy_snapshot": BattleSnapshot(
208
+ hp=58,
209
+ attack=10,
210
+ defense=5,
211
+ stamina=32,
212
+ hit_rate=0.86,
213
+ dodge_rate=0.14,
214
+ live_state_multiplier=1.0,
215
+ ),
216
+ "defeated_flag": "encounter::ancient_tower_wraith_defeated",
217
+ "reward_items": ["古塔地图"],
218
+ "quest_objectives": [],
219
+ },
220
+ ("废弃矿洞入口", "骷髅兵"): {
221
+ "enemy_snapshot": BattleSnapshot(
222
+ hp=38,
223
+ attack=7,
224
+ defense=4,
225
+ stamina=22,
226
+ hit_rate=0.75,
227
+ dodge_rate=0.05,
228
+ live_state_multiplier=1.0,
229
+ ),
230
+ "defeated_flag": "encounter::mine_skeleton_defeated",
231
+ "reward_items": ["骷髅碎骨"],
232
+ "quest_objectives": ["前往废弃矿洞调查"],
233
+ },
234
+ ("黑暗森林入口", "野狼"): {
235
+ "enemy_snapshot": BattleSnapshot(
236
+ hp=58,
237
+ attack=11,
238
+ defense=5,
239
+ stamina=36,
240
+ hit_rate=0.82,
241
+ dodge_rate=0.14,
242
+ live_state_multiplier=1.0,
243
+ ),
244
+ "defeated_flag": "",
245
+ "reward_items": [],
246
+ "quest_objectives": [],
247
+ },
248
+ ("森林深处", "森林巨魔"): {
249
+ "enemy_snapshot": BattleSnapshot(
250
+ hp=120,
251
+ attack=15,
252
+ defense=10,
253
+ stamina=130,
254
+ hit_rate=0.88,
255
+ dodge_rate=0.1,
256
+ live_state_multiplier=1.0,
257
+ ),
258
+ "defeated_flag": FOREST_TROLL_DEFEATED_FLAG,
259
+ "reward_items": [],
260
+ "quest_id": MAIN_QUEST_TROLL_ID,
261
+ "quest_objectives": [FOREST_TROLL_BOSS_OBJECTIVE],
262
+ },
263
+ }
264
+
265
+
266
+ def action_time_cost_minutes(action_type: str) -> int:
267
+ return ACTION_TIME_COSTS.get(str(action_type or "").upper(), 10)
268
+
269
+
270
+ def resolve_battle(
271
+ player: BattleSnapshot,
272
+ enemy: BattleSnapshot,
273
+ *,
274
+ player_unarmed: bool = False,
275
+ ) -> dict[str, Any]:
276
+ enemy_power = max(enemy.power, 1.0)
277
+ player_power = player.power
278
+
279
+ if player_unarmed and player.attack <= enemy.defense:
280
+ player_power *= 0.5
281
+
282
+ ratio = player_power / enemy_power
283
+ if ratio < 0.6:
284
+ outcome = "forced_retreat"
285
+ elif ratio < 1.0:
286
+ outcome = "pyrrhic_win"
287
+ elif ratio < 1.5:
288
+ outcome = "normal_win"
289
+ else:
290
+ outcome = "dominant_win"
291
+
292
+ return {
293
+ "player_power": round(player_power, 2),
294
+ "enemy_power": round(enemy_power, 2),
295
+ "ratio": round(ratio, 3),
296
+ "outcome": outcome,
297
+ }
298
+
299
+
300
+ def resolve_trade(
301
+ game_state,
302
+ *,
303
+ merchant_name: str,
304
+ item_name: str,
305
+ confirm: bool,
306
+ ) -> dict[str, Any]:
307
+ npc = game_state.world.npcs.get(merchant_name)
308
+ if npc is None or not npc.can_trade:
309
+ return {"applied": False, "reason": "invalid_merchant"}
310
+ if npc.location != game_state.player.location:
311
+ return {"applied": False, "reason": "merchant_not_here"}
312
+ if item_name not in npc.shop_inventory:
313
+ return {"applied": False, "reason": "item_not_sold_here"}
314
+
315
+ item_info = game_state.world.item_registry.get(item_name)
316
+ if item_info is None:
317
+ return {"applied": False, "reason": "unknown_item"}
318
+
319
+ if not confirm:
320
+ return {
321
+ "applied": False,
322
+ "reason": "awaiting_confirmation",
323
+ "price": item_info.value,
324
+ }
325
+
326
+ if game_state.player.gold < item_info.value:
327
+ return {"applied": False, "reason": "insufficient_gold", "price": item_info.value}
328
+
329
+ game_state.player.gold -= item_info.value
330
+ game_state.player.inventory.append(item_name)
331
+ game_state.last_recent_gain = item_name
332
+ follow_up_actions = build_contextual_actions(game_state, recent_gain=item_name)
333
+ return {
334
+ "applied": True,
335
+ "reason": "purchased",
336
+ "price": item_info.value,
337
+ "item_name": item_name,
338
+ "follow_up_actions": follow_up_actions,
339
+ }
340
+
341
+
342
+ def get_battle_encounter(location_name: str, enemy_name: str) -> dict[str, Any] | None:
343
+ return BATTLE_ENCOUNTER_CONFIG.get((str(location_name), str(enemy_name)))
344
+
345
+
346
+ def _has_status(game_state, keyword: str) -> bool:
347
+ keyword = str(keyword or "")
348
+ return any(keyword in effect.name for effect in game_state.player.status_effects)
349
+
350
+
351
+ def _has_map(game_state) -> bool:
352
+ owned_items = set(game_state.player.inventory) | {
353
+ str(item)
354
+ for item in game_state.player.equipment.values()
355
+ if item
356
+ }
357
+ return any("地图" in item for item in owned_items)
358
+
359
+
360
+ def _has_named_map(game_state, map_name: str) -> bool:
361
+ owned_items = set(game_state.player.inventory) | {
362
+ str(item)
363
+ for item in game_state.player.equipment.values()
364
+ if item
365
+ }
366
+ return str(map_name) in owned_items
367
+
368
+
369
+ def _is_accessible_destination(game_state, destination: str) -> bool:
370
+ target_location = game_state.world.locations.get(destination)
371
+ if target_location is None:
372
+ return False
373
+ if target_location.is_accessible:
374
+ return True
375
+ required_item = str(target_location.required_item or "")
376
+ owned_items = set(game_state.player.inventory) | {
377
+ str(item)
378
+ for item in game_state.player.equipment.values()
379
+ if item
380
+ }
381
+ return bool(required_item) and required_item in owned_items
382
+
383
+
384
+ def _is_route_visible(game_state, destination: str) -> bool:
385
+ if destination == "河边渡口":
386
+ return bool(game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG))
387
+ return True
388
+
389
+
390
+ def _is_forest_troll_hunt_active(game_state) -> bool:
391
+ quest = game_state.world.quests.get(MAIN_QUEST_TROLL_ID)
392
+ if quest is None or quest.status != "active":
393
+ return False
394
+ return not bool(game_state.world.global_flags.get(FOREST_TROLL_DEFEATED_FLAG))
395
+
396
+
397
+ def _is_main_quest_report_pending(game_state) -> bool:
398
+ quest = game_state.world.quests.get(MAIN_QUEST_ID)
399
+ if quest is None or quest.status != "active":
400
+ return False
401
+ if not game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG):
402
+ return False
403
+ return not bool(quest.objectives.get(REPORT_TO_CHIEF_OBJECTIVE))
404
+
405
+
406
+ def _find_encounter_location(enemy_name: str) -> str | None:
407
+ enemy_name = str(enemy_name or "")
408
+ if not enemy_name:
409
+ return None
410
+ for (location_name, encounter_enemy), _config in BATTLE_ENCOUNTER_CONFIG.items():
411
+ if str(encounter_enemy) == enemy_name:
412
+ return str(location_name)
413
+ return None
414
+
415
+
416
+ def _move_action(
417
+ target: str,
418
+ *,
419
+ priority: int,
420
+ text: str | None = None,
421
+ preserve_text: bool = False,
422
+ ) -> dict[str, Any]:
423
+ action = _make_action(
424
+ action_type="MOVE",
425
+ target=target,
426
+ text=text or f"前往{target}",
427
+ priority=priority,
428
+ )
429
+ if preserve_text:
430
+ action["preserve_text"] = True
431
+ return action
432
+
433
+
434
+ def _make_action(
435
+ *,
436
+ action_type: str,
437
+ text: str,
438
+ target: Any = None,
439
+ priority: int = 50,
440
+ ) -> dict[str, Any]:
441
+ return {
442
+ "id": 0,
443
+ "text": text,
444
+ "action_type": action_type,
445
+ "target": target,
446
+ "priority": priority,
447
+ }
448
+
449
+
450
+ def _dedupe_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
451
+ deduped: list[dict[str, Any]] = []
452
+ seen: set[tuple[str, str]] = set()
453
+ for action in sorted(
454
+ actions,
455
+ key=lambda item: int(item.get("priority", 0) or 0),
456
+ reverse=True,
457
+ ):
458
+ normalized = dict(action)
459
+ normalized.setdefault("priority", 0)
460
+ key = (
461
+ str(normalized.get("action_type")),
462
+ str(normalized.get("target")),
463
+ )
464
+ if key in seen:
465
+ continue
466
+ seen.add(key)
467
+ normalized["id"] = len(deduped) + 1
468
+ deduped.append(normalized)
469
+ return deduped
470
+
471
+
472
+ def _first_incomplete_objective(game_state):
473
+ active_quests = [
474
+ quest
475
+ for quest in game_state.world.quests.values()
476
+ if quest.status == "active"
477
+ ]
478
+ active_quests.sort(key=lambda quest: (quest.quest_type != "main", quest.quest_id))
479
+ for quest in active_quests:
480
+ for objective, completed in quest.objectives.items():
481
+ if not completed:
482
+ return quest, objective
483
+ return None, None
484
+
485
+
486
+ def _extract_dialogue_target(objective: str) -> str | None:
487
+ suffixes = ("对话", "交谈", "确认情报", "了解情况")
488
+ text = str(objective or "")
489
+ if not text.startswith("与"):
490
+ return None
491
+ candidate = text[1:]
492
+ for separator in ("对话", "交谈"):
493
+ if separator in candidate:
494
+ candidate = candidate.split(separator, 1)[0]
495
+ break
496
+ else:
497
+ for suffix in suffixes:
498
+ if candidate.endswith(suffix):
499
+ candidate = candidate[: -len(suffix)]
500
+ break
501
+ candidate = candidate.strip()
502
+ return candidate or None
503
+
504
+
505
+ def _extract_location_target(objective: str) -> str | None:
506
+ text = str(objective or "")
507
+ for prefix in ("前往",):
508
+ if text.startswith(prefix):
509
+ candidate = text[len(prefix):]
510
+ for suffix in ("调查", "探索", "查看", "侦察"):
511
+ if candidate.endswith(suffix):
512
+ candidate = candidate[: -len(suffix)]
513
+ break
514
+ return candidate or None
515
+ return None
516
+
517
+
518
+ def _find_next_step(game_state, destination: str) -> str | None:
519
+ if destination == game_state.player.location:
520
+ return destination
521
+ visited = {game_state.player.location}
522
+ queue: deque[tuple[str, list[str]]] = deque([(game_state.player.location, [])])
523
+ while queue:
524
+ current, path = queue.popleft()
525
+ current_loc = game_state.world.locations.get(current)
526
+ if current_loc is None:
527
+ continue
528
+ for neighbor in current_loc.connected_to:
529
+ if neighbor in visited:
530
+ continue
531
+ visited.add(neighbor)
532
+ new_path = path + [neighbor]
533
+ if neighbor == destination:
534
+ return new_path[0]
535
+ queue.append((neighbor, new_path))
536
+ return None
537
+
538
+
539
+ def build_village_chief_follow_up_actions(game_state) -> list[dict[str, Any]]:
540
+ blacksmith_text = "前往村庄铁匠铺准备武器"
541
+ path_text = None
542
+ if _is_forest_troll_hunt_active(game_state):
543
+ blacksmith_text = "前往村庄铁匠铺准备武器和防具"
544
+ path_text = "沿村口小路赶赴森林深处"
545
+ return _dedupe_actions(
546
+ [
547
+ _make_action(
548
+ action_type="VIEW_MAP",
549
+ text="查看地图",
550
+ priority=120,
551
+ ),
552
+ _move_action(
553
+ "森林深处" if path_text else "村口小路",
554
+ priority=116,
555
+ text=path_text,
556
+ preserve_text=bool(path_text),
557
+ ),
558
+ _move_action(
559
+ "村庄杂货铺",
560
+ priority=112,
561
+ text="前往村庄杂货铺准备火把",
562
+ preserve_text=True,
563
+ ),
564
+ _move_action(
565
+ "村庄铁匠铺",
566
+ priority=108,
567
+ text=blacksmith_text,
568
+ preserve_text=True,
569
+ ),
570
+ ]
571
+ )
572
+
573
+
574
+ def build_map_actions(game_state) -> list[dict[str, Any]]:
575
+ if not _has_map(game_state):
576
+ return []
577
+
578
+ current_location = game_state.world.locations.get(game_state.player.location)
579
+ if current_location is None:
580
+ return []
581
+
582
+ special_route_order = {
583
+ "村庄广场": ["村庄铁匠铺", "村庄旅店", "村口小路", "村庄杂货铺"],
584
+ "黑暗森林入口": ["村口小路", "溪边营地", "森林深处"],
585
+ "河边渡口": ["废弃矿洞入口", "山麓盗贼营", "村口小路"],
586
+ "山麓盗贼营": ["精��遗迹", "河边渡口"],
587
+ "古塔废墟": ["村口小路"],
588
+ }
589
+ route_order = special_route_order.get(
590
+ current_location.name,
591
+ list(current_location.connected_to),
592
+ )
593
+
594
+ actions: list[dict[str, Any]] = []
595
+ for index, destination in enumerate(route_order):
596
+ # 不显示"前往当前场景"的无效选项
597
+ if destination == game_state.player.location:
598
+ continue
599
+ if destination not in current_location.connected_to:
600
+ continue
601
+ if not _is_accessible_destination(game_state, destination):
602
+ continue
603
+ required_map = LOCATION_MAP_REQUIREMENTS.get(destination)
604
+ if required_map and not _has_named_map(game_state, required_map):
605
+ continue
606
+ if not _is_route_visible(game_state, destination):
607
+ continue
608
+ if (
609
+ destination == "森林深处"
610
+ and game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG)
611
+ and "森林之钥" not in game_state.player.inventory
612
+ ):
613
+ continue
614
+ actions.append(
615
+ _move_action(
616
+ destination,
617
+ priority=120 - index * 4,
618
+ )
619
+ )
620
+
621
+ return _dedupe_actions(actions)
622
+
623
+
624
+ def build_shop_menu_actions(game_state, merchant_name: str) -> list[dict[str, Any]]:
625
+ npc = game_state.world.npcs.get(merchant_name)
626
+ if npc is None or not npc.can_trade:
627
+ return []
628
+
629
+ actions: list[dict[str, Any]] = []
630
+ for index, item_name in enumerate(npc.shop_inventory):
631
+ item_info = game_state.world.item_registry.get(item_name)
632
+ price = int(item_info.value) if item_info else 0
633
+ actions.append(
634
+ _make_action(
635
+ action_type="TRADE",
636
+ target={"merchant": merchant_name, "item": item_name, "confirm": False},
637
+ text=f"购买{item_name}({price}金币)",
638
+ priority=120 - index * 4,
639
+ )
640
+ )
641
+
642
+ actions.append(
643
+ _make_action(
644
+ action_type="SCENE_OPTIONS",
645
+ target=npc.location,
646
+ text="暂不购买,先离开柜台",
647
+ priority=60,
648
+ )
649
+ )
650
+ return _dedupe_actions(actions)
651
+
652
+
653
+ def build_scene_actions(game_state, location_name: str | None = None) -> list[dict[str, Any]]:
654
+ current_name = str(location_name or game_state.player.location)
655
+ actions: list[dict[str, Any]] = []
656
+
657
+ if current_name == "村庄广场":
658
+ actions.append(
659
+ _make_action(
660
+ action_type="TALK",
661
+ target="村长老伯",
662
+ text="与村长老伯对话",
663
+ priority=120,
664
+ )
665
+ )
666
+ actions.append(
667
+ _make_action(
668
+ action_type="RUMOR",
669
+ target={"source": "布告栏", "topic": "rumor_menu"},
670
+ text="查看布告栏上的异闻",
671
+ priority=108,
672
+ )
673
+ )
674
+ if _has_named_map(game_state, "村庄地图"):
675
+ actions.append(
676
+ _make_action(
677
+ action_type="VIEW_MAP",
678
+ text="查看地图",
679
+ priority=104,
680
+ )
681
+ )
682
+ actions.append(_move_action("村庄铁匠铺", priority=100))
683
+ actions.append(_move_action("村庄旅店", priority=96))
684
+ actions.append(_move_action("村口小路", priority=92))
685
+ actions.append(_move_action("村庄杂货铺", priority=88))
686
+ return _dedupe_actions(actions)
687
+
688
+ if current_name == "村口小路":
689
+ traveler_available = (
690
+ game_state.world.global_flags.get(TRAVELER_RUMOR_HEARD_FLAG)
691
+ and game_state.world.npcs.get("神秘旅人")
692
+ and game_state.world.npcs["神秘旅人"].location == "村口小路"
693
+ )
694
+ if traveler_available:
695
+ actions.append(
696
+ _make_action(
697
+ action_type="TALK",
698
+ target="神秘旅人",
699
+ text="与神秘旅人交谈",
700
+ priority=120,
701
+ )
702
+ )
703
+ if game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG):
704
+ actions.append(_move_action("河边渡口", priority=112))
705
+ if _has_named_map(game_state, "村庄地图"):
706
+ actions.append(
707
+ _make_action(
708
+ action_type="VIEW_MAP",
709
+ text="查看地图",
710
+ priority=108,
711
+ )
712
+ )
713
+ if _is_main_quest_report_pending(game_state):
714
+ actions.append(
715
+ _move_action(
716
+ "村庄广场",
717
+ priority=120,
718
+ text="回村向村长汇报发现",
719
+ preserve_text=True,
720
+ )
721
+ )
722
+ else:
723
+ actions.append(_move_action("村庄广场", priority=112))
724
+ if _has_named_map(game_state, "村庄地图"):
725
+ actions.append(_move_action("黑暗森林入口", priority=104))
726
+ return _dedupe_actions(actions)
727
+
728
+ if current_name == "村庄铁匠铺":
729
+ actions.append(
730
+ _make_action(
731
+ action_type="TALK",
732
+ target="铁匠格林",
733
+ text="与铁匠格林对话",
734
+ priority=120,
735
+ )
736
+ )
737
+ if _has_named_map(game_state, "村庄地图"):
738
+ actions.append(
739
+ _make_action(
740
+ action_type="VIEW_MAP",
741
+ text="查看地图",
742
+ priority=104,
743
+ )
744
+ )
745
+ actions.append(_move_action("村庄广场", priority=96))
746
+ return _dedupe_actions(actions)
747
+
748
+ if current_name == "村庄旅店":
749
+ actions.append(
750
+ _make_action(
751
+ action_type="TALK",
752
+ target="旅店老板娘莉娜",
753
+ text="与旅店老板娘莉娜对话",
754
+ priority=120,
755
+ )
756
+ )
757
+ actions.append(
758
+ _make_action(
759
+ action_type="RUMOR",
760
+ target={"source": "旅店老板娘莉娜", "topic": "rumor_menu"},
761
+ text="向莉娜打听最近的异状",
762
+ priority=116,
763
+ )
764
+ )
765
+ actions.append(
766
+ _make_action(
767
+ action_type="REST",
768
+ text="在旅店休息片刻",
769
+ priority=110,
770
+ )
771
+ )
772
+ if _has_named_map(game_state, "村庄地图"):
773
+ actions.append(
774
+ _make_action(
775
+ action_type="VIEW_MAP",
776
+ text="查看地图",
777
+ priority=104,
778
+ )
779
+ )
780
+ actions.append(_move_action("村庄广场", priority=96))
781
+ return _dedupe_actions(actions)
782
+
783
+ if current_name == "村庄杂货铺":
784
+ actions.append(
785
+ _make_action(
786
+ action_type="TALK",
787
+ target="杂货商人阿尔",
788
+ text="与杂货商人阿尔对话",
789
+ priority=120,
790
+ )
791
+ )
792
+ if _has_named_map(game_state, "村庄地图"):
793
+ actions.append(
794
+ _make_action(
795
+ action_type="VIEW_MAP",
796
+ text="查看地图",
797
+ priority=104,
798
+ )
799
+ )
800
+ actions.append(_move_action("村庄广场", priority=96))
801
+ return _dedupe_actions(actions)
802
+
803
+ if current_name == "黑暗森林入口":
804
+ goblin_defeated = game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG)
805
+ tracks_found = game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG)
806
+ barrier_seen = game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG)
807
+ troll_hunt_active = _is_forest_troll_hunt_active(game_state)
808
+ if not goblin_defeated:
809
+ actions.append(
810
+ _make_action(
811
+ action_type="ATTACK",
812
+ target="哥布林",
813
+ text="击败哥布林",
814
+ priority=120,
815
+ )
816
+ )
817
+ else:
818
+ if not tracks_found:
819
+ actions.append(
820
+ _make_action(
821
+ action_type="EXPLORE",
822
+ target="黑暗森林入口",
823
+ text="调查哥布林留下的痕迹",
824
+ priority=120,
825
+ )
826
+ )
827
+ elif troll_hunt_active and "森林之钥" in game_state.player.inventory:
828
+ actions.append(
829
+ _move_action(
830
+ "森林深处",
831
+ priority=120,
832
+ text="前往森林深处探索",
833
+ preserve_text=True,
834
+ )
835
+ )
836
+ elif not barrier_seen:
837
+ actions.append(_move_action("森林深处", priority=120))
838
+ elif _is_main_quest_report_pending(game_state):
839
+ actions.append(
840
+ _move_action(
841
+ "村口小路",
842
+ priority=120,
843
+ text="返回村庄向村长汇报",
844
+ preserve_text=True,
845
+ )
846
+ )
847
+ if _has_named_map(game_state, "黑暗森林地图"):
848
+ actions.append(
849
+ _make_action(
850
+ action_type="VIEW_MAP",
851
+ text="查看地图",
852
+ priority=112 if tracks_found else 108,
853
+ )
854
+ )
855
+ actions.append(_move_action("溪边营地", priority=104))
856
+ if tracks_found and not barrier_seen and "森林之钥" in game_state.player.inventory:
857
+ actions.append(
858
+ _move_action(
859
+ "森林深处",
860
+ priority=116,
861
+ text="前往森林���处探索" if troll_hunt_active else None,
862
+ preserve_text=troll_hunt_active,
863
+ )
864
+ )
865
+ if _is_main_quest_report_pending(game_state) and not barrier_seen and not troll_hunt_active:
866
+ actions.append(
867
+ _move_action(
868
+ "村口小路",
869
+ priority=114,
870
+ text="先返回村庄汇报情况",
871
+ preserve_text=True,
872
+ )
873
+ )
874
+ elif not barrier_seen or troll_hunt_active:
875
+ actions.append(_move_action("村口小路", priority=96))
876
+ return _dedupe_actions(actions)
877
+
878
+ if current_name == "河边渡口":
879
+ actions.append(
880
+ _make_action(
881
+ action_type="TALK",
882
+ target="渡口老渔夫",
883
+ text="与渡口老渔夫对话",
884
+ priority=120,
885
+ )
886
+ )
887
+ if _has_named_map(game_state, "山麓地图"):
888
+ actions.append(
889
+ _make_action(
890
+ action_type="VIEW_MAP",
891
+ text="查看地图",
892
+ priority=116,
893
+ )
894
+ )
895
+ actions.append(_move_action("废弃矿洞入口", priority=112))
896
+ actions.append(_move_action("山麓盗贼营", priority=108))
897
+ actions.append(_move_action("村口小路", priority=104))
898
+ return _dedupe_actions(actions)
899
+
900
+ if current_name == "山麓盗贼营":
901
+ if not game_state.world.global_flags.get("encounter::bandit_scout_defeated"):
902
+ actions.append(
903
+ _make_action(
904
+ action_type="ATTACK",
905
+ target="盗贼斥候",
906
+ text="击败盗贼斥候",
907
+ priority=120,
908
+ )
909
+ )
910
+ if _has_named_map(game_state, "山麓地图"):
911
+ actions.append(
912
+ _make_action(
913
+ action_type="VIEW_MAP",
914
+ text="查看地图",
915
+ priority=112,
916
+ )
917
+ )
918
+ actions.append(_move_action("河边渡口", priority=108))
919
+ return _dedupe_actions(actions)
920
+
921
+ if current_name == "古塔废墟":
922
+ if not game_state.world.global_flags.get("encounter::ancient_tower_wraith_defeated"):
923
+ actions.append(
924
+ _make_action(
925
+ action_type="ATTACK",
926
+ target="游荡幽灵",
927
+ text="击退游荡幽灵",
928
+ priority=120,
929
+ )
930
+ )
931
+ if _has_named_map(game_state, "古塔地图"):
932
+ actions.append(
933
+ _make_action(
934
+ action_type="VIEW_MAP",
935
+ text="查看地图",
936
+ priority=108,
937
+ )
938
+ )
939
+ actions.append(_move_action("村口小路", priority=104))
940
+ return _dedupe_actions(actions)
941
+
942
+ if current_name == "废弃矿洞入口":
943
+ if not game_state.world.global_flags.get("encounter::mine_skeleton_defeated"):
944
+ actions.append(
945
+ _make_action(
946
+ action_type="ATTACK",
947
+ target="骷髅兵",
948
+ text="击退骷髅兵",
949
+ priority=120,
950
+ )
951
+ )
952
+ else:
953
+ actions.append(
954
+ _make_action(
955
+ action_type="EXPLORE",
956
+ target="废弃矿洞入口",
957
+ text="搜查矿洞入口的遗留痕迹",
958
+ priority=120,
959
+ )
960
+ )
961
+ if _has_named_map(game_state, "山麓地图"):
962
+ actions.append(
963
+ _make_action(
964
+ action_type="VIEW_MAP",
965
+ text="查看地图",
966
+ priority=108,
967
+ )
968
+ )
969
+ actions.append(_move_action("河边渡口", priority=104))
970
+ return _dedupe_actions(actions)
971
+
972
+ if current_name == "溪边营地":
973
+ actions.append(
974
+ _make_action(
975
+ action_type="REST",
976
+ text="在营地休息恢复体力",
977
+ priority=120,
978
+ )
979
+ )
980
+ actions.append(
981
+ _make_action(
982
+ action_type="EXPLORE",
983
+ target="溪边营地",
984
+ text="搜寻营地遗留的物资线索",
985
+ priority=112,
986
+ )
987
+ )
988
+ if _has_named_map(game_state, "黑暗森林地图"):
989
+ actions.append(
990
+ _make_action(
991
+ action_type="VIEW_MAP",
992
+ text="查看地图",
993
+ priority=104,
994
+ )
995
+ )
996
+ actions.append(_move_action("黑暗森林入口", priority=96))
997
+ return _dedupe_actions(actions)
998
+
999
+ if current_name == "精灵遗迹":
1000
+ actions.append(
1001
+ _make_action(
1002
+ action_type="TALK",
1003
+ target="遗迹守护者",
1004
+ text="与遗迹守护者对话",
1005
+ priority=120,
1006
+ )
1007
+ )
1008
+ if _has_named_map(game_state, "山麓地图"):
1009
+ actions.append(
1010
+ _make_action(
1011
+ action_type="VIEW_MAP",
1012
+ text="查看地图",
1013
+ priority=108,
1014
+ )
1015
+ )
1016
+ actions.append(_move_action("山麓盗贼营", priority=100))
1017
+ return _dedupe_actions(actions)
1018
+
1019
+ if current_name == "森林深处":
1020
+ if game_state.world.global_flags.get(FOREST_TROLL_HOARD_PENDING_FLAG):
1021
+ actions.append(
1022
+ _make_action(
1023
+ action_type="CLAIM_REWARD",
1024
+ target={"source": "forest_troll_hoard"},
1025
+ text="确认拾取洞穴中的战利品",
1026
+ priority=124,
1027
+ )
1028
+ )
1029
+ elif (
1030
+ _is_forest_troll_hunt_active(game_state)
1031
+ and game_state.world.global_flags.get(FOREST_TROLL_INTRO_SEEN_FLAG)
1032
+ ):
1033
+ actions.append(
1034
+ _make_action(
1035
+ action_type="ATTACK",
1036
+ target="森林巨魔",
1037
+ text="攻击森林巨魔",
1038
+ priority=120,
1039
+ )
1040
+ )
1041
+ else:
1042
+ actions.append(
1043
+ _make_action(
1044
+ action_type="EXPLORE",
1045
+ target="森林深处",
1046
+ text="深入调查森林异变的根源",
1047
+ priority=120,
1048
+ )
1049
+ )
1050
+ if _has_named_map(game_state, "黑暗森林地图"):
1051
+ actions.append(
1052
+ _make_action(
1053
+ action_type="VIEW_MAP",
1054
+ text="查看地图",
1055
+ priority=108,
1056
+ )
1057
+ )
1058
+ actions.append(_move_action("黑暗森林入口", priority=100))
1059
+ return _dedupe_actions(actions)
1060
+
1061
+ return build_adjacent_actions(game_state)
1062
+
1063
+
1064
+ def build_arrival_event(game_state, location_name: str) -> dict[str, Any] | None:
1065
+ config = ARRIVAL_EVENT_CONFIG.get(str(location_name))
1066
+ if config is None:
1067
+ return None
1068
+ return {
1069
+ "event_key": config["event_key"],
1070
+ "story_text": config["story_text"],
1071
+ "options": build_scene_actions(game_state, location_name),
1072
+ }
1073
+
1074
+
1075
+ def build_goal_directed_actions(game_state) -> list[dict[str, Any]]:
1076
+ quest, objective = _first_incomplete_objective(game_state)
1077
+ if not quest or not objective:
1078
+ return []
1079
+
1080
+ actions: list[dict[str, Any]] = []
1081
+ inventory = set(game_state.player.inventory)
1082
+ current_location = game_state.world.locations.get(game_state.player.location)
1083
+ dialogue_target = _extract_dialogue_target(objective)
1084
+ if dialogue_target:
1085
+ npc = game_state.world.npcs.get(dialogue_target)
1086
+ if npc is None:
1087
+ npc = next(
1088
+ (
1089
+ candidate
1090
+ for candidate in game_state.world.npcs.values()
1091
+ if dialogue_target in candidate.name or candidate.name in objective
1092
+ ),
1093
+ None,
1094
+ )
1095
+ if npc and npc.location == game_state.player.location:
1096
+ actions.append(
1097
+ _make_action(
1098
+ action_type="TALK",
1099
+ target=npc.name,
1100
+ text=f"与{npc.name}对话",
1101
+ priority=120,
1102
+ )
1103
+ )
1104
+ elif npc:
1105
+ next_step = _find_next_step(game_state, npc.location)
1106
+ if next_step:
1107
+ actions.append(
1108
+ _make_action(
1109
+ action_type="MOVE",
1110
+ target=next_step,
1111
+ text=f"前往{next_step}",
1112
+ priority=112,
1113
+ )
1114
+ )
1115
+
1116
+ location_target = _extract_location_target(objective)
1117
+ if location_target:
1118
+ if any("地图" in item for item in inventory):
1119
+ actions.append(
1120
+ _make_action(
1121
+ action_type="VIEW_MAP",
1122
+ text="查看地图",
1123
+ priority=110,
1124
+ )
1125
+ )
1126
+ if (
1127
+ location_target == "森林深处"
1128
+ and _is_forest_troll_hunt_active(game_state)
1129
+ and "森林之钥" in inventory
1130
+ and game_state.player.location != "森林深处"
1131
+ ):
1132
+ actions.append(
1133
+ _make_action(
1134
+ action_type="MOVE",
1135
+ target="森林深处",
1136
+ text="赶赴森林深处",
1137
+ priority=105,
1138
+ )
1139
+ )
1140
+ else:
1141
+ next_step = _find_next_step(game_state, location_target)
1142
+ if next_step and next_step != game_state.player.location:
1143
+ actions.append(
1144
+ _make_action(
1145
+ action_type="MOVE",
1146
+ target=next_step,
1147
+ text=f"前往{next_step}",
1148
+ priority=105,
1149
+ )
1150
+ )
1151
+
1152
+ if location_target == "黑暗森林入口" and game_state.player.location == "村庄广场":
1153
+ if "火把" not in inventory:
1154
+ actions.append(
1155
+ _make_action(
1156
+ action_type="MOVE",
1157
+ target="村庄杂货铺",
1158
+ text="前往村庄杂货铺准备火把",
1159
+ priority=104,
1160
+ )
1161
+ )
1162
+ if (
1163
+ not game_state.player.equipment.get("weapon")
1164
+ and "铁剑" not in inventory
1165
+ and "短剑" not in inventory
1166
+ ):
1167
+ actions.append(
1168
+ _make_action(
1169
+ action_type="MOVE",
1170
+ target="村庄铁匠铺",
1171
+ text="前往村庄铁匠铺准备武器",
1172
+ priority=103,
1173
+ )
1174
+ )
1175
+
1176
+ if "击败" in str(objective):
1177
+ enemy_name = str(objective).replace("击败", "").replace("森林中的", "").replace("矿洞中的", "").replace("的怪物", "").strip()
1178
+ encounter_location = _find_encounter_location(enemy_name)
1179
+ if enemy_name in {"怪物", "敌人"} and current_location:
1180
+ local_enemies = list(current_location.enemies or [])
1181
+ if local_enemies:
1182
+ enemy_name = local_enemies[0]
1183
+ encounter_location = _find_encounter_location(enemy_name)
1184
+ else:
1185
+ hunt_step = next(
1186
+ (
1187
+ neighbor
1188
+ for neighbor in current_location.connected_to
1189
+ if game_state.world.locations.get(neighbor)
1190
+ and game_state.world.locations[neighbor].enemies
1191
+ ),
1192
+ None,
1193
+ )
1194
+ if hunt_step:
1195
+ actions.append(
1196
+ _make_action(
1197
+ action_type="MOVE",
1198
+ target=hunt_step,
1199
+ text=f"前往{hunt_step}搜索怪物",
1200
+ priority=108,
1201
+ )
1202
+ )
1203
+ enemy_name = ""
1204
+ if enemy_name and encounter_location and encounter_location != game_state.player.location:
1205
+ next_step = _find_next_step(game_state, encounter_location)
1206
+ if next_step and next_step != game_state.player.location:
1207
+ actions.append(
1208
+ _make_action(
1209
+ action_type="MOVE",
1210
+ target=next_step,
1211
+ text=f"前往{next_step}",
1212
+ priority=108,
1213
+ )
1214
+ )
1215
+ elif enemy_name:
1216
+ actions.append(
1217
+ _make_action(
1218
+ action_type="ATTACK",
1219
+ target=enemy_name,
1220
+ text=f"击败{enemy_name}",
1221
+ priority=100,
1222
+ )
1223
+ )
1224
+
1225
+ if "调查" in str(objective) or "找到" in str(objective):
1226
+ if (
1227
+ str(objective) == FOREST_CAUSE_OBJECTIVE
1228
+ and game_state.player.location == "黑暗森林入口"
1229
+ and game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG)
1230
+ ):
1231
+ actions.append(
1232
+ _make_action(
1233
+ action_type="EXPLORE",
1234
+ target="黑暗森林入口",
1235
+ text="调查哥布林留下的痕迹",
1236
+ priority=118,
1237
+ )
1238
+ )
1239
+ return _dedupe_actions(actions)
1240
+ actions.append(
1241
+ _make_action(
1242
+ action_type="EXPLORE",
1243
+ target=game_state.player.location,
1244
+ text=f"围绕“{objective}”继续调查",
1245
+ priority=92,
1246
+ )
1247
+ )
1248
+
1249
+ return _dedupe_actions(actions)
1250
+
1251
+
1252
+ def build_adjacent_actions(game_state) -> list[dict[str, Any]]:
1253
+ current_location = game_state.world.locations.get(game_state.player.location)
1254
+ if current_location is None:
1255
+ return []
1256
+
1257
+ actions: list[dict[str, Any]] = []
1258
+ owned_items = set(game_state.player.inventory) | {
1259
+ str(item)
1260
+ for item in game_state.player.equipment.values()
1261
+ if item
1262
+ }
1263
+
1264
+ for neighbor in current_location.connected_to:
1265
+ # 不显示"前往当前场景"的无效选项
1266
+ if neighbor == game_state.player.location:
1267
+ continue
1268
+ target_location = game_state.world.locations.get(neighbor)
1269
+ if target_location is None:
1270
+ continue
1271
+ if not target_location.is_accessible:
1272
+ required_item = str(target_location.required_item or "")
1273
+ if not required_item or required_item not in owned_items:
1274
+ continue
1275
+ actions.append(
1276
+ _make_action(
1277
+ action_type="MOVE",
1278
+ target=neighbor,
1279
+ text=f"前往{neighbor}",
1280
+ priority=78 if not target_location.is_discovered else 72,
1281
+ )
1282
+ )
1283
+
1284
+ for npc_name in current_location.npcs_present:
1285
+ if npc_name not in game_state.world.npcs:
1286
+ continue
1287
+ actions.append(
1288
+ _make_action(
1289
+ action_type="TALK",
1290
+ target=npc_name,
1291
+ text=f"与{npc_name}对话",
1292
+ priority=68,
1293
+ )
1294
+ )
1295
+
1296
+ return _dedupe_actions(actions)
1297
+
1298
+
1299
+ def merge_demo_options(
1300
+ base_options: list[dict[str, Any]],
1301
+ *extra_option_groups: list[dict[str, Any]],
1302
+ limit: int = 3,
1303
+ ) -> list[dict[str, Any]]:
1304
+ merged: list[dict[str, Any]] = [option for option in base_options if isinstance(option, dict)]
1305
+ for group in extra_option_groups:
1306
+ merged.extend(group)
1307
+ deduped = _dedupe_actions(merged)
1308
+ return deduped[:limit]
1309
+
1310
+
1311
+ def build_contextual_actions(
1312
+ game_state,
1313
+ *,
1314
+ recent_gain: str | None = None,
1315
+ ) -> list[dict[str, Any]]:
1316
+ actions: list[dict[str, Any]] = []
1317
+ inventory = set(game_state.player.inventory)
1318
+ light_level = str(game_state.world.light_level)
1319
+ location = game_state.world.locations.get(game_state.player.location)
1320
+ time_of_day = str(game_state.world.time_of_day)
1321
+
1322
+ if recent_gain:
1323
+ item_info = game_state.world.item_registry.get(recent_gain)
1324
+ if item_info and item_info.item_type in {"weapon", "armor", "accessory"}:
1325
+ slot = "weapon" if item_info.item_type == "weapon" else "armor"
1326
+ if game_state.player.equipment.get(slot) != recent_gain:
1327
+ actions.append(
1328
+ _make_action(
1329
+ action_type="EQUIP",
1330
+ target=recent_gain,
1331
+ text=f"装备{recent_gain}",
1332
+ priority=100,
1333
+ )
1334
+ )
1335
+ if "地图" in str(recent_gain):
1336
+ actions.append(
1337
+ _make_action(
1338
+ action_type="VIEW_MAP",
1339
+ text="查看地图",
1340
+ priority=95,
1341
+ )
1342
+ )
1343
+
1344
+ in_dark_area = (
1345
+ light_level in {"黑暗", "昏暗", "幽暗", "漆黑"}
1346
+ or (location is not None and location.location_type == "dungeon")
1347
+ or (
1348
+ location is not None
1349
+ and location.location_type in {"wilderness", "special"}
1350
+ and time_of_day in {"夜晚", "深夜"}
1351
+ )
1352
+ )
1353
+ if "火把" in inventory and in_dark_area and not _has_status(game_state, "火把"):
1354
+ actions.append(
1355
+ _make_action(
1356
+ action_type="USE_ITEM",
1357
+ target="火把",
1358
+ text="使用火把照明",
1359
+ priority=90,
1360
+ )
1361
+ )
1362
+
1363
+ if game_state.player.hp < max(1, game_state.player.max_hp // 2):
1364
+ for potion_name in ("小型治疗药水", "治疗药水"):
1365
+ if potion_name in inventory:
1366
+ actions.append(
1367
+ _make_action(
1368
+ action_type="USE_ITEM",
1369
+ target=potion_name,
1370
+ text=f"使用{potion_name}",
1371
+ priority=85,
1372
+ )
1373
+ )
1374
+ break
1375
+
1376
+ if game_state.player.hunger < 50:
1377
+ for food_name in ("面包", "烤肉", "麦酒", "草药包"):
1378
+ if food_name in inventory:
1379
+ actions.append(
1380
+ _make_action(
1381
+ action_type="USE_ITEM",
1382
+ target=food_name,
1383
+ text=f"食用{food_name}",
1384
+ priority=80,
1385
+ )
1386
+ )
1387
+ break
1388
+
1389
+ if (
1390
+ game_state.player.location in OVERNIGHT_REST_LOCATIONS
1391
+ and hasattr(game_state, "can_overnight_rest")
1392
+ and game_state.can_overnight_rest()
1393
+ ):
1394
+ actions.append(
1395
+ _make_action(
1396
+ action_type="OVERNIGHT_REST",
1397
+ text="在此处过夜",
1398
+ priority=88,
1399
+ )
1400
+ )
1401
+
1402
+ return _dedupe_actions(actions)
scene_assets.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ IMAGE_DIR = Path(__file__).resolve().parent / "image"
7
+
8
+
9
+ def _candidate_path(name: str | None) -> Path | None:
10
+ if not name:
11
+ return None
12
+ candidate = IMAGE_DIR / f"{name}.png"
13
+ if candidate.exists():
14
+ return candidate
15
+ return None
16
+
17
+
18
+ def get_scene_image_path(game_state, focus_npc: str | None = None) -> str | None:
19
+ npc_candidate = _candidate_path(focus_npc)
20
+ if npc_candidate is not None:
21
+ return str(npc_candidate)
22
+
23
+ for name in (
24
+ getattr(game_state.world, "current_scene", None),
25
+ getattr(game_state.player, "location", None),
26
+ ):
27
+ scene_candidate = _candidate_path(name)
28
+ if scene_candidate is not None:
29
+ return str(scene_candidate)
30
+
31
+ return None
state_manager.py CHANGED
The diff for this file is too large to render. See raw diff
 
story_engine.py CHANGED
The diff for this file is too large to render. See raw diff