侧边栏壁纸
博主头像
PPP的日记

行动起来,活在当下

  • 累计撰写 13 篇文章
  • 累计创建 14 个标签
  • 累计收到 23 条评论

目 录CONTENT

文章目录

StoryWeaver:规则锁定的 NLP 文字冒险架构设计与实现

TL;DR: 本文介绍一个课程大作业级别的 NLP 文字冒险游戏项目。核心架构采用"规则层锁定核心判定,大模型只负责叙事表现"的混合策略。实现完整的战斗状态机、生存系统(饱食度/理智/士气)、地图移动约束、物品白名单、环境影响和 AI 图片生成,配套 Gradio 交互界面和完整的 pytest 测试体系。


一、为什么做这个项目

纯 LLM 驱动的文字冒险存在一个根本问题:大模型无法保证核心游戏机制的确定性。战斗结果、物品获取、剧情推进都可能因模型"发挥"而偏离设计预期,这对课程答辩和可复现测试是致命的。

StoryWeaver 的核心设计原则:用规则层接管所有数值判定,用大模型只做叙事层的语言生成。

 玩家自由输入 → 规则层做确定性判定 → 大模型生成叙事表现

二、系统架构

 ┌──────────────────────────────────────────────────────────────────────────┐
 │                              app.py (Gradio UI)                          │
 │                    用户输入 / 按钮事件 / 状态面板渲染                      │
 └──────────────────────────────────────────────────────────────────────────┘
                                     ↓
 ┌──────────────────────────────────────────────────────────────────────────┐
 │                           nlu_engine.py (NLU)                            │
 │                 玩家文本 → 结构化意图(战斗/移动/拾取/对话等)               │
 └──────────────────────────────────────────────────────────────────────────┘
                                     ↓
 ┌──────────────────────────────────────────────────────────────────────────┐
 │                          rules_engine.py (规则引擎)                       │
 │  战斗状态机 | 生存系数 | 环境修正 | 移动约束 | 地图迷雾 | 场景图生成          │
 └──────────────────────────────────────────────────────────────────────────┘
                                     ↓
 ┌──────────────────────────────────────────────────────────────────────────┐
 │                        state_manager.py (状态管理)                        │
 │              GameState 唯一数据源 | 状态校验 | 状态更新 | 事件日志          │
 └──────────────────────────────────────────────────────────────────────────┘
                                     ↓
 ┌──────────────────────────────────────────────────────────────────────────┐
 │                         story_engine.py (叙事引擎)                        │
 │              规则判定结果 → 大模型生成自然语言叙事 / 受控文案回退            │
 └──────────────────────────────────────────────────────────────────────────┘

三、核心模块实现

3.1 战斗状态机:规则层固定判定

这是本项目最关键的设计。所有战斗结果由规则层计算,大模型只生成战斗表现文案:

 # rules_engine.py
 def resolve_combat(self, game_state: "GameState", monster_name: str) -> dict[str, Any]:
     monster = self.config.monsters.get(monster_name)
     if monster is None:
         return {"outcome_tier": "invalid", "model_result_locked": True, ...}
 ​
     # 构建玩家战斗快照(含装备加成、生存状态影响)
     player_snapshot = self._build_player_combat_snapshot(game_state)
 ​
     # 计算战斗功率
     monster_power = self._combat_power(monster.attack, monster.defense, monster.stamina, 1.0)
     player_power = self._combat_power(
         player_snapshot["attack"],
         player_snapshot["defense"],
         player_snapshot["stamina"],
         player_snapshot["state_coefficient"],
     )
 ​
     ratio = player_power / monster_power if monster_power else 0.0
 ​
     # 依据功率比划分胜负等级
     if threshold_failed or effective_ratio < self.config.combat.impossible_ratio:
         outcome_tier = "impossible"   # 必败
         player_victory = False
     elif effective_ratio < self.config.combat.normal_ratio:
         outcome_tier = "pyrrhic"       # 惨胜
     elif effective_ratio < self.config.combat.crushing_ratio:
         outcome_tier = "normal"        # 普通胜利
     else:
         outcome_tier = "crushing"     # 压倒性胜利
 ​
     # 计算资源消耗(HP、体力、饱食度、士气、理智)
     resource_changes = self._combat_resource_changes(outcome_tier, closeness, armor_break_blocked)
 ​
     return {
         "outcome_tier": outcome_tier,
         "player_victory": player_victory,
         "model_result_locked": True,  # 关键标志:模型不可更改结果
         "resource_changes": resource_changes,
         "loot": list(monster.loot) if player_victory else [],
         "experience_reward": monster.experience_reward if player_victory else 0,
         ...
     }

