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

View File

@@ -184,7 +184,7 @@ def serialize_events_for_client(events: List[Event]) -> List[dict]:
related_ids = [str(a) for a in related_raw if a is not None]
serialized.append({
"id": getattr(event, "event_id", None) or f"{stamp_int or 'evt'}-{idx}",
"id": getattr(event, "id", None) or f"{stamp_int or 'evt'}-{idx}",
"text": str(event),
"content": getattr(event, "content", ""),
"year": year,
@@ -274,10 +274,31 @@ def check_llm_connectivity() -> tuple[bool, str]:
def init_game():
"""初始化游戏世界,逻辑复用自 src/run/run.py"""
from datetime import datetime
from src.sim.load_game import get_events_db_path
print("正在初始化游戏世界...")
game_map = load_cultivation_world_map()
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
# 生成时间戳命名的存档路径
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
save_name = f"save_{timestamp}"
saves_dir = CONFIG.paths.saves
saves_dir.mkdir(parents=True, exist_ok=True)
save_path = saves_dir / f"{save_name}.json"
events_db_path = get_events_db_path(save_path)
# 使用 SQLite 事件存储创建 World
world = World.create_with_db(
map=game_map,
month_stamp=create_month_stamp(Year(100), Month.JANUARY),
events_db_path=events_db_path,
)
print(f"事件数据库: {events_db_path}")
# 记录当前存档路径(供后续保存使用)
game_instance["current_save_path"] = save_path
sim = Simulator(world)
# 宗门初始化逻辑
@@ -645,6 +666,80 @@ def get_state():
except Exception as e:
return {"step": 0, "error": "Fatal: " + str(e)}
@app.get("/api/events")
def get_events(
avatar_id: str = None,
avatar_id_1: str = None,
avatar_id_2: str = None,
cursor: str = None,
limit: int = 100,
):
"""
分页获取事件列表。
Query Parameters:
avatar_id: 按单个角色筛选。
avatar_id_1: Pair 查询:角色 1。
avatar_id_2: Pair 查询:角色 2需同时提供 avatar_id_1
cursor: 分页 cursor获取该位置之前的事件。
limit: 每页数量,默认 100。
"""
world = game_instance.get("world")
if world is None:
return {"events": [], "next_cursor": None, "has_more": False}
event_manager = getattr(world, "event_manager", None)
if event_manager is None:
return {"events": [], "next_cursor": None, "has_more": False}
# 构建 pair 参数
avatar_id_pair = None
if avatar_id_1 and avatar_id_2:
avatar_id_pair = (avatar_id_1, avatar_id_2)
# 调用分页查询
events, next_cursor, has_more = event_manager.get_events_paginated(
avatar_id=avatar_id,
avatar_id_pair=avatar_id_pair,
cursor=cursor,
limit=limit,
)
return {
"events": serialize_events_for_client(events),
"next_cursor": next_cursor,
"has_more": has_more,
}
@app.delete("/api/events/cleanup")
def cleanup_events(
keep_major: bool = True,
before_month_stamp: int = None,
):
"""
清理历史事件(用户触发)。
Query Parameters:
keep_major: 是否保留大事,默认 true。
before_month_stamp: 删除此时间之前的事件。
"""
world = game_instance.get("world")
if world is None:
return {"deleted": 0, "error": "No world"}
event_manager = getattr(world, "event_manager", None)
if event_manager is None:
return {"deleted": 0, "error": "No event manager"}
deleted = event_manager.cleanup(
keep_major=keep_major,
before_month_stamp=before_month_stamp,
)
return {"deleted": deleted}
@app.get("/api/map")
def get_map():
"""获取静态地图数据(仅需加载一次)"""
@@ -1183,14 +1278,16 @@ def api_save_game(req: SaveGameRequest):
sim = game_instance.get("sim")
if not world or not sim:
raise HTTPException(status_code=503, detail="Game not initialized")
# 尝试从 world 属性获取(如果以后添加了)
existed_sects = getattr(world, "existed_sects", [])
if not existed_sects:
# fallback: 所有 sects
existed_sects = list(sects_by_id.values())
success, filename = save_game(world, sim, existed_sects, save_path=None) # save_path=None 会自动生成时间戳文件名
# 使用当前存档路径(保持 SQLite 数据库关联)
current_save_path = game_instance.get("current_save_path")
success, filename = save_game(world, sim, existed_sects, save_path=current_save_path)
if success:
return {"status": "ok", "filename": filename}
else:
@@ -1212,14 +1309,15 @@ def api_load_game(req: LoadGameRequest):
# 加载
new_world, new_sim, new_sects = load_game(target_path)
# 确保挂载 existed_sects 以便下次保存
new_world.existed_sects = new_sects
# 替换全局实例
game_instance["world"] = new_world
game_instance["sim"] = new_sim
game_instance["current_save_path"] = target_path
return {"status": "ok", "message": "Game loaded"}
except Exception as e:
import traceback

View File

@@ -17,6 +17,15 @@ from src.run.load_map import load_cultivation_world_map
from src.utils.config import CONFIG
def get_events_db_path(save_path: Path) -> Path:
"""
根据存档路径计算事件数据库路径。
例如save_20260105_1423.json -> save_20260105_1423_events.db
"""
return save_path.with_suffix("").with_name(save_path.stem + "_events.db")
def load_game(save_path: Optional[Path] = None) -> Tuple[World, Simulator, List[Sect]]:
"""
从文件加载游戏状态
@@ -53,13 +62,20 @@ def load_game(save_path: Optional[Path] = None) -> Tuple[World, Simulator, List[
# 重建地图(地图本身不变,只需重建宗门总部位置)
game_map = load_cultivation_world_map()
# 读取世界数据
world_data = save_data.get("world", {})
month_stamp = MonthStamp(world_data["month_stamp"])
# 重建World对象
world = World(map=game_map, month_stamp=month_stamp)
# 计算事件数据库路径
events_db_path = get_events_db_path(save_path)
# 重建World对象使用 SQLite 事件存储)
world = World.create_with_db(
map=game_map,
month_stamp=month_stamp,
events_db_path=events_db_path,
)
# 获取本局启用的宗门
existed_sect_ids = world_data.get("existed_sect_ids", [])
@@ -86,19 +102,27 @@ def load_game(save_path: Optional[Path] = None) -> Tuple[World, Simulator, List[
# 将所有avatar添加到world
world.avatar_manager.avatars = all_avatars
# 重建事件历史
# 检查是否需要从 JSON 迁移事件(向后兼容)
db_event_count = world.event_manager.count()
events_data = save_data.get("events", [])
for event_data in events_data:
event = Event.from_dict(event_data)
world.event_manager.add_event(event)
if db_event_count == 0 and len(events_data) > 0:
# SQLite 数据库是空的,但 JSON 中有事件,执行迁移
print(f"正在从 JSON 迁移 {len(events_data)} 条事件到 SQLite...")
for event_data in events_data:
event = Event.from_dict(event_data)
world.event_manager.add_event(event)
print("事件迁移完成")
else:
print(f"已从 SQLite 加载 {db_event_count} 条事件")
# 重建Simulator
simulator_data = save_data.get("simulator", {})
simulator = Simulator(world)
simulator.birth_rate = simulator_data.get("birth_rate", CONFIG.game.npc_birth_rate_per_month)
print(f"存档加载成功!共加载 {len(all_avatars)} 个角色{len(events_data)} 条事件")
print(f"存档加载成功!共加载 {len(all_avatars)} 个角色")
return world, simulator, existed_sects
except Exception as e:

View File

@@ -10,6 +10,7 @@ from src.classes.world import World
from src.sim.simulator import Simulator
from src.classes.sect import Sect
from src.utils.config import CONFIG
from src.sim.load_game import get_events_db_path
def save_game(
@@ -17,18 +18,18 @@ def save_game(
simulator: Simulator,
existed_sects: List[Sect],
save_path: Optional[Path] = None
) -> bool:
) -> tuple[bool, str]:
"""
保存游戏状态到文件
Args:
world: 世界对象
simulator: 模拟器对象
existed_sects: 本局启用的宗门列表
save_path: 保存路径默认为saves/save.json
Returns:
保存是否成功
(是否成功, 文件名)
"""
try:
# 确定保存路径
@@ -57,40 +58,39 @@ def save_game(
avatars_data = []
for avatar in world.avatar_manager.avatars.values():
avatars_data.append(avatar.to_save_dict())
# 保存事件历史(限制数量)
max_events = CONFIG.save.max_events_to_save
events_data = []
recent_events = world.event_manager.get_recent_events(limit=max_events)
for event in recent_events:
events_data.append(event.to_dict())
# 事件已实时写入 SQLite不再保存到 JSON。
# 记录事件数据库路径到元信息中(供参考)。
events_db_path = get_events_db_path(save_path)
meta["events_db"] = str(events_db_path.name)
meta["event_count"] = world.event_manager.count()
# 保存模拟器数据
simulator_data = {
"birth_rate": simulator.birth_rate
}
# 组装完整的存档数据
# 组装完整的存档数据(不含 events事件在 SQLite 中)
save_data = {
"meta": meta,
"world": world_data,
"avatars": avatars_data,
"events": events_data,
"simulator": simulator_data
}
# 写入文件
with open(save_path, "w", encoding="utf-8") as f:
json.dump(save_data, f, ensure_ascii=False, indent=2)
print(f"游戏已保存到: {save_path}")
return True
print(f"事件数据库: {events_db_path} ({meta['event_count']} 条事件)")
return True, save_path.name
except Exception as e:
print(f"保存游戏失败: {e}")
import traceback
traceback.print_exc()
return False
return False, ""
def get_save_info(save_path: Path) -> Optional[dict]: