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:
@@ -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: 下一页的 cursor,None 表示没有更多。
|
||||
- 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()
|
||||
|
||||
543
src/classes/event_storage.py
Normal file
543
src/classes/event_storage.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user