wzh0617 commited on
Commit
f0690fd
·
1 Parent(s): 7f27d18

Upload 4 files

Browse files
Files changed (2) hide show
  1. state_manager.py +287 -1
  2. story_engine.py +58 -0
state_manager.py CHANGED
@@ -18,6 +18,7 @@ from __future__ import annotations
18
 
19
  import copy
20
  import logging
 
21
  from typing import Any, Optional
22
  from pydantic import BaseModel, Field
23
 
@@ -491,10 +492,78 @@ class GameState:
491
  rest_available=True,
492
  ambient_description="溪水潺潺流淌,偶有鸟鸣声在林间回荡,这里是森林中的一片安宁绿洲。",
493
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  }
495
 
496
  self.world.discovered_locations = ["村庄广场", "村庄铁匠铺", "村庄旅店", "村庄杂货铺", "村口小路"]
497
 
 
 
 
 
 
498
  # --- 初始 NPC ---
499
  self.world.npcs = {
500
  "村长老伯": NPCState(
@@ -569,6 +638,37 @@ class GameState:
569
  schedule={"清晨": "村庄旅店", "夜晚": "村口小路"},
570
  backstory="似乎在寻找什么。偶尔从斗篷下露出的手指上有奇异的魔法纹路。",
571
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  }
573
 
574
  # --- 初始任务 ---
@@ -612,6 +712,46 @@ class GameState:
612
  ),
613
  prerequisites=[],
614
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  }
616
 
617
  # --- 初始物品注册表 ---
@@ -630,6 +770,14 @@ class GameState:
630
  "地图碎片": ItemInfo(name="地图碎片", item_type="quest_item", description="一片残破的地图,标记着森林深处的某个位置。", quest_related=True, value=0, lore_text="这张地图似乎非常古老,纸张已经泛黄,但上面的墨迹依然清晰。"),
631
  "森林之钥": ItemInfo(name="森林之钥", item_type="key", description="一把散发着微弱绿光的古老钥匙,似乎能打开森林深处的某个入口。", rarity="rare", quest_related=True, value=0, lore_text="钥匙上刻着精灵文字,翻译过来是:'唯有勇者可通行'。"),
632
  "神秘卷轴": ItemInfo(name="神秘卷轴", item_type="quest_item", description="记载着古老知识的卷轴,散发着微弱的魔力波动。", rarity="rare", quest_related=True, value=0),
 
 
 
 
 
 
 
 
633
  }
634
 
635
  # --- 初始传闻 ---
@@ -637,8 +785,25 @@ class GameState:
637
  "最近森林里的哥布林越来越嚣张了,好几个猎人都不敢进去了。",
638
  "听说铁匠格林以前在王都待过,不知道为什么来了这个小村子。",
639
  "旅店里来了个奇怪的旅人,整天把自己裹得严严实实的。",
 
 
 
 
640
  ]
641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  # --- 玩家初始装备 ---
643
  self.player.inventory = ["面包", "面包", "小型治疗药水"]
644
 
@@ -1111,7 +1276,18 @@ class GameState:
1111
  if self.world.rumors:
1112
  rumors_desc = "\n【流传的传闻】\n" + "\n".join(f" - {r}" for r in self.world.rumors[-3:])
1113
 
1114
- # 8. 一致性约束指令
 
 
 
 
 
 
 
 
 
 
 
1115
  consistency_rules = (
1116
  "\n【一致性约束 —— 你必须严格遵守】\n"
1117
  "1. 已死亡的NPC不可再出现或对话(除非有特殊复活剧情)。\n"
@@ -1145,6 +1321,7 @@ class GameState:
1145
  move_desc,
1146
  event_desc,
1147
  rumors_desc,
 
1148
  consistency_rules,
1149
  ])
1150
 
@@ -1248,6 +1425,103 @@ class GameState:
1248
 
1249
  return contradictions
1250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1251
  def is_game_over(self) -> bool:
1252
  """
1253
  判断游戏是否结束。
@@ -1319,6 +1593,9 @@ class GameState:
1319
  # 更新 NPC 位置(根据时间表)
1320
  self._update_npc_schedules()
1321
 
 
 
 
1322
  return tick_log
1323
 
1324
  def _apply_status_effects(self) -> list[str]:
@@ -1385,6 +1662,15 @@ class GameState:
1385
 
1386
  return effect_log
1387
 
 
 
 
 
 
 
 
 
 
1388
  def _check_quest_deadlines(self):
1389
  """检查限时任务是否过期"""
1390
  for quest in self.world.quests.values():
 
18
 
19
  import copy
20
  import logging
21
+ import re
22
  from typing import Any, Optional
23
  from pydantic import BaseModel, Field
24
 
 
492
  rest_available=True,
493
  ambient_description="溪水潺潺流淌,偶有鸟鸣声在林间回荡,这里是森林中的一片安宁绿洲。",
494
  ),
495
+ # -------- 扩展地点 --------
496
+ "河边渡口": LocationInfo(
497
+ name="河边渡口",
498
+ location_type="wilderness",
499
+ description="一座破旧的木制渡口,宽阔的河流在此缓缓流淌。一艘半沉的渡船拴在码头桩上。",
500
+ connected_to=["村口小路", "废弃矿洞入口", "山麓盗贼营"],
501
+ npcs_present=["渡口老渔夫"],
502
+ danger_level=3,
503
+ is_discovered=False,
504
+ ambient_description="河水拍打着朽烂的木桩,远处有鹰在盘旋,对岸隐约可见矿洞的轮廓。",
505
+ ),
506
+ "废弃矿洞入口": LocationInfo(
507
+ name="废弃矿洞入口",
508
+ location_type="dungeon",
509
+ description="荒废多年的铁矿洞,入口被蛛网和碎石半堵。矿道里传来金属碰撞的回声。",
510
+ connected_to=["河边渡口", "矿洞深层"],
511
+ enemies=["骷髅兵", "矿洞蝙蝠群", "锈铁傀儡"],
512
+ danger_level=5,
513
+ is_discovered=False,
514
+ ambient_description="腐朽的矿车轨道延伸向黑暗深处,空气里弥漫着铁锈和硫磺的气味。",
515
+ ),
516
+ "矿洞深层": LocationInfo(
517
+ name="矿洞深层",
518
+ location_type="dungeon",
519
+ description="矿洞最深处,一个巨大的地下空间。墙壁上嵌着发光的矿石,中央有一座被遗忘的祭坛。",
520
+ connected_to=["废弃矿洞入口"],
521
+ enemies=["亡灵矿工", "岩石巨像"],
522
+ danger_level=8,
523
+ is_discovered=False,
524
+ is_accessible=False,
525
+ required_item="矿工旧钥匙",
526
+ ambient_description="发光矿石将洞穴映成幽蓝色,祭坛上刻着无人能读的文字,隐约有低沉的嗡鸣。",
527
+ ),
528
+ "山麓盗贼营": LocationInfo(
529
+ name="山麓盗贼营",
530
+ location_type="wilderness",
531
+ description="藏在山脚灌木丛后的盗贼据点,几顶破帐篷围着一堆余烬。看起来已被匆忙弃置。",
532
+ connected_to=["河边渡口", "精灵遗迹"],
533
+ enemies=["盗贼斥候", "盗贼头目"],
534
+ danger_level=5,
535
+ is_discovered=False,
536
+ ambient_description="地上散落着翻倒的酒桶和吃了一半的干粮,有人走得很匆忙。",
537
+ ),
538
+ "精灵遗迹": LocationInfo(
539
+ name="精灵遗迹",
540
+ location_type="special",
541
+ description="一片被藤蔓覆盖的古老石柱林,精灵文字在月光下隐约发光。空气中有淡淡的魔力涌动。",
542
+ connected_to=["山麓盗贼营"],
543
+ npcs_present=["遗迹守护者"],
544
+ danger_level=4,
545
+ is_discovered=False,
546
+ ambient_description="石柱上的符文随风明灭,仿佛在回应某种古老的感召。脚下的青苔异常柔软。",
547
+ ),
548
+ "古塔废墟": LocationInfo(
549
+ name="古塔废墟",
550
+ location_type="dungeon",
551
+ description="一座半坍塌的石塔,据说曾是某位法师的研究所。顶层似乎还有东西在闪烁。",
552
+ connected_to=["村口小路"],
553
+ enemies=["石像鬼", "游荡幽灵"],
554
+ danger_level=6,
555
+ is_discovered=False,
556
+ ambient_description="风从塔身的裂缝中呼啸而过,残破的阶梯上覆满了青苔和鸟粪。",
557
+ ),
558
  }
559
 
560
  self.world.discovered_locations = ["村庄广场", "村庄铁匠铺", "村庄旅店", "村庄杂货铺", "村口小路"]
561
 
562
+ # 扩展村口小路的连接 —— 链接到新区域
563
+ self.world.locations["村口小路"].connected_to = [
564
+ "村庄广场", "黑暗森林入口", "河边渡口", "古塔废墟",
565
+ ]
566
+
567
  # --- 初始 NPC ---
568
  self.world.npcs = {
569
  "村长老伯": NPCState(
 
638
  schedule={"清晨": "村庄旅店", "夜晚": "村口小路"},
639
  backstory="似乎在寻找什么。偶尔从斗篷下露出的手指上有奇异的魔法纹路。",
640
  ),
641
+ # -------- 扩展 NPC --------
642
+ "渡口老渔夫": NPCState(
643
+ name="渡口老渔���",
644
+ npc_type="quest_giver",
645
+ location="河边渡口",
646
+ attitude="friendly",
647
+ description="一个皮肤黝黑、满脸皱纹的老人,正坐在码头上修补渔网。",
648
+ race="人类",
649
+ occupation="渔夫",
650
+ relationship_level=5,
651
+ can_give_quest=True,
652
+ available_quests=["side_quest_02"],
653
+ memory=[],
654
+ schedule={"清晨": "河边渡口", "正午": "河边渡口", "夜晚": "村庄旅店"},
655
+ backstory="在这条河边住了四十年,对河流两岸的地形了如指掌。最近总念叨对岸矿洞里的怪响。",
656
+ ),
657
+ "遗迹守护者": NPCState(
658
+ name="遗迹守护者",
659
+ npc_type="quest_giver",
660
+ location="精灵遗迹",
661
+ attitude="cautious",
662
+ description="一个身形消瘦的半精灵,穿着褪色的绿袍,眼神中有深深的疲惫。",
663
+ race="半精灵",
664
+ occupation="守护者",
665
+ relationship_level=-5,
666
+ can_give_quest=True,
667
+ available_quests=["side_quest_03"],
668
+ memory=[],
669
+ schedule={"清晨": "精灵遗迹", "正午": "精灵遗迹", "夜晚": "精灵遗迹"},
670
+ backstory="最后一位遗迹守护者,独自守护这片先祖的圣地已有三十年。对外来者充满警惕,但内心渴望帮助。",
671
+ ),
672
  }
673
 
674
  # --- 初始任务 ---
 
712
  ),
713
  prerequisites=[],
714
  ),
715
+ # -------- 扩展任务 --------
716
+ "side_quest_02": QuestState(
717
+ quest_id="side_quest_02",
718
+ title="河底的秘密",
719
+ description="渡口老渔夫说他最近总在河里捞到奇怪的骨头,而且对岸矿洞方向夜里总是有光。他请你去查明真相。",
720
+ quest_type="side",
721
+ status="active",
722
+ giver_npc="渡口老渔夫",
723
+ objectives={
724
+ "与渡口老渔夫交谈": False,
725
+ "前往废弃矿洞调查": False,
726
+ "找到矿洞异常的原因": False,
727
+ },
728
+ rewards=QuestRewards(
729
+ gold=60,
730
+ experience=40,
731
+ items=["矿工旧钥匙"],
732
+ reputation_changes={"村庄": 10},
733
+ ),
734
+ prerequisites=[],
735
+ ),
736
+ "side_quest_03": QuestState(
737
+ quest_id="side_quest_03",
738
+ title="守护者的试炼",
739
+ description="精灵遗迹的守护者提出一个交换条件:通过她设下的试炼,就能获得古老的精灵祝福。",
740
+ quest_type="side",
741
+ status="active",
742
+ giver_npc="遗迹守护者",
743
+ objectives={
744
+ "与遗迹守护者交谈": False,
745
+ "通过守护者的试炼": False,
746
+ },
747
+ rewards=QuestRewards(
748
+ experience=50,
749
+ unlock_skill="精灵祝福",
750
+ karma_change=10,
751
+ title="遗迹认可者",
752
+ ),
753
+ prerequisites=[],
754
+ ),
755
  }
756
 
757
  # --- 初始物品注册表 ---
 
770
  "地图碎片": ItemInfo(name="地图碎片", item_type="quest_item", description="一片残破的地图,标记着森林深处的某个位置。", quest_related=True, value=0, lore_text="这张地图似乎非常古老,纸张已经泛黄,但上面的墨迹依然清晰。"),
771
  "森林之钥": ItemInfo(name="森林之钥", item_type="key", description="一把散发着微弱绿光的古老钥匙,似乎能打开森林深处的某个入口。", rarity="rare", quest_related=True, value=0, lore_text="钥匙上刻着精灵文字,翻译过来是:'唯有勇者可通行'。"),
772
  "神秘卷轴": ItemInfo(name="神秘卷轴", item_type="quest_item", description="记载着古老知识的卷轴,散发着微弱的魔力波动。", rarity="rare", quest_related=True, value=0),
773
+ # -------- 扩展物品 --------
774
+ "矿工旧钥匙": ItemInfo(name="矿工旧钥匙", item_type="key", description="一把生锈的铜钥匙,上面刻着一个矿镐图案。", rarity="uncommon", quest_related=True, value=0, lore_text="钥匙柄上隐约可见'B-7采掘区'的刻字。"),
775
+ "骷髅碎骨": ItemInfo(name="骷髅碎骨", item_type="material", description="从骷髅兵身上掉落的骨头碎片,泛着不自然的寒光。", rarity="common", value=5),
776
+ "盗贼日志": ItemInfo(name="盗贼日志", item_type="quest_item", description="一本沾满泥渍的笔记本,记录着盗贼团伙近期的行动计划。", rarity="uncommon", quest_related=True, value=0),
777
+ "精灵护符": ItemInfo(name="精灵护符", item_type="accessory", description="由精灵遗迹守护者亲手制作的小型护符,散发着柔和的绿色微光。", rarity="rare", stat_bonus={"perception": 3, "sanity": 5}, value=50, lore_text="佩戴者能感受到来自远古精灵的庇佑。"),
778
+ "锈蚀铁锤": ItemInfo(name="锈蚀铁锤", item_type="weapon", description="矿洞里发现的旧铁锤,虽然锈迹斑斑但依然沉重有力。", rarity="common", stat_bonus={"attack": 4}, value=15),
779
+ "荧光苔藓": ItemInfo(name="荧光苔藓", item_type="consumable", description="矿洞深处生长的发光苔藓,据说有微弱的疗伤效果。", usable=True, use_effect="恢复 15 HP,恢复 5 理智", rarity="uncommon", value=12),
780
+ "古塔法师笔记": ItemInfo(name="古塔法师笔记", item_type="quest_item", description="在古塔废墟中找到的残破笔记,记载着某种仪式的片段。", rarity="rare", quest_related=True, value=0, lore_text="字迹已经模糊,但仍能辨认出几个关键的魔法符号。"),
781
  }
782
 
783
  # --- 初始传闻 ---
 
785
  "最近森林里的哥布林越来越嚣张了,好几个猎人都不敢进去了。",
786
  "听说铁匠格林以前在王都待过,不知道为什么来了这个小村子。",
787
  "旅店里来了个奇怪的旅人,整天把自己裹得严严实实的。",
788
+ "河对岸的旧矿洞晚上闹鬼,渡口的老渔夫说他亲眼看见过蓝色的火光。",
789
+ "山脚下好像有一伙盗贼扎了营,最近有商队被劫的消息。",
790
+ "村子东边的古塔里据说住过一个法师,后来不知为何法师消失了,塔也荒废了。",
791
+ "有人在精灵遗迹附近见到过一个穿绿袍的身影,不知是人是鬼。",
792
  ]
793
 
794
+ # --- 随机事件候选池 ---
795
+ self.random_event_pool: list[str] = [
796
+ "一个受伤的旅行商人跌跌撞撞地出现在你面前,他的货物散落一地。",
797
+ "天空忽然暗了下来,远处传来隐约的雷声,一场暴雨似乎即将来临。",
798
+ "你注意到路边有一个被藤蔓遮住的旧箱子,看起来已经放了很久。",
799
+ "一只受伤的猎鹰坠落在你脚边,它的腿上绑着一小卷羊皮纸。",
800
+ "远处传来激烈的打斗声,似乎有人遭到了伏击。",
801
+ "一个衣衫褴褛的流浪汉向你走来,自称知道附近藏宝的秘密。",
802
+ "你经过的路上发现了一串新鲜的、不属于任何已知生物的巨大脚印。",
803
+ "一阵古怪的风带来了焦糊的味道,顺着风向看去,远处有一缕黑烟升起。",
804
+ ]
805
+ self.pending_random_event: str | None = None # 本回合触发的随机事件
806
+
807
  # --- 玩家初始装备 ---
808
  self.player.inventory = ["面包", "面包", "小型治疗药水"]
809
 
 
1276
  if self.world.rumors:
1277
  rumors_desc = "\n【流传的传闻】\n" + "\n".join(f" - {r}" for r in self.world.rumors[-3:])
1278
 
1279
+ # 8. 随机事件
1280
+ random_event_desc = ""
1281
+ if getattr(self, 'pending_random_event', None):
1282
+ random_event_desc = (
1283
+ f"\n【本回合随机事件 —— 必须融入本次叙事】\n"
1284
+ f"{self.pending_random_event}\n"
1285
+ f"请将此事件自然地融入剧情描写中,作为额外的意外插曲。"
1286
+ f"玩家可以选择回应或忽视此事件。至少一个选项应与此事件相关。"
1287
+ )
1288
+ self.pending_random_event = None # 用后清除
1289
+
1290
+ # 9. 一致性约束指令
1291
  consistency_rules = (
1292
  "\n【一致性约束 —— 你必须严格遵守】\n"
1293
  "1. 已死亡的NPC不可再出现或对话(除非有特殊复活剧情)。\n"
 
1321
  move_desc,
1322
  event_desc,
1323
  rumors_desc,
1324
+ random_event_desc,
1325
  consistency_rules,
1326
  ])
1327
 
 
1425
 
1426
  return contradictions
1427
 
1428
+ def pre_validate_action(self, intent: dict) -> tuple[bool, str]:
1429
+ """
1430
+ 预校验玩家意图的合法性(在 LLM 调用前立即拦截非法操作)。
1431
+
1432
+ 设计思路:
1433
+ - 在任何 API 调用之前就检测明显违反一致性的操作
1434
+ - 不合法时立即驳回,避免浪费 API 调用和回合
1435
+ - 检测维度:物品是否在背包/装备中、技能是否已习得、
1436
+ raw_input 中是否提及使用不存在的物品
1437
+
1438
+ Returns:
1439
+ (is_valid, rejection_reason): 合法返回 (True, ""), 否则返回 (False, "拒绝原因")
1440
+ """
1441
+ action = intent.get("intent", "")
1442
+ target = intent.get("target", "") or ""
1443
+ details = intent.get("details", "") or ""
1444
+ raw_input = intent.get("raw_input", "") or ""
1445
+
1446
+ inventory = list(self.player.inventory)
1447
+ equipped_items = [v for v in self.player.equipment.values() if v]
1448
+ all_owned = set(inventory) | set(equipped_items)
1449
+
1450
+ # --- 检测 1: USE_ITEM / EQUIP: target 必须在背包或装备中 ---
1451
+ if action in ("USE_ITEM", "EQUIP") and target:
1452
+ if target not in all_owned:
1453
+ return False, f"你的背包中没有「{target}」,无法使用或装备。"
1454
+ if action == "EQUIP" and target not in inventory:
1455
+ if target in equipped_items:
1456
+ return False, f"「{target}」已经装备在身上了。"
1457
+ return False, f"你的背包中没有「{target}」,无法装备。"
1458
+
1459
+ # --- 检测 2: SKILL: 必须已习得 ---
1460
+ if action == "SKILL" and target:
1461
+ if target not in self.player.skills:
1462
+ return False, f"你尚未习得技能「{target}」。"
1463
+
1464
+ # --- 检测 3: 扫描 raw_input 中是否在使用上下文中引用了未拥有的已知物品 ---
1465
+ known_items: set[str] = set(self.world.item_registry.keys())
1466
+ for event in self.event_log:
1467
+ sc = event.state_changes
1468
+ if isinstance(sc, dict):
1469
+ for item in sc.get("items_gained", []):
1470
+ known_items.add(str(item))
1471
+ for item in sc.get("items_lost", []):
1472
+ known_items.add(str(item))
1473
+
1474
+ unavailable_known = {
1475
+ item for item in known_items
1476
+ if item not in all_owned and len(item) >= 2
1477
+ }
1478
+
1479
+ use_verbs = [
1480
+ "使用", "用", "吃", "喝", "装备", "穿上", "戴上",
1481
+ "拿出", "掏出", "挥舞", "举起", "服用", "食用", "拔出", "拿起",
1482
+ ]
1483
+
1484
+ for item_name in unavailable_known:
1485
+ if item_name not in raw_input:
1486
+ continue
1487
+ for verb in use_verbs:
1488
+ if verb + item_name in raw_input:
1489
+ return False, f"你的背包中没有「{item_name}」,无法{verb}。"
1490
+
1491
+ # --- 检测 4: 检查 raw_input 中是否提及使用完全未知的物品 ---
1492
+ # 匹配常见的"使用物品"语句模式,提取物品名称并校验
1493
+ extraction_patterns = [
1494
+ (r'(?:掏出|拿出|拔出|举起)(.{2,8}?)(?:$|[,。!?,\s来])', "使用"),
1495
+ (r'吃(?:一个|一块|一份|了个|了一个)?(.{2,8}?)(?:$|[,。!?,\s来])', "吃"),
1496
+ (r'喝(?:一瓶|一杯|一口|了一瓶|了)?(.{2,8}?)(?:$|[,。!?,\s来])', "喝"),
1497
+ (r'用(.{2,6}?)(?:打|攻击|砍|刺|射|劈|挡|切|割)', "使用"),
1498
+ ]
1499
+
1500
+ non_item_words = {
1501
+ "拳头", "双手", "手", "脚", "头", "身体",
1502
+ "魔法", "技能", "力量", "勇气", "智慧",
1503
+ "办法", "方法", "速度", "周围", "四周",
1504
+ }
1505
+
1506
+ full_text = raw_input + " " + details
1507
+
1508
+ for pattern, verb_desc in extraction_patterns:
1509
+ match = re.search(pattern, full_text)
1510
+ if match:
1511
+ mentioned = match.group(1).strip()
1512
+ if not mentioned or mentioned in non_item_words:
1513
+ continue
1514
+ if mentioned not in all_owned:
1515
+ # 模糊匹配:"剑" 可能是 "铁剑" 的简称
1516
+ fuzzy_match = any(
1517
+ mentioned in owned or owned in mentioned
1518
+ for owned in all_owned
1519
+ )
1520
+ if not fuzzy_match:
1521
+ return False, f"你并没有「{mentioned}」,请检查你的背包。"
1522
+
1523
+ return True, ""
1524
+
1525
  def is_game_over(self) -> bool:
1526
  """
