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

110 lines
5.2 KiB
TypeScript

import type { CollectionPreview, LaunchAvailability } from '../deployment-plan.js';
import { parseServerName, selectedZombieMap, type ZombieGame } from '../map-aliases.js';
const gameLabels: Record<ZombieGame, string> = { t5: 'Black Ops I', t6: 'Black Ops II' };
export interface CollectionMapRow {
key: string;
mapName: string;
target: number;
existing: number;
create: number;
remove: number;
}
export interface CollectionMapGroup {
game: ZombieGame | null;
label: string;
rows: CollectionMapRow[];
}
export interface CollectionKeyCoverage {
game: ZombieGame;
label: string;
required: number;
/** Reviewed creations covered by their game pool, not the pool's total size. */
available: number;
/** Creations without lobby identities have not been checked against the pool. */
unassigned: number;
}
/** Keep pool coverage separate for each game, including creations without a free lobby. */
export function collectionKeyCoverage(preview: CollectionPreview): CollectionKeyCoverage[] {
const coverage = new Map<ZombieGame, CollectionKeyCoverage>();
for (const server of preview.servers) {
const game = preview.entries[server.entryIndex].recipe.game;
const pool = coverage.get(game) ?? { game, label: gameLabels[game], required: 0, available: 0, unassigned: 0 };
pool.required++;
if (!server.name) pool.unassigned++;
else if (server.hasSavedKey) pool.available++;
coverage.set(game, pool);
}
return [...coverage.values()].sort((a, b) => a.game.localeCompare(b.game));
}
function removalMap(name: string): { game: ZombieGame | null; key: string; mapName: string } {
const parsed = parseServerName(name);
const game = parsed?.game.toLowerCase();
const supportedGame = game === 't5' || game === 't6' ? game : null;
if (parsed && supportedGame && (!parsed.gameMode || parsed.gameMode === 'zombie')) {
// Resolve friendly legacy aliases without merging T6 variants that share an engine map.
const map = selectedZombieMap(supportedGame, parsed.mapCanonical, parsed.mapSlug);
if (map) return { game: supportedGame, key: `${supportedGame}:${map.slug}`, mapName: map.friendly };
}
// A raw identity is preferable to assigning an unknown server to the wrong map.
return { game: supportedGame, key: `${supportedGame ?? 'other'}:server:${name}`, mapName: name };
}
/** Summarize affected inventory, including exact-mode removals outside the saved collection. */
export function collectionMapGroups(preview: CollectionPreview): CollectionMapGroup[] {
const groups = new Map<ZombieGame | null, CollectionMapGroup>();
const rows = new Map<string, CollectionMapRow>();
const existingNames = new Map<string, Set<string>>();
const serverRows = new Map<string, CollectionMapRow>();
const addRow = (game: ZombieGame | null, row: CollectionMapRow) => {
const group = groups.get(game) ?? { game, label: game ? gameLabels[game] : 'Other servers', rows: [] };
group.rows.push(row);
groups.set(game, group);
rows.set(row.key, row);
existingNames.set(row.key, new Set());
return row;
};
for (const entry of preview.entries) {
const { game, mapSlug, mapName } = entry.recipe;
const row = addRow(game, { key: `${game}:${mapSlug}`, mapName, target: entry.count,
existing: entry.existing, create: entry.missing, remove: 0 });
for (const name of entry.existingServers) {
existingNames.get(row.key)!.add(name);
// Backend coverage knows stored map selections even when a name is nonstandard.
serverRows.set(name, row);
}
}
if (preview.mode === 'exact') {
for (const { name } of preview.removals) {
const identity = removalMap(name);
const row = serverRows.get(name) ?? rows.get(identity.key) ?? addRow(identity.game, {
key: identity.key, mapName: identity.mapName, target: 0, existing: 0, create: 0, remove: 0,
});
row.remove++;
const counted = existingNames.get(row.key)!;
// Coverage excludes pending removals. Include those identities in displayed inventory
// while counting ordinary excess servers only once.
if (!counted.has(name)) { row.existing++; counted.add(name); }
}
}
return [...groups.values()].sort((a, b) => (a.game ?? 'z').localeCompare(b.game ?? 'z'));
}
/** Capacity remains an estimate: insufficient capacity can wait in the startup queue. */
export function collectionCapacitySummary(availability?: LaunchAvailability): string {
if (availability?.requested === 0) return 'No new server capacity needed.';
if (!availability || availability.estimatedAvailable === null) return 'Capacity estimate unavailable; launches can wait for capacity.';
if (availability.estimatedAvailable >= availability.requested) {
return availability.requested === 1 ? 'Estimated capacity for 1 server.' : `Estimated capacity for all ${availability.requested} servers.`;
}
return `Estimated capacity for ${availability.estimatedAvailable} of ${availability.requested} server${availability.requested === 1 ? '' : 's'}; launches can wait for capacity.`;
}