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:
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user