1527
  判断游戏是否结束。
 
1593
  # 更新 NPC 位置(根据时间表)
1594
  self._update_npc_schedules()
1595
 
1596
+ # 随机事件(约 25% 概率触发)
1597
+ self._roll_random_event()
1598
+
1599
  return tick_log
1600
 
1601
  def _apply_status_effects(self) -> list[str]:
 
1662
 
1663
  return effect_log
1664
 
1665
+ def _roll_random_event(self):
1666
+ """以一定概率从事件池中抽取一个随机事件,注入到下一次 Prompt 中"""
1667
+ import random
1668
+ self.pending_random_event = None
1669
+ if self.random_event_pool and random.random() < 0.25:
1670
+ event = random.choice(self.random_event_pool)
1671
+ self.pending_random_event = event
1672
+ logger.info(f"随机事件触发: {event}")
1673
+
1674
  def _check_quest_deadlines(self):
1675
  """检查限时任务是否过期"""
1676
  for quest in self.world.quests.values():
story_engine.py CHANGED
@@ -367,6 +367,13 @@ MERGED_SYSTEM_PROMPT_TEMPLATE = """【最高优先级指令 ── 输出格式
367
  - 战斗场景要紧张刺激
368
  - 货币统一称"金币"
369
 
 
 
 
 
 
 
 
370
  【选项规则】
371
  - 恰好 3 个选项,覆盖不同策略方向(激进/谨慎/探索/社交等)
372
  - 选项中的人物/物品/地点必须已在当前或之前剧情中出现过
@@ -532,6 +539,10 @@ class StoryEngine:
532
  # 再次检查
533
  consistency_issues = self.game_state.check_consistency(state_changes)
534
 
 
 
 
 
535
  # ============================================
536
  # 清理状态变更:阻止非消耗品被错误移除
537
  # ============================================
@@ -1007,6 +1018,51 @@ class StoryEngine:
1007
 
1008
  return changes, warnings
1009
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  def _validate_options(self, options: list[dict]) -> list[dict]:
1011
  """
