Simplify collection launch review
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user