战斗功率公式:

 def _combat_power(self, attack, defense, stamina, coefficient):
     return (
         (attack * self.config.combat.attack_weight) +
         (defense * self.config.combat.defense_weight) +
         (stamina * self.config.combat.stamina_weight)
     ) * coefficient

3.2 生存系统:梯度系数替代阈值判断

 def recalculate_survival_state(self, game_state: "GameState") -> dict[str, Any]:
     # 梯度系数:饱和区间增益,极低区间惩罚
     hunger_coeff = self._gradient_multiplier(game_state.player.hunger)
     sanity_coeff = self._gradient_multiplier(game_state.player.sanity)
     morale_coeff = self._gradient_multiplier(game_state.player.morale)
 ​
     combat_multiplier = (hunger_coeff + sanity_coeff + morale_coeff) / 3
 ​
     # 状态标志
     flags = {
         "peak_state": all(v > 80 for v in (hunger, sanity, morale)),
         "near_death": sum(v < 10 for v in (...)) >= 2,
         "heroic_state": any(v >= 80 for v in (...)),
         "extreme_weakness": any(v < 10 for v in (...)),
     }
 ​
     # 不同状态对应不同战斗加成/惩罚
     if flags["peak_state"]:
         combat_multiplier *= 1.15
         hit_rate_bonus += 6
         dodge_rate_bonus += 5
     if flags["near_death"]:
         combat_multiplier *= 0.5
         hit_rate_bonus -= 10

梯度系数计算(非线性映射,避免一刀切阈值):

 def _gradient_multiplier(self, value: int) -> float:
     if value >= 80:
         return 1.2 + ((value - 80) / 20) * 0.3  # 1.2 ~ 1.5
     if value >= 30:
         return 1.0  # 正常区间
     if value >= 10:
         return 0.7 + ((value - 10) / 20) * 0.2  # 0.7 ~ 0.9
     return 0.3 + (value / 10) * 0.3  # 0.3 ~ 0.6

3.3 地图移动约束:BFS 最短路径

 def resolve_movement(self, game_state, target_location, *, simulate_events=False):
     current = game_state.player.location
 ​
     # BFS 查找最短路径
     path = self._shortest_path(game_state, current, target_location)
     if not path:
         return {"allowed": False, "reason": f"无法从 {current} 前往 {target_location}"}
 ​
     # 路径必须相邻,不允许跳跃
     if len(path) > 1:
         return {
             "allowed": False,
             "reason": "移动必须经过相邻节点,不允许跳跃",
             "required_path": path[1:],
         }
 ​
     # 结算耗时、体力消耗、随机遭遇
     for scene_id in path[1:]:
         time_cost_units += node.travel_time_cost
         stamina_cost += node.travel_stamina_cost
         if simulate_events and node.encounter_chance > 0:
             if random.random() < node.encounter_chance:
                 encounter_triggered = True
                 break

3.4 物品白名单与拾取闭环

 def build_scene_loot(self, game_state, defeated_monster=None) -> list[str]:
     # 1. 优先检查场景专属掉落
     scene_specific = self.config.scene_specific_loot.get(game_state.player.location, ())
     for item_name in scene_specific:
         if item_name not in owned:
             return [item_name]
 ​
     # 2. 检查怪物掉落(击败怪物后)
     if defeated_monster and defeated_monster in self.config.monsters:
         candidates = list(self.config.monsters[defeated_monster].loot)
     else:
         # 3. 场景绑定物品(白名单)
         candidates = list(self.config.scene_item_bindings.get(binding_group, ()))
 ​
     return [item for item in candidates if item not in owned][:1]

拾取必须二次确认(防止自动入包破坏策略性):

 # 待拾取物品进入 pending_pickups
 # UI 提供选项:拾取 / 放弃 / 查看

3.5 环境影响系统

 def _combat_environment_modifiers(self, game_state):
     hit_rate_bonus = 0
     power_multiplier = 1.0
 ​
     # 黑暗环境
     if light_level in {"dark", "black"} and not self._has_light_mitigation(game_state):
         hit_rate_bonus -= 20 if light_level == "dark" else 30
         dodge_rate_bonus += 8 if light_level == "dark" else 15
         power_multiplier *= 0.92
 ​
     # 雨雪天气
     if weather in {"小雨", "暴风雨"} and not self._has_rain_mitigation(game_state):
         hit_rate_bonus -= 10
         power_multiplier *= 0.95
 ​
     # 火把可抵消黑暗,雨衣可抵消雨雪
     return {"hit_rate_bonus": hit_rate_bonus, "power_multiplier": power_multiplier}

