Files
electric-cherry/src/lib/ui/collection-launch.ts
T

158 lines
6.8 KiB
TypeScript

import type { CollectionMode, CollectionPreview } from '../deployment-plan.js';
import type { ZombieGame } from '../map-aliases.js';
export interface CollectionLaunchInput {
blueprintId: string;
mode: CollectionMode;
libraryIds: Partial<Record<ZombieGame, string>>;
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<typeof fetch>) => 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<typeof setTimeout> | 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;
},
};
}