1012
  验证生成的选项:移除引用了玩家不拥有的物品的选项。
@@ -1315,6 +1371,8 @@ class StoryEngine:
1315
 
1316
  if consistency_issues:
1317
  logger.warning(f"发现一致性问题: {consistency_issues}")
 
 
1318
 
1319
  # 清理状态变更
1320
  event_type = outline.get("event_type", "")
 
367
  - 战斗场景要紧张刺激
368
  - 货币统一称"金币"
369
 
370
+ 【剧情多样性规则 ── 极其重要】
371
+ - 禁止连续两个回合出现相同类型的敌人(如上回合打了哥布林,本回合不能再打哥布林)
372
+ - 连续战斗不得超过 2 个回合,之后必须穿插非战斗事件(对话/探索/发现/交易)
373
+ - 鼓励引入新NPC、新线索、支线事件,让世界有"活"的感觉
374
+ - 如果世界状态中标注了【本回合随机事件】,必须将其融入叙事
375
+ - 尽量引导玩家前往不同的区域探索,而非反复停留在同一地点
376
+
377
  【选项规则】
378
  - 恰好 3 个选项,覆盖不同策略方向(激进/谨慎/探索/社交等)
379
  - 选项中的人物/物品/地点必须已在当前或之前剧情中出现过
 
