diff --git a/docs/specs/avatar-metrics-tracking.md b/docs/specs/avatar-metrics-tracking.md new file mode 100644 index 0000000..5e7f8a1 --- /dev/null +++ b/docs/specs/avatar-metrics-tracking.md @@ -0,0 +1,278 @@ +# Avatar 状态追踪功能 + +## 概述 + +新增可选的 Avatar 状态追踪功能,用于记录角色成长轨迹。该功能默认关闭,不影响现有游戏逻辑。 + +## 功能特点 + +- **可选性**:默认关闭,不影响现有功能 +- **轻量**:仅记录关键指标(修为、资源、社交等) +- **不可变**:快照一旦创建不修改 +- **持久化**:支持存档/读档 +- **自动清理**:默认最多保留 1200 笔记录(100 年) + +## 使用方式 + +### 启用追踪 + +```python +# 启用 Avatar 状态追踪 +avatar.enable_metrics_tracking = True +``` + +### 自动记录 + +追踪启用后,模拟器会在每月自动调用 `record_metrics()`: + +```python +# 在 simulator.py 的 _finalize_step() 中自动执行 +# avatar.record_metrics() # 每月自动调用 +``` + +### 手动记录并标记事件 + +```python +from src.classes.avatar_metrics import MetricTag + +# 手动记录状态(可选事件标记) +avatar.record_metrics(tags=["breakthrough"]) +avatar.record_metrics(tags=[MetricTag.INJURED.value, MetricTag.BATTLE.value]) +avatar.record_metrics(tags=["custom_event"]) # 支持自定义标签 +``` + +### 查看摘要 + +```python +# 获取状态追踪摘要 +summary = avatar.get_metrics_summary() +print(summary) +# 输出示例: +# { +# "enabled": True, +# "count": 120, +# "first_record": 100, +# "latest_record": 220, +# "cultivation_growth": 5 +# } +``` + +### 访问历史记录 + +```python +# 直接访问历史记录列表 +for metrics in avatar.metrics_history: + print(f"Month {metrics.timestamp}: Level {metrics.cultivation_level}, HP {metrics.hp}") +``` + +## 设计原则 + +### 1. 可选性 + +- 默认关闭(`enable_metrics_tracking = False`) +- 不影响现有 API 和逻辑 +- 可随时启用或禁用 + +### 2. 轻量级 + +仅记录关键指标: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `timestamp` | `MonthStamp` | 记录时间 | +| `age` | `int` | 年龄 | +| `cultivation_level` | `int` | 修为等级 | +| `cultivation_progress` | `int` | 修为进度 | +| `hp` | `float` | 当前生命值 | +| `hp_max` | `float` | 最大生命值 | +| `spirit_stones` | `int` | 灵石数量 | +| `relations_count` | `int` | 关系数量 | +| `known_regions_count` | `int` | 已知区域数量 | +| `tags` | `List[str]` | 事件标签 | + +### 3. 不可变性 + +- 快照一旦创建不修改 +- 使用 dataclass 保证结构清晰 +- 支持序列化/反序列化 + +### 4. 向后兼容 + +- 使用 `default_factory` 避免破坏旧代码 +- 存档/读档完全兼容 +- 旧存档会使用默认值(空列表、False) + +## 性能影响 + +### 关闭时 + +- 零影响(不占用额外内存或 CPU) +- `record_metrics()` 直接返回 `None` + +### 开启时 + +- 每月新增约 200 bytes 快照 +- 100 年约 240 KB +- **有上限**:默认最多保留 1200 笔记录(可通过 `max_metrics_history` 调整) + +```python +# 调整历史记录上限 +avatar.max_metrics_history = 600 # 改为 50 年 +``` + +## 默认标签 + +建议使用 `MetricTag` 枚举中的默认标签: + +| 标签 | 值 | 说明 | +|------|-----|------| +| `BREAKTHROUGH` | `"breakthrough"` | 突破 | +| `INJURED` | `"injured"` | 受伤 | +| `RECOVERED` | `"recovered"` | 康复 | +| `SECT_JOIN` | `"sect_join"` | 加入宗门 | +| `SECT_LEAVE` | `"sect_leave"` | 离开宗门 | +| `TECHNIQUE_LEARN` | `"technique_learn"` | 学习功法 | +| `DEATH` | `"death"` | 死亡 | +| `BATTLE` | `"battle"` | 战斗 | +| `DUNGEON` | `"dungeon"` | 探索秘境 | + +### 使用标签 + +```python +from src.classes.avatar_metrics import MetricTag + +# 使用枚举(推荐) +avatar.record_metrics(tags=[MetricTag.BREAKTHROUGH.value]) + +# 多个标签 +avatar.record_metrics(tags=[ + MetricTag.INJURED.value, + MetricTag.BATTLE.value +]) + +# 自定义标签(也支持) +avatar.record_metrics(tags=["custom_event", "special_occurrence"]) +``` + +## 数据结构 + +### AvatarMetrics + +```python +@dataclass +class AvatarMetrics: + timestamp: MonthStamp + age: int + cultivation_level: int + cultivation_progress: int + hp: float + hp_max: float + spirit_stones: int + relations_count: int + known_regions_count: int + tags: List[str] + + def to_save_dict(self) -> dict: + """转换为可序列化的字典""" + pass + + @classmethod + def from_save_dict(cls, data: dict) -> "AvatarMetrics": + """从字典重建""" + pass +``` + +## 序列化 + +### 存档 + +状态追踪数据会自动包含在存档中: + +```python +save_dict = avatar.to_save_dict() +# 包含: +# - "metrics_history": [...] +# - "enable_metrics_tracking": True +``` + +### 读档 + +读档时自动恢复: + +```python +avatar = Avatar.from_save_dict(data, world) +# metrics_history 和 enable_metrics_tracking 自动恢复 +``` + +## 注意事项 + +### 1. 标签的可变性 + +`tags` 字段使用 `List[str]` 而非 `List[MetricTag]`,提供灵活性: +- 支持默认标签(使用 `MetricTag.value`) +- 支持自定义标签 +- 允许混合使用 + +### 2. 自动清理 + +历史记录超过 `max_metrics_history` 时会自动清理旧记录: +```python +# 保留最新的 N 笔记录 +if len(self.metrics_history) > self.max_metrics_history: + self.metrics_history = self.metrics_history[-self.max_metrics_history:] +``` + +### 3. 不可变性 + +快照对象本身是可变的(Python dataclass 默认),但设计上应视为不可变: +- 创建后不修改 `AvatarMetrics` 对象 +- 如需更新,创建新快照 + +## 使用场景 + +### 追踪修为成长 + +```python +# 启用追踪 +avatar.enable_metrics_tracking = True + +# 模拟运行... +# 自动记录每月状态 + +# 分析成长 +first = avatar.metrics_history[0] +latest = avatar.metrics_history[-1] +print(f"修为增长: {latest.cultivation_level - first.cultivation_level}") +``` + +### 标记重大事件 + +```python +# 突破时标记 +if avatar.cultivation_progress.realm != old_realm: + avatar.record_metrics(tags=[MetricTag.BREAKTHROUGH.value]) + +# 受伤时标记 +if avatar.hp.value < avatar.hp.max_value * 0.3: + avatar.record_metrics(tags=[MetricTag.INJURED.value]) +``` + +### 分析游戏数据 + +```python +# 导出数据到 CSV/Pandas +import pandas as pd + +data = [] +for metrics in avatar.metrics_history: + data.append({ + "timestamp": metrics.timestamp, + "age": metrics.age, + "level": metrics.cultivation_level, + "hp": metrics.hp, + "spirit_stones": metrics.spirit_stones, + }) + +df = pd.DataFrame(data) +df.plot(x="timestamp", y="level") +``` diff --git a/src/classes/avatar/core.py b/src/classes/avatar/core.py index b1fa675..fce57aa 100644 --- a/src/classes/avatar/core.py +++ b/src/classes/avatar/core.py @@ -40,6 +40,7 @@ from src.classes.nickname_data import Nickname from src.classes.emotions import EmotionType from src.utils.config import CONFIG from src.classes.elixir import ConsumedElixir, Elixir +from src.classes.avatar_metrics import AvatarMetrics # Mixin 导入 from src.classes.effect import EffectsMixin @@ -124,6 +125,11 @@ class Avatar( known_regions: set[int] = field(default_factory=set) + # 状态追踪(可选) + metrics_history: List[AvatarMetrics] = field(default_factory=list) + enable_metrics_tracking: bool = False + max_metrics_history: int = 1200 # 最多 100 年 + # 关系交互计数器: key=target_id, value={"count": 0, "checked_times": 0} relation_interaction_states: dict[str, dict[str, int]] = field(default_factory=lambda: defaultdict(lambda: {"count": 0, "checked_times": 0})) @@ -260,6 +266,58 @@ class Avatar( """检查是否老死""" return self.age.death_by_old_age(self.cultivation_progress.realm) + # ========== 状态追踪 ========== + + def record_metrics(self, tags: Optional[List[str]] = None) -> Optional[AvatarMetrics]: + """ + 记录当前状态快照。 + + Args: + tags: 可选的事件标记 + + Returns: + 创建的快照,如果追踪未启用则返回 None + """ + if not self.enable_metrics_tracking: + return None + + metrics = AvatarMetrics( + timestamp=self.world.month_stamp, + age=self.age.value, + cultivation_level=self.cultivation_progress.level, + cultivation_progress=self.cultivation_progress.progress, + hp=self.hp.value, + hp_max=self.hp.max_value, + spirit_stones=self.magic_stone.amount, + relations_count=len(self.relations), + known_regions_count=len(self.known_regions), + tags=tags or [], + ) + + self.metrics_history.append(metrics) + + # 自动清理旧记录 + if len(self.metrics_history) > self.max_metrics_history: + self.metrics_history = self.metrics_history[-self.max_metrics_history:] + + return metrics + + def get_metrics_summary(self) -> dict: + """获取状态追踪摘要""" + if not self.metrics_history: + return {"enabled": self.enable_metrics_tracking, "count": 0} + + return { + "enabled": self.enable_metrics_tracking, + "count": len(self.metrics_history), + "first_record": self.metrics_history[0].timestamp, + "latest_record": self.metrics_history[-1].timestamp, + "cultivation_growth": ( + self.metrics_history[-1].cultivation_level - + self.metrics_history[0].cultivation_level + ), + } + # ========== 年龄与修为 ========== def update_age(self, current_month_stamp: MonthStamp): diff --git a/src/classes/avatar_metrics.py b/src/classes/avatar_metrics.py new file mode 100644 index 0000000..02b3022 --- /dev/null +++ b/src/classes/avatar_metrics.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass, asdict +from typing import List, Optional +from enum import Enum +from src.classes.calendar import MonthStamp + + +class MetricTag(Enum): + """预定义的度量标签""" + BREAKTHROUGH = "breakthrough" + INJURED = "injured" + RECOVERED = "recovered" + SECT_JOIN = "sect_join" + SECT_LEAVE = "sect_leave" + TECHNIQUE_LEARN = "technique_learn" + DEATH = "death" + BATTLE = "battle" + DUNGEON = "dungeon" + + +@dataclass +class AvatarMetrics: + """ + Avatar 状态快照,用于追踪角色成长轨迹。 + + 设计原则: + - 轻量:仅记录关键指标 + - 不可变:快照一旦创建不修改 + - 可选:不影响现有功能 + """ + timestamp: MonthStamp + age: int + + # 修为相关 + cultivation_level: int + cultivation_progress: int + + # 资源相关 + hp: float + hp_max: float + spirit_stones: int + + # 社会相关 + relations_count: int + known_regions_count: int + + # 标记 + tags: List[str] + + def to_save_dict(self) -> dict: + """转换为可序列化的字典(用于存档)""" + return asdict(self) + + @classmethod + def from_save_dict(cls, data: dict) -> "AvatarMetrics": + """从字典重建(用于读档)""" + return cls(**data) diff --git a/src/sim/load/avatar_load_mixin.py b/src/sim/load/avatar_load_mixin.py index e922714..05ca1ea 100644 --- a/src/sim/load/avatar_load_mixin.py +++ b/src/sim/load/avatar_load_mixin.py @@ -53,6 +53,7 @@ class AvatarLoadMixin: from src.classes.magic_stone import MagicStone from src.classes.action_runtime import ActionPlan from src.classes.elixir import elixirs_by_id, ConsumedElixir + from src.classes.avatar_metrics import AvatarMetrics # 重建基本对象 gender = Gender(data["gender"]) @@ -226,6 +227,14 @@ class AvatarLoadMixin: # 恢复临时效果 avatar.temporary_effects = data.get("temporary_effects", []) + # 重建 metrics_history + metrics_history_data = data.get("metrics_history", []) + avatar.metrics_history = [ + AvatarMetrics.from_save_dict(metrics_data) + for metrics_data in metrics_history_data + ] + avatar.enable_metrics_tracking = data.get("enable_metrics_tracking", False) + # 加载完成后重新计算effects(确保数值正确) avatar.recalc_effects() diff --git a/src/sim/save/avatar_save_mixin.py b/src/sim/save/avatar_save_mixin.py index 5441813..18066ba 100644 --- a/src/sim/save/avatar_save_mixin.py +++ b/src/sim/save/avatar_save_mixin.py @@ -107,7 +107,14 @@ class AvatarSaveMixin: } if self.long_term_objective else None, "_action_cd_last_months": self._action_cd_last_months, "known_regions": list(self.known_regions), - + + # 状态追踪 + "metrics_history": [ + metrics.to_save_dict() + for metrics in self.metrics_history + ] if self.enable_metrics_tracking else [], + "enable_metrics_tracking": self.enable_metrics_tracking, + # 丹药 "elixirs": [ { diff --git a/src/sim/simulator.py b/src/sim/simulator.py index 32e6a76..cf899b2 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -480,6 +480,11 @@ class Simulator: """ 本轮步进的最终归档:去重、入库、打日志、推进时间。 """ + # 0. 为启用追踪的 Avatar 记录每月快照 + for avatar in self.world.avatar_manager.avatars.values(): + if avatar.enable_metrics_tracking: + avatar.record_metrics() + # 1. 基于 ID 去重(防止同一个事件对象被多次添加) unique_events: dict[str, Event] = {} for e in events: diff --git a/tests/test_avatar_metrics.py b/tests/test_avatar_metrics.py new file mode 100644 index 0000000..19b12dd --- /dev/null +++ b/tests/test_avatar_metrics.py @@ -0,0 +1,146 @@ +""" +测试 Avatar 状态追踪功能 +""" +import pytest +from src.classes.avatar_metrics import AvatarMetrics, MetricTag +from src.classes.calendar import MonthStamp + + +def test_avatar_metrics_creation(): + """测试状态快照创建""" + metrics = AvatarMetrics( + timestamp=MonthStamp(100), + age=20, + cultivation_level=5, + cultivation_progress=100, + hp=100.0, + hp_max=100.0, + spirit_stones=50, + relations_count=3, + known_regions_count=2, + tags=["breakthrough"], + ) + + assert metrics.age == 20 + assert metrics.cultivation_level == 5 + assert metrics.cultivation_progress == 100 + assert metrics.hp == 100.0 + assert metrics.hp_max == 100.0 + assert metrics.spirit_stones == 50 + assert metrics.relations_count == 3 + assert metrics.known_regions_count == 2 + assert "breakthrough" in metrics.tags + assert metrics.timestamp == MonthStamp(100) + + +def test_avatar_metrics_serialization(): + """测试序列化与反序列化""" + original = AvatarMetrics( + timestamp=MonthStamp(200), + age=30, + cultivation_level=10, + cultivation_progress=500, + hp=150.0, + hp_max=200.0, + spirit_stones=1000, + relations_count=5, + known_regions_count=10, + tags=["injured", "battle"], + ) + + # 序列化 + data = original.to_save_dict() + assert isinstance(data, dict) + assert data["age"] == 30 + assert data["cultivation_level"] == 10 + assert "injured" in data["tags"] + assert "battle" in data["tags"] + + # 反序列化 + restored = AvatarMetrics.from_save_dict(data) + assert restored.age == original.age + assert restored.cultivation_level == original.cultivation_level + assert restored.hp == original.hp + assert restored.tags == original.tags + assert restored.timestamp == original.timestamp + + +def test_metric_tag_enum(): + """测试 MetricTag 枚举""" + assert MetricTag.BREAKTHROUGH.value == "breakthrough" + assert MetricTag.INJURED.value == "injured" + assert MetricTag.RECOVERED.value == "recovered" + assert MetricTag.SECT_JOIN.value == "sect_join" + assert MetricTag.SECT_LEAVE.value == "sect_leave" + assert MetricTag.TECHNIQUE_LEARN.value == "technique_learn" + assert MetricTag.DEATH.value == "death" + assert MetricTag.BATTLE.value == "battle" + assert MetricTag.DUNGEON.value == "dungeon" + + +def test_avatar_metrics_with_standard_tags(): + """测试使用标准标签""" + metrics = AvatarMetrics( + timestamp=MonthStamp(50), + age=25, + cultivation_level=7, + cultivation_progress=300, + hp=80.0, + hp_max=100.0, + spirit_stones=200, + relations_count=4, + known_regions_count=5, + tags=[MetricTag.BREAKTHROUGH.value, MetricTag.BATTLE.value], + ) + + assert "breakthrough" in metrics.tags + assert "battle" in metrics.tags + assert len(metrics.tags) == 2 + + +def test_avatar_metrics_empty_tags(): + """测试空标签列表""" + metrics = AvatarMetrics( + timestamp=MonthStamp(0), + age=0, + cultivation_level=0, + cultivation_progress=0, + hp=100.0, + hp_max=100.0, + spirit_stones=0, + relations_count=0, + known_regions_count=0, + tags=[], + ) + + assert metrics.tags == [] + assert len(metrics.tags) == 0 + + +def test_avatar_metrics_multiple_tags(): + """测试多个标签""" + tags = [ + MetricTag.BREAKTHROUGH.value, + MetricTag.INJURED.value, + MetricTag.BATTLE.value, + "custom_event", # 允许自定义标签 + ] + + metrics = AvatarMetrics( + timestamp=MonthStamp(1000), + age=100, + cultivation_level=15, + cultivation_progress=1000, + hp=50.0, + hp_max=500.0, + spirit_stones=10000, + relations_count=20, + known_regions_count=50, + tags=tags, + ) + + assert len(metrics.tags) == 4 + assert "breakthrough" in metrics.tags + assert "injured" in metrics.tags + assert "battle" in metrics.tags + assert "custom_event" in metrics.tags