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

@@ -66,6 +66,34 @@ export interface LLMConfigDTO {
mode: string;
}
// --- Events Pagination ---
export interface EventDTO {
id: string;
text: string;
content: string;
year: number;
month: number;
month_stamp: number;
related_avatar_ids: string[];
is_major: boolean;
is_story: boolean;
}
export interface EventsResponseDTO {
events: EventDTO[];
next_cursor: string | null;
has_more: boolean;
}
export interface FetchEventsParams {
avatar_id?: string;
avatar_id_1?: string;
avatar_id_2?: string;
cursor?: string;
limit?: number;
}
export const gameApi = {
// --- World State ---
@@ -165,5 +193,25 @@ export const gameApi = {
saveLLMConfig(config: LLMConfigDTO) {
return httpClient.post<{ status: string; message: string }>('/api/config/llm/save', config);
},
// --- Events Pagination ---
fetchEvents(params: FetchEventsParams = {}) {
const query = new URLSearchParams();
if (params.avatar_id) query.set('avatar_id', params.avatar_id);
if (params.avatar_id_1) query.set('avatar_id_1', params.avatar_id_1);
if (params.avatar_id_2) query.set('avatar_id_2', params.avatar_id_2);
if (params.cursor) query.set('cursor', params.cursor);
if (params.limit) query.set('limit', String(params.limit));
const qs = query.toString();
return httpClient.get<EventsResponseDTO>(`/api/events${qs ? '?' + qs : ''}`);
},
cleanupEvents(keepMajor = true, beforeMonthStamp?: number) {
const query = new URLSearchParams();
query.set('keep_major', String(keepMajor));
if (beforeMonthStamp !== undefined) query.set('before_month_stamp', String(beforeMonthStamp));
return httpClient.delete<{ deleted: number }>(`/api/events/cleanup?${query}`);
}
};

View File

@@ -1,60 +1,124 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick, onMounted } from 'vue'
import { useWorldStore } from '../../stores/world'
import { NSelect } from 'naive-ui'
import { NSelect, NSpin, NButton } from 'naive-ui'
import { highlightAvatarNames, buildAvatarColorMap } from '../../utils/eventHelper'
import type { GameEvent } from '../../types/core'
const worldStore = useWorldStore()
const filterValue = ref('all')
const filterValue1 = ref('all')
const filterValue2 = ref<string | null>(null) // null 表示未启用双人筛选
const eventListRef = ref<HTMLElement | null>(null)
const filterOptions = computed(() => [
{ label: '所有人', value: 'all' },
...worldStore.avatarList.map(avatar => ({
label: (avatar.name ?? avatar.id) + (avatar.is_dead ? ' (已故)' : ''),
value: avatar.id
...worldStore.avatarList.map(avatar => ({
label: (avatar.name ?? avatar.id) + (avatar.is_dead ? ' (已故)' : ''),
value: avatar.id
}))
])
const filteredEvents = computed(() => {
const allEvents = worldStore.events || []
if (filterValue.value === 'all') {
return allEvents
// 第二人的选项(排除第一人和"所有人"
const filterOptions2 = computed(() =>
worldStore.avatarList
.filter(avatar => avatar.id !== filterValue1.value)
.map(avatar => ({
label: (avatar.name ?? avatar.id) + (avatar.is_dead ? ' (已故)' : ''),
value: avatar.id
}))
)
// 直接使用 store 中的事件(已由 API 过滤)
const displayEvents = computed(() => worldStore.events || [])
// 向上滚动加载更多
function handleScroll(e: Event) {
const el = e.target as HTMLElement
if (!el) return
// 当滚动到顶部附近时,加载更多
if (el.scrollTop < 100 && worldStore.eventsHasMore && !worldStore.eventsLoading) {
const oldScrollHeight = el.scrollHeight
worldStore.loadMoreEvents().then(() => {
// 保持滚动位置(在顶部加载了新内容后)
nextTick(() => {
const newScrollHeight = el.scrollHeight
el.scrollTop = newScrollHeight - oldScrollHeight + el.scrollTop
})
})
}
return allEvents.filter(event => event.relatedAvatarIds.includes(filterValue.value))
}
// 构建筛选参数
function buildFilter() {
if (filterValue2.value && filterValue1.value !== 'all') {
// 双人筛选
return { avatar_id_1: filterValue1.value, avatar_id_2: filterValue2.value }
} else if (filterValue1.value !== 'all') {
// 单人筛选
return { avatar_id: filterValue1.value }
}
return {}
}
// 加载事件并滚动到底部
async function reloadEvents() {
await worldStore.resetEvents(buildFilter())
nextTick(() => {
if (eventListRef.value) {
eventListRef.value.scrollTop = eventListRef.value.scrollHeight
}
})
}
// 切换第一人筛选
watch(filterValue1, async (newVal) => {
// 如果选了"所有人",清除第二人筛选
if (newVal === 'all') {
filterValue2.value = null
}
await reloadEvents()
})
// 智能滚动:仅当用户处于底部时才自动跟随滚动
watch(filteredEvents, () => {
const el = eventListRef.value
let shouldAutoScroll = false
// 切换第二人筛选
watch(filterValue2, async () => {
await reloadEvents()
})
if (!el) {
// 之前没有元素(列表为空),现在有新数据进入,应当默认滚动到底部
shouldAutoScroll = true
} else {
// 元素存在,判断当前是否处于底部
// 1. 如果内容不满一页,视为“在底部”
// 2. 如果已溢出,判断距离底部的位置(阈值 50px
const isScrollable = el.scrollHeight > el.clientHeight
const isAtBottom = !isScrollable || (el.scrollHeight - el.scrollTop - el.clientHeight < 50)
shouldAutoScroll = isAtBottom
// 添加第二人
function addSecondFilter() {
// 默认选择列表中的第一个(排除当前第一人)
const options = filterOptions2.value
if (options.length > 0) {
filterValue2.value = options[0].value
}
}
if (shouldAutoScroll) {
// 移除第二人筛选
function removeSecondFilter() {
filterValue2.value = null
}
// 智能滚动:仅当用户处于底部时才自动跟随滚动(用于实时推送的新事件)
watch(displayEvents, () => {
const el = eventListRef.value
if (!el) return
const isScrollable = el.scrollHeight > el.clientHeight
const isAtBottom = !isScrollable || (el.scrollHeight - el.scrollTop - el.clientHeight < 50)
if (isAtBottom) {
nextTick(() => {
// DOM更新后再次获取元素
const updatedEl = eventListRef.value
if (updatedEl) {
updatedEl.scrollTop = updatedEl.scrollHeight
if (eventListRef.value) {
eventListRef.value.scrollTop = eventListRef.value.scrollHeight
}
})
}
}, { deep: true })
// 切换筛选对象时,强制滚动到底部
watch(filterValue, () => {
// 初始加载
onMounted(async () => {
await worldStore.resetEvents({})
nextTick(() => {
if (eventListRef.value) {
eventListRef.value.scrollTop = eventListRef.value.scrollHeight
@@ -62,9 +126,11 @@ watch(filterValue, () => {
})
})
const emptyEventMessage = computed(() => (
filterValue.value === 'all' ? '暂无事件' : '该修士暂无事件'
))
const emptyEventMessage = computed(() => {
if (filterValue2.value) return '这两人之间暂无事件'
if (filterValue1.value !== 'all') return '该修士暂无事件'
return '暂无事件'
})
function formatEventDate(event: { year: number; month: number }) {
return `${event.year}${event.month}`
@@ -84,16 +150,49 @@ function renderEventContent(event: GameEvent): string {
<section class="sidebar-section">
<div class="sidebar-header">
<h3>事件记录</h3>
<n-select
v-model:value="filterValue"
:options="filterOptions"
size="tiny"
class="event-filter"
/>
<div class="filter-group">
<n-select
v-model:value="filterValue1"
:options="filterOptions"
size="tiny"
class="event-filter"
/>
<!-- 双人筛选 -->
<template v-if="filterValue2 !== null">
<n-select
v-model:value="filterValue2"
:options="filterOptions2"
size="tiny"
class="event-filter"
/>
<n-button size="tiny" quaternary @click="removeSecondFilter" class="remove-btn">
&times;
</n-button>
</template>
<!-- 添加第二人按钮仅当选择了单人时显示 -->
<n-button
v-else-if="filterValue1 !== 'all'"
size="tiny"
quaternary
@click="addSecondFilter"
class="add-btn"
>
+ 添加第二人
</n-button>
</div>
</div>
<div v-if="filteredEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
<div v-else class="event-list" ref="eventListRef">
<div v-for="event in filteredEvents" :key="event.id" class="event-item">
<div v-if="worldStore.eventsLoading && displayEvents.length === 0" class="loading">
<n-spin size="small" />
<span>加载中...</span>
</div>
<div v-else-if="displayEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
<div v-else class="event-list" ref="eventListRef" @scroll="handleScroll">
<!-- 顶部加载指示器 -->
<div v-if="worldStore.eventsHasMore" class="load-more-hint">
<span v-if="worldStore.eventsLoading">加载中...</span>
<span v-else>向上滚动加载更多</span>
</div>
<div v-for="event in displayEvents" :key="event.id" class="event-item">
<div class="event-date">{{ formatEventDate(event) }}</div>
<div class="event-content" v-html="renderEventContent(event)"></div>
</div>
@@ -124,8 +223,34 @@ function renderEventContent(event: GameEvent): string {
white-space: nowrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 4px;
}
.event-filter {
width: 200px;
width: 120px;
}
.add-btn {
color: #888;
font-size: 11px;
white-space: nowrap;
}
.add-btn:hover {
color: #aaa;
}
.remove-btn {
color: #888;
font-size: 16px;
padding: 0 4px;
}
.remove-btn:hover {
color: #f66;
}
.event-list {
@@ -160,10 +285,25 @@ function renderEventContent(event: GameEvent): string {
white-space: pre-line;
}
.empty {
.empty, .loading {
padding: 20px;
text-align: center;
color: #666;
font-size: 12px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.load-more-hint {
text-align: center;
padding: 8px;
color: #666;
font-size: 11px;
border-bottom: 1px solid #2a2a2a;
}
</style>

View File

@@ -39,6 +39,11 @@ export const useUiStore = defineStore('ui', () => {
detailError.value = null;
}
function clearHoverCache() {
// 清除详情缓存,强制下次选择时重新加载。
detailData.value = null;
}
async function refreshDetail() {
if (!selectedTarget.value) return;
@@ -78,9 +83,10 @@ export const useUiStore = defineStore('ui', () => {
detailData,
isLoadingDetail,
detailError,
select,
clearSelection,
clearHoverCache,
refreshDetail
};
});

View File

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