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:
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
|
||||
import { ref, shallowRef, computed } from 'vue';
|
||||
import type { AvatarSummary, GameEvent, MapMatrix, RegionSummary, CelestialPhenomenon } from '../types/core';
|
||||
import type { TickPayloadDTO, InitialStateDTO } from '../types/api';
|
||||
import type { FetchEventsParams } from '../api/game';
|
||||
import { gameApi } from '../api/game';
|
||||
import { processNewEvents, mergeAndSortEvents } from '../utils/eventHelper';
|
||||
|
||||
@@ -16,7 +17,13 @@ export const useWorldStore = defineStore('world', () => {
|
||||
const avatars = shallowRef<Map<string, AvatarSummary>>(new Map());
|
||||
|
||||
const events = shallowRef<GameEvent[]>([]);
|
||||
|
||||
|
||||
// 分页状态
|
||||
const eventsCursor = ref<string | null>(null);
|
||||
const eventsHasMore = ref(false);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFilter = ref<FetchEventsParams>({});
|
||||
|
||||
const mapData = shallowRef<MapMatrix>([]);
|
||||
const regions = shallowRef<Map<string | number, RegionSummary>>(new Map());
|
||||
|
||||
@@ -66,9 +73,31 @@ export const useWorldStore = defineStore('world', () => {
|
||||
|
||||
function addEvents(rawEvents: any[]) {
|
||||
if (!rawEvents || rawEvents.length === 0) return;
|
||||
|
||||
const newEvents = processNewEvents(rawEvents, year.value, month.value);
|
||||
events.value = mergeAndSortEvents(events.value, newEvents);
|
||||
|
||||
let newEvents = processNewEvents(rawEvents, year.value, month.value);
|
||||
|
||||
// 根据当前筛选条件过滤(数据在 SQLite 中不会丢失)
|
||||
const filter = eventsFilter.value;
|
||||
if (filter.avatar_id) {
|
||||
newEvents = newEvents.filter(e =>
|
||||
e.relatedAvatarIds?.includes(filter.avatar_id!)
|
||||
);
|
||||
} else if (filter.avatar_id_1 && filter.avatar_id_2) {
|
||||
newEvents = newEvents.filter(e =>
|
||||
e.relatedAvatarIds?.includes(filter.avatar_id_1!) &&
|
||||
e.relatedAvatarIds?.includes(filter.avatar_id_2!)
|
||||
);
|
||||
}
|
||||
|
||||
if (newEvents.length === 0) return;
|
||||
|
||||
// WebSocket 推送的新事件直接追加到末尾(最新事件在底部)
|
||||
// 使用 Set 去重(基于 id)
|
||||
const existingIds = new Set(events.value.map(e => e.id));
|
||||
const uniqueNewEvents = newEvents.filter(e => !existingIds.has(e.id));
|
||||
if (uniqueNewEvents.length > 0) {
|
||||
events.value = [...events.value, ...uniqueNewEvents];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTick(payload: TickPayloadDTO) {
|
||||
@@ -90,8 +119,11 @@ export const useWorldStore = defineStore('world', () => {
|
||||
stateRes.avatars.forEach(av => avatarMap.set(av.id, av));
|
||||
}
|
||||
avatars.value = avatarMap;
|
||||
// 事件通过 resetEvents() 从分页 API 加载,这里只重置状态。
|
||||
events.value = [];
|
||||
if (stateRes.events) addEvents(stateRes.events);
|
||||
eventsCursor.value = null;
|
||||
eventsHasMore.value = false;
|
||||
eventsFilter.value = {};
|
||||
currentPhenomenon.value = stateRes.phenomenon || null;
|
||||
isLoaded.value = true;
|
||||
}
|
||||
@@ -112,6 +144,9 @@ export const useWorldStore = defineStore('world', () => {
|
||||
regions.value = regionMap;
|
||||
|
||||
applyStateSnapshot(stateRes);
|
||||
|
||||
// 从分页 API 加载事件。
|
||||
await resetEvents({});
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize world', e);
|
||||
}
|
||||
@@ -131,10 +166,76 @@ export const useWorldStore = defineStore('world', () => {
|
||||
month.value = 0;
|
||||
avatars.value = new Map();
|
||||
events.value = [];
|
||||
eventsCursor.value = null;
|
||||
eventsHasMore.value = false;
|
||||
eventsFilter.value = {};
|
||||
isLoaded.value = false;
|
||||
currentPhenomenon.value = null;
|
||||
}
|
||||
|
||||
// --- 事件分页 ---
|
||||
|
||||
async function loadEvents(filter: FetchEventsParams = {}, append = false) {
|
||||
if (eventsLoading.value) return;
|
||||
eventsLoading.value = true;
|
||||
|
||||
try {
|
||||
const params: FetchEventsParams = { ...filter, limit: 100 };
|
||||
if (append && eventsCursor.value) {
|
||||
params.cursor = eventsCursor.value;
|
||||
}
|
||||
|
||||
const res = await gameApi.fetchEvents(params);
|
||||
|
||||
// 转换为 GameEvent 格式
|
||||
const newEvents: GameEvent[] = res.events.map(e => ({
|
||||
id: e.id,
|
||||
text: e.text,
|
||||
content: e.content,
|
||||
year: e.year,
|
||||
month: e.month,
|
||||
monthStamp: e.month_stamp,
|
||||
relatedAvatarIds: e.related_avatar_ids,
|
||||
isMajor: e.is_major,
|
||||
isStory: e.is_story,
|
||||
}));
|
||||
|
||||
// API 返回倒序(最新在前),反转成时间正序(最旧在前,最新在后)
|
||||
const sortedNewEvents = newEvents.reverse();
|
||||
|
||||
if (append) {
|
||||
// 加载更旧的事件,添加到顶部。
|
||||
events.value = [...sortedNewEvents, ...events.value];
|
||||
} else {
|
||||
// 切换筛选条件:直接用 API 数据替换,不做 merge。
|
||||
// TODO: API 请求期间 WebSocket 推送的事件可能丢失,用户可手动刷新。
|
||||
events.value = sortedNewEvents;
|
||||
eventsFilter.value = filter;
|
||||
}
|
||||
|
||||
eventsCursor.value = res.next_cursor;
|
||||
eventsHasMore.value = res.has_more;
|
||||
} catch (e) {
|
||||
console.error('Failed to load events', e);
|
||||
} finally {
|
||||
eventsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreEvents() {
|
||||
if (!eventsHasMore.value || eventsLoading.value) return;
|
||||
await loadEvents(eventsFilter.value, true);
|
||||
}
|
||||
|
||||
async function resetEvents(filter: FetchEventsParams = {}) {
|
||||
eventsLoading.value = false; // 强制允许新请求,避免被旧请求阻塞。
|
||||
eventsCursor.value = null;
|
||||
eventsHasMore.value = false;
|
||||
events.value = []; // 清空旧数据,避免筛选切换时显示残留。
|
||||
eventsFilter.value = filter; // 立即更新筛选条件,让 addEvents 也能正确过滤。
|
||||
await loadEvents(filter, false);
|
||||
}
|
||||
|
||||
async function getPhenomenaList() {
|
||||
if (phenomenaList.value.length > 0) return phenomenaList.value;
|
||||
try {
|
||||
@@ -163,17 +264,24 @@ export const useWorldStore = defineStore('world', () => {
|
||||
avatars,
|
||||
avatarList,
|
||||
events,
|
||||
eventsCursor,
|
||||
eventsHasMore,
|
||||
eventsLoading,
|
||||
eventsFilter,
|
||||
mapData,
|
||||
regions,
|
||||
isLoaded,
|
||||
frontendConfig,
|
||||
currentPhenomenon,
|
||||
phenomenaList,
|
||||
|
||||
|
||||
initialize,
|
||||
fetchState,
|
||||
handleTick,
|
||||
reset,
|
||||
loadEvents,
|
||||
loadMoreEvents,
|
||||
resetEvents,
|
||||
getPhenomenaList,
|
||||
changePhenomenon
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user