- Implemented `serialize_active_domains` function to gather and format active hidden domains from the game world. - Updated `game_loop` to include active domains in the broadcast state. - Enhanced `StatusBar` component to display active domains with a new `StatusWidget`, including dynamic labels and colors based on the number of active domains. - Added localization support for hidden domain messages in both English and Chinese. - Updated the world store to manage active domains state and synchronize with the backend.
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
import { defineStore } from 'pinia';
|
|
import { ref, shallowRef, computed } from 'vue';
|
|
import type { AvatarSummary, GameEvent, MapMatrix, RegionSummary, CelestialPhenomenon, HiddenDomainInfo } from '../types/core';
|
|
import type { TickPayloadDTO, InitialStateDTO } from '../types/api';
|
|
import type { FetchEventsParams } from '../types/api';
|
|
import { worldApi, eventApi } from '../api';
|
|
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[]>([]);
|
|
|
|
// 秘境列表
|
|
const activeDomains = shallowRef<HiddenDomainInfo[]>([]);
|
|
|
|
// 请求计数器,用于处理 loadEvents 的竞态条件。
|
|
let eventsRequestId = 0;
|
|
// 请求计数器,用于处理 fetchState 的竞态条件。
|
|
let fetchStateRequestId = 0;
|
|
|
|
// --- 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;
|
|
|
|
// 使用通用合并排序函数,确保顺序正确(基于 createdAt 或时间戳)
|
|
events.value = mergeAndSortEvents(events.value, newEvents);
|
|
}
|
|
|
|
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;
|
|
}
|
|
// 处理秘境同步
|
|
if (payload.active_domains !== undefined) {
|
|
activeDomains.value = payload.active_domains;
|
|
} else {
|
|
// 如果后端不传,说明本回合无秘境,清空
|
|
activeDomains.value = [];
|
|
}
|
|
}
|
|
|
|
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;
|
|
activeDomains.value = [];
|
|
}
|
|
|
|
// 提前加载地图数据(在 LLM 初始化期间可用)。
|
|
async function preloadMap() {
|
|
try {
|
|
const mapRes = await worldApi.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;
|
|
// 标记地图已加载,让 MapLayer 可以渲染。
|
|
isLoaded.value = true;
|
|
console.log('[WorldStore] Map preloaded');
|
|
} catch (e) {
|
|
console.warn('[WorldStore] Failed to preload map, will retry on initialize', e);
|
|
}
|
|
}
|
|
|
|
// 提前加载角色数据(在 checking_llm 阶段 world 已创建)。
|
|
async function preloadAvatars() {
|
|
try {
|
|
const stateRes = await worldApi.fetchInitialState();
|
|
// 只更新角色,不标记完全初始化。
|
|
const avatarMap = new Map();
|
|
if (stateRes.avatars) {
|
|
stateRes.avatars.forEach(av => avatarMap.set(av.id, av));
|
|
}
|
|
avatars.value = avatarMap;
|
|
setTime(stateRes.year, stateRes.month);
|
|
console.log('[WorldStore] Avatars preloaded:', avatarMap.size);
|
|
} catch (e) {
|
|
console.warn('[WorldStore] Failed to preload avatars, will retry on initialize', e);
|
|
}
|
|
}
|
|
|
|
async function initialize() {
|
|
try {
|
|
// 如果地图还没加载,一起加载。
|
|
const needMapLoad = mapData.value.length === 0;
|
|
|
|
if (needMapLoad) {
|
|
const [stateRes, mapRes] = await Promise.all([
|
|
worldApi.fetchInitialState(),
|
|
worldApi.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);
|
|
} else {
|
|
// 地图已预加载,只需获取状态。
|
|
const stateRes = await worldApi.fetchInitialState();
|
|
applyStateSnapshot(stateRes);
|
|
}
|
|
|
|
// 从分页 API 加载事件。
|
|
await resetEvents({});
|
|
|
|
} catch (e) {
|
|
console.error('Failed to initialize world', e);
|
|
}
|
|
}
|
|
|
|
async function fetchState() {
|
|
const currentRequestId = ++fetchStateRequestId;
|
|
try {
|
|
const stateRes = await worldApi.fetchInitialState();
|
|
// 如果不是最新请求,丢弃响应。
|
|
if (currentRequestId !== fetchStateRequestId) {
|
|
return;
|
|
}
|
|
applyStateSnapshot(stateRes);
|
|
} catch (e) {
|
|
// 如果不是最新请求,不处理错误。
|
|
if (currentRequestId !== fetchStateRequestId) {
|
|
return;
|
|
}
|
|
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;
|
|
activeDomains.value = [];
|
|
}
|
|
|
|
// --- 事件分页 ---
|
|
|
|
async function loadEvents(filter: FetchEventsParams = {}, append = false) {
|
|
if (eventsLoading.value) return;
|
|
|
|
// 每次请求增加计数器,只接受最新请求的响应。
|
|
const currentRequestId = ++eventsRequestId;
|
|
eventsLoading.value = true;
|
|
|
|
try {
|
|
const params: FetchEventsParams = { ...filter, limit: 100 };
|
|
if (append && eventsCursor.value) {
|
|
params.cursor = eventsCursor.value;
|
|
}
|
|
|
|
const res = await eventApi.fetchEvents(params);
|
|
|
|
// 如果不是最新请求,丢弃响应。
|
|
if (currentRequestId !== eventsRequestId) {
|
|
return;
|
|
}
|
|
|
|
// 转换为 GameEvent 格式
|
|
const newEvents: GameEvent[] = res.events.map(e => ({
|
|
id: e.id,
|
|
text: e.text,
|
|
content: e.content,
|
|
year: e.year,
|
|
month: e.month,
|
|
timestamp: e.month_stamp,
|
|
monthStamp: e.month_stamp,
|
|
relatedAvatarIds: e.related_avatar_ids,
|
|
isMajor: e.is_major,
|
|
isStory: e.is_story,
|
|
createdAt: e.created_at,
|
|
}));
|
|
|
|
// API 返回倒序(最新在前),反转成时间正序(最旧在前,最新在后)
|
|
const sortedNewEvents = newEvents.reverse();
|
|
|
|
if (append) {
|
|
// 加载更旧的事件,添加到顶部。
|
|
events.value = [...sortedNewEvents, ...events.value];
|
|
} else {
|
|
// 切换筛选条件:直接用 API 数据替换。
|
|
events.value = sortedNewEvents;
|
|
eventsFilter.value = filter;
|
|
}
|
|
|
|
eventsCursor.value = res.next_cursor;
|
|
eventsHasMore.value = res.has_more;
|
|
} catch (e) {
|
|
// 如果不是最新请求,不处理错误。
|
|
if (currentRequestId !== eventsRequestId) {
|
|
return;
|
|
}
|
|
console.error('Failed to load events', e);
|
|
} finally {
|
|
// 只有最新请求才更新 loading 状态。
|
|
if (currentRequestId === eventsRequestId) {
|
|
eventsLoading.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadMoreEvents() {
|
|
if (!eventsHasMore.value || eventsLoading.value) return;
|
|
await loadEvents(eventsFilter.value, true);
|
|
}
|
|
|
|
async function resetEvents(filter: FetchEventsParams = {}) {
|
|
// 使旧请求失效,允许新请求。
|
|
eventsRequestId++;
|
|
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 worldApi.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 worldApi.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,
|
|
|
|
preloadMap,
|
|
preloadAvatars,
|
|
initialize,
|
|
fetchState,
|
|
handleTick,
|
|
reset,
|
|
loadEvents,
|
|
loadMoreEvents,
|
|
resetEvents,
|
|
getPhenomenaList,
|
|
changePhenomenon,
|
|
activeDomains
|
|
};
|
|
});
|