539
  # 再次检查
540
  consistency_issues = self.game_state.check_consistency(state_changes)
541
 
542
+ # 移除与非法物品相关的状态变更(安全网)
543
+ if consistency_issues:
544
+ state_changes = self._strip_invalid_item_effects(state_changes)
545
+
546
  # ============================================
547
  # 清理状态变更:阻止非消耗品被错误移除
548
  # ============================================
 
1018
 
1019
  return changes, warnings
1020
 
1021
+ def _strip_invalid_item_effects(self, state_changes: dict) -> dict:
1022
+ """
1023
+ 当 LLM 生成了涉及不存在物品的状态变更时,移除相关效果(安全网)。
1024
+
1025
+ 例如:LLM 生成了"吃了包子" → hunger_change: +10, items_lost: ["包子"],
1026
+ 但包子不在背包中。此方法移除 items_lost 和相关的属性效果。
1027
+ """
1028
+ changes = dict(state_changes)
1029
+
1030
+ if "items_lost" not in changes:
1031
+ return changes
1032
+
1033
+ items_gained = {str(i) for i in changes.get("items_gained", [])}
1034
+ invalid_count = 0
1035
+ valid_items_lost = []
1036
+
1037
+ for item in changes["items_lost"]:
1038
+ item_str = str(item)
1039
+ # 同回合获得的物品可以立即消耗(如拾取后使用)
1040
+ if item_str in items_gained:
1041
+ valid_items_lost.append(item)
1042
+ continue
1043
+ if item_str in self.game_state.player.inventory:
1044
+ valid_items_lost.append(item)
1045
+ else:
1046
+ invalid_count += 1
1047
+ logger.warning(f"[安全网] 阻止消耗不存在的物品「{item_str}」")
1048
+
1049
+ if invalid_count > 0:
1050
+ if valid_items_lost:
1051
+ changes["items_lost"] = valid_items_lost
1052
+ else:
1053
+ changes.pop("items_lost", None)
1054
+ # 所有 items_lost 都无效 → 属性变更可能全部源于非法物品使用
1055
+ for key in ["hunger_change", "hp_change", "mp_change",
1056
+ "morale_change", "sanity_change"]:
1057
+ if key in changes:
1058
+ logger.warning(f"[安全网] 因物品不合法,阻止 {key}: {changes[key]}")
1059
+ changes.pop(key)
1060
+ if "status_effects_added" in changes:
1061
+ logger.warning("[安全网] 因物品不合法,阻止状态效果添加")
1062
+ changes.pop("status_effects_added")
1063
+
1064
+ return changes
1065
+
1066
  def _validate_options(self, options: list[dict]) -> list[dict]:
1067
  """
1068
  验证生成的选项:移除引用了玩家不拥有的物品的选项。
 
1371
 
1372
  if consistency_issues:
1373
  logger.warning(f"发现一致性问题: {consistency_issues}")
1374
+ # 移除与非法物品相关的状态变更(安全网)
1375
+ state_changes = self._strip_invalid_item_effects(state_changes)
1376
 
1377
  # 清理状态变更
1378
  event_type = outline.get("event_type", "")