110 lines
5.2 KiB
TypeScript
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.`;
|
|
}
|