Files
cultivation-world-simulator/src/classes/event_storage.py
Zihao Xu a1f08dd0ab feat: SQLite event storage with pagination and filtering
Implement SQLite-based event persistence as specified in sqlite-event-manager.md.

## Changes

### Backend
- **EventStorage** (`src/classes/event_storage.py`): New SQLite storage layer
  - Cursor-based pagination with compound cursor `{month_stamp}_{rowid}`
  - Avatar filtering (single and pair queries)
  - Major/minor event separation
  - Cleanup API with `keep_major` and `before_month_stamp` filters

- **EventManager** (`src/classes/event_manager.py`): Refactored to use SQLite
  - Delegates to EventStorage for persistence
  - Memory fallback mode for testing
  - New `get_events_paginated()` method

- **API** (`src/server/main.py`):
  - `GET /api/events` - Paginated event retrieval with filtering
  - `DELETE /api/events/cleanup` - User-triggered cleanup

### Frontend
- **EventPanel.vue**: Scroll-to-load pagination, dual-person filter UI
- **world.ts**: Event state management with pagination
- **game.ts**: New API client methods

### Testing
- 81 new tests for EventStorage, EventManager, and API
- Added `pytest-asyncio` and `httpx` to requirements.txt

## Known Issues: Save/Load is Currently Broken

After loading a saved game, the following issues occur:

1. **Wrong database used**: API returns events from the startup database instead
   of the loaded save's `_events.db` file
2. **Events from wrong time period**: Shows events from year 115 when loaded
   save is at year 114
3. **Pagination broken after load**: `has_more` returns `False` despite hundreds
   of events in the saved database
4. **Filter functionality broken**: Character selection filter stops working
   after loading a game

Root cause: `load_game.py` does not properly switch the EventManager's database
connection to the loaded save's events database.
2026-01-07 00:40:34 -08:00

