feat: add avatar metrics tracking feature (#111)

* feat: add avatar metrics tracking feature (#110)

Add AvatarMetrics dataclass for tracking avatar state snapshots

- Add AvatarMetrics dataclass for recording monthly snapshots
- Add metrics_history field to Avatar with opt-in tracking
- Implement automatic monthly snapshot recording in Simulator
- Add backward compatibility support for existing save files
- Set default tracking limit to 1200 months (100 years)
- Add comprehensive tests with 100% coverage
- Move documentation to specs directory with simplified chinese

* fix: convert Traditional Chinese comments to Simplified Chinese

修正程式碼中的繁體中文註解為簡體中文,以符合專案規範。

Fix Traditional Chinese comments to Simplified Chinese in codebase.
This commit is contained in:
teps3105
2026-01-30 23:07:45 +08:00
committed by GitHub
parent 202de66654
commit 3ddd7868b6
7 changed files with 560 additions and 1 deletions

View File

@@ -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")
```

View File

@@ -40,6 +40,7 @@ from src.classes.nickname_data import Nickname
from src.classes.emotions import EmotionType from src.classes.emotions import EmotionType
from src.utils.config import CONFIG from src.utils.config import CONFIG
from src.classes.elixir import ConsumedElixir, Elixir from src.classes.elixir import ConsumedElixir, Elixir
from src.classes.avatar_metrics import AvatarMetrics
# Mixin 导入 # Mixin 导入
from src.classes.effect import EffectsMixin from src.classes.effect import EffectsMixin
@@ -124,6 +125,11 @@ class Avatar(
known_regions: set[int] = field(default_factory=set) 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} # 关系交互计数器: 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})) 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) 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): def update_age(self, current_month_stamp: MonthStamp):

View File

@@ -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)

View File

@@ -53,6 +53,7 @@ class AvatarLoadMixin:
from src.classes.magic_stone import MagicStone from src.classes.magic_stone import MagicStone
from src.classes.action_runtime import ActionPlan from src.classes.action_runtime import ActionPlan
from src.classes.elixir import elixirs_by_id, ConsumedElixir from src.classes.elixir import elixirs_by_id, ConsumedElixir
from src.classes.avatar_metrics import AvatarMetrics
# 重建基本对象 # 重建基本对象
gender = Gender(data["gender"]) gender = Gender(data["gender"])
@@ -226,6 +227,14 @@ class AvatarLoadMixin:
# 恢复临时效果 # 恢复临时效果
avatar.temporary_effects = data.get("temporary_effects", []) 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确保数值正确 # 加载完成后重新计算effects确保数值正确
avatar.recalc_effects() avatar.recalc_effects()

View File

@@ -107,7 +107,14 @@ class AvatarSaveMixin:
} if self.long_term_objective else None, } if self.long_term_objective else None,
"_action_cd_last_months": self._action_cd_last_months, "_action_cd_last_months": self._action_cd_last_months,
"known_regions": list(self.known_regions), "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": [ "elixirs": [
{ {

View File

@@ -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 去重(防止同一个事件对象被多次添加) # 1. 基于 ID 去重(防止同一个事件对象被多次添加)
unique_events: dict[str, Event] = {} unique_events: dict[str, Event] = {}
for e in events: for e in events:

View File

@@ -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