import type { CollectionMode, CollectionPreview } from '../deployment-plan.js'; import type { ZombieGame } from '../map-aliases.js'; export interface CollectionLaunchInput { blueprintId: string; mode: CollectionMode; libraryIds: Partial>; image: string; } export interface CollectionLaunchState { preview: CollectionPreview | null; checking: boolean; error: string; changeNotice: string; } function changesSince(previous: CollectionPreview | null, next: CollectionPreview): string { if (!previous) return ''; const sorted = (values: unknown[]) => JSON.stringify(values.map(value => JSON.stringify(value)).sort()); const removals = (preview: CollectionPreview) => sorted(preview.removals.map(server => [server.name, server.reason])); const creations = (preview: CollectionPreview) => sorted(preview.servers.map(server => { const recipe = preview.entries[server.entryIndex]?.recipe; return [server.name, recipe?.game, recipe?.mapSlug]; })); const retained = (preview: CollectionPreview) => sorted([...new Set(preview.retainedServers)]); if (removals(previous) !== removals(next)) { return 'Servers to remove changed. Review the updated removal list before applying this collection.'; } const createsChanged = creations(previous) !== creations(next); const keepsChanged = retained(previous) !== retained(next); if (createsChanged && keepsChanged) return 'Servers to create and keep changed. Review the updated fleet changes before applying.'; if (createsChanged) return 'Servers to create changed. Review the updated launch list before applying.'; if (keepsChanged) return 'Servers to keep changed. Review the updated fleet changes before applying.'; return ''; } /** Refresh collection plans without retaining a launchable preview while its inputs are changing. */ export function createCollectionLaunchController( onChange: (state: CollectionLaunchState) => void, dependencies: { fetch?: typeof fetch; debounceMs?: number } = {}, ) { const fetchPreview = dependencies.fetch ?? ((...args: Parameters) => fetch(...args)); const debounceMs = dependencies.debounceMs ?? 200; let selection: string | null = null; let context: string | null = null; let previous: CollectionPreview | null = null; let state: CollectionLaunchState = { preview: null, checking: false, error: '', changeNotice: '' }; let revision = 0; let timer: ReturnType | undefined; let request: AbortController | undefined; let suspended = false; let disposed = false; function publish(next: CollectionLaunchState) { if (state.preview === next.preview && state.checking === next.checking && state.error === next.error && state.changeNotice === next.changeNotice) return; state = next; onChange(next); } function cancel() { revision++; clearTimeout(timer); timer = undefined; request?.abort(); request = undefined; } async function check(current: number) { if (disposed || suspended || current !== revision || !selection) return; timer = undefined; const controller = new AbortController(); request = controller; try { const response = await fetchPreview('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: selection, signal: controller.signal, }); const body = await response.json(); if (disposed || suspended || current !== revision) return; if (!response.ok) throw new Error(body.error ?? 'Could not check collection readiness. Try again.'); const preview = body as CollectionPreview; const expiresAt = Date.parse(preview.expiresAt); if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new Error('The collection check expired. Try again.'); const changeNotice = changesSince(previous, preview); previous = preview; publish({ preview, checking: false, error: '', changeNotice }); if (!disposed && !suspended && current === revision) { timer = setTimeout(refresh, Math.min(expiresAt - Date.now(), 2_147_483_647)); } } catch (error) { if (!disposed && !suspended && current === revision) { publish({ preview: null, checking: false, changeNotice: '', error: error instanceof Error ? error.message : 'Could not check collection readiness. Try again.' }); } } finally { if (current === revision) request = undefined; } } function refresh() { if (disposed || !selection || (request && !suspended)) return; suspended = false; cancel(); publish({ preview: null, checking: true, error: '', changeNotice: '' }); void check(revision); } function invalidate() { cancel(); selection = null; suspended = false; publish({ preview: null, checking: false, error: '', changeNotice: '' }); } return { /** A null selection pauses checks during host reloads while preserving the last plan for comparison. */ select(input: CollectionLaunchInput | null) { if (disposed) return; if (!input) { invalidate(); return; } const nextContext = JSON.stringify([input.blueprintId, input.mode]); const next = JSON.stringify({ blueprintId: input.blueprintId, mode: input.mode, libraryIds: { t5: input.libraryIds.t5, t6: input.libraryIds.t6 }, image: input.image }); if (selection === next) return; cancel(); if (context !== nextContext) previous = null; context = nextContext; selection = next; suspended = false; publish({ preview: null, checking: true, error: '', changeNotice: '' }); const current = revision; timer = setTimeout(() => void check(current), debounceMs); }, refresh, /** Start a fresh dialog session without comparing it with any earlier plan. */ clear() { if (disposed) return; previous = null; context = null; invalidate(); }, /** Preserve the reviewed ID during submission; refresh() resumes after a definite rejection. */ suspend() { if (disposed) return; suspended = true; cancel(); publish({ ...state, checking: false }); }, dispose() { cancel(); selection = null; previous = null; context = null; disposed = true; }, }; }