544 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
SQLite 事件存储层。
提供事件的持久化存储、分页查询和清理功能。
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from contextlib import contextmanager
from src.run.log import get_logger
if TYPE_CHECKING:
from src.classes.event import Event
class EventStorage:
"""
SQLite 事件存储层。
提供:
- 实时写入事件
- 分页查询cursor-based
- 按角色/角色对查询
- 历史清理
"""
def __init__(self, db_path: Path):
"""
初始化数据库连接,创建表(如不存在)。
Args:
db_path: 数据库文件路径。
"""
self._db_path = db_path
self._conn: Optional[sqlite3.Connection] = None
self._logger = get_logger().logger
self._init_db()
def _init_db(self) -> None:
"""初始化数据库连接和表结构。"""
try:
# 确保目录存在。
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
# 启用外键约束。
self._conn.execute("PRAGMA foreign_keys = ON")
# 创建表。
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
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 IF NOT EXISTS 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 IF NOT EXISTS idx_events_month_stamp
ON events(month_stamp DESC);
CREATE INDEX IF NOT EXISTS idx_events_is_major
ON events(is_major);
CREATE INDEX IF NOT EXISTS idx_event_avatars_avatar_id
ON event_avatars(avatar_id);
CREATE INDEX IF NOT EXISTS idx_event_avatars_event_id
ON event_avatars(event_id);
""")
self._conn.commit()
self._logger.info(f"EventStorage initialized: {self._db_path}")
except Exception as e:
self._logger.error(f"Failed to initialize EventStorage: {e}")
raise
@contextmanager
def _transaction(self):
"""事务上下文管理器。"""
try:
yield self._conn
self._conn.commit()
except Exception:
self._conn.rollback()
raise
def add_event(self, event: "Event") -> bool:
"""
写入单个事件。
失败时记录日志并返回 False不抛异常。
Args:
event: 要写入的事件对象。
Returns:
写入是否成功。
"""
if self._conn is None:
self._logger.error("EventStorage not initialized")
return False
try:
with self._transaction():
# 插入事件主表。
self._conn.execute(
"""
INSERT OR IGNORE INTO events (id, month_stamp, content, is_major, is_story)
VALUES (?, ?, ?, ?, ?)
""",
(
event.id,
int(event.month_stamp),
event.content,
event.is_major,
event.is_story,
)
)
# 插入关联表。
if event.related_avatars:
for avatar_id in event.related_avatars:
self._conn.execute(
"""
INSERT OR IGNORE INTO event_avatars (event_id, avatar_id)
VALUES (?, ?)
""",
(event.id, str(avatar_id))
)
return True
except Exception as e:
self._logger.error(f"Failed to write event {event.id}: {e}")
return False
def _parse_cursor(self, cursor: str) -> tuple[int, int]:
"""
解析复合 cursor。
格式: {month_stamp}_{rowid}
Returns:
(month_stamp, rowid)
"""
parts = cursor.split("_", 1)
if len(parts) != 2:
raise ValueError(f"Invalid cursor format: {cursor}")
return int(parts[0]), int(parts[1])
def _make_cursor(self, month_stamp: int, rowid: int) -> str:
"""生成复合 cursor。"""
return f"{month_stamp}_{rowid}"
def get_events(
self,
avatar_id: Optional[str] = None,
avatar_id_pair: Optional[tuple[str, str]] = None,
cursor: Optional[str] = None,
limit: int = 100,
) -> tuple[list["Event"], Optional[str]]:
"""
分页查询事件。
Args:
avatar_id: 按单个角色筛选。
avatar_id_pair: Pair 查询(两个角色之间的事件)。
cursor: 分页 cursor获取该位置之前的事件。
limit: 每页数量。
Returns:
(events, next_cursor)next_cursor 为 None 表示没有更多。
"""
from src.classes.event import Event
from src.classes.calendar import MonthStamp
if self._conn is None:
return [], None
try:
# 构建查询。
params: list = []
if avatar_id_pair:
# Pair 查询:两个角色都相关的事件。
id1, id2 = avatar_id_pair
base_query = """
SELECT DISTINCT e.rowid, e.id, e.month_stamp, e.content, e.is_major, e.is_story
FROM events e
JOIN event_avatars ea1 ON e.id = ea1.event_id AND ea1.avatar_id = ?
JOIN event_avatars ea2 ON e.id = ea2.event_id AND ea2.avatar_id = ?
"""
params.extend([id1, id2])
elif avatar_id:
# 单角色查询。
base_query = """
SELECT DISTINCT e.rowid, e.id, e.month_stamp, e.content, e.is_major, e.is_story
FROM events e
JOIN event_avatars ea ON e.id = ea.event_id AND ea.avatar_id = ?
"""
params.append(avatar_id)
else:
# 全部事件。
base_query = """
SELECT rowid, id, month_stamp, content, is_major, is_story
FROM events e
"""
# Cursor 条件(获取更旧的事件)。
# 使用 rowid 保证同一 month_stamp 内的确定性顺序。
where_clauses = []
if cursor:
cursor_month, cursor_rowid = self._parse_cursor(cursor)
where_clauses.append(
"(e.month_stamp < ? OR (e.month_stamp = ? AND e.rowid < ?))"
)
params.extend([cursor_month, cursor_month, cursor_rowid])
# 组装 WHERE。
if where_clauses:
base_query += " WHERE " + " AND ".join(where_clauses)
# 排序和分页(最新的在前,向上加载更旧的)。
# 使用 rowid 保证同一 month_stamp 内的插入顺序。
base_query += " ORDER BY e.month_stamp DESC, e.rowid DESC LIMIT ?"
params.append(limit + 1) # 多取一条判断是否有更多。
rows = self._conn.execute(base_query, params).fetchall()
# 判断是否有更多。
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
# 构建事件对象。
events = []
last_rowid = None
last_month_stamp = None
for row in rows:
# 获取关联的 avatar IDs。
avatar_rows = self._conn.execute(
"SELECT avatar_id FROM event_avatars WHERE event_id = ?",
(row["id"],)
).fetchall()
related_avatars = [r["avatar_id"] for r in avatar_rows]
event = Event(
month_stamp=MonthStamp(row["month_stamp"]),
content=row["content"],
related_avatars=related_avatars if related_avatars else None,
is_major=bool(row["is_major"]),
is_story=bool(row["is_story"]),
id=row["id"],
)
events.append(event)
last_rowid = row["rowid"]
last_month_stamp = row["month_stamp"]
# 生成 next_cursor。
next_cursor = None
if has_more and last_rowid is not None:
next_cursor = self._make_cursor(last_month_stamp, last_rowid)
return events, next_cursor
except Exception as e:
self._logger.error(f"Failed to query events: {e}")
return [], None
def get_events_by_avatar(self, avatar_id: str, limit: int = 50) -> list["Event"]:
"""
后端用:获取角色相关事件(供 LLM prompt 使用)。
返回最新的 N 条,按时间正序排列。
"""
events, _ = self.get_events(avatar_id=avatar_id, limit=limit)
return list(reversed(events)) # 转为时间正序。
def get_events_between(self, id1: str, id2: str, limit: int = 50) -> list["Event"]:
"""
后端用:获取两角色之间的事件。
返回最新的 N 条,按时间正序排列。
"""
events, _ = self.get_events(avatar_id_pair=(id1, id2), limit=limit)
return list(reversed(events)) # 转为时间正序。
def get_major_events_by_avatar(self, avatar_id: str, limit: int = 10) -> list["Event"]:
"""获取角色的大事(长期记忆)。"""
from src.classes.event import Event
from src.classes.calendar import MonthStamp
if self._conn is None:
return []
try:
rows = self._conn.execute(
"""
SELECT DISTINCT e.id, e.month_stamp, e.content, e.is_major, e.is_story
FROM events e
JOIN event_avatars ea ON e.id = ea.event_id AND ea.avatar_id = ?
WHERE e.is_major = TRUE AND e.is_story = FALSE
ORDER BY e.month_stamp DESC
LIMIT ?
""",
(avatar_id, limit)
).fetchall()
events = []
for row in rows:
avatar_rows = self._conn.execute(
"SELECT avatar_id FROM event_avatars WHERE event_id = ?",
(row["id"],)
).fetchall()
related_avatars = [r["avatar_id"] for r in avatar_rows]
event = Event(
month_stamp=MonthStamp(row["month_stamp"]),
content=row["content"],
related_avatars=related_avatars if related_avatars else None,
is_major=bool(row["is_major"]),
is_story=bool(row["is_story"]),
id=row["id"],
)
events.append(event)
return list(reversed(events)) # 时间正序。
except Exception as e:
self._logger.error(f"Failed to query major events: {e}")
return []
def get_minor_events_by_avatar(self, avatar_id: str, limit: int = 10) -> list["Event"]:
"""获取角色的小事(短期记忆,包括故事)。"""
from src.classes.event import Event
from src.classes.calendar import MonthStamp
if self._conn is None:
return []
try:
rows = self._conn.execute(
"""
SELECT DISTINCT e.id, e.month_stamp, e.content, e.is_major, e.is_story
FROM events e
JOIN event_avatars ea ON e.id = ea.event_id AND ea.avatar_id = ?
WHERE e.is_major = FALSE OR e.is_story = TRUE
ORDER BY e.month_stamp DESC
LIMIT ?
""",
(avatar_id, limit)
).fetchall()
events = []
for row in rows:
avatar_rows = self._conn.execute(
"SELECT avatar_id FROM event_avatars WHERE event_id = ?",
(row["id"],)
).fetchall()
related_avatars = [r["avatar_id"] for r in avatar_rows]
event = Event(
month_stamp=MonthStamp(row["month_stamp"]),
content=row["content"],
related_avatars=related_avatars if related_avatars else None,
is_major=bool(row["is_major"]),
is_story=bool(row["is_story"]),
id=row["id"],
)
events.append(event)
return list(reversed(events)) # 时间正序。
except Exception as e:
self._logger.error(f"Failed to query minor events: {e}")
return []
def get_major_events_between(self, id1: str, id2: str, limit: int = 10) -> list["Event"]:
"""获取两个角色之间的大事(长期记忆)。"""
from src.classes.event import Event
from src.classes.calendar import MonthStamp
if self._conn is None:
return []
try:
rows = self._conn.execute(
"""
SELECT DISTINCT e.id, e.month_stamp, e.content, e.is_major, e.is_story
FROM events e
JOIN event_avatars ea1 ON e.id = ea1.event_id AND ea1.avatar_id = ?
JOIN event_avatars ea2 ON e.id = ea2.event_id AND ea2.avatar_id = ?
WHERE e.is_major = TRUE AND e.is_story = FALSE
ORDER BY e.month_stamp DESC
LIMIT ?
""",
(id1, id2, limit)
).fetchall()
events = []
for row in rows:
avatar_rows = self._conn.execute(
"SELECT avatar_id FROM event_avatars WHERE event_id = ?",
(row["id"],)
).fetchall()
related_avatars = [r["avatar_id"] for r in avatar_rows]
event = Event(
month_stamp=MonthStamp(row["month_stamp"]),
content=row["content"],
related_avatars=related_avatars if related_avatars else None,
is_major=bool(row["is_major"]),
is_story=bool(row["is_story"]),
id=row["id"],
)
events.append(event)
return list(reversed(events)) # 时间正序。
except Exception as e:
self._logger.error(f"Failed to query major events between: {e}")
return []
def get_minor_events_between(self, id1: str, id2: str, limit: int = 10) -> list["Event"]:
"""获取两个角色之间的小事(短期记忆)。"""
from src.classes.event import Event
from src.classes.calendar import MonthStamp
if self._conn is None:
return []
try:
rows = self._conn.execute(
"""
SELECT DISTINCT e.id, e.month_stamp, e.content, e.is_major, e.is_story
FROM events e
JOIN event_avatars ea1 ON e.id = ea1.event_id AND ea1.avatar_id = ?
JOIN event_avatars ea2 ON e.id = ea2.event_id AND ea2.avatar_id = ?
WHERE e.is_major = FALSE OR e.is_story = TRUE
ORDER BY e.month_stamp DESC
LIMIT ?
""",
(id1, id2, limit)
).fetchall()
events = []
for row in rows:
avatar_rows = self._conn.execute(
"SELECT avatar_id FROM event_avatars WHERE event_id = ?",
(row["id"],)
).fetchall()
related_avatars = [r["avatar_id"] for r in avatar_rows]
event = Event(
month_stamp=MonthStamp(row["month_stamp"]),
content=row["content"],
related_avatars=related_avatars if related_avatars else None,
is_major=bool(row["is_major"]),
is_story=bool(row["is_story"]),
id=row["id"],
)
events.append(event)
return list(reversed(events)) # 时间正序。
except Exception as e:
self._logger.error(f"Failed to query minor events between: {e}")
return []
def get_recent_events(self, limit: int = 100) -> list["Event"]:
"""获取最近的事件(供初始状态 API 使用)。"""
events, _ = self.get_events(limit=limit)
return list(reversed(events)) # 时间正序。
def cleanup(self, keep_major: bool = True, before_month_stamp: Optional[int] = None) -> int:
"""
清理事件。
Args:
keep_major: 是否保留大事。
before_month_stamp: 删除此时间之前的事件。
Returns:
删除的事件数量。
"""
if self._conn is None:
return 0
try:
conditions = []
params: list = []
if keep_major:
conditions.append("is_major = FALSE")
if before_month_stamp is not None:
conditions.append("month_stamp < ?")
params.append(before_month_stamp)
# 如果没有条件且要保留大事,则无需删除任何内容
if not conditions and keep_major:
return 0
where_clause = " AND ".join(conditions) if conditions else "1=1"
with self._transaction():
cursor = self._conn.execute(
f"DELETE FROM events WHERE {where_clause}",
params
)
deleted = cursor.rowcount
self._logger.info(f"Cleaned up {deleted} events")
return deleted
except Exception as e:
self._logger.error(f"Failed to cleanup events: {e}")
return 0
def count(self) -> int:
"""获取事件总数。"""
if self._conn is None:
return 0
try:
row = self._conn.execute("SELECT COUNT(*) FROM events").fetchone()
return row[0] if row else 0
except Exception:
return 0
def close(self) -> None:
"""关闭数据库连接。"""
if self._conn:
try:
self._conn.close()
self._logger.info("EventStorage closed")
except Exception as e:
self._logger.error(f"Failed to close EventStorage: {e}")
finally:
self._conn = None