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.
This commit is contained in:
Zihao Xu
2026-01-07 00:40:34 -08:00
parent e4ff312f58
commit a1f08dd0ab
14 changed files with 2892 additions and 195 deletions

View File

@@ -1,126 +1,247 @@
from typing import Dict, List
from collections import deque, defaultdict
"""
事件管理器。
from src.classes.event import Event
重构后使用 SQLite 存储,提供与旧版兼容的接口。
"""
from __future__ import annotations
from pathlib import Path
from typing import List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from src.classes.event import Event
from src.classes.event_storage import EventStorage
class EventManager:
"""
全局事件管理器:统一保存事件,并提供按角色、按角色对、按时间的查询
- 限长清理,避免内存无限增长。
- 幂等写入(基于 event_id
- 仅对恰为两人参与的事件建立“按人对”索引。
事件管理器:使用 SQLite 持久化存储
保持与旧版兼容的接口:
- add_event: 添加事件
- get_recent_events: 获取最近事件
- get_events_by_avatar: 按角色查询
- get_events_between: 按角色对查询
- get_major_events_by_avatar: 获取角色大事
- get_minor_events_by_avatar: 获取角色小事
- get_major_events_between: 获取角色对大事
- get_minor_events_between: 获取角色对小事
"""
def __init__(self, *, max_global_events: int = 5000, max_index_events: int = 200) -> None:
self.max_global_events = max_global_events
self.max_index_events = max_index_events
def __init__(self, storage: Optional["EventStorage"] = None):
"""
初始化事件管理器。
self._events: deque[Event] = deque()
self._by_id: Dict[str, Event] = {}
self._by_avatar: Dict[str, deque[Event]] = defaultdict(deque)
self._by_pair: Dict[frozenset[str], deque[Event]] = defaultdict(deque)
# 按角色分类的大事/小事索引
self._by_avatar_major: Dict[str, deque[Event]] = defaultdict(deque)
self._by_avatar_minor: Dict[str, deque[Event]] = defaultdict(deque)
# 按角色对分类的大事/小事索引
self._by_pair_major: Dict[frozenset[str], deque[Event]] = defaultdict(deque)
self._by_pair_minor: Dict[frozenset[str], deque[Event]] = defaultdict(deque)
Args:
storage: SQLite 存储层。如果为 None则使用内存模式仅用于测试
"""
self._storage = storage
# 内存后备(仅当 storage 为 None 时使用,用于测试或迁移期间)。
self._memory_events: List["Event"] = []
def _append_with_limit(self, dq: deque, item: Event) -> None:
dq.append(item)
if len(dq) > self.max_index_events:
dq.popleft()
@classmethod
def create_with_db(cls, db_path: Path) -> "EventManager":
"""
工厂方法:创建使用 SQLite 的事件管理器。
def add_event(self, event: Event) -> None:
# 过滤掉空事件
Args:
db_path: 数据库文件路径。
Returns:
配置好的 EventManager 实例。
"""
from src.classes.event_storage import EventStorage
storage = EventStorage(db_path)
return cls(storage)
@classmethod
def create_in_memory(cls) -> "EventManager":
"""
工厂方法:创建内存模式的事件管理器(仅用于测试)。
Returns:
内存模式的 EventManager 实例。
"""
return cls(storage=None)
def add_event(self, event: "Event") -> None:
"""
添加事件。
如果有 SQLite 存储,实时写入数据库。
否则存入内存后备列表。
"""
# 过滤空事件。
from src.classes.event import is_null_event
if is_null_event(event):
return
# 幂等:若已存在同 id跳过
if getattr(event, "id", None) and event.id in self._by_id:
return
if getattr(event, "id", None):
self._by_id[event.id] = event
if self._storage:
self._storage.add_event(event)
else:
# 内存后备模式。
self._memory_events.append(event)
# 全局
self._events.append(event)
if len(self._events) > self.max_global_events:
self._events.popleft()
def get_recent_events(self, limit: int = 100) -> List["Event"]:
"""获取最近的事件(时间正序)。"""
if self._storage:
return self._storage.get_recent_events(limit=limit)
else:
return self._memory_events[-limit:]
# 分索引:按人/人对
rel = event.related_avatars or []
rel_unique = list(dict.fromkeys(rel)) # 去重但保持顺序
for aid in rel_unique:
self._append_with_limit(self._by_avatar[aid], event)
# 故事事件进入小事索引,不进入大事索引
if event.is_story:
self._append_with_limit(self._by_avatar_minor[aid], event)
elif event.is_major:
self._append_with_limit(self._by_avatar_major[aid], event)
else:
self._append_with_limit(self._by_avatar_minor[aid], event)
# 仅当且仅当"恰有两位参与者"时建立按人对索引
if len(rel_unique) == 2:
a, b = rel_unique[0], rel_unique[1]
pair_key = frozenset([a, b])
self._append_with_limit(self._by_pair[pair_key], event)
# 角色对也建立分类索引
if event.is_story:
self._append_with_limit(self._by_pair_minor[pair_key], event)
elif event.is_major:
self._append_with_limit(self._by_pair_major[pair_key], event)
else:
self._append_with_limit(self._by_pair_minor[pair_key], event)
def get_events_by_avatar(self, avatar_id: str, *, limit: int = 50) -> List["Event"]:
"""获取角色相关的事件(时间正序)。"""
if self._storage:
return self._storage.get_events_by_avatar(avatar_id, limit=limit)
else:
# 内存后备模式:简单过滤。
result = []
for e in reversed(self._memory_events):
if e.related_avatars and avatar_id in e.related_avatars:
result.append(e)
if len(result) >= limit:
break
return list(reversed(result))
# —— 查询接口 ——
def get_recent_events(self, limit: int = 100) -> List[Event]:
if limit <= 0:
return []
return list(self._events)[-limit:]
def get_events_between(self, avatar_id1: str, avatar_id2: str, *, limit: int = 50) -> List["Event"]:
"""获取两个角色之间的事件(时间正序)。"""
if self._storage:
return self._storage.get_events_between(avatar_id1, avatar_id2, limit=limit)
else:
# 内存后备模式:简单过滤。
result = []
for e in reversed(self._memory_events):
if e.related_avatars:
if avatar_id1 in e.related_avatars and avatar_id2 in e.related_avatars:
result.append(e)
if len(result) >= limit:
break
return list(reversed(result))
def get_events_by_avatar(self, avatar_id: str, *, limit: int = 50) -> List[Event]:
dq = self._by_avatar.get(avatar_id)
if not dq:
return []
return list(dq)[-limit:]
def get_major_events_by_avatar(self, avatar_id: str, *, limit: int = 10) -> List["Event"]:
"""获取角色的大事(长期记忆,时间正序)。"""
if self._storage:
return self._storage.get_major_events_by_avatar(avatar_id, limit=limit)
else:
result = []
for e in reversed(self._memory_events):
if e.is_major and not e.is_story:
if e.related_avatars and avatar_id in e.related_avatars:
result.append(e)
if len(result) >= limit:
break
return list(reversed(result))
def get_events_between(self, avatar_id1: str, avatar_id2: str, *, limit: int = 50) -> List[Event]:
key = frozenset([avatar_id1, avatar_id2])
dq = self._by_pair.get(key)
if not dq:
return []
return list(dq)[-limit:]
def get_minor_events_by_avatar(self, avatar_id: str, *, limit: int = 10) -> List["Event"]:
"""获取角色的小事(短期记忆,时间正序)。"""
if self._storage:
return self._storage.get_minor_events_by_avatar(avatar_id, limit=limit)
else:
result = []
for e in reversed(self._memory_events):
if not e.is_major or e.is_story:
if e.related_avatars and avatar_id in e.related_avatars:
result.append(e)
if len(result) >= limit:
break
return list(reversed(result))
def get_major_events_by_avatar(self, avatar_id: str, *, limit: int = 10) -> List[Event]:
"""获取角色的大事(长期记忆"""
dq = self._by_avatar_major.get(avatar_id)
if not dq:
return []
return list(dq)[-limit:]
def get_major_events_between(self, avatar_id1: str, avatar_id2: str, *, limit: int = 10) -> List["Event"]:
"""获取两个角色之间的大事(长期记忆,时间正序)。"""
if self._storage:
return self._storage.get_major_events_between(avatar_id1, avatar_id2, limit=limit)
else:
result = []
for e in reversed(self._memory_events):
if e.is_major and not e.is_story:
if e.related_avatars:
if avatar_id1 in e.related_avatars and avatar_id2 in e.related_avatars:
result.append(e)
if len(result) >= limit:
break
return list(reversed(result))
def get_minor_events_by_avatar(self, avatar_id: str, *, limit: int = 10) -> List[Event]:
"""获取角色的小事(短期记忆"""
dq = self._by_avatar_minor.get(avatar_id)
if not dq:
return []
return list(dq)[-limit:]
def get_minor_events_between(self, avatar_id1: str, avatar_id2: str, *, limit: int = 10) -> List["Event"]:
"""获取两个角色之间的小事(短期记忆,时间正序)。"""
if self._storage:
return self._storage.get_minor_events_between(avatar_id1, avatar_id2, limit=limit)
else:
result = []
for e in reversed(self._memory_events):
if not e.is_major or e.is_story:
if e.related_avatars:
if avatar_id1 in e.related_avatars and avatar_id2 in e.related_avatars:
result.append(e)
if len(result) >= limit:
break
return list(reversed(result))
def get_major_events_between(self, avatar_id1: str, avatar_id2: str, *, limit: int = 10) -> List[Event]:
"""获取两个角色之间的大事(长期记忆)"""
key = frozenset([avatar_id1, avatar_id2])
dq = self._by_pair_major.get(key)
if not dq:
return []
return list(dq)[-limit:]
# --- 分页查询接口(新增)---
def get_minor_events_between(self, avatar_id1: str, avatar_id2: str, *, limit: int = 10) -> List[Event]:
"""获取两个角色之间的小事(短期记忆)"""
key = frozenset([avatar_id1, avatar_id2])
dq = self._by_pair_minor.get(key)
if not dq:
return []
return list(dq)[-limit:]
def get_events_paginated(
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], bool]:
"""
分页查询事件。
Args:
avatar_id: 按单个角色筛选。
avatar_id_pair: Pair 查询(两个角色之间的事件)。
cursor: 分页 cursor获取该位置之前的事件。
limit: 每页数量。
Returns:
(events, next_cursor, has_more)
- events: 事件列表(时间倒序,最新在前)。
- next_cursor: 下一页的 cursorNone 表示没有更多。
- has_more: 是否有更多数据。
"""
if self._storage:
events, next_cursor = self._storage.get_events(
avatar_id=avatar_id,
avatar_id_pair=avatar_id_pair,
cursor=cursor,
limit=limit,
)
return events, next_cursor, next_cursor is not None
else:
# 内存模式不支持完整分页,返回最近的。
events = self.get_recent_events(limit=limit)
return list(reversed(events)), None, False
# --- 清理接口 ---
def cleanup(self, keep_major: bool = True, before_month_stamp: Optional[int] = None) -> int:
"""
清理事件。
Args:
keep_major: 是否保留大事。
before_month_stamp: 删除此时间之前的事件。
Returns:
删除的事件数量。
"""
if self._storage:
return self._storage.cleanup(keep_major=keep_major, before_month_stamp=before_month_stamp)
else:
# 内存模式:简单清空。
count = len(self._memory_events)
self._memory_events.clear()
return count
def count(self) -> int:
"""获取事件总数。"""
if self._storage:
return self._storage.count()
else:
return len(self._memory_events)
def close(self) -> None:
"""关闭资源。"""
if self._storage:
self._storage.close()

View File

@@ -0,0 +1,543 @@
"""
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

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from src.classes.map import Map
@@ -63,4 +64,29 @@ class World():
"动作": "你有一系列可以执行的动作。要注意动作的效果、限制条件、区域和时间。",
"装备与丹药": "通过兵器、辅助装备、丹药等装备,可以获得额外的属性加成,获得或小或大的增益。拥有好的装备或者服用好的丹药,能获得很大好处。",
}
return desc
return desc
@classmethod
def create_with_db(
cls,
map: "Map",
month_stamp: MonthStamp,
events_db_path: Path,
) -> "World":
"""
工厂方法:创建使用 SQLite 持久化事件的 World 实例。
Args:
map: 地图对象。
month_stamp: 时间戳。
events_db_path: 事件数据库文件路径。
Returns:
配置好的 World 实例。
"""
event_manager = EventManager.create_with_db(events_db_path)
return cls(
map=map,
month_stamp=month_stamp,
event_manager=event_manager,
)