diff --git a/docs/specs/sqlite-event-manager.md b/docs/specs/sqlite-event-manager.md new file mode 100644 index 0000000..0be13b4 --- /dev/null +++ b/docs/specs/sqlite-event-manager.md @@ -0,0 +1,330 @@ +# SQLite EventManager 重构规范 + +## 概述 + +将 EventManager 从内存存储迁移到 SQLite,实现事件持久化、分页加载和更好的查询性能。 + +## 决策摘要 + +| 项目 | 决定 | +|------|------| +| 写入策略 | 实时写入(每个事件立即写入 SQLite) | +| 前端加载 | 分页加载,向上滚动加载更旧事件 | +| 每页数量 | 100 条 | +| 数据库位置 | 每个存档一个,格式 `{save_name}_events.db` | +| 索引策略 | SQLite 原生索引 | +| 数据清理 | 用户控制(提供"清理历史"按钮) | +| 旧数据迁移 | 自动迁移(加载时从 JSON 迁移到 SQLite) | +| 实时推送 | 保持现有 WebSocket 方式 | +| 错误处理 | 静默失败,记录日志但不影响游戏运行 | +| API 分页 | Cursor 分页(复合 cursor: month_stamp + rowid) | +| Pair 查询 | 在事件筛选器中增加"筛选两人"选项 | +| 多存档 | 支持,使用时间戳命名(如 `save_20260105_1423`) | +| 优先级 | **高** | + +--- + +## 数据库设计 + +### 表结构 + +```sql +-- 事件主表 +CREATE TABLE events ( + id TEXT PRIMARY KEY, -- UUID + month_stamp INTEGER NOT NULL, -- 月份时间戳 + content TEXT NOT NULL, -- 事件内容 + is_major BOOLEAN DEFAULT FALSE, -- 是否为大事(长期记忆) + is_story BOOLEAN DEFAULT FALSE, -- 是否为故事事件 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 事件-角色关联表 +CREATE TABLE event_avatars ( + event_id TEXT NOT NULL, + avatar_id TEXT NOT NULL, + PRIMARY KEY (event_id, avatar_id), + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE +); + +-- 索引 +CREATE INDEX idx_events_month_stamp ON events(month_stamp DESC); +CREATE INDEX idx_events_is_major ON events(is_major); +CREATE INDEX idx_event_avatars_avatar_id ON event_avatars(avatar_id); +CREATE INDEX idx_event_avatars_event_id ON event_avatars(event_id); +``` + +### 复合 Cursor 格式 + +``` +{month_stamp}_{rowid} +``` + +使用 SQLite rowid 而非 UUID,保证同一 month_stamp 内的确定性排序(UUID 排序不稳定)。 + +示例: `1764_12345` + +--- + +## API 设计 + +### GET /api/events + +获取事件列表(分页)。 + +**Query Parameters:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| avatar_id | string | 否 | 按单个角色筛选 | +| avatar_id_1 | string | 否 | Pair 查询:角色 1 | +| avatar_id_2 | string | 否 | Pair 查询:角色 2(需同时提供 avatar_id_1) | +| cursor | string | 否 | 分页 cursor,获取该位置之前的事件 | +| limit | int | 否 | 每页数量,默认 100 | + +**Response:** + +```json +{ + "events": [ + { + "id": "uuid", + "text": "147年5月: 张三开始修炼", + "content": "张三开始修炼", + "year": 147, + "month": 5, + "month_stamp": 1764, + "related_avatar_ids": ["avatar_id_1"], + "is_major": false, + "is_story": false + } + ], + "next_cursor": "1764_12345", // null 表示没有更多 + "has_more": true +} +``` + +### DELETE /api/events/cleanup + +清理历史事件(用户触发)。 + +**Query Parameters:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| keep_major | bool | 否 | 是否保留大事,默认 true | +| before_month_stamp | int | 否 | 删除此时间之前的事件 | + +--- + +## 后端实现 + +### 新增文件 + +``` +src/classes/event_storage.py # SQLite 存储层 +``` + +### 修改文件 + +``` +src/classes/event_manager.py # 重构为使用 SQLite +src/server/main.py # 新增分页 API +src/sim/save/save_game.py # 关联数据库文件 +src/sim/load/load_game.py # 加载时连接数据库 +``` + +### EventStorage 类 + +```python +class EventStorage: + """SQLite 事件存储层。""" + + def __init__(self, db_path: Path): + """初始化数据库连接,创建表(如不存在)。""" + + def add_event(self, event: Event) -> bool: + """ + 写入单个事件。 + 失败时记录日志并返回 False,不抛异常。 + """ + + def get_events( + self, + avatar_id: str | None = None, + avatar_id_pair: tuple[str, str] | None = None, + cursor: str | None = None, + limit: int = 100, + ) -> tuple[list[Event], str | None]: + """ + 分页查询事件。 + 返回 (events, next_cursor)。 + """ + + def get_events_by_avatar(self, avatar_id: str, limit: int = 50) -> list[Event]: + """后端用:获取角色相关事件(供 LLM prompt 使用)。""" + + def get_events_between(self, id1: str, id2: str, limit: int = 50) -> list[Event]: + """后端用:获取两角色之间的事件。""" + + def cleanup(self, keep_major: bool = True, before: int | None = None) -> int: + """清理事件,返回删除数量。""" + + def close(self): + """关闭数据库连接。""" +``` + +### EventManager 重构 + +```python +class EventManager: + """重构后的 EventManager,使用 SQLite 存储。""" + + def __init__(self, storage: EventStorage): + self._storage = storage + # 移除所有内存索引(_events, _by_avatar, _by_pair 等) + + def add_event(self, event: Event) -> None: + """实时写入 SQLite。""" + self._storage.add_event(event) + + # 其他查询方法委托给 storage +``` + +--- + +## 前端实现 + +### 修改文件 + +``` +web/src/stores/world.ts # 事件状态管理 +web/src/components/panels/EventPanel.vue # 分页 UI +web/src/api/game.ts # 新增 API +``` + +### 事件状态管理 + +```typescript +// world.ts +interface EventState { + events: GameEvent[]; + cursor: string | null; + hasMore: boolean; + isLoading: boolean; +} + +// 新增 actions +async function loadMoreEvents(): Promise; +async function resetEvents(avatarId?: string, avatarId2?: string): Promise; +``` + +### EventPanel 改动 + +1. **向上滚动加载**:监听滚动事件,当接近顶部时触发 `loadMoreEvents()` +2. **筛选器扩展**:增加"筛选两人"选项 +3. **加载指示器**:显示加载状态 +4. **切换筛选重置**:切换角色时调用 `resetEvents()` 重新加载 + +### 筛选器 UI + +``` +[所有人 ▼] [+ 添加第二人] + +// 选择第二人后变为: +[张三 ▼] [李四 ▼] [×] +``` + +--- + +## 存档系统整合 + +### 文件结构 + +``` +assets/saves/ +├── save_20260105_1423.json # 游戏状态 +├── save_20260105_1423_events.db # 事件数据库 +├── save_20260105_1500.json +└── save_20260105_1500_events.db +``` + +### 保存流程 + +1. 保存 JSON 存档(现有逻辑) +2. 确保数据库文件存在(实时写入已处理) +3. 可选:执行 `VACUUM` 优化数据库大小 + +### 加载流程 + +1. 读取 JSON 存档 +2. 连接对应的 `_events.db` 文件 +3. 如果数据库不存在,创建空数据库 + +### 新建游戏 + +1. 生成时间戳名称:`save_YYYYMMDD_HHMM` +2. 创建空数据库文件 +3. 初始化游戏状态 + +--- + +## 错误处理 + +### SQLite 写入失败 + +```python +def add_event(self, event: Event) -> bool: + try: + # 写入逻辑 + return True + except Exception as e: + logger.error(f"Failed to write event: {e}") + return False # 不抛异常,游戏继续运行 +``` + +### 数据库文件丢失 + +加载存档时,如果 `_events.db` 不存在: +- 记录警告日志 +- 创建新的空数据库 +- 游戏正常运行(但没有历史事件) + +--- + +## TODO(未来扩展) + +```python +# TODO(xzhseh): 事件搜索功能 +# - 支持按关键词搜索事件内容 +# - 可能需要 SQLite FTS5 扩展 + +# TODO(xzhseh): 事件分类/标签系统 +# - 添加 event_type 字段(combat/cultivation/social/death 等) +# - 支持按类型筛选 +``` + +--- + +## 实现顺序 + +1. **Phase 1: 后端核心** ✅ + - [x] 创建 `EventStorage` 类 + - [x] 重构 `EventManager` + - [x] 修复 `event_id` → `id` bug + - [x] 实现分页 API + +2. **Phase 2: 存档整合** ✅ + - [x] 修改保存/加载逻辑 + - [x] 支持多存档槽位 + - [x] 时间戳命名 + +3. **Phase 3: 前端** ✅ + - [x] 分页加载 UI + - [x] 向上滚动加载 + - [x] 双人筛选器 + +4. **Phase 4: 清理** + - [x] 用户清理历史 API(`/api/events/cleanup`) + - [ ] 清理历史 UI 按钮 + - [x] 移除旧的内存索引代码(EventManager 已重构)