UNPKG

endfield-mcp

Version:

MCP Server for Arknights: Endfield (TypeScript / stdio + Streamable HTTP)

240 lines • 10.1 kB
/** * Startup data sync orchestration. * * Owns retry/backoff scheduling, single-flight locking, and the cache-clear * cascade triggered when sync writes new data. Mirrors the design of * PRTS-MCP's `ts/src/startupSync.ts`, simplified to a single dataset * (GameData tables) for now. New datasets (story, audio) plug in by adding * another `syncDataset` block — the orchestration scaffolding is reusable. * * Contract: * - Runs in a background task launched from `server.ts` before listen(). * - Skips sync when `EF_DATA_PATH` is explicitly set (user manages their * own data; we must not overwrite it). * - Each dataset syncs under a single-flight lock so overlapping retries * share a mutex. * - Retries with exponential backoff (30s / 120s / 600s) when network is * unavailable, then gives up until next process start. * - Clears data-layer caches when a sync writes new data so stale reads * don't leak. */ import { join } from "node:path"; import { loadConfig } from "./config.js"; import { archiveSpecForDataset, GAMEDATA_TABLES, STORY_CN, WORLDVIEW, } from "./data/datasets.js"; import { clearCharacterCaches } from "./data/characters.js"; import { clearTextCaches } from "./data/texts.js"; import { clearStoryCaches } from "./data/story.js"; import { clearWorldviewCaches } from "./data/worldview.js"; import { createLogger } from "./utils/log.js"; import { syncReleaseArchive, } from "./data/sync.js"; // --------------------------------------------------------------------------- // Logging // --------------------------------------------------------------------------- const log = createLogger("ef.sync"); // --------------------------------------------------------------------------- // Single-flight + retry scaffolding // --------------------------------------------------------------------------- const SYNC_RETRY_DELAYS_MS = [30_000, 120_000, 600_000]; const syncInFlight = new Set(); function shouldRetrySync(status) { return status === "offline_fallback" || status === "no_data"; } /** * Run a sync function under a single-flight lock keyed by label. * * Returns: * - "skipped" if a sync with the same label is already running * - "retry" if the sync function returned true (needs another attempt) * - "done" if the sync function returned false (terminal state) */ async function singleFlightSync(label, runSync) { if (syncInFlight.has(label)) { log("INFO", `${label} sync is already running; skipping overlapping attempt.`); return "skipped"; } syncInFlight.add(label); try { return (await runSync()) ? "retry" : "done"; } finally { syncInFlight.delete(label); } } /** * Schedule a retry with exponential backoff. * * Each delay is unref'd so the timer never keeps the process alive on its * own. After the last attempt fails, gives up until next process start. */ function scheduleSyncRetry(label, runSync, attempt = 0) { const delayMs = SYNC_RETRY_DELAYS_MS[attempt]; if (delayMs === undefined) { log("WARN", `${label} sync still needs retry after ${SYNC_RETRY_DELAYS_MS.length} attempts; waiting for next process start.`); return; } const timer = setTimeout(() => { void singleFlightSync(label, runSync) .then((result) => { if (result === "skipped") scheduleSyncRetry(label, runSync, attempt); else if (result === "retry") scheduleSyncRetry(label, runSync, attempt + 1); }) .catch((err) => { log("ERROR", `${label} retry sync threw unexpectedly: ${err instanceof Error ? err.message : String(err)}`); scheduleSyncRetry(label, runSync, attempt + 1); }); }, delayMs); timer.unref?.(); log("INFO", `${label} sync will retry in ${Math.round(delayMs / 1000)}s.`); } // --------------------------------------------------------------------------- // Per-dataset sync runners // --------------------------------------------------------------------------- function logSyncResult(label, r) { const sha = r.commitSha ? r.commitSha.slice(0, 8) : "unknown"; if (r.status === "updated") { log("INFO", `${label} updated from GitHub Release (${r.spec.repo} @ ${sha}).`); } else if (r.status === "up_to_date") { log("INFO", `${label} is up to date (${r.spec.repo} @ ${sha}).`); } else if (r.status === "offline_fallback") { log("WARN", `Network unavailable; using cached ${label} (${r.spec.repo} @ ${sha}). Error: ${r.error}`); } else { log("ERROR", `${label} sync failed — no data. Error: ${r.error}`); } } /** * Build the sync runner for the GameData tables dataset. * * The returned function returns `true` when a retry is needed (network * failure), `false` otherwise. Cache clearing is invoked when sync writes * new data — reader modules expose `clearXxxCaches()` which the runner * calls after a successful refresh so stale projections don't linger. */ function makeTablesSyncRunner(localZip, localRoot) { const archiveSpec = archiveSpecForDataset(GAMEDATA_TABLES, localZip, localRoot); return async () => { const r = await syncReleaseArchive(archiveSpec); logSyncResult("GameData tables", r); if (r.status === "updated") { // Drop stale in-memory caches so the next read picks up the freshly // written files. Both layers must clear: text indices (i18n hash → // string maps) and the character table projection. clearTextCaches(); clearCharacterCaches(); // Future readers (item/enemy/stage) call their clearXxxCaches() here // as they land. } return shouldRetrySync(r.status); }; } /** * Build the sync runner for the Story CN dataset. * * Story data lives in its own subdirectory under cfg.dataPath to keep * the 9271 conv/ files separate from the tables/ bundle. Cache clearing * drops the story catalog + search index so the next read picks up new * data after a refresh. */ function makeStorySyncRunner(localZip, localRoot) { const archiveSpec = archiveSpecForDataset(STORY_CN, localZip, localRoot); return async () => { const r = await syncReleaseArchive(archiveSpec); logSyncResult("Story CN", r); if (r.status === "updated") { clearStoryCaches(); } return shouldRetrySync(r.status); }; } /** * Build the sync runner for the Worldview dataset. * * Worldview data lives in its own subdirectory under cfg.dataPath, mirroring * the story layout. Cache clearing drops the PRTS/wiki catalogs + the * TextTable string-key bridge + search corpus so the next read picks up new * data after a refresh. */ function makeWorldviewSyncRunner(localZip, localRoot) { const archiveSpec = archiveSpecForDataset(WORLDVIEW, localZip, localRoot); return async () => { const r = await syncReleaseArchive(archiveSpec); logSyncResult("Worldview", r); if (r.status === "updated") { clearWorldviewCaches(); } return shouldRetrySync(r.status); }; } // --------------------------------------------------------------------------- // Startup entry point // --------------------------------------------------------------------------- export async function runStartupSync() { const cfg = loadConfig(); const startupTasks = []; if (cfg.isCustomDataPath) { log("INFO", `EF_DATA_PATH is custom (${cfg.dataPath}); auto-sync disabled.`); return; } // GameData tables if (GAMEDATA_TABLES.requiredFiles.length === 0) { log("WARN", "GameData tables dataset has no requiredFiles pinned; skipping sync (defensive — requiredFiles are pinned in datasets.ts, this branch should not fire)."); } else { const localZip = join(cfg.dataPath, "archives", GAMEDATA_TABLES.assetName); const runTablesSync = makeTablesSyncRunner(localZip, cfg.dataPath); startupTasks.push(singleFlightSync("GameData tables", runTablesSync) .catch((err) => { log("ERROR", `GameData tables sync threw unexpectedly: ${err instanceof Error ? err.message : String(err)}`); return true; }) .then((result) => { if (result !== "done") { scheduleSyncRetry("GameData tables", runTablesSync); } })); } // Story CN if (STORY_CN.requiredFiles.length === 0) { log("WARN", "Story CN dataset has no requiredFiles; skipping sync."); } else { const storyRoot = join(cfg.dataPath, "story"); const localZip = join(storyRoot, "archives", STORY_CN.assetName); const runStorySync = makeStorySyncRunner(localZip, storyRoot); startupTasks.push(singleFlightSync("Story CN", runStorySync) .catch((err) => { log("ERROR", `Story CN sync threw unexpectedly: ${err instanceof Error ? err.message : String(err)}`); return true; }) .then((result) => { if (result !== "done") { scheduleSyncRetry("Story CN", runStorySync); } })); } // Worldview if (WORLDVIEW.requiredFiles.length === 0) { log("WARN", "Worldview dataset has no requiredFiles; skipping sync."); } else { const worldviewRoot = join(cfg.dataPath, "worldview"); const localZip = join(worldviewRoot, "archives", WORLDVIEW.assetName); const runWorldviewSync = makeWorldviewSyncRunner(localZip, worldviewRoot); startupTasks.push(singleFlightSync("Worldview", runWorldviewSync) .catch((err) => { log("ERROR", `Worldview sync threw unexpectedly: ${err instanceof Error ? err.message : String(err)}`); return true; }) .then((result) => { if (result !== "done") { scheduleSyncRetry("Worldview", runWorldviewSync); } })); } await Promise.all(startupTasks); } //# sourceMappingURL=startupSync.js.map