Files
cultivation-world-simulator/web/src/stores/world.ts

289 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
export const useWorldStore = defineStore('world', () => {
// --- State ---
const year = ref(0);
const month = ref(0);
// 使用 shallowRef 存储大量数据以优化性能
// Key: Avatar ID
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());
const isLoaded = ref(false);
const frontendConfig = ref<Record<string, any>>({});
const currentPhenomenon = ref<CelestialPhenomenon | null>(null);
const phenomenaList = shallowRef<CelestialPhenomenon[]>([]);
// --- Getters ---
const avatarList = computed(() => Array.from(avatars.value.values()));
// --- Actions ---
function setTime(y: number, m: number) {
year.value = y;
month.value = m;
}
function updateAvatars(list: Partial<AvatarSummary>[]) {
const next = new Map(avatars.value);
let changed = false;
for (const av of list) {
if (!av.id) continue;
const existing = next.get(av.id);
if (existing) {
// Merge
next.set(av.id, { ...existing, ...av } as AvatarSummary);
changed = true;
} else {
// New Avatar? Only insert if it has enough info (at least name)
// This handles newly born avatars sent by backend
if (av.name) {
next.set(av.id, av as AvatarSummary);
changed = true;
}
}
// Else: ignore. Do NOT insert new avatars from tick updates unless they have full info.
}
if (changed) {
avatars.value = next;
}
}
function addEvents(rawEvents: any[]) {
if (!rawEvents || rawEvents.length === 0) return;
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) {
if (!isLoaded.value) return;
setTime(payload.year, payload.month);
if (payload.avatars) updateAvatars(payload.avatars);
if (payload.events) addEvents(payload.events);
if (payload.phenomenon !== undefined) {
currentPhenomenon.value = payload.phenomenon;
}
}
function applyStateSnapshot(stateRes: InitialStateDTO) {
setTime(stateRes.year, stateRes.month);
const avatarMap = new Map();
if (stateRes.avatars) {
stateRes.avatars.forEach(av => avatarMap.set(av.id, av));
}
avatars.value = avatarMap;
// 事件通过 resetEvents() 从分页 API 加载,这里只重置状态。
events.value = [];
eventsCursor.value = null;
eventsHasMore.value = false;
eventsFilter.value = {};
currentPhenomenon.value = stateRes.phenomenon || null;
isLoaded.value = true;
}
async function initialize() {
try {
const [stateRes, mapRes] = await Promise.all([
gameApi.fetchInitialState(),
gameApi.fetchMap()
]);
mapData.value = mapRes.data;
if (mapRes.config) {
frontendConfig.value = mapRes.config;
}
const regionMap = new Map();
mapRes.regions.forEach(r => regionMap.set(r.id, r));
regions.value = regionMap;
applyStateSnapshot(stateRes);
// 从分页 API 加载事件。
await resetEvents({});
} catch (e) {
console.error('Failed to initialize world', e);
}
}
async function fetchState() {
try {
const stateRes = await gameApi.fetchInitialState();
applyStateSnapshot(stateRes);
} catch (e) {
console.error('Failed to fetch state snapshot', e);
}
}
function reset() {
year.value = 0;
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 {
const res = await gameApi.fetchPhenomenaList();
// The API returns DTOs which match CelestialPhenomenon structure enough for frontend display
phenomenaList.value = res.phenomena as CelestialPhenomenon[];
return phenomenaList.value;
} catch (e) {
console.error(e);
return [];
}
}
async function changePhenomenon(id: number) {
await gameApi.setPhenomenon(id);
// 乐观更新:直接从列表里找到并设置,不等下一次 tick
const p = phenomenaList.value.find(item => item.id === id);
if (p) {
currentPhenomenon.value = p;
}
}
return {
year,
month,
avatars,
avatarList,
events,
eventsCursor,
eventsHasMore,
eventsLoading,
eventsFilter,
mapData,
regions,
isLoaded,
frontendConfig,
currentPhenomenon,
phenomenaList,
// Functions.
initialize,
fetchState,
handleTick,
reset,
loadEvents,
loadMoreEvents,
resetEvents,
getPhenomenaList,
changePhenomenon
};
});