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:
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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