NewProject / state_manager.py
wzh0617's picture
Upload 7 files
4998893 verified
"""
state_manager.py - StoryWeaver 状态管理器
职责:
1. 定义游戏世界的完整数据模型(Pydantic BaseModel)
2. 维护全局状态的唯一真相来源 (Single Source of Truth)
3. 提供状态变更、校验、一致性检查、序列化等核心方法
4. 记录事件日志用于一致性维护
设计思路:
- 所有数据结构使用 Pydantic BaseModel,天然支持 JSON 序列化/反序列化
- GameState 是顶层容器,包含 PlayerState、WorldState、EventLog
- event_log 是一致性维护的灵魂:每次操作都记录快照,用于矛盾检测
- to_prompt() 方法将结构化数据转为自然语言,注入 LLM 的 System Prompt
"""
from __future__ import annotations
import copy
import logging
import random
import re
from typing import Any, Optional
from pydantic import BaseModel, Field
from demo_rules import OVERNIGHT_REST_LOCATIONS, action_time_cost_minutes, build_scene_actions
from utils import clamp
logger = logging.getLogger("StoryWeaver")
# ============================================================
# 辅助数据模型
# ============================================================
class StatusEffect(BaseModel):
"""
状态效果模型(Buff / Debuff)
设计思路:
- 每个状态效果有持续时间和属性修正,每回合自动结算
- source 记录来源,便于在 Prompt 中说明"你身上中了哥布林的毒"
- stackable 控制是否可叠加,防止无限叠加 bug
"""
name: str # 效果名(中毒、祝福、隐身…)
effect_type: str = "debuff" # buff / debuff / neutral
stat_modifiers: dict[str, int] = Field(default_factory=dict)
# 属性修正 {"attack": -3, "defense": +2}
duration: int = 3 # 剩余持续回合数(-1 = 永久)
description: str = "" # 效果描述
source: str = "" # 来源("哥布林的毒刃")
stackable: bool = False # 是否可叠加
class ItemInfo(BaseModel):
"""
物品详情模型
设计思路:
- item_type 区分装备/消耗品/任务道具等,不同类型有不同交互逻辑
- rarity 影响掉落概率和商店价格
- quest_related 标记任务道具,防止玩家丢弃关键物品
- lore_text 提供物品背景,丰富生成文本的细节
"""
name: str # 物品名称
item_type: str = "misc" # weapon / armor / consumable / quest_item / material / key / misc
description: str = "" # 物品描述
rarity: str = "common" # common / uncommon / rare / epic / legendary
stat_bonus: dict[str, int] = Field(default_factory=dict)
# 装备时属性加成 {"attack": +5}
usable: bool = False # 是否可主动使用
use_effect: str = "" # 使用效果描述(如"恢复 30 HP")
value: int = 0 # 商店价值(金币)
quest_related: bool = False # 是否为任务道具
lore_text: str = "" # 物品背景故事
class NPCState(BaseModel):
"""
NPC 状态模型
设计思路:
- npc_type 决定交互方式(商人可交易、任务NPC可接任务、敌人可战斗)
- memory 是一致性维护的关键:NPC"记住"与玩家的互动历史
- schedule 模拟 NPC 日常行为,不同时间段出现在不同地点
- relationship_level 影响对话态度和任务可用性
"""
name: str # NPC 名称
npc_type: str = "civilian" # civilian / merchant / quest_giver / enemy / companion / boss
location: str = "" # 所在地点
attitude: str = "neutral" # friendly / neutral / cautious / hostile
is_alive: bool = True # 是否存活
description: str = "" # 外观描述
race: str = "人类" # 种族
occupation: str = "" # 职业(铁匠、旅店老板、守卫…)
faction: str = "" # 所属阵营
# --- 交互相关 ---
relationship_level: int = 0 # 与玩家好感度(-100 ~ 100)
dialogue_tags: list[str] = Field(default_factory=list)
# 已触发的对话标签(防止重复触发)
can_trade: bool = False # 是否可交易
shop_inventory: list[str] = Field(default_factory=list)
# 商店物品(如果是商人)
can_give_quest: bool = False # 是否可发布任务
available_quests: list[str] = Field(default_factory=list)
# 可发布的任务 ID
# --- 战斗相关(敌人/Boss) ---
hp: int = 0
max_hp: int = 0
attack: int = 0
defense: int = 0
loot_table: list[str] = Field(default_factory=list)
# 击败后掉落物品
weakness: str = "" # 弱点(火、光…)
special_ability: str = "" # 特殊能力
# --- 记忆与行为 ---
memory: list[str] = Field(default_factory=list)
# NPC 记住的关键事件
schedule: dict[str, str] = Field(default_factory=dict)
# 时间行为表 {"清晨": "在市场摆摊"}
backstory: str = "" # 背景故事
class QuestRewards(BaseModel):
"""
任务奖励模型
设计思路:
- 奖励类型丰富,覆盖经济、声望、技能、称号等多维度
- 每种奖励都可选,通过组合实现多样化的奖励体验
"""
gold: int = 0 # 金币奖励
experience: int = 0 # 经验值奖励
items: list[str] = Field(default_factory=list)
# 奖励物品
reputation_changes: dict[str, int] = Field(default_factory=dict)
# 声望变化 {"精灵族": +10}
karma_change: int = 0 # 善恶值变化
unlock_location: str = "" # 解锁新地点
unlock_skill: str = "" # 解锁新技能
title: str = "" # 解锁称号
class QuestState(BaseModel):
"""
任务状态模型
设计思路:
- quest_type 区分主线/支线/隐藏任务,影响 UI 展示和优先级
- objectives 是任务子目标字典,每个子目标独立追踪
- branching_choices 支持任务内分支(如"放走囚犯"导向不同结局)
- time_limit / turns_remaining 支持限时任务机制
- prerequisites 保证任务链的逻辑顺序
"""
quest_id: str # 任务唯一 ID
title: str # 任务名称
description: str # 任务描述
quest_type: str = "main" # main / side / hidden / daily
status: str = "active" # active / completed / failed / expired
giver_npc: str = "" # 任务发布者 NPC
# --- 目标 ---
objectives: dict[str, bool] = Field(default_factory=dict)
# 子目标 {"找到钥匙": False, "打开宝箱": False}
# --- 奖励 ---
rewards: QuestRewards = Field(default_factory=QuestRewards)
# --- 约束 ---
time_limit: int = -1 # 限时回合数(-1 = 无限)
turns_remaining: int = -1 # 剩余回合数
prerequisites: list[str] = Field(default_factory=list)
# 前置任务 ID
level_requirement: int = 1 # 等级要求
karma_requirement: Optional[int] = None # 善恶值要求
# --- 分支 ---
branching_choices: dict[str, str] = Field(default_factory=dict)
# 关键选择 {"放走囚犯": "mercy_path"}
chosen_path: str = "" # 已选择的路线
consequences: list[str] = Field(default_factory=list)
# 完成后的剧情后果描述
class LocationInfo(BaseModel):
"""
地点详情模型
设计思路:
- connected_to 构成游戏地图的拓扑结构,控制玩家可移动范围
- danger_level 影响遭遇概率和 NPC 行为
- is_accessible + required_item 实现"锁门/钥匙"机制
- ambient_description 用于丰富 LLM 生成的场景描写
- special_events 支持地点触发式事件
"""
name: str # 地点名称
location_type: str = "town" # town / dungeon / wilderness / shop / special
description: str = "" # 地点描述
connected_to: list[str] = Field(default_factory=list)
# 可前往的相邻地点
npcs_present: list[str] = Field(default_factory=list)
# 当前在该地点的 NPC
available_items: list[str] = Field(default_factory=list)
# 可拾取/发现的物品
enemies: list[str] = Field(default_factory=list)
# 可能遭遇的敌人
danger_level: int = 0 # 危险等级 (0=安全, 10=极度危险)
weather: str = "晴朗" # 当前天气
is_discovered: bool = False # 是否已被玩家发现
is_accessible: bool = True # 是否可进入
required_item: str = "" # 进入所需道具
ambient_description: str = "" # 环境氛围描述
special_events: list[str] = Field(default_factory=list)
# 该地点可触发的特殊事件
rest_available: bool = False # 是否可以休息恢复
shop_available: bool = False # 是否有商店
# ============================================================
# 玩家状态
# ============================================================
class PlayerState(BaseModel):
"""
玩家角色状态(RPG 核心属性)
设计思路:
- 基础属性 + 战斗属性 + 装备栏 + 社交属性 构成完整的角色模型
- reputation / karma / relationships 影响 NPC 态度和剧情分支
- morale / sanity / hunger 增加生存维度,丰富游戏体验
- known_lore 记录玩家获得的情报,影响可用对话选项
- death_count 支持"轮回"类剧情彩蛋
"""
# --- 基础属性 ---
name: str = "旅人" # 玩家名称
title: str = "无名冒险者" # 称号(随剧情解锁)
level: int = 1 # 等级
experience: int = 0 # 当前经验值
exp_to_next_level: int = 100 # 升级所需经验
# --- 战斗属性 ---
hp: int = 100 # 当前生命值
max_hp: int = 100 # 最大生命值
mp: int = 50 # 魔力值
max_mp: int = 50 # 最大魔力值
attack: int = 10 # 攻击力
defense: int = 5 # 防御力
attack_power: int = 10 # 实战攻击力(基础攻击+装备加成)
defense_power: int = 5 # 实战防御力(基础防御+装备加成)
stamina: int = 100 # 体力值
max_stamina: int = 100 # 最大体力值
speed: int = 8 # 速度(影响行动顺序)
luck: int = 5 # 幸运(影响暴击、掉落)
perception: int = 5 # 感知(影响探索发现、陷阱识别)
# --- 装备栏 ---
equipment: dict[str, Optional[str]] = Field(default_factory=lambda: {
"weapon": None, # 武器
"armor": None, # 护甲
"accessory": None, # 饰品
"helmet": None, # 头盔
"boots": None, # 靴子
})
# --- 状态 ---
location: str = "村庄" # 当前所在地点
inventory: list[str] = Field(default_factory=list)
# 背包物品列表
skills: list[str] = Field(default_factory=list)
# 已习得技能列表
status_effects: list[StatusEffect] = Field(default_factory=list)
# 状态效果列表
gold: int = 50 # 金币
reputation: dict[str, int] = Field(default_factory=dict)
# 阵营声望 {"精灵族": 10}
morale: int = 100 # 士气(0=崩溃, 100=高昂)
sanity: int = 100 # 理智值(探索黑暗区域消耗)
hunger: int = 100 # 饱食度(0=饥饿惩罚)
karma: int = 0 # 善恶值(正=善, 负=恶)
known_lore: list[str] = Field(default_factory=list)
# 已知传说/情报片段
relationships: dict[str, int] = Field(default_factory=dict)
# 与特定 NPC 的好感度
death_count: int = 0 # 累计死亡次数
# ============================================================
# 世界状态
# ============================================================
class WorldState(BaseModel):
"""
世界状态容器
设计思路:
- 包含所有非玩家的世界数据:地图、NPC、任务、物品注册表
- time_of_day + day_count + weather + season 构成动态环境系统
- global_flags 是灵活的剧情标记系统,支持分支判断
- rumors / active_threats 丰富 NPC 对话内容
- faction_relations 支持阵营间动态关系
"""
current_scene: str = "村庄广场" # 当前场景名称
time_of_day: str = "清晨" # 清晨 / 上午 / 正午 / 下午 / 黄昏 / 夜晚 / 深夜
day_count: int = 1 # 当前天数
weather: str = "晴朗" # 晴朗 / 多云 / 小雨 / 暴风雨 / 大雪 / 浓雾
light_level: str = "明亮" # 明亮 / 柔和 / 昏暗 / 幽暗 / 漆黑
time_progress_units: int = 0 # 当前时段内累积的动作耗时点数
last_weather_change_minutes: int = -999999 # 上次天气变化时的累计分钟数
season: str = "春" # 春 / 夏 / 秋 / 冬
# --- 地图 ---
locations: dict[str, LocationInfo] = Field(default_factory=dict)
discovered_locations: list[str] = Field(default_factory=list)
# --- NPC ---
npcs: dict[str, NPCState] = Field(default_factory=dict)
# --- 任务 ---
quests: dict[str, QuestState] = Field(default_factory=dict)
# --- 物品注册表 ---
item_registry: dict[str, ItemInfo] = Field(default_factory=dict)
# --- 全局标记 ---
global_flags: dict[str, bool] = Field(default_factory=dict)
world_events: list[str] = Field(default_factory=list)
# 已发生的全局事件
recent_environment_events: list["EnvironmentEvent"] = Field(default_factory=list)
active_threats: list[str] = Field(default_factory=list)
# 当前全局威胁
rumors: list[str] = Field(default_factory=list)
# 流传的传闻
faction_relations: dict[str, dict[str, str]] = Field(default_factory=dict)
# 阵营间关系
# ============================================================
# 事件日志
# ============================================================
class GameEvent(BaseModel):
"""
事件日志模型(一致性维护的关键)
设计思路:
- 每次状态变更都记录为一个事件,包含完整的上下文信息
- state_changes 记录该事件引发的状态变更快照
- consequence_tags 用于后续一致性检查(如 "killed_goblin_king")
- is_reversible 标记不可逆事件,LLM 生成时需特别注意
- involved_npcs + location 便于按维度检索历史事件
"""
turn: int # 发生在第几回合
day: int = 1 # 发生在第几天
time_of_day: str = "" # 发生时的时段
event_type: str = "" # COMBAT / DIALOGUE / MOVE / ITEM / QUEST / TRADE / REST / DISCOVERY / DEATH / LEVEL_UP
description: str = "" # 事件简述
location: str = "" # 事件发生地点
involved_npcs: list[str] = Field(default_factory=list)
# 涉及的 NPC
state_changes: dict = Field(default_factory=dict)
# 状态变更快照
player_action: str = "" # 触发该事件的玩家操作
consequence_tags: list[str] = Field(default_factory=list)
# 后果标签
is_reversible: bool = True # 是否可逆
class EnvironmentEvent(BaseModel):
"""Structured environment event used by UI, logs, and prompt injection."""
event_id: str
category: str = "environment" # weather / light / environment
title: str = ""
description: str = ""
location: str = ""
time_of_day: str = ""
weather: str = ""
light_level: str = ""
severity: str = "low" # low / medium / high
state_changes: dict[str, Any] = Field(default_factory=dict)
prompt_hint: str = ""
WorldState.model_rebuild()
# ============================================================
# 游戏主控类
# ============================================================
class GameState:
"""
游戏全局状态管理器 —— 项目的灵魂
职责:
1. 持有并管理 PlayerState、WorldState、EventLog
2. 提供状态变更、校验、一致性检查的统一入口
3. 将结构化状态序列化为自然语言 Prompt
4. 每回合自动结算状态效果、时间推进、任务超时等
核心设计原则:
- 所有状态修改必须通过 apply_changes() 进入
- 每次修改都伴随 validate() 校验和 log_event() 记录
- check_consistency() 在生成前检测可能的矛盾
"""
def __init__(self, player_name: str = "旅人"):
"""初始化游戏状态,创建默认的起始世界"""
self.player = PlayerState(name=player_name)
self.world = WorldState()
self.event_log: list[GameEvent] = []
self.turn: int = 0
self.game_mode: str = "exploration" # exploration / combat / dialogue / cutscene / game_over
self.difficulty: str = "normal" # easy / normal / hard
self.story_arc: str = "序章" # 当前故事章节
self.ending_flags: dict[str, bool] = {} # 结局条件追踪
self.combat_log: list[str] = [] # 最近战斗记录
self.achievement_list: list[str] = [] # 已解锁成就
self.elapsed_minutes_total: int = 0
self.last_recent_gain: str | None = None
self.last_interacted_npc: str | None = None
# 初始化起始世界
self._init_starting_world()
self.refresh_combat_stats()
# 純文本地图渲染使用的“当前位置 + 足迹历史”
# current_location 必须始终与 self.player.location 保持一致。
self.current_location: str = str(self.player.location)
self.location_history: list[str] = []
self.world.time_progress_units = 36
self.pending_environment_event: EnvironmentEvent | None = None
self._sync_world_clock()
self.world.light_level = self._determine_light_level()
def _init_starting_world(self):
"""
创建游戏的起始世界设定。
包含初始地点、NPC、任务和物品,为故事提供起点。
"""
# --- 初始地点 ---
self.world.locations = {
"村庄广场": LocationInfo(
name="村庄广场",
location_type="town",
description="一个宁静的小村庄中心广场,阳光温暖地照耀着鹅卵石路面。周围有几家商铺和一口古老的水井。",
connected_to=["村庄铁匠铺", "村庄旅店", "村口小路", "村庄杂货铺"],
npcs_present=["村长老伯"],
danger_level=0,
is_discovered=True,
rest_available=False,
ambient_description="阳光斑驳地洒在广场上,远处传来铁匠铺叮叮当当的锤声。",
),
"村庄铁匠铺": LocationInfo(
name="村庄铁匠铺",
location_type="shop",
description="一间热气腾腾的铁匠铺,炉火正旺。墙上挂满了各式武器和护甲。",
connected_to=["村庄广场"],
npcs_present=["铁匠格林"],
danger_level=0,
is_discovered=True,
shop_available=True,
ambient_description="炉火映红了铁匠粗犷的脸庞,空气中弥漫着金属和碳的气味。",
),
"村庄旅店": LocationInfo(
name="村庄旅店",
location_type="shop",
description="一家温馨的小旅店,空气中弥漫着烤肉和麦酒的香气。壁炉里的火焰跳动着。",
connected_to=["村庄广场"],
npcs_present=["旅店老板娘莉娜"],
danger_level=0,
is_discovered=True,
rest_available=True,
shop_available=True,
ambient_description="壁炉噼啪作响,几位旅客正低声交谈,空气温暖而舒适。",
),
"村庄杂货铺": LocationInfo(
name="村庄杂货铺",
location_type="shop",
description="一家琳琅满目的杂货铺,从草药到绳索应有尽有。老板是个精明的商人。",
connected_to=["村庄广场"],
npcs_present=["杂货商人阿尔"],
danger_level=0,
is_discovered=True,
shop_available=True,
ambient_description="货架上摆满了稀奇古怪的商品,柜台后的商人正用算盘噼里啪啦地算账。",
),
"村口小路": LocationInfo(
name="村口小路",
location_type="wilderness",
description="通往村外的一条泥泞小路,两旁长满了野草。远处隐约可见黑暗森林的轮廓。",
connected_to=["村庄广场", "黑暗森林入口"],
danger_level=2,
is_discovered=True,
ambient_description="微风拂过野草,远处的森林在薄雾中若隐若现,传来不知名鸟兽的叫声。",
),
"黑暗森林入口": LocationInfo(
name="黑暗森林入口",
location_type="wilderness",
description="森林的入口处,参天大树遮蔽了阳光,地面覆盖着厚厚的落叶。一股不祥的气息扑面而来。",
connected_to=["村口小路", "森林深处", "溪边营地"],
enemies=["哥布林", "野狼"],
danger_level=4,
is_discovered=False,
ambient_description="树冠密集得几乎遮蔽了所有阳光,偶尔传来树枝折断的声音,不知道是风还是别的什么。",
),
"森林深处": LocationInfo(
name="森林深处",
location_type="dungeon",
description="森林的最深处,古树盘根错节。空气中弥漫着腐朽和魔力的气息,据说这里住着森林的主人。",
connected_to=["黑暗森林入口"],
enemies=["哥布林巫师", "巨型蜘蛛", "森林巨魔"],
danger_level=7,
is_discovered=False,
is_accessible=True,
ambient_description="黑暗几乎吞噬了一切,只有奇异的荧光苔藓发出微弱的光。远处传来低沉的咆哮。",
),
"溪边营地": LocationInfo(
name="溪边营地",
location_type="wilderness",
description="森林中一处难得的开阔地带,一条清澈的小溪从旁流过。适合扎营休息。",
connected_to=["黑暗森林入口"],
danger_level=2,
is_discovered=False,
rest_available=True,
ambient_description="溪水潺潺流淌,偶有鸟鸣声在林间回荡,这里是森林中的一片安宁绿洲。",
),
# -------- 扩展地点 --------
"河边渡口": LocationInfo(
name="河边渡口",
location_type="wilderness",
description="一座破旧的木制渡口,宽阔的河流在此缓缓流淌。一艘半沉的渡船拴在码头桩上。",
connected_to=["村口小路", "废弃矿洞入口", "山麓盗贼营"],
npcs_present=["渡口老渔夫"],
danger_level=3,
is_discovered=False,
ambient_description="河水拍打着朽烂的木桩,远处有鹰在盘旋,对岸隐约可见矿洞的轮廓。",
),
"废弃矿洞入口": LocationInfo(
name="废弃矿洞入口",
location_type="dungeon",
description="荒废多年的铁矿洞,入口被蛛网和碎石半堵。矿道里传来金属碰撞的回声。",
connected_to=["河边渡口", "矿洞深层"],
enemies=["骷髅兵", "矿洞蝙蝠群", "锈铁傀儡"],
danger_level=5,
is_discovered=False,
ambient_description="腐朽的矿车轨道延伸向黑暗深处,空气里弥漫着铁锈和硫磺的气味。",
),
"矿洞深层": LocationInfo(
name="矿洞深层",
location_type="dungeon",
description="矿洞最深处,一个巨大的地下空间。墙壁上嵌着发光的矿石,中央有一座被遗忘的祭坛。",
connected_to=["废弃矿洞入口"],
enemies=["亡灵矿工", "岩石巨像"],
danger_level=8,
is_discovered=False,
is_accessible=False,
required_item="矿工旧钥匙",
ambient_description="发光矿石将洞穴映成幽蓝色,祭坛上刻着无人能读的文字,隐约有低沉的嗡鸣。",
),
"山麓盗贼营": LocationInfo(
name="山麓盗贼营",
location_type="wilderness",
description="藏在山脚灌木丛后的盗贼据点,几顶破帐篷围着一堆余烬。看起来已被匆忙弃置。",
connected_to=["河边渡口", "精灵遗迹"],
enemies=["盗贼斥候", "盗贼头目"],
danger_level=5,
is_discovered=False,
ambient_description="地上散落着翻倒的酒桶和吃了一半的干粮,有人走得很匆忙。",
),
"精灵遗迹": LocationInfo(
name="精灵遗迹",
location_type="special",
description="一片被藤蔓覆盖的古老石柱林,精灵文字在月光下隐约发光。空气中有淡淡的魔力涌动。",
connected_to=["山麓盗贼营"],
npcs_present=["遗迹守护者"],
danger_level=4,
is_discovered=False,
ambient_description="石柱上的符文随风明灭,仿佛在回应某种古老的感召。脚下的青苔异常柔软。",
),
"古塔废墟": LocationInfo(
name="古塔废墟",
location_type="dungeon",
description="一座半坍塌的石塔,据说曾是某位法师的研究所。顶层似乎还有东西在闪烁。",
connected_to=["村口小路"],
enemies=["石像鬼", "游荡幽灵"],
danger_level=6,
is_discovered=False,
ambient_description="风从塔身的裂缝中呼啸而过,残破的阶梯上覆满了青苔和鸟粪。",
),
}
self.world.discovered_locations = ["村庄广场", "村庄铁匠铺", "村庄旅店", "村庄杂货铺", "村口小路"]
# 扩展村口小路的连接 —— 链接到新区域
self.world.locations["村口小路"].connected_to = [
"村庄广场", "黑暗森林入口", "河边渡口", "古塔废墟",
]
# --- 初始 NPC ---
self.world.npcs = {
"村长老伯": NPCState(
name="村长老伯",
npc_type="quest_giver",
location="村庄广场",
attitude="friendly",
description="一位白发苍苍但精神矍铄的老人,是这个村庄的领导者。他的眼中带着忧虑。",
race="人类",
occupation="村长",
relationship_level=20,
can_give_quest=True,
available_quests=["main_quest_01"],
memory=[],
schedule={"清晨": "村庄广场", "上午": "村庄广场", "正午": "村庄广场", "下午": "村庄广场", "黄昏": "村庄广场", "夜晚": "村庄旅店"},
backstory="在这个村庄生活了七十年的老者,见证过上一次暗潮来袭,深知森林中潜伏的危险。",
),
"铁匠格林": NPCState(
name="铁匠格林",
npc_type="merchant",
location="村庄铁匠铺",
attitude="neutral",
description="一个肌肉发达的中年矮人,手臂上布满烧伤痕迹。沉默寡言但手艺精湛。",
race="矮人",
occupation="铁匠",
can_trade=True,
shop_inventory=["小刀", "短剑", "铁剑", "皮甲", "木盾"],
relationship_level=0,
schedule={"清晨": "村庄铁匠铺", "上午": "村庄铁匠铺", "正午": "村庄铁匠铺", "下午": "村庄铁匠铺", "黄昏": "村庄铁匠铺", "夜晚": "村庄旅店"},
backstory="曾经是王都的御用铁匠,因一场变故隐居此地。对远方的怪物有独到的了解。",
),
"旅店老板娘莉娜": NPCState(
name="旅店老板娘莉娜",
npc_type="merchant",
location="村庄旅店",
attitude="friendly",
description="一位热情开朗的红发女子,笑容温暖。她的旅店是村里情报的集散地。",
race="人类",
occupation="旅店老板",
can_trade=True,
shop_inventory=["面包", "烤肉", "麦酒", "草药包"],
relationship_level=10,
schedule={"清晨": "村庄旅店", "上午": "村庄旅店", "正午": "村庄旅店", "下午": "村庄旅店", "黄昏": "村庄旅店", "夜晚": "村庄旅店"},
backstory="年轻时曾是一名冒险者,后来受伤退役经营旅店。对旅行者总是格外关照。",
),
"杂货商人阿尔": NPCState(
name="杂货商人阿尔",
npc_type="merchant",
location="村庄杂货铺",
attitude="neutral",
description="一个精明的瘦长男子,鹰钩鼻上架着一副圆框眼镜。善于讨价还价。",
race="人类",
occupation="商人",
can_trade=True,
shop_inventory=["火把", "绳索", "解毒药水", "小型治疗药水"],
relationship_level=-5,
schedule={"清晨": "村庄杂货铺", "上午": "村庄杂货铺", "正午": "村庄广场", "下午": "村庄杂货铺", "黄昏": "村庄杂货铺", "夜晚": "村庄杂货铺"},
backstory="来自远方的行商,在村中定居多年。对各地的传闻消息灵通,但消息总是要收费的。",
),
"神秘旅人": NPCState(
name="神秘旅人",
npc_type="quest_giver",
location="村庄旅店",
attitude="cautious",
description="一个身披灰色斗篷的旅人,面容隐藏在兜帽之下,只露出锐利的双眼。",
race="未知",
occupation="旅人",
relationship_level=-10,
can_give_quest=True,
available_quests=["side_quest_01"],
memory=[],
schedule={"清晨": "村庄旅店", "夜晚": "村口小路"},
backstory="似乎在寻找什么。偶尔从斗篷下露出的手指上有奇异的魔法纹路。",
),
# -------- 扩展 NPC --------
"渡口老渔夫": NPCState(
name="渡口老渔夫",
npc_type="quest_giver",
location="河边渡口",
attitude="friendly",
description="一个皮肤黝黑、满脸皱纹的老人,正坐在码头上修补渔网。",
race="人类",
occupation="渔夫",
relationship_level=5,
can_give_quest=True,
available_quests=["side_quest_02"],
memory=[],
schedule={"清晨": "河边渡口", "上午": "河边渡口", "正午": "河边渡口", "下午": "河边渡口", "黄昏": "河边渡口", "夜晚": "村庄旅店"},
backstory="在这条河边住了四十年,对河流两岸的地形了如指掌。最近总念叨对岸矿洞里的怪响。",
),
"遗迹守护者": NPCState(
name="遗迹守护者",
npc_type="quest_giver",
location="精灵遗迹",
attitude="cautious",
description="一个身形消瘦的半精灵,穿着褪色的绿袍,眼神中有深深的疲惫。",
race="半精灵",
occupation="守护者",
relationship_level=-5,
can_give_quest=True,
available_quests=["side_quest_03"],
memory=[],
schedule={"清晨": "精灵遗迹", "正午": "精灵遗迹", "夜晚": "精灵遗迹"},
backstory="最后一位遗迹守护者,独自守护这片先祖的圣地已有三十年。对外来者充满警惕,但内心渴望帮助。",
),
}
# --- 初始任务 ---
self.world.quests = {
"main_quest_01": QuestState(
quest_id="main_quest_01",
title="森林中的阴影",
description="村长老伯告诉你,最近森林中频繁出现怪物袭击事件。他请求你前往调查。",
quest_type="main",
status="active",
giver_npc="村长老伯",
objectives={
"与村长对话了解情况": False,
"前往黑暗森林入口调查": False,
"击败森林中的怪物": False,
"调查怪物活动的原因": False,
"与村长老伯对话汇报发现": False,
},
rewards=QuestRewards(
gold=100,
experience=50,
items=["森林之钥"],
reputation_changes={"村庄": 20},
karma_change=5,
),
),
"main_quest_02": QuestState(
quest_id="main_quest_02",
title="森林深处的咆哮",
description="村长老伯确认森林巨魔已经苏醒,并命你持森林之钥深入黑暗森林,将这头怪物彻底斩杀。",
quest_type="main",
status="inactive",
giver_npc="村长老伯",
objectives={
"前往森林深处": False,
"击败森林巨魔": False,
},
rewards=QuestRewards(
gold=0,
experience=90,
reputation_changes={"村庄": 30},
karma_change=10,
),
prerequisites=["main_quest_01"],
),
"side_quest_01": QuestState(
quest_id="side_quest_01",
title="失落的传承",
description="神秘旅人似乎在寻找一件古老的遗物。也许帮助他能得到意想不到的回报。",
quest_type="side",
status="inactive",
giver_npc="神秘旅人",
objectives={
"与神秘旅人交谈": False,
"找到古老遗物的线索": False,
},
rewards=QuestRewards(
experience=30,
items=["神秘卷轴"],
unlock_skill="暗影感知",
),
prerequisites=[],
),
# -------- 扩展任务 --------
"side_quest_02": QuestState(
quest_id="side_quest_02",
title="河底的秘密",
description="渡口老渔夫说他最近总在河里捞到奇怪的骨头,而且对岸矿洞方向夜里总是有光。他请你去查明真相。",
quest_type="side",
status="inactive",
giver_npc="渡口老渔夫",
objectives={
"与渡口老渔夫交谈": False,
"前往废弃矿洞调查": False,
"找到矿洞异常的原因": False,
},
rewards=QuestRewards(
gold=60,
experience=40,
items=["矿工旧钥匙"],
reputation_changes={"村庄": 10},
),
prerequisites=[],
),
"side_quest_03": QuestState(
quest_id="side_quest_03",
title="守护者的试炼",
description="精灵遗迹的守护者提出一个交换条件:通过她设下的试炼,就能获得古老的精灵祝福。",
quest_type="side",
status="inactive",
giver_npc="遗迹守护者",
objectives={
"与遗迹守护者交谈": False,
"通过守护者的试炼": False,
},
rewards=QuestRewards(
experience=50,
unlock_skill="精灵祝福",
karma_change=10,
title="遗迹认可者",
),
prerequisites=[],
),
}
# --- 初始物品注册表 ---
self.world.item_registry = {
"小刀": ItemInfo(name="小刀", item_type="weapon", description="一把朴素但实用的小刀,便于近身防身。", rarity="common", stat_bonus={"attack": 2}, value=5),
"短剑": ItemInfo(name="短剑", item_type="weapon", description="一把适合新手携带的短剑,轻便易用。", rarity="common", stat_bonus={"attack": 3}, value=10),
"铁剑": ItemInfo(name="铁剑", item_type="weapon", description="一把标准的铁制长剑,刀锋锐利。", rarity="common", stat_bonus={"attack": 5}, value=30),
"皮甲": ItemInfo(name="皮甲", item_type="armor", description="硬化皮革制成的轻甲,兼顾防护与灵活性。", rarity="common", stat_bonus={"defense": 3}, value=25),
"木盾": ItemInfo(name="木盾", item_type="armor", description="坚硬橡木制成的盾牌,可以抵挡基础攻击。", rarity="common", stat_bonus={"defense": 2}, value=15),
"面包": ItemInfo(name="面包", item_type="consumable", description="新鲜烤制的面包,香气扑鼻。", usable=True, use_effect="恢复 10 饱食度", value=5),
"烤肉": ItemInfo(name="烤肉", item_type="consumable", description="多汁的烤肉,令人食指大动。", usable=True, use_effect="恢复 25 饱食度", value=10),
"麦酒": ItemInfo(name="麦酒", item_type="consumable", description="村庄特产的麦酒,味道醇厚。", usable=True, use_effect="恢复 10 士气,降低 5 理智", value=8),
"草药包": ItemInfo(name="草药包", item_type="consumable", description="采集的新鲜草药,可以制作简单药剂。", usable=True, use_effect="恢复 20 HP", value=15),
"火把": ItemInfo(name="火把", item_type="misc", description="浸过油脂的火把,可在黑暗中照明。", usable=True, use_effect="照亮周围区域", value=3),
"绳索": ItemInfo(name="绳索", item_type="misc", description="结实的麻绳,在探险中很实用。", value=5),
"解毒药水": ItemInfo(name="解毒药水", item_type="consumable", description="散发着清苦气味的药水,可以解除中毒状态。", usable=True, use_effect="解除中毒状态", value=20),
"小型治疗药水": ItemInfo(name="小型治疗药水", item_type="consumable", description="泛着淡红色光芒的药水。", usable=True, use_effect="恢复 30 HP", value=25),
"村庄地图": ItemInfo(name="村庄地图", item_type="quest_item", description="一张画着村庄与周边道路的实用地图,边角处有村长留下的简短记号。", quest_related=True, value=0, lore_text="这张地图把村庄广场、店铺和村口小路都标得很清楚,显然是给初次上路的人准备的。"),
"黑暗森林地图": ItemInfo(name="黑暗森林地图", item_type="quest_item", description="一张补全了森林入口、溪边营地和深处路径的地图。", quest_related=True, value=0, lore_text="地图边缘沾着泥水和血迹,像是刚从危险地带抢出来的。"),
"山麓地图": ItemInfo(name="山麓地图", item_type="quest_item", description="记着渡口、盗贼营和遗迹路径的山麓地图。", quest_related=True, value=0, lore_text="粗糙的炭笔线条标出了山道、渡口和盗贼常走的隐蔽小径。"),
"古塔地图": ItemInfo(name="古塔地图", item_type="quest_item", description="一张标记古塔废墟出入口和危险区域的旧图。", quest_related=True, value=0, lore_text="纸面上反复描重的几处塔层,似乎都是前人特意警告的危险位置。"),
"森林之钥": ItemInfo(name="森林之钥", item_type="key", description="一把散发着微弱绿光的古老钥匙,似乎能打开森林深处的某个入口。", rarity="rare", quest_related=True, value=0, lore_text="钥匙上刻着精灵文字,翻译过来是:'唯有勇者可通行'。"),
"神秘卷轴": ItemInfo(name="神秘卷轴", item_type="quest_item", description="记载着古老知识的卷轴,散发着微弱的魔力波动。", rarity="rare", quest_related=True, value=0),
# -------- 扩展物品 --------
"矿工旧钥匙": ItemInfo(name="矿工旧钥匙", item_type="key", description="一把生锈的铜钥匙,上面刻着一个矿镐图案。", rarity="uncommon", quest_related=True, value=0, lore_text="钥匙柄上隐约可见'B-7采掘区'的刻字。"),
"骷髅碎骨": ItemInfo(name="骷髅碎骨", item_type="material", description="从骷髅兵身上掉落的骨头碎片,泛着不自然的寒光。", rarity="common", value=5),
"盗贼日志": ItemInfo(name="盗贼日志", item_type="quest_item", description="一本沾满泥渍的笔记本,记录着盗贼团伙近期的行动计划。", rarity="uncommon", quest_related=True, value=0),
"精灵护符": ItemInfo(name="精灵护符", item_type="accessory", description="由精灵遗迹守护者亲手制作的小型护符,散发着柔和的绿色微光。", rarity="rare", stat_bonus={"perception": 3, "sanity": 5}, value=50, lore_text="佩戴者能感受到来自远古精灵的庇佑。"),
"锈蚀铁锤": ItemInfo(name="锈蚀铁锤", item_type="weapon", description="矿洞里发现的旧铁锤,虽然锈迹斑斑但依然沉重有力。", rarity="common", stat_bonus={"attack": 4}, value=15),
"荧光苔藓": ItemInfo(name="荧光苔藓", item_type="consumable", description="矿洞深处生长的发光苔藓,据说有微弱的疗伤效果。", usable=True, use_effect="恢复 15 HP,恢复 5 理智", rarity="uncommon", value=12),
"古塔法师笔记": ItemInfo(name="古塔法师笔记", item_type="quest_item", description="在古塔废墟中找到的残破笔记,记载着某种仪式的片段。", rarity="rare", quest_related=True, value=0, lore_text="字迹已经模糊,但仍能辨认出几个关键的魔法符号。"),
}
# --- 初始传闻 ---
self.world.rumors = [
"最近森林里的哥布林越来越嚣张了,好几个猎人都不敢进去了。",
"听说铁匠格林以前在王都待过,不知道为什么来了这个小村子。",
"旅店里来了个奇怪的旅人,整天把自己裹得严严实实的。",
"河对岸的旧矿洞晚上闹鬼,渡口的老渔夫说他亲眼看见过蓝色的火光。",
"山脚下好像有一伙盗贼扎了营,最近有商队被劫的消息。",
"村子东边的古塔里据说住过一个法师,后来不知为何法师消失了,塔也荒废了。",
"有人在精灵遗迹附近见到过一个穿绿袍的身影,不知是人是鬼。",
]
# --- 显式环境事件模板 ---
self.environment_event_pool: list[dict[str, Any]] = [
{
"event_id": "lanterns_dim",
"category": "light",
"title": "灯火忽暗",
"description": "屋内的灯火突然暗了一截,墙角的影子被拉得细长。",
"location_types": ["town", "shop"],
"time_slots": ["黄昏", "夜晚", "深夜"],
"severity": "medium",
"state_changes": {"sanity_change": -2},
"prompt_hint": "环境光线明显变暗,叙事里要体现角色对阴影和氛围变化的反应。",
},
{
"event_id": "cold_gust",
"category": "environment",
"title": "冷风穿林",
"description": "一阵带着湿意的冷风从林间掠过,让人本能地绷紧肩背。",
"location_types": ["wilderness", "dungeon"],
"time_slots": ["下午", "黄昏", "夜晚", "深夜"],
"severity": "low",
"state_changes": {"morale_change": -3},
"prompt_hint": "风声和体感温度都发生了变化,适合让玩家察觉环境压力正在上升。",
},
{
"event_id": "forest_rustle",
"category": "environment",
"title": "林影骚动",
"description": "黑暗树影间传来急促的窸窣声,仿佛刚有什么东西贴着边缘掠过。",
"location_types": ["wilderness", "dungeon"],
"time_slots": ["黄昏", "夜晚", "深夜"],
"min_danger": 3,
"severity": "medium",
"state_changes": {"sanity_change": -3},
"prompt_hint": "这是偏悬疑的环境扰动,至少一个后续选项应允许玩家追查或回避。",
},
{
"event_id": "fireplace_relief",
"category": "environment",
"title": "火光回暖",
"description": "炉火和热气驱散了紧绷感,让呼吸也慢慢平稳下来。",
"location_types": ["shop", "town"],
"requires_rest_available": True,
"time_slots": ["黄昏", "夜晚", "深夜"],
"severity": "low",
"state_changes": {"morale_change": 4, "sanity_change": 2},
"prompt_hint": "这是偏正向的氛围事件,叙事里可以体现安全感和短暂放松。",
},
{
"event_id": "fog_pressures_in",
"category": "environment",
"title": "雾气压近",
"description": "潮湿的雾从地面漫上来,视野被一点点吞没,声音也变得含混。",
"location_types": ["wilderness", "dungeon"],
"time_slots": ["清晨", "黄昏", "夜晚", "深夜"],
"weathers": ["浓雾", "小雨"],
"severity": "medium",
"state_changes": {"sanity_change": -2},
"prompt_hint": "视野受限且不安感上升,叙事里应弱化远景、强调近距离感官细节。",
},
]
# --- 玩家初始装备 ---
self.player.inventory = ["面包", "面包", "小型治疗药水"]
self.player.location = self.world.current_scene
# ============================================================
# 核心方法
# ============================================================
def update_location(self, new_location: str) -> None:
"""
更新当前位置并维护足迹历史。
规则:
- 当 new_location 与当前地点不同:把旧地点写入 location_history,然后更新 current_location
- 当 new_location 相同:不追加历史
- 同步更新 self.player.location 与 self.world.current_scene,确保一致性
"""
target = str(new_location or "").strip()
if not target:
return
if target == self.current_location:
return
old_location = self.current_location
if old_location:
self.location_history.append(old_location)
# 让游戏状态和地图状态始终一致
self.current_location = target
self.player.location = target
self.world.current_scene = target
# ============================================================
# 状态变更应用
# ============================================================
def apply_changes(self, changes: dict) -> list[str]:
"""
接收 Qwen 返回的状态变更 JSON,校验并应用到当前状态。
设计思路:
- LLM 返回的变更是增量式的(如 hp_change: -10),而非绝对值
- 逐字段解析和应用,确保每个变更都经过校验
- 返回变更日志列表,方便 UI 展示
Args:
changes: Qwen 输出中解析出的状态变更字典
Returns:
变更描述列表 ["HP: 100 → 90", "位置: 村庄 → 森林"]
"""
change_log: list[str] = []
# --- 过滤 None 值:LLM 可能将 null 字段返回为 None,全部跳过 ---
_filtered = {}
for k, v in changes.items():
if v is None:
continue
# 字符串 "None" / "null" 也视为空
if isinstance(v, str) and v.strip().lower() in ("none", "null", ""):
continue
# 数值 0 的 change 字段无意义,也跳过
if isinstance(v, (int, float)) and v == 0 and k.endswith("_change"):
continue
# 空列表 / 空字典跳过
if isinstance(v, (list, dict)) and len(v) == 0:
continue
_filtered[k] = v
changes = _filtered
# --- 玩家属性变更 ---
if "hp_change" in changes:
old_hp = self.player.hp
self.player.hp = clamp(
self.player.hp + int(changes["hp_change"]),
0,
self.player.max_hp,
)
if self.player.hp != old_hp:
change_log.append(f"HP: {old_hp}{self.player.hp}")
if "mp_change" in changes:
old_mp = self.player.mp
self.player.mp = clamp(
self.player.mp + int(changes["mp_change"]),
0,
self.player.max_mp,
)
if self.player.mp != old_mp:
change_log.append(f"MP: {old_mp}{self.player.mp}")
if "gold_change" in changes:
old_gold = self.player.gold
self.player.gold = max(0, self.player.gold + int(changes["gold_change"]))
if self.player.gold != old_gold:
change_log.append(f"金币: {old_gold}{self.player.gold}")
if "exp_change" in changes:
old_exp = self.player.experience
self.player.experience += int(changes["exp_change"])
change_log.append(f"经验: {old_exp}{self.player.experience}")
# 检查是否升级
while self.player.experience >= self.player.exp_to_next_level:
self._level_up()
change_log.append(f"升级!当前等级: {self.player.level}")
if "morale_change" in changes:
old_morale = self.player.morale
self.player.morale = clamp(
self.player.morale + int(changes["morale_change"]),
0, 100,
)
if self.player.morale != old_morale:
change_log.append(f"士气: {old_morale}{self.player.morale}")
if "sanity_change" in changes:
old_sanity = self.player.sanity
self.player.sanity = clamp(
self.player.sanity + int(changes["sanity_change"]),
0, 100,
)
if self.player.sanity != old_sanity:
change_log.append(f"理智: {old_sanity}{self.player.sanity}")
if "hunger_change" in changes:
old_hunger = self.player.hunger
self.player.hunger = clamp(
self.player.hunger + int(changes["hunger_change"]),
0, 100,
)
if self.player.hunger != old_hunger:
change_log.append(f"饱食度: {old_hunger}{self.player.hunger}")
if "stamina_change" in changes:
old_stamina = self.player.stamina
self.player.stamina = clamp(
self.player.stamina + int(changes["stamina_change"]),
0,
self.player.max_stamina,
)
if self.player.stamina != old_stamina:
change_log.append(f"体力: {old_stamina}{self.player.stamina}")
if "karma_change" in changes:
old_karma = self.player.karma
self.player.karma += int(changes["karma_change"])
if self.player.karma != old_karma:
change_log.append(f"善恶值: {old_karma}{self.player.karma}")
# --- 位置变更 ---
if "new_location" in changes:
old_loc = self.player.location
new_loc = str(changes["new_location"])
if new_loc.strip().lower() not in ("", "none", "null") and new_loc != old_loc:
current_loc = self.world.locations.get(old_loc)
target_loc = self.world.locations.get(new_loc)
if target_loc is None:
change_log.append(f"忽略非法位置变更: {new_loc}")
elif current_loc and new_loc not in current_loc.connected_to:
change_log.append(f"忽略非法位置变更: {old_loc}{new_loc}")
elif (
not target_loc.is_accessible
and target_loc.required_item
and target_loc.required_item not in self.player.inventory
):
change_log.append(f"忽略未解锁地点: {new_loc}")
else:
self.update_location(new_loc)
change_log.append(f"位置: {old_loc}{new_loc}")
# 发现新地点
if new_loc not in self.world.discovered_locations:
self.world.discovered_locations.append(new_loc)
change_log.append(f"发现新地点: {new_loc}")
if new_loc in self.world.locations:
self.world.locations[new_loc].is_discovered = True
# --- 物品变更 ---
# 货币关键词列表:这些物品不进背包,而是直接转换为金币
_CURRENCY_KEYWORDS = ["铜币", "银币", "铜钱", "银两", "金币", "货币", "钱袋", "钱币", "硬币"]
if "items_gained" in changes:
for item in changes["items_gained"]:
item_str = str(item)
# 检查是否为货币类物品 —— 如果是,跳过入背包(金币已通过 gold_change 处理)
is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS)
if is_currency:
# 如果 gold_change 没有设置,尝试自动补偿少量金币
if "gold_change" not in changes:
old_gold = self.player.gold
self.player.gold += 3 # 默认少量金币
change_log.append(f"金币: {old_gold}{self.player.gold}")
logger.info(f"货币物品 '{item_str}' 已转换为金币,不放入背包")
continue
self.player.inventory.append(item_str)
self.last_recent_gain = item_str
change_log.append(f"获得物品: {item}")
if "items_lost" in changes:
for item in changes["items_lost"]:
item_str = str(item)
# 货币类物品也不需要从背包移除
is_currency = any(kw in item_str for kw in _CURRENCY_KEYWORDS)
if is_currency:
continue
if item_str in self.player.inventory:
self.player.inventory.remove(item_str)
change_log.append(f"失去物品: {item}")
# --- 技能变更 ---
if "skills_gained" in changes:
for skill in changes["skills_gained"]:
skill_str = str(skill)
if skill_str not in self.player.skills:
self.player.skills.append(skill_str)
change_log.append(f"习得技能: {skill}")
# --- 状态效果 ---
if "status_effects_added" in changes:
for effect_data in changes["status_effects_added"]:
if isinstance(effect_data, dict):
effect = StatusEffect(**effect_data)
self.player.status_effects.append(effect)
# 构建详细的状态效果日志
parts = [f"获得状态: {effect.name}"]
if effect.description:
parts.append(f"({effect.description})")
if effect.stat_modifiers:
mod_strs = []
_STAT_CN = {
"hp": "生命", "mp": "魔力",
"attack": "攻击力", "defense": "防御力",
"speed": "速度", "luck": "幸运",
"perception": "感知", "sanity": "理智",
"hunger": "饱食度", "morale": "士气",
"gold": "金币", "karma": "善恶值",
"experience": "经验",
}
for stat, val in effect.stat_modifiers.items():
cn = _STAT_CN.get(stat, stat)
sign = "+" if val > 0 else ""
mod_strs.append(f"{cn}{sign}{val}/回合")
parts.append(f"[{', '.join(mod_strs)}]")
if effect.duration > 0:
parts.append(f"持续{effect.duration}回合")
elif effect.duration == -1:
parts.append("永久")
change_log.append(" ".join(parts))
elif isinstance(effect_data, str):
effect = StatusEffect(name=effect_data)
self.player.status_effects.append(effect)
change_log.append(f"获得状态: {effect_data}")
if "status_effects_removed" in changes:
for name in changes["status_effects_removed"]:
self.player.status_effects = [
e for e in self.player.status_effects if e.name != str(name)
]
change_log.append(f"移除状态: {name}")
# --- NPC 相关变更 ---
if "npc_changes" in changes:
for npc_name, npc_data in changes["npc_changes"].items():
if npc_name in self.world.npcs:
npc = self.world.npcs[npc_name]
if "attitude" in npc_data:
new_attitude = str(npc_data["attitude"])
if npc.attitude != new_attitude:
npc.attitude = new_attitude
change_log.append(f"NPC {npc_name} 态度变为: {npc.attitude}")
if "is_alive" in npc_data:
new_is_alive = bool(npc_data["is_alive"])
was_alive = npc.is_alive
if npc.is_alive != new_is_alive:
npc.is_alive = new_is_alive
if was_alive and not npc.is_alive:
change_log.append(f"NPC {npc_name} 已死亡")
if "relationship_change" in npc_data:
old_rel = npc.relationship_level
npc.relationship_level = clamp(
npc.relationship_level + int(npc_data["relationship_change"]),
-100, 100,
)
if npc.relationship_level != old_rel:
change_log.append(
f"NPC {npc_name} 好感度: {old_rel}{npc.relationship_level}"
)
if "hp_change" in npc_data:
old_hp = npc.hp
npc.hp = max(0, npc.hp + int(npc_data["hp_change"]))
if npc.hp <= 0:
npc.is_alive = False
change_log.append(f"NPC {npc_name} 被击败")
elif npc.hp != old_hp:
change_log.append(f"NPC {npc_name} HP: {old_hp}{npc.hp}")
if "memory_add" in npc_data:
npc.memory.append(str(npc_data["memory_add"]))
# --- 任务变更 ---
if "quest_updates" in changes:
for quest_id, quest_data in changes["quest_updates"].items():
if quest_id in self.world.quests:
quest = self.world.quests[quest_id]
if "objectives_completed" in quest_data:
for obj in quest_data["objectives_completed"]:
if str(obj) in quest.objectives:
quest.objectives[str(obj)] = True
change_log.append(f"完成目标: {obj}")
if "status" in quest_data:
quest.status = str(quest_data["status"])
_QUEST_STATUS_CN = {
"active": "进行中", "in_progress": "进行中",
"IN_PROGRESS": "进行中", "ACTIVE": "进行中",
"completed": "已完成", "COMPLETED": "已完成",
"failed": "已失败", "FAILED": "已失败",
"expired": "已过期", "EXPIRED": "已过期",
}
status_cn = _QUEST_STATUS_CN.get(quest.status, quest.status)
change_log.append(f"任务【{quest.title}】进展至\"{status_cn}\"")
# --- 世界状态变更 ---
if "weather_change" in changes:
valid_weathers = {"晴朗", "多云", "小雨", "暴风雨", "大雪", "浓雾"}
new_weather = str(changes["weather_change"])
if new_weather in valid_weathers:
if self.world.weather != new_weather:
self.world.weather = new_weather
self.world.last_weather_change_minutes = self.elapsed_minutes_total
change_log.append(f"天气变为: {self.world.weather}")
else:
logger.warning(f"无效的 weather_change 值 '{new_weather}',已忽略。")
if "time_change" in changes:
valid_times = ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜"]
new_time = str(changes["time_change"])
if new_time in valid_times:
old_time = self.world.time_of_day
if new_time != old_time:
self.world.time_of_day = new_time
change_log.append(f"时间流逝: {old_time}{self.world.time_of_day}")
else:
logger.warning(f"无效的 time_change 值 '{new_time}',已忽略。合法值: {valid_times}")
if "weather_change" in changes or "time_change" in changes:
old_light = self.world.light_level
self.world.light_level = self._determine_light_level()
if old_light != self.world.light_level:
change_log.append(f"光照变化: {old_light}{self.world.light_level}")
if "global_flags_set" in changes:
for flag, value in changes["global_flags_set"].items():
self.world.global_flags[flag] = bool(value)
# 全局标记仅内部使用,不展示给用户
logger.info(f"全局标记设置: {flag} = {value}")
if "world_event" in changes:
world_event = str(changes["world_event"])
if not self.world.world_events or self.world.world_events[-1] != world_event:
self.world.world_events.append(world_event)
change_log.append(f"世界事件: {world_event}")
# --- 装备变更 ---
if "equip" in changes:
for slot, item_name in changes["equip"].items():
if slot in self.player.equipment:
old_item = self.player.equipment[slot]
new_item = item_name if item_name and str(item_name).lower() not in ("none", "null", "") else None
# 1. 如果旧装备栏有物品,卸下时放回背包
if old_item and old_item != "无":
if old_item not in self.player.inventory:
self.player.inventory.append(old_item)
logger.info(f"卸下装备 '{old_item}' 放回背包")
# 2. 如果要装备新物品,从背包中移除
if new_item:
new_item_str = str(new_item)
if new_item_str in self.player.inventory:
self.player.inventory.remove(new_item_str)
logger.info(f"从背包取出 '{new_item_str}' 装备到 [{slot}]")
self.player.equipment[slot] = new_item
display_old = old_item or "无"
display_new = new_item or "无"
change_log.append(f"装备 [{slot}]: {display_old}{display_new}")
# --- 玩家称号变更 ---
if "title_change" in changes:
old_title = self.player.title
self.player.title = str(changes["title_change"])
change_log.append(f"称号: {old_title}{self.player.title}")
# 战斗派生属性需要与装备和基础属性保持同步
self.refresh_combat_stats()
if change_log:
logger.info(f"状态变更: {change_log}")
return change_log
def validate(self) -> tuple[bool, list[str]]:
"""
校验当前状态的合法性。
设计思路:
- 检查所有数值是否在合法范围内
- HP <= 0 时标记游戏结束
- 理智过低时施加特殊效果
- 返回 (是否合法, 问题列表)
Returns:
(is_valid, issues): 合法性标志和问题描述列表
"""
issues: list[str] = []
# HP 校验 —— 核心逻辑:HP <= 0 触发死亡
if self.player.hp <= 0:
self.player.hp = 0
self.game_mode = "game_over"
self.player.death_count += 1
issues.append("玩家生命值归零,触发死亡结局!")
# MP 范围校验
self.player.mp = clamp(self.player.mp, 0, self.player.max_mp)
self.player.stamina = clamp(self.player.stamina, 0, self.player.max_stamina)
# 饱食度惩罚
if self.player.hunger <= 0:
self.player.hunger = 0
issues.append("玩家极度饥饿,攻击力和防御力下降!")
# 理智值校验
if self.player.sanity <= 0:
self.player.sanity = 0
self.game_mode = "game_over"
issues.append("玩家理智归零,陷入疯狂!触发疯狂结局!")
# 士气校验
if self.player.morale <= 10:
issues.append("玩家士气极低,行动效率降低。")
# 金币不能为负
if self.player.gold < 0:
self.player.gold = 0
issues.append("金币不足。")
is_valid = self.game_mode != "game_over"
return is_valid, issues
def get_equipment_stat_bonuses(self) -> dict[str, int]:
"""Aggregate stat bonuses from currently equipped items."""
bonuses: dict[str, int] = {}
for item_name in self.player.equipment.values():
if not item_name:
continue
item_info = self.world.item_registry.get(str(item_name))
if item_info is None:
continue
for stat_name, amount in item_info.stat_bonus.items():
bonuses[stat_name] = bonuses.get(stat_name, 0) + int(amount)
return bonuses
def refresh_combat_stats(self) -> None:
"""Refresh deterministic combat stats from base values + equipment bonuses."""
bonuses = self.get_equipment_stat_bonuses()
self.player.attack_power = max(1, int(self.player.attack) + int(bonuses.get("attack", 0)))
self.player.defense_power = max(0, int(self.player.defense) + int(bonuses.get("defense", 0)))
def _status_multiplier(self, value: int) -> float:
if value >= 90:
return 1.5
if value >= 80:
return 1.2
if value >= 30:
return 1.0
if value >= 20:
return 0.9
if value >= 10:
return 0.7
if value >= 5:
return 0.6
return 0.3
def get_survival_state_snapshot(self) -> dict[str, Any]:
hunger_multiplier = self._status_multiplier(self.player.hunger)
sanity_multiplier = self._status_multiplier(self.player.sanity)
morale_multiplier = self._status_multiplier(self.player.morale)
# 体力也纳入战斗乘数:低体力会显著降低战斗能力
stamina_multiplier = self._status_multiplier(self.player.stamina)
combined_multiplier = min(
hunger_multiplier,
sanity_multiplier,
morale_multiplier,
stamina_multiplier,
)
peak_state = all(
value >= 80
for value in (self.player.hunger, self.player.sanity, self.player.morale, self.player.stamina)
)
near_death = sum(
value < 10
for value in (self.player.hunger, self.player.sanity, self.player.morale)
) >= 2
if peak_state:
combined_multiplier *= 1.1
if near_death:
combined_multiplier *= 0.5
return {
"hunger_multiplier": hunger_multiplier,
"sanity_multiplier": sanity_multiplier,
"morale_multiplier": morale_multiplier,
"stamina_multiplier": stamina_multiplier,
"combined_multiplier": round(combined_multiplier, 3),
"peak_state": peak_state,
"near_death": near_death,
}
def get_effective_player_stats(self) -> dict[str, int]:
"""Return display-oriented effective stats after equipment bonuses."""
bonuses = self.get_equipment_stat_bonuses()
tracked_stats = ("attack", "defense", "speed", "luck", "perception", "stamina")
state_snapshot = self.get_survival_state_snapshot()
multiplier = float(state_snapshot["combined_multiplier"])
effective_stats: dict[str, int] = {}
for stat_name in tracked_stats:
base_value = int(getattr(self.player, stat_name))
bonus_value = int(bonuses.get(stat_name, 0))
boosted_value = int(round((base_value + bonus_value) * multiplier))
cap = self.player.max_stamina if stat_name == "stamina" else max(base_value + bonus_value, 1)
if multiplier >= 1:
effective_stats[stat_name] = max(base_value + bonus_value, boosted_value)
else:
effective_stats[stat_name] = clamp(boosted_value, 1, max(cap, boosted_value))
return effective_stats
def get_clock_minutes(self) -> int:
return int(self.world.time_progress_units) * 10
def get_minute_of_day(self) -> int:
return self.get_clock_minutes() % (24 * 60)
def get_clock_display(self) -> str:
total_minutes = self.get_clock_minutes() % (24 * 60)
hours = total_minutes // 60
minutes = total_minutes % 60
return f"{hours:02d}:{minutes:02d}"
def _time_of_day_from_minutes(self, total_minutes: int) -> str:
minute_of_day = total_minutes % (24 * 60)
if 300 <= minute_of_day < 480:
return "清晨"
if 480 <= minute_of_day < 720:
return "上午"
if 720 <= minute_of_day < 840:
return "正午"
if 840 <= minute_of_day < 1080:
return "下午"
if 1080 <= minute_of_day < 1260:
return "黄昏"
if 1260 <= minute_of_day < 1440:
return "夜晚"
return "深夜"
def _sync_world_clock(self):
self.world.time_of_day = self._time_of_day_from_minutes(self.get_clock_minutes())
def can_overnight_rest(self) -> bool:
current_loc = self.world.locations.get(self.player.location)
if current_loc is None or not current_loc.rest_available:
return False
if self.player.location not in OVERNIGHT_REST_LOCATIONS:
return False
return self.get_minute_of_day() >= 19 * 60
def prepare_overnight_rest(self) -> tuple[list[str], dict[str, int]]:
"""Advance to next morning and return full-recovery deltas for overnight rest."""
if not self.can_overnight_rest():
return [], {}
old_clock = self.get_clock_display()
old_time_of_day = self.world.time_of_day
old_day_count = self.world.day_count
old_light = self.world.light_level
minute_of_day = self.get_minute_of_day()
minutes_until_midnight = (24 * 60) - minute_of_day
target_elapsed_minutes = self.elapsed_minutes_total + minutes_until_midnight + 6 * 60
self.elapsed_minutes_total = target_elapsed_minutes
self.world.day_count = target_elapsed_minutes // (24 * 60) + 1
self.world.time_progress_units = (target_elapsed_minutes % (24 * 60)) // 10
self._sync_world_clock()
self.world.light_level = self._determine_light_level()
tick_log: list[str] = []
if self.world.day_count != old_day_count:
tick_log.append(f"新的一天!第{self.world.day_count}天")
new_clock = self.get_clock_display()
if new_clock != old_clock:
tick_log.append(f"时间流逝: {old_clock}{new_clock}")
if self.world.time_of_day != old_time_of_day:
tick_log.append(f"时段变化: {old_time_of_day}{self.world.time_of_day}")
if self.world.light_level != old_light:
tick_log.append(f"光照变化: {old_light}{self.world.light_level}")
hunger_cost = 20 if self.player.location == "村庄旅店" else 25
recovery_changes: dict[str, int] = {
"hp_change": self.player.max_hp - self.player.hp,
"mp_change": self.player.max_mp - self.player.mp,
"stamina_change": self.player.max_stamina - self.player.stamina,
"morale_change": 100 - self.player.morale,
"sanity_change": 100 - self.player.sanity,
"hunger_change": -hunger_cost,
}
return tick_log, {
key: value
for key, value in recovery_changes.items()
if int(value) != 0
}
def to_prompt(self) -> str:
"""
将当前完整状态序列化为自然语言描述,注入 System Prompt。
设计思路(需求文档核心要求):
- System Prompt 必须包含当前状态描述
- 描述要全面但简洁,避免 token 浪费
- 包括:场景、玩家状态、已发生的重要事件、NPC 信息
- 加入一致性约束指令,提醒 LLM 不要产生矛盾
"""
# 1. 场景与环境
scene_desc = (
f"【当前场景】{self.world.current_scene}\n"
f"【时间】第{self.world.day_count}{self.world.time_of_day}\n"
f"【天气】{self.world.weather}\n"
f"【季节】{self.world.season}"
)
# 2. 玩家状态
effects_str = "、".join(e.name for e in self.player.status_effects) if self.player.status_effects else "无"
equipped = {k: (v or "无") for k, v in self.player.equipment.items()}
equip_str = "、".join(f"{k}={v}" for k, v in equipped.items())
# 背包物品标注消耗品/可重复使用
if self.player.inventory:
inv_items = []
for item_name in self.player.inventory:
if self.is_item_consumable(item_name):
inv_items.append(f"{item_name}[消耗品]")
else:
inv_items.append(f"{item_name}[可重复使用]")
inventory_str = "、".join(inv_items)
else:
inventory_str = "空"
skills_str = "、".join(self.player.skills) if self.player.skills else "无"
player_desc = (
f"【玩家】{self.player.name}{self.player.title})\n"
f" 等级: {self.player.level} | 经验: {self.player.experience}/{self.player.exp_to_next_level}\n"
f" HP: {self.player.hp}/{self.player.max_hp}\n"
f" MP: {self.player.mp}/{self.player.max_mp}\n"
f" 攻击: {self.player.attack} | 防御: {self.player.defense} | 实战攻击: {self.player.attack_power} | 实战防御: {self.player.defense_power}\n"
f" 速度: {self.player.speed} | 幸运: {self.player.luck} | 感知: {self.player.perception}\n"
f" 金币: {self.player.gold} | 善恶值: {self.player.karma}\n"
f" 士气: {self.player.morale} | 理智: {self.player.sanity} | 饱食度: {self.player.hunger}\n"
f" 装备: {equip_str}\n"
f" 背包: {inventory_str}\n"
f" 技能: {skills_str}\n"
f" 状态效果: {effects_str}\n"
f" 所在位置: {self.player.location}"
)
# 3. 当前场景中的 NPC
current_npcs = [
npc for npc in self.world.npcs.values()
if npc.location == self.player.location and npc.is_alive
]
if current_npcs:
npc_lines = []
for npc in current_npcs:
mem = ";".join(npc.memory[-3:]) if npc.memory else "无记忆"
npc_lines.append(
f" - {npc.name}{npc.occupation}, {npc.race}, 态度: {npc.attitude}, "
f"好感度: {npc.relationship_level}, 记忆: {mem})"
)
npc_desc = "【场景中的NPC】\n" + "\n".join(npc_lines)
else:
npc_desc = "【场景中的NPC】无"
# 4. 当前活跃任务
active_quests = [q for q in self.world.quests.values() if q.status == "active"]
if active_quests:
quest_lines = []
for q in active_quests:
objectives = ";".join(
f"{'✅' if done else '❌'}{obj}" for obj, done in q.objectives.items()
)
time_info = f"(剩余 {q.turns_remaining} 回合)" if q.turns_remaining > 0 else ""
quest_lines.append(f" - [{q.quest_type.upper()}] {q.title}: {objectives}{time_info}")
quest_desc = "【活跃任务】\n" + "\n".join(quest_lines)
else:
quest_desc = "【活跃任务】无"
# 5. 已发现地点的连接关系
loc_info = self.world.locations.get(self.player.location)
if loc_info:
accessible = [
name for name in loc_info.connected_to
if name in self.world.locations
and self.world.locations[name].is_accessible
]
blocked = [
f"{name}(需要: {self.world.locations[name].required_item})"
for name in loc_info.connected_to
if name in self.world.locations
and not self.world.locations[name].is_accessible
]
move_desc = f"【可前往的地点】{'、'.join(accessible) if accessible else '无'}"
if blocked:
move_desc += f"\n【被阻挡的地点】{'、'.join(blocked)}"
else:
move_desc = "【可前往的地点】未知"
# 6. 近期事件(最近 5 条)
if self.event_log:
recent = self.event_log[-5:]
event_lines = [f" - [回合{e.turn}] {e.description}" for e in recent]
event_desc = "【近期事件】\n" + "\n".join(event_lines)
else:
event_desc = "【近期事件】无"
# 7. 传闻
rumors_desc = ""
if self.world.rumors:
rumors_desc = "\n【流传的传闻】\n" + "\n".join(f" - {r}" for r in self.world.rumors[-3:])
# 8. 显式环境事件
environment_event_desc = ""
if self.pending_environment_event:
env = self.pending_environment_event
environment_event_desc = (
f"\n【本回合环境事件 —— 必须融入本次叙事】\n"
f"[{env.category.upper()}|{env.severity}] {env.title}\n"
f"{env.description}\n"
f"{env.prompt_hint}\n"
f"请将此事件自然地融入剧情描写中,作为本回合可感知的环境变化。"
f"玩家可以选择回应、调查、规避或忽视它。至少一个选项应与此事件相关。"
)
self.pending_environment_event = None # 用后清除
# 9. 一致性约束指令
consistency_rules = (
"\n【一致性约束 —— 你必须严格遵守】\n"
"1. 已死亡的NPC不可再出现或对话(除非有特殊复活剧情)。\n"
"2. 玩家背包中没有的物品不可使用或赠送。\n"
"3. 玩家不可到达未连接的地点,被阻挡的地点需要对应物品才能进入。\n"
"4. 时间线不可回退,已发生的事件不可矛盾。\n"
"5. NPC的态度和记忆应与历史事件一致。\n"
"6. 战斗伤害应考虑攻击力和防御力的差值,结果要合理。\n"
"7. 所有状态变更必须在 state_changes 字段中明确输出。\n"
"8. 每次生成的文本描写必须使用全新的比喻和意象,严禁重复之前回合用过的修辞和句式。\n"
"9. 【物品消耗规则】只有消耗品(药水、食物等一次性物品)在使用后才会消失,应放入 items_lost。"
"非消耗品(哨子、武器、工具、乐器、钥匙等可重复使用的物品)使用后仍然保留在背包中,"
"绝对不要将它们放入 items_lost。例如:吹响哨子后哨子仍在背包中;使用火把照明后火把仍在。\n"
"10. 【选项物品约束】生成的选项中如果涉及使用某个物品,该物品必须当前在玩家背包中。"
"不要生成使用玩家不拥有的物品的选项。\n"
'11. 【货币规则】游戏内货币统一为"金币",对应 gold_change 字段。严禁使用"铜币""银币""银两"等名称。'
"任何钱财/财物类收获(如击败怪物掉落的钱币、交易获得的货款等)必须通过 gold_change 表达,"
"严禁将任何种类的钱币放入 items_gained。\n"
'12. 【装备规则】装备物品时必须使用 equip 字段指定槽位和物品名称(如 "weapon": "小刀")。'
"系统会自动将装备的物品从背包移到装备栏,并将旧装备放回背包。"
"因此装备操作时不要在 items_lost/items_gained 中重复处理该物品。"
"合法槽位:weapon / armor / accessory / helmet / boots。卸下装备时将对应槽位设为 null。"
)
# 组合完整 Prompt
full_prompt = "\n\n".join([
scene_desc,
player_desc,
npc_desc,
quest_desc,
move_desc,
event_desc,
rumors_desc,
environment_event_desc,
consistency_rules,
])
return full_prompt
def log_event(
self,
event_type: str,
description: str,
player_action: str = "",
involved_npcs: list[str] | None = None,
state_changes: dict | None = None,
consequence_tags: list[str] | None = None,
is_reversible: bool = True,
):
"""
记录一条事件到 event_log。
每次状态变更都应该调用此方法,确保完整的历史记录。
事件日志是一致性维护的基石。
"""
event = GameEvent(
turn=self.turn,
day=self.world.day_count,
time_of_day=self.world.time_of_day,
event_type=event_type,
description=description,
location=self.player.location,
involved_npcs=involved_npcs or [],
state_changes=state_changes or {},
player_action=player_action,
consequence_tags=consequence_tags or [],
is_reversible=is_reversible,
)
self.event_log.append(event)
logger.info(f"事件记录: [{event_type}] {description}")
def check_consistency(self, proposed_changes: dict) -> list[str]:
"""
对比事件日志和当前状态,检测拟议变更中的矛盾。
设计思路:
- 在 apply_changes 之前调用,预防性检测
- 返回所有发现的矛盾描述列表
- 空列表 = 无矛盾,可以安全应用
检测维度:
1. 已死亡 NPC 是否被重新引用
2. 不存在的物品是否被消耗
3. 不可达的地点是否被移动到
4. 任务目标是否已经跳跃完成
"""
contradictions: list[str] = []
# 检测1: 已死亡NPC是否被引用
if "npc_changes" in proposed_changes:
for npc_name in proposed_changes["npc_changes"]:
if npc_name in self.world.npcs and not self.world.npcs[npc_name].is_alive:
contradictions.append(
f"矛盾: 试图与已死亡的NPC '{npc_name}' 交互"
)
# 检测2: 不存在的物品是否被消耗
if "items_lost" in proposed_changes:
for item in proposed_changes["items_lost"]:
if str(item) not in self.player.inventory:
contradictions.append(
f"矛盾: 试图消耗不在背包中的物品 '{item}'"
)
elif not self.is_item_consumable(str(item)):
# 非消耗品不应因使用而消失(交易/丢弃除外,由引擎层判断)
contradictions.append(
f"矛盾: 物品 '{item}' 不是消耗品,使用后不应消失。请将其从 items_lost 中移除。"
)
# 检测3: 位置移动是否合法
if "new_location" in proposed_changes:
target = str(proposed_changes["new_location"])
if target.strip().lower() not in ("", "none", "null") and target != self.player.location:
current_loc = self.world.locations.get(self.player.location)
target_loc = self.world.locations.get(target)
if target_loc is None:
contradictions.append(
f"矛盾: 试图移动到未注册的地点 '{target}'"
)
if current_loc and target not in current_loc.connected_to:
contradictions.append(
f"矛盾: 试图移动到不相邻的地点 '{target}'(当前位置: {self.player.location})"
)
if target_loc and not target_loc.is_accessible:
if target_loc.required_item and target_loc.required_item not in self.player.inventory:
contradictions.append(
f"矛盾: 地点 '{target}' 被锁定,需要 '{target_loc.required_item}'"
)
# 检测4: 金币是否足够(如果是消费操作)
if "gold_change" in proposed_changes:
change = int(proposed_changes["gold_change"])
if change < 0 and self.player.gold + change < 0:
contradictions.append(
f"矛盾: 金币不足(当前: {self.player.gold},需要: {abs(change)})"
)
if contradictions:
logger.warning(f"一致性检查发现矛盾: {contradictions}")
return contradictions
def pre_validate_action(self, intent: dict) -> tuple[bool, str]:
"""
预校验玩家意图的合法性(在 LLM 调用前立即拦截非法操作)。
设计思路:
- 在任何 API 调用之前就检测明显违反一致性的操作
- 不合法时立即驳回,避免浪费 API 调用和回合
- 检测维度:物品是否在背包/装备中、技能是否已习得、
raw_input 中是否提及使用不存在的物品
Returns:
(is_valid, rejection_reason): 合法返回 (True, ""), 否则返回 (False, "拒绝原因")
"""
action = intent.get("intent", "")
raw_target = intent.get("target")
target = intent.get("target", "") or ""
details = intent.get("details", "") or ""
raw_input = intent.get("raw_input", "") or ""
action_upper = str(action or "").upper()
inventory = list(self.player.inventory)
equipped_items = [v for v in self.player.equipment.values() if v]
all_owned = set(inventory) | set(equipped_items)
def normalize_item_phrase(text: str) -> str:
cleaned = str(text or "").strip()
cleaned = re.sub(
r"^(?:喝掉|吃掉|使用|服用|装备|穿上|戴上|拿出|掏出|拔出|举起|喝|吃|用|掉)",
"",
cleaned,
)
cleaned = re.sub(r"^(?:一瓶|一杯|一口|一个|一份|一块|一把)", "", cleaned)
cleaned = re.sub(r"(?:照明|攻击|挥舞|挥动|一下|试试)$", "", cleaned)
return cleaned.strip()
normalized_target = normalize_item_phrase(target)
def _resolve_dialogue_npc() -> NPCState | None:
"""Resolve TALK target from explicit target or alias mentions in free text."""
text_blob = f"{target} {details} {raw_input}".strip()
if not text_blob:
return None
# 1) Exact NPC name match first.
explicit_target = str(target or "").strip()
if explicit_target and explicit_target in self.world.npcs:
npc = self.world.npcs.get(explicit_target)
if npc and npc.is_alive:
return npc
# 2) Name / occupation fuzzy match from free text (e.g. "和村长聊天").
alive_npcs = [npc for npc in self.world.npcs.values() if npc.is_alive]
ranked_candidates: list[tuple[int, NPCState]] = []
for npc in alive_npcs:
score = 0
if npc.name and npc.name in text_blob:
score += 3
if explicit_target and (
(npc.name and explicit_target in npc.name)
or (npc.name and npc.name in explicit_target)
):
score += 2
if npc.occupation and npc.occupation in text_blob:
score += 2
if explicit_target and npc.occupation and (
explicit_target in npc.occupation or npc.occupation in explicit_target
):
score += 1
if score > 0:
ranked_candidates.append((score, npc))
if ranked_candidates:
ranked_candidates.sort(key=lambda item: item[0], reverse=True)
return ranked_candidates[0][1]
return None
# --- 检测 1: USE_ITEM / EQUIP: target 必须在背包或装备中 ---
if action_upper in ("USE_ITEM", "EQUIP") and target:
if target not in all_owned:
return False, f"你的背包中没有「{target}」,无法使用或装备。"
if action_upper == "EQUIP" and target not in inventory:
if target in equipped_items:
return False, f"「{target}」已经装备在身上了。"
return False, f"你的背包中没有「{target}」,无法装备。"
# 体力耗尽时禁止移动和战斗
if action_upper in ("MOVE", "ATTACK", "COMBAT") and self.player.stamina <= 0:
return False, "你精疲力竭,体力耗尽,无法行动。需要先在旅店或营地休息恢复体力。"
if action_upper == "TRADE":
if not isinstance(raw_target, dict):
return False, "交易指令缺少商品信息,请从商店列表中选择要购买的物品。"
merchant_name = str(raw_target.get("merchant") or "")
item_name = str(raw_target.get("item") or raw_target.get("item_name") or "")
if not merchant_name or not item_name:
return False, "交易信息不完整,请重新从商店列表选择商品。"
if action_upper in ("ATTACK", "COMBAT"):
scene_actions = build_scene_actions(self, self.player.location)
attack_targets = [
str(option.get("target"))
for option in scene_actions
if str(option.get("action_type", "")).upper() == "ATTACK"
and isinstance(option.get("target"), str)
and str(option.get("target")).strip()
]
if target:
if target not in attack_targets:
if attack_targets:
return (
False,
f"当前无法攻击「{target}」。你现在可攻击的目标只有:{'、'.join(attack_targets)}。",
)
return False, "当前场景没有可攻击目标,先观察环境或移动到有敌人的地点。"
elif not attack_targets:
return False, "当前场景没有可攻击目标,先观察环境或移动到有敌人的地点。"
if action_upper == "MOVE" and target:
current_loc = self.world.locations.get(self.player.location)
target_loc = self.world.locations.get(str(target))
if current_loc is None or target_loc is None:
return False, "你无法前往一个未注册的地点。"
if str(target) not in current_loc.connected_to:
return False, f"当前位置只能前往相邻地点,不能直接前往「{target}」。"
if not target_loc.is_accessible:
if target_loc.required_item and target_loc.required_item not in all_owned:
return False, f"「{target}」尚未解锁,需要「{target_loc.required_item}」。"
return False, f"「{target}」当前无法进入。"
if action_upper == "VIEW_MAP":
if not any("地图" in item for item in all_owned):
return False, "你还没有获得可查看的地图。"
# --- 检测 2: TALK: 对话对象必须在当前地点 ---
if action_upper == "TALK":
dialogue_npc = _resolve_dialogue_npc()
if dialogue_npc is not None:
if dialogue_npc.location != self.player.location:
return (
False,
f"「{dialogue_npc.name}」目前在「{dialogue_npc.location}」,你现在在「{self.player.location}」。"
f"无法隔空对话,请先前往对方所在地点。"
)
else:
local_alive_npcs = [
npc.name
for npc in self.world.npcs.values()
if npc.is_alive and npc.location == self.player.location
]
if not local_alive_npcs:
return False, "这里没有可对话的角色,无法进行聊天。"
# --- 检测 2: SKILL: 必须已习得 ---
if action_upper == "SKILL" and target:
if target not in self.player.skills:
return False, f"你尚未习得技能「{target}」。"
wants_overnight_rest = action_upper == "OVERNIGHT_REST" or (
action_upper == "REST"
and any(keyword in f"{details} {raw_input}" for keyword in ("过夜", "睡一晚", "住一晚", "睡到天亮"))
)
# --- 检测 3: REST/OVERNIGHT_REST: 当前位置必须允许休息 ---
if action_upper in ("REST", "OVERNIGHT_REST"):
current_loc = self.world.locations.get(self.player.location)
if current_loc is None or not current_loc.rest_available:
return False, "这里不适合休息,试着前往旅店或营地。"
if wants_overnight_rest and not self.can_overnight_rest():
return False, "现在还不能在这里过夜。晚上七点后,且仅限旅店或溪边营地。"
# --- 检测 4: 扫描 raw_input 中是否在使用上下文中引用了未拥有的已知物品 ---
known_items: set[str] = set(self.world.item_registry.keys())
for event in self.event_log:
sc = event.state_changes
if isinstance(sc, dict):
for item in sc.get("items_gained", []):
known_items.add(str(item))
for item in sc.get("items_lost", []):
known_items.add(str(item))
unavailable_known = {
item for item in known_items
if item not in all_owned and len(item) >= 2
}
use_verbs = [
"使用", "用", "吃", "喝", "装备", "穿上", "戴上",
"拿出", "掏出", "挥舞", "举起", "服用", "食用", "拔出", "拿起",
]
for item_name in unavailable_known:
if item_name not in raw_input:
continue
for verb in use_verbs:
if verb + item_name in raw_input:
return False, f"你的背包中没有「{item_name}」,无法{verb}。"
# --- 检测 5: 检查 raw_input 中是否提及使用完全未知的物品 ---
# 匹配常见的"使用物品"语句模式,提取物品名称并校验
extraction_patterns = [
(r'(?:掏出|拿出|拔出|举起)(.{2,8}?)(?:$|[,。!?,\s来])', "使用"),
(r'吃(?:一个|一块|一份|了个|了一个)?(.{2,8}?)(?:$|[,。!?,\s来])', "吃"),
(r'喝(?:一瓶|一杯|一口|了一瓶|了)?(.{2,8}?)(?:$|[,。!?,\s来])', "喝"),
(r'用(.{2,6}?)(?:打|攻击|砍|刺|射|劈|挡|切|割)', "使用"),
]
non_item_words = {
"拳头", "双手", "手", "脚", "头", "身体",
"魔法", "技能", "力量", "勇气", "智慧",
"办法", "方法", "速度", "周围", "四周",
}
full_text = raw_input + " " + details
for pattern, verb_desc in extraction_patterns:
match = re.search(pattern, full_text)
if match:
mentioned = normalize_item_phrase(match.group(1).strip())
if not mentioned or mentioned in non_item_words:
continue
if mentioned not in all_owned:
if normalized_target and (
mentioned in normalized_target
or normalized_target in mentioned
):
continue
# 模糊匹配:"剑" 可能是 "铁剑" 的简称
fuzzy_match = any(
mentioned in normalize_item_phrase(owned)
or normalize_item_phrase(owned) in mentioned
for owned in all_owned
)
if not fuzzy_match:
return False, f"你并没有「{mentioned}」,请检查你的背包。"
return True, ""
def is_game_over(self) -> bool:
"""
判断游戏是否结束。
结束条件:
1. HP <= 0(死亡)
2. 理智 <= 0(疯狂)
3. 触发终局标记
"""
if self.player.hp <= 0:
return True
if self.player.sanity <= 0:
return True
if self.game_mode == "game_over":
return True
# 检查终局标记
if self.ending_flags.get("game_complete", False):
return True
return False
def tick_time(self, player_intent: Optional[dict] = None) -> list[str]:
"""
按动作消耗推进游戏时间。
设计思路:
- 回合数递增
- 不同动作消耗不同的时间点数
- 累积点数达到阈值时,时间段按固定顺序轮转
- 每过一个完整日夜循环,天数+1
- 自动减少饱食度,模拟饥饿机制
- 结算状态效果持续时间
- 检查限时任务
Returns:
tick_log: 本回合时间流逝引起的状态变化描述列表
"""
tick_log: list[str] = []
self.turn += 1
action_units = self._estimate_time_cost_units(player_intent)
if action_units <= 0:
return tick_log
old_clock = self.get_clock_display()
old_time_of_day = self.world.time_of_day
previous_units = self.world.time_progress_units
total_units = previous_units + action_units
day_rollovers = total_units // 144
self.world.time_progress_units = total_units % 144
if day_rollovers > 0:
self.world.day_count += day_rollovers
tick_log.append(f"新的一天!第 {self.world.day_count} 天")
action_minutes = action_units * 10
self.elapsed_minutes_total += action_minutes
self._sync_world_clock()
new_clock = self.get_clock_display()
if new_clock != old_clock:
tick_log.append(f"时间流逝: {old_clock}{new_clock}")
if self.world.time_of_day != old_time_of_day:
tick_log.append(f"时段变化: {old_time_of_day}{self.world.time_of_day}")
intent_name = str((player_intent or {}).get("intent", "")).upper()
previous_elapsed_minutes = self.elapsed_minutes_total - action_minutes
crossed_half_hours = (
self.elapsed_minutes_total // 30
- previous_elapsed_minutes // 30
)
hunger_delta = -int(max(crossed_half_hours, 0))
thirty_minute_blocks = max(1, (action_minutes + 29) // 30)
if intent_name in {"MOVE"}:
stamina_delta = -3 * thirty_minute_blocks
elif intent_name in {"ATTACK", "COMBAT"}:
stamina_delta = -5 * thirty_minute_blocks
elif intent_name == "REST":
stamina_delta = 12 * thirty_minute_blocks
else:
stamina_delta = 0
old_hunger = self.player.hunger
self.player.hunger = clamp(self.player.hunger + hunger_delta, 0, 100)
if self.player.hunger != old_hunger:
tick_log.append(f"饱食度: {old_hunger}{self.player.hunger}")
old_stamina = self.player.stamina
self.player.stamina = clamp(self.player.stamina + stamina_delta, 0, self.player.max_stamina)
if self.player.stamina != old_stamina:
tick_log.append(f"体力: {old_stamina}{self.player.stamina}")
crossed_half_days = self.elapsed_minutes_total // 720 - (self.elapsed_minutes_total - action_minutes) // 720
for _ in range(max(crossed_half_days, 0)):
if self.player.hunger <= 0:
old_hp = self.player.hp
hp_loss = max(1, int(round(self.player.max_hp * 0.1)))
self.player.hp = max(0, self.player.hp - hp_loss)
tick_log.append(f"饥饿伤害: {old_hp}{self.player.hp}")
elif self.player.hunger > 80:
old_hp = self.player.hp
hp_gain = max(1, int(round(self.player.max_hp * 0.03)))
self.player.hp = min(self.player.max_hp, self.player.hp + hp_gain)
if self.player.hp != old_hp:
tick_log.append(f"充足补给恢复: {old_hp}{self.player.hp}")
effect_log = self._apply_status_effects()
tick_log.extend(effect_log)
self._check_quest_deadlines()
self._update_npc_schedules()
self._update_environment_cycle(tick_log)
return tick_log
def _estimate_time_cost_units(self, player_intent: Optional[dict] = None) -> int:
"""Estimate how much in-world time a player action should consume."""
if not isinstance(player_intent, dict):
return 3
action_type = str(player_intent.get("intent", "")).upper()
return max(1, action_time_cost_minutes(action_type) // 10)
def _determine_light_level(self) -> str:
"""Derive current light level from time of day and weather."""
base_levels = {
"清晨": "柔和",
"上午": "明亮",
"正午": "明亮",
"下午": "柔和",
"黄昏": "昏暗",
"夜晚": "幽暗",
"深夜": "漆黑",
}
ordered_levels = ["明亮", "柔和", "昏暗", "幽暗", "漆黑"]
weather_penalty = {
"晴朗": 0,
"多云": 0,
"小雨": 1,
"大雪": 1,
"浓雾": 1,
"暴风雨": 2,
}
base_level = base_levels.get(self.world.time_of_day, "柔和")
current_index = ordered_levels.index(base_level)
darker_by = weather_penalty.get(self.world.weather, 0)
next_index = min(len(ordered_levels) - 1, current_index + darker_by)
return ordered_levels[next_index]
def _update_environment_cycle(self, tick_log: list[str]):
"""Advance light/weather and roll explicit environment events."""
self.pending_environment_event = None
self._update_light_level_event(tick_log)
self._maybe_shift_weather(tick_log)
self._update_light_level_event(tick_log)
self._roll_environment_event(tick_log)
def _update_light_level_event(self, tick_log: list[str]):
old_light = self.world.light_level
new_light = self._determine_light_level()
if new_light == old_light:
return
self.world.light_level = new_light
event = EnvironmentEvent(
event_id=f"light-{self.turn}",
category="light",
title=f"光照转为{new_light}",
description=f"随着时间与天气变化,周围环境现在呈现出{new_light}的光照状态。",
location=self.player.location,
time_of_day=self.world.time_of_day,
weather=self.world.weather,
light_level=new_light,
severity="medium" if new_light in {"幽暗", "漆黑"} else "low",
prompt_hint="请在叙事中体现能见度、阴影和角色主观感受的变化。",
)
self._register_environment_event(
event,
tick_log,
inject_prompt=new_light in {"昏暗", "幽暗", "漆黑"},
)
def _maybe_shift_weather(self, tick_log: list[str]):
"""Occasionally shift weather to keep the environment dynamic."""
if self.elapsed_minutes_total - int(self.world.last_weather_change_minutes) < 180:
return
chance = 0.18 if self.world.time_of_day in {"清晨", "黄昏"} else 0.08
if random.random() >= chance:
return
weather_transitions = {
"晴朗": ["多云", "小雨"],
"多云": ["晴朗", "小雨", "浓雾"],
"小雨": ["多云", "暴风雨", "浓雾"],
"浓雾": ["多云", "小雨", "晴朗"],
"暴风雨": ["小雨", "多云"],
"大雪": ["多云"],
}
next_candidates = weather_transitions.get(self.world.weather, ["晴朗", "多云"])
new_weather = random.choice(next_candidates)
if new_weather == self.world.weather:
return
loc = self.world.locations.get(self.player.location)
weather_effects: dict[str, Any] = {"weather_change": new_weather}
if loc and loc.location_type in {"wilderness", "dungeon"}:
if new_weather == "暴风雨":
weather_effects.update({"morale_change": -3, "sanity_change": -2})
elif new_weather == "浓雾":
weather_effects.update({"sanity_change": -1})
elif new_weather == "晴朗" and self.world.weather in {"小雨", "浓雾", "暴风雨"}:
weather_effects.update({"morale_change": 2})
event = EnvironmentEvent(
event_id=f"weather-{self.turn}",
category="weather",
title=f"天气转为{new_weather}",
description=f"周围的天象正在变化,空气与视野都随着天气转向{new_weather}。",
location=self.player.location,
time_of_day=self.world.time_of_day,
weather=new_weather,
light_level=self.world.light_level,
severity="medium" if new_weather in {"暴风雨", "浓雾"} else "low",
state_changes=weather_effects,
prompt_hint="请把天气变化作为当前回合的重要氛围来源,影响角色观察和选择。",
)
self._register_environment_event(event, tick_log, inject_prompt=True)
self.world.light_level = self._determine_light_level()
def _roll_environment_event(self, tick_log: list[str]):
"""Roll a structured environment event using explicit template filters."""
loc = self.world.locations.get(self.player.location)
if loc is None or not self.environment_event_pool:
return
# 提高基础触发率,让环境事件更常进入叙事与决策反馈
chance = 0.15
if loc.danger_level >= 3:
chance += 0.1
if self.world.light_level in {"幽暗", "漆黑"}:
chance += 0.08
if self.world.weather in {"暴风雨", "浓雾"}:
chance += 0.06
if random.random() >= chance:
return
candidates: list[dict[str, Any]] = []
for template in self.environment_event_pool:
if template.get("location_types") and loc.location_type not in template["location_types"]:
continue
if template.get("time_slots") and self.world.time_of_day not in template["time_slots"]:
continue
if template.get("weathers") and self.world.weather not in template["weathers"]:
continue
if template.get("requires_rest_available") and not loc.rest_available:
continue
if loc.danger_level < int(template.get("min_danger", 0)):
continue
candidates.append(template)
if not candidates:
return
template = random.choice(candidates)
event = EnvironmentEvent(
event_id=f"{template['event_id']}-{self.turn}",
category=str(template.get("category", "environment")),
title=str(template.get("title", "环境异动")),
description=str(template.get("description", "")),
location=self.player.location,
time_of_day=self.world.time_of_day,
weather=self.world.weather,
light_level=self.world.light_level,
severity=str(template.get("severity", "low")),
state_changes=copy.deepcopy(template.get("state_changes", {})),
prompt_hint=str(template.get("prompt_hint", "")),
)
self._register_environment_event(event, tick_log, inject_prompt=True)
def _register_environment_event(
self,
event: EnvironmentEvent,
tick_log: list[str],
*,
inject_prompt: bool,
):
"""Persist an environment event, optionally inject it into the next prompt, and apply effects."""
self.world.recent_environment_events.append(event)
self.world.recent_environment_events = self.world.recent_environment_events[-8:]
if inject_prompt:
self.pending_environment_event = event
if event.category == "light":
tick_log.append(f"光照变化: {event.title}")
changes = copy.deepcopy(event.state_changes)
changes.setdefault("world_event", event.title)
change_log = self.apply_changes(changes)
tick_log.extend(change_log)
logger.info("环境事件触发: %s", event.title)
def _apply_status_effects(self) -> list[str]:
"""每回合结算状态效果:应用修正、递减持续时间、移除过期效果
Returns:
effect_log: 状态效果结算引起的变化描述列表
"""
effect_log: list[str] = []
expired = []
for effect in self.player.status_effects:
# 应用属性修正(每回合)
if "hp" in effect.stat_modifiers:
old_hp = self.player.hp
self.player.hp = clamp(
self.player.hp + effect.stat_modifiers["hp"],
0, self.player.max_hp,
)
if old_hp != self.player.hp:
effect_log.append(f"{effect.name}: HP {old_hp}{self.player.hp}")
if "mp" in effect.stat_modifiers:
old_mp = self.player.mp
self.player.mp = clamp(
self.player.mp + effect.stat_modifiers["mp"],
0, self.player.max_mp,
)
if old_mp != self.player.mp:
effect_log.append(f"{effect.name}: MP {old_mp}{self.player.mp}")
if "sanity" in effect.stat_modifiers:
old_sanity = self.player.sanity
self.player.sanity = clamp(
self.player.sanity + effect.stat_modifiers["sanity"],
0, 100,
)
if old_sanity != self.player.sanity:
effect_log.append(f"{effect.name}: 理智 {old_sanity}{self.player.sanity}")
# 感知、攻击、防御、速度、幸运、士气、饱食度等直接加减属性
for stat_key, stat_cn in [("perception", "感知"), ("attack", "攻击力"),
("defense", "防御力"), ("speed", "速度"),
("luck", "幸运"), ("morale", "士气"),
("hunger", "饱食度")]:
if stat_key in effect.stat_modifiers:
old_val = getattr(self.player, stat_key)
max_val = 100 if stat_key in ("morale", "hunger") else None
new_val = old_val + effect.stat_modifiers[stat_key]
if max_val is not None:
new_val = clamp(new_val, 0, max_val)
setattr(self.player, stat_key, new_val)
if old_val != new_val:
effect_log.append(f"{effect.name}: {stat_cn} {old_val}{new_val}")
# 递减持续时间
if effect.duration > 0:
effect.duration -= 1
if effect.duration <= 0:
expired.append(effect)
# duration == -1 表示永久效果,不递减
# 移除过期效果
for effect in expired:
self.player.status_effects.remove(effect)
effect_log.append(f"状态效果 '{effect.name}' 已过期并移除")
logger.info(f"状态效果 '{effect.name}' 已过期")
return effect_log
def _check_quest_deadlines(self):
"""检查限时任务是否过期"""
for quest in self.world.quests.values():
if quest.status == "active" and quest.turns_remaining > 0:
quest.turns_remaining -= 1
if quest.turns_remaining <= 0:
quest.status = "failed"
logger.info(f"任务 '{quest.title}' 已超时失败!")
def _update_npc_schedules(self):
"""根据当前时间段更新 NPC 位置"""
for npc in self.world.npcs.values():
if not npc.is_alive:
continue
if self.world.time_of_day in npc.schedule:
old_loc = npc.location
new_loc = npc.schedule[self.world.time_of_day]
if old_loc != new_loc:
npc.location = new_loc
# 更新地点的 NPC 列表
if old_loc in self.world.locations:
loc = self.world.locations[old_loc]
if npc.name in loc.npcs_present:
loc.npcs_present.remove(npc.name)
if new_loc in self.world.locations:
loc = self.world.locations[new_loc]
if npc.name not in loc.npcs_present:
loc.npcs_present.append(npc.name)
def _level_up(self):
"""
角色升级逻辑。
每次升级:
- 等级+1
- 扣除当前升级所需经验
- 下次升级所需经验提升 50%
- 属性随机增长
- HP/MP 完全恢复
"""
self.player.experience -= self.player.exp_to_next_level
self.player.level += 1
self.player.exp_to_next_level = int(self.player.exp_to_next_level * 1.5)
# 属性提升
self.player.max_hp += 10
self.player.max_mp += 5
self.player.attack += 2
self.player.defense += 1
self.player.speed += 1
self.player.perception += 1
# 升级后满血满蓝
self.player.hp = self.player.max_hp
self.player.mp = self.player.max_mp
logger.info(
f"升级!等级: {self.player.level}, "
f"HP: {self.player.max_hp}, MP: {self.player.max_mp}, "
f"ATK: {self.player.attack}, DEF: {self.player.defense}"
)
def get_death_narrative_context(self) -> str:
"""生成死亡结局的上下文信息(供 story_engine 使用)"""
cause = "生命值归零" if self.player.hp <= 0 else "理智崩溃"
last_event = self.event_log[-1].description if self.event_log else "未知"
return (
f"玩家 {self.player.name}{cause}而倒下。\n"
f"最后发生的事件: {last_event}\n"
f"死亡次数: {self.player.death_count}\n"
f"存活天数: {self.world.day_count}\n"
f"最终善恶值: {self.player.karma}"
)
def is_item_consumable(self, item_name: str) -> bool:
"""
判断物品是否为消耗品(使用后会消失)。
规则:
- item_registry 中 item_type == "consumable" 的物品是消耗品
- item_type == "material" 的物品也视为消耗品(合成材料,用完即消失)
- 其他类型(weapon, armor, key, quest_item, misc 等)为可重复使用物品
- 未注册物品默认为非消耗品(更安全,避免误删)
"""
if item_name in self.world.item_registry:
item_info = self.world.item_registry[item_name]
return item_info.item_type in ("consumable", "material")
# 对未注册物品,用关键词启发式判断
consumable_keywords = ["药水", "药剂", "食物", "面包", "烤肉", "麦酒",
"草药", "卷轴", "炸弹", "手雷", "箭矢", "弹药",
"丹药", "果实", "干粮", "肉干", "饮料", "汤",
"符咒", "一次性"]
for keyword in consumable_keywords:
if keyword in item_name:
return True
return False # 默认为非消耗品
def get_available_actions(self) -> list[str]:
"""根据当前场景和状态返回可用的行动类型"""
actions = ["观察", "对话", "移动"]
# 当前场景信息
loc = self.world.locations.get(self.player.location)
if loc:
if loc.rest_available:
actions.append("休息")
if loc.shop_available:
actions.append("交易")
if loc.enemies:
actions.append("战斗")
if loc.available_items:
actions.append("搜索")
# 背包中有可用物品
if self.player.inventory:
actions.append("使用物品")
# 有技能可用
if self.player.skills:
actions.append("使用技能")
return actions
def get_scene_summary(self) -> str:
"""获取当前场景的简短摘要(用于 UI 展示)"""
loc = self.world.locations.get(self.player.location)
desc = loc.description if loc else "未知区域"
ambient = loc.ambient_description if loc else ""
npcs = [
npc.name for npc in self.world.npcs.values()
if npc.location == self.player.location and npc.is_alive
]
npc_str = f"可见NPC: {'、'.join(npcs)}" if npcs else ""
return f"{desc}\n{ambient}\n{npc_str}".strip()
def get_consumable_rule_effects(self, item_name: str) -> dict[str, Any]:
"""Parse deterministic consumable effects from item metadata."""
item_info = self.world.item_registry.get(str(item_name))
if item_info is None or not item_info.usable:
return {}
effect_text = str(item_info.use_effect or "").strip()
if not effect_text:
return {}
changes: dict[str, Any] = {}
numeric_rules = [
(r"恢复\s*(\d+)\s*HP", "hp_change", 1),
(r"恢复\s*(\d+)\s*MP", "mp_change", 1),
(r"恢复\s*(\d+)\s*饱食度", "hunger_change", 1),
(r"恢复\s*(\d+)\s*士气", "morale_change", 1),
(r"恢复\s*(\d+)\s*理智", "sanity_change", 1),
(r"降低\s*(\d+)\s*HP", "hp_change", -1),
(r"降低\s*(\d+)\s*MP", "mp_change", -1),
(r"降低\s*(\d+)\s*饱食度", "hunger_change", -1),
(r"降低\s*(\d+)\s*士气", "morale_change", -1),
(r"降低\s*(\d+)\s*理智", "sanity_change", -1),
]
for pattern, key, sign in numeric_rules:
for match in re.finditer(pattern, effect_text):
amount = int(match.group(1)) * sign
changes[key] = int(changes.get(key, 0)) + amount
if "解除中毒状态" in effect_text:
changes["status_effects_removed"] = ["中毒"]
return changes
def get_rest_rule_effects(self) -> dict[str, int]:
"""Return conservative default recovery when resting at a valid location."""
loc = self.world.locations.get(self.player.location)
if loc is None or not loc.rest_available:
return {}
if loc.shop_available:
base_recovery = {
"hp_change": 20,
"mp_change": 10,
"morale_change": 10,
"sanity_change": 6,
}
else:
base_recovery = {
"hp_change": 12,
"mp_change": 6,
"morale_change": 6,
"sanity_change": 4,
}
filtered: dict[str, int] = {}
if self.player.hp < self.player.max_hp:
filtered["hp_change"] = base_recovery["hp_change"]
if self.player.mp < self.player.max_mp:
filtered["mp_change"] = base_recovery["mp_change"]
if self.player.morale < 100:
filtered["morale_change"] = base_recovery["morale_change"]
if self.player.sanity < 100:
filtered["sanity_change"] = base_recovery["sanity_change"]
return filtered
def get_environment_snapshot(self, limit: int = 3) -> dict[str, Any]:
"""Return a compact environment summary for UI and logs."""
loc = self.world.locations.get(self.player.location)
recent_events = [
event.model_dump()
for event in self.world.recent_environment_events[-limit:]
]
return {
"weather": self.world.weather,
"light_level": self.world.light_level,
"time_of_day": self.world.time_of_day,
"season": self.world.season,
"location_type": loc.location_type if loc else "unknown",
"danger_level": loc.danger_level if loc else 0,
"rest_available": bool(loc.rest_available) if loc else False,
"shop_available": bool(loc.shop_available) if loc else False,
"recent_events": recent_events,
}