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

@@ -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