3.6 AI 图片生成:本地优先 + SVG 回退

 def build_scene_image_payload(self, game_state) -> dict | None:
     # 1. 优先加载本地同名图片
     local_payload = self._build_local_image_payload("scene", scene_name)
     if local_payload is not None:
         return local_payload
 ​
     # 2. 白名单场景生成 SVG 回退图
     node = self.config.scene_nodes.get(scene_name)
     if node is None or node.scene_type not in self.config.scene_image_whitelist:
         return None  # 不在白名单则不展示图片
 ​
     svg = self._build_scene_svg(
         title=scene_name,
         subtitle=f"{time} | {weather} | {light_level}",
         scene_type=node.scene_type,
     )
     image_data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg).decode()
     return {"kind": "scene", "mode": "svg_fallback", "image_data_uri": image_data_uri}

白名单控制确保只有高频重要场景才生成图片,避免无意义调用。

3.7 UI 状态面板去重优化

 def build_scene_info(self, game_state) -> dict:
     summary_parts = []
 ​
     # 收集描述片段
     if location.description:
         summary_parts.append(location.description)
     if location.ambient_description:
         summary_parts.append(location.ambient_description)
     if npcs:
         summary_parts.append(f"可见人物:{'、'.join(npcs)}。")
     if pending_pickups:
         summary_parts.append(f"地面上留有待处理物品:{'、'.join(pending_pickups)}。")
 ​
     # 去重:移除与环境标签重复的内容
     summary = " ".join(summary_parts)
     for tag in (game_state.world.time_of_day, game_state.world.weather, game_state.player.location):
         summary = summary.replace(tag, "")
     summary = " ".join(summary.split())
 ​
     return {"tags": tags, "summary": summary.strip()}

四、运行链路

 玩家输入 → NLU 意图识别 → 预校验 → 规则层判定 → 状态更新 → 叙事生成/受控文案 → UI同步 → 日志记录

关键原则:

  1. GameState 是唯一状态源

  2. RulesEngine 是核心规则执行器

  3. StoryEngine 只在允许范围内调用模型生成文本

  4. app.py 只负责交互编排,不负责核心玩法判定


五、测试覆盖

完整的 pytest 测试套件覆盖所有规则:

 pytest tests/test_rules_config.py \
        tests/test_combat_rules.py \
        tests/test_survival_rules.py \
        tests/test_movement_rules.py \
        tests/test_item_rules.py \
        tests/test_environment_rules.py \
        tests/test_ui_scene_state.py -v

验证点示例

  • 低战力不能越级秒杀高防怪(不可能战斗)

  • 高战力可碾压基础怪(压倒性胜利)

  • 徒手对高防目标强制最低伤害且消耗翻倍

  • 饥饿半天扣血是否闭环执行

  • 生命值归零是否锁死游戏流程

  • 非相邻移动是否被拒绝

  • 黑暗环境扣理智和士气,火把是否抵消 debuff


六、Trade-offs 与局限性

决策

Trade-off

改进方向

规则层锁定

保证确定性,但限制了自由度

可添加"规则允许范围内"的模型自由度

梯度系数

比阈值更平滑,但计算复杂

可简化为分段线性函数

物品白名单

保证平衡性,但内容有限

可动态扩展掉落表

SVG 回退图

不依赖外部服务,但视觉效果简单

可接入 Stable Diffusion API

Qwen API fallback

无 API Key 时可运行,但叙事质量降级

可添加更多本地 fallback 模板


七、项目结构

 StoryWeaver/
 ├── app.py                    # Gradio UI、输入处理、状态渲染
 ├── nlu_engine.py            # 玩家文本 → 结构化意图
 ├── story_engine.py          # 叙事生成 + 受控文案
 ├── state_manager.py         # GameState 唯一数据源
 ├── rules_engine.py          # 规则执行器(战斗/生存/移动/环境)
 ├── rules_config.py          # 固化配置(怪物/地图/物品白名单)
 ├── utils.py                  # 通用工具(clamp 等)
 ├── requirements.txt
 ├── .env                      # QWEN_API_KEY(可选)
 ├── image/                    # 本地场景/NPC 图片
 ├── tests/                    # pytest 测试套件
 └── docs/superpowers/         # 设计说明文档

八、快速开始

 # 1. 安装依赖
 pip install -r requirements.txt
 ​
 # 2. 配置 API Key(可选)
 echo "QWEN_API_KEY=your_key" > .env
 ​
 # 3. 启动
 python app.py
 # 访问 http://127.0.0.1:7860
 ​
 # 4. 运行测试
 pytest tests/ -v

九、Further Reading

0

评论区