From fbe7b40f9b5c33b01c2555965251c041d94ce82e Mon Sep 17 00:00:00 2001 From: ny <64143453+nyaoouo@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:35:16 +0800 Subject: [PATCH] feat: add world state sync configuration and trigger on config load --- src/index.ts | 3 +- src/services/configService.ts | 6 ++ src/services/configWatcherService.ts | 19 +++++ src/services/worldStateService.ts | 123 +++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index de36b392..96ecd886 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,12 +21,13 @@ import mongoose from "mongoose"; import { JSONStringify } from "json-with-bigint"; import { startWebServer } from "./services/webService"; -import { validateConfig } from "@/src/services/configWatcherService"; +import { validateConfig, triggerConfigLoadedCallbacks } from "@/src/services/configWatcherService"; // Patch JSON.stringify to work flawlessly with Bigints. JSON.stringify = JSONStringify; validateConfig(); +triggerConfigLoadedCallbacks(); mongoose .connect(config.mongodbUrl) diff --git a/src/services/configService.ts b/src/services/configService.ts index b14ffb67..c31aab5a 100644 --- a/src/services/configService.ts +++ b/src/services/configService.ts @@ -71,6 +71,12 @@ export interface IConfig { duviriOverride?: string; nightwaveOverride?: string; circuitGameModes?: string[]; + sync?: { + enabled?: boolean; + url?: string; + fields?: { [key: string]: "replace" | "merge" }; + interval?: number; // in seconds + }; }; dev?: { keepVendorsExpired?: boolean; diff --git a/src/services/configWatcherService.ts b/src/services/configWatcherService.ts index 197a9567..567a585f 100644 --- a/src/services/configWatcherService.ts +++ b/src/services/configWatcherService.ts @@ -17,6 +17,7 @@ fs.watchFile(configPath, () => { process.exit(1); } validateConfig(); + triggerConfigLoadedCallbacks(); const webPorts = getWebPorts(); if (config.httpPort != webPorts.http || config.httpsPort != webPorts.https) { @@ -50,3 +51,21 @@ export const saveConfig = async (): Promise => { amnesia = true; await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2)); }; + +const onConfigLoadedCallbacks = [] as (() => void)[]; + +export const onConfigLoaded = (callback: () => void): void => { + onConfigLoadedCallbacks.push(callback); +}; + +export const triggerConfigLoadedCallbacks = (): void => { + onConfigLoadedCallbacks.forEach(callback => { + try { + callback(); + } catch (e) { + if (e instanceof Error) { + logger.error(`Error in config loaded callback: ${e.message}`); + } + } + }); +}; diff --git a/src/services/worldStateService.ts b/src/services/worldStateService.ts index 8fa82eed..747f5ec1 100644 --- a/src/services/worldStateService.ts +++ b/src/services/worldStateService.ts @@ -5,6 +5,7 @@ import syndicateMissions from "@/static/fixed_responses/worldState/syndicateMiss import { buildConfig } from "@/src/services/buildConfigService"; import { unixTimesInMs } from "@/src/constants/timeConstants"; import { config } from "@/src/services/configService"; +import { onConfigLoaded } from "@/src/services/configWatcherService"; import { SRng } from "@/src/services/rngService"; import { ExportRegions, ExportSyndicates, IRegion } from "warframe-public-export-plus"; import { @@ -1491,3 +1492,125 @@ const nightwaveTagToSeason: Record = { RadioLegionIntermissionSyndicate: 1, // Intermission I RadioLegionSyndicate: 0 // The Wolf of Saturn Six }; + +let syncWorldStateTimer: NodeJS.Timeout | null = null; + +export const syncWorldState = async (): Promise => { + if (syncWorldStateTimer) { + clearTimeout(syncWorldStateTimer); + syncWorldStateTimer = null; + } + if (!config.worldState?.sync) { + logger.info("World state sync is disabled, skipping"); + return; + } + const { enabled, url, fields, interval } = config.worldState.sync; + if (!enabled || !url || !fields) { + logger.info("World state sync is not enabled or misconfigured, skipping"); + return; + } + const res = await fetch(url, { method: "GET" }); + if (!res.ok) { + logger.error("Failed to fetch remote world state, will retry in 5 min", { + status: res.status, + statusText: res.statusText + }); + syncWorldStateTimer = setTimeout( + () => { + void syncWorldState(); + }, + 5 * 60 * 1000 + ); + return; + } + const data = await res.json(); + if (!data || typeof data !== "object") { + logger.error("Invalid world state sync response", { data }); + return; + } + type AnyObj = { [key: string]: object | Array | string | number | boolean }; + const staticWorldState_ = staticWorldState as AnyObj; + const data_ = data as AnyObj; + + for (const [name, action_] of Object.entries(fields)) { + if (!(name in data)) { + logger.warn(`Field ${name} not found in world state sync response`, { data }); + continue; + } + const action = action_ as string; + if (action === "replace") { + staticWorldState_[name] = data_[name]; + continue; + } + if (action === "merge") { + switch (name) { + case "Events": + { + const remoteValue = data_[name] as Array; + const localValue = staticWorldState_[name] as Array; + if (Array.isArray(remoteValue) && Array.isArray(localValue)) { + localValue.push(...remoteValue); + } + } + break; + default: + logger.warn(`Not supported merge action for field ${name} in world state sync`); + } + continue; + } + logger.warn(`Unknown action ${action} for field ${name} in world state sync`); + continue; + } + let nextSync = Number.MAX_SAFE_INTEGER; + if (interval && interval > 0) { + nextSync = Date.now() + interval * 1000; // Convert seconds to milliseconds + } + for (const key in fields) { + switch (key) { + case "VoidTraders": + case "Invasions": + case "SyndicateMissions": + case "ActiveMissions": + case "NodeOverrides": + case "PrimeVaultTraders": + case "DailyDeals": { + if (Array.isArray(staticWorldState_[key])) { + const items = staticWorldState_[key] as ( + | { Expiry?: { $date?: { $numberLong?: string } } } + | undefined + )[]; + for (const item of items) { + const expire = item?.Expiry?.$date?.$numberLong; + if (typeof expire !== "string") continue; + const expireMs = parseInt(expire, 10); + if (isNaN(expireMs)) continue; + nextSync = Math.min(nextSync, expireMs); + } + } + } + } + } + logger.info(`World state sync completed, next sync at ${nextSync}`); + if (nextSync < Number.MAX_SAFE_INTEGER) { + let nextSyncDelta = nextSync - Date.now(); + if (nextSyncDelta < 60 * 1000) { + // Don't schedule syncs more often than once per minute + logger.warn("Next world state sync is scheduled too soon, delaying it by 1 minute"); + nextSyncDelta = 60 * 1000; + } else { + nextSyncDelta += 1000; + } + logger.info(`Next world state sync in ${Math.round(nextSyncDelta / 1000)} seconds`); + syncWorldStateTimer = setTimeout(() => { + void syncWorldState(); + }, nextSyncDelta); + } else { + logger.info("No next world state sync scheduled"); + } +}; + +onConfigLoaded(() => { + if (config.worldState?.sync?.enabled) { + void syncWorldState(); + } +});