// The desired-state compute (design §8) — a **pure function** of `config × scan results × detection × // art cache → ProviderEntry[]`, unit-testable with no host and no filesystem. The engine // orchestrator (index.ts / cli.ts) does the I/O (scan, detect, art-probe) and hands the results here. // // `enumerateTitles` is factored out so the orchestrator can learn which titles need art (and warm the // cache) BEFORE the final `computeDesired`, without re-implementing the exclude/dedupe rules. import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire"; import type { Config, GameOverride } from "@rom-manager/contract"; import type { ArtVerdict } from "../art/provider.js"; import { killPrep } from "./close.js"; import { type DetectedEmulator, resolveEmulators } from "./emulators.js"; import { type EffectiveLaunch, resolveLaunch } from "./launch.js"; import { type Platform, resolvePlatforms } from "./platforms.js"; import type { Os } from "./quote.js"; import type { RomCandidate } from "./scanner.js"; import { dedupeKey, type ParsedTitle, parseTitle, pickBestRelease, } from "./titles.js"; /** A candidate the compute rejected, with a human reason (surfaced in the UI/CLI). */ export interface Skipped { external_id: string; title: string; reason: string; } /** One enumerated title (post-exclude, post-dedupe) — the unit both art-warming and reconcile key on. */ export interface EnumeratedTitle { candidate: RomCandidate; platform: Platform; externalId: string; parsed: ParsedTitle; override: GameOverride | undefined; } /** A summary of one desired-state compute (design §8/§9 Sync page). */ export interface SyncReport { /** Candidates considered (post-exclude, post-dedupe). */ considered: number; /** Entries produced. */ included: number; skipped: Skipped[]; /** Titles the operator explicitly excluded (per-game override) — surfaced so the UI can re-include. */ excluded: { external_id: string; title: string }[]; warnings: string[]; /** Entries dropped by the hard `maxEntries` cap. */ truncated: number; /** Included-entry count per platform. */ perPlatform: Record; /** True once `included >= warnEntries` (UI scale warning). */ overWarn: boolean; } export interface DesiredResult { entries: ProviderEntry[]; report: SyncReport; } /** Layer the effective launch: platform default ← per-platform override ← per-game override. */ const effectiveLaunch = ( platform: Platform, config: Config, override: GameOverride | undefined, ): EffectiveLaunch => { const plat = config.platformLaunch[platform.id]; const base = plat ?? platform.defaultLaunch; return { emulator: override?.emulator ?? base.emulator, core: override?.core ?? base.core, extraArgs: override?.extraArgs ?? base.extraArgs, }; }; /** Whether a platform has more than one root — the trigger for a root ordinal in its `external_id`s. */ const rootOrdinals = (config: Config): Map> => { const perPlatform = new Map(); for (const r of config.roots) { const list = perPlatform.get(r.platform) ?? []; if (!list.includes(r.dir)) list.push(r.dir); perPlatform.set(r.platform, list); } const out = new Map>(); for (const [platform, dirs] of perPlatform) { if (dirs.length <= 1) continue; // no disambiguation needed out.set(platform, new Map(dirs.map((d, i) => [d, i]))); } return out; }; /** * The stable `external_id` for a candidate: `/`, with a `/` segment * inserted only when the platform has multiple roots (design §4). */ const externalIdFor = ( candidate: RomCandidate, ordinals: Map>, ): string => { const ord = ordinals.get(candidate.platform)?.get(candidate.rootDir); return ord === undefined ? `${candidate.platform}/${candidate.relPath}` : `${candidate.platform}/${ord}/${candidate.relPath}`; }; /** * Enumerate the titles a scan yields after excludes and optional region dedupe. Pure and cheap * (no launch resolution / no art) — the orchestrator calls this to know what art to warm. */ export const enumerateTitles = ( config: Config, scan: RomCandidate[], ): { survivors: EnumeratedTitle[]; skipped: Skipped[]; excluded: { external_id: string; title: string }[]; } => { const platforms = resolvePlatforms(config.platforms); const ordinals = rootOrdinals(config); const skipped: Skipped[] = []; const excluded: { external_id: string; title: string }[] = []; // 1) Prepare: external_id, parsed title, resolve platform, drop excluded. const prepared: EnumeratedTitle[] = []; for (const candidate of scan) { const externalId = externalIdFor(candidate, ordinals); const parsed = parseTitle(candidate.stem); const platform = platforms.get(candidate.platform); if (!platform) { skipped.push({ external_id: externalId, title: parsed.displayTitle, reason: `unknown platform "${candidate.platform}"`, }); continue; } const override = config.gameOverrides[externalId]; if (override?.exclude) { // Surfaced (not silent) so the Games UI can list and re-include it. excluded.push({ external_id: externalId, title: override.title ?? parsed.displayTitle, }); continue; } prepared.push({ candidate, platform, externalId, parsed, override }); } // 2) Optional region dedupe (per platform, opt-in). const dedupeSet = new Set(config.sync.dedupeRegions); const survivors: EnumeratedTitle[] = []; const byGroup = new Map(); for (const p of prepared) { if (!dedupeSet.has(p.platform.id)) { survivors.push(p); continue; } const key = `${p.platform.id} ${dedupeKey(p.parsed)}`; const list = byGroup.get(key) ?? []; list.push(p); byGroup.set(key, list); } for (const group of byGroup.values()) { const best = pickBestRelease(group, config.sync.regionPriority); survivors.push(best); for (const p of group) { if (p !== best) { skipped.push({ external_id: p.externalId, title: p.parsed.displayTitle, reason: "deduped (another region preferred)", }); } } } return { survivors, skipped, excluded }; }; /** Merge cached art with a per-game portrait override; `undefined` if there is nothing to attach. */ const resolveArtwork = ( verdict: ArtVerdict | undefined, override: GameOverride | undefined, ): Artwork | undefined => { const base = verdict?.art ?? undefined; if (!override?.art) return base ?? undefined; return { ...(base ?? {}), portrait: override.art }; }; /** * Compute the desired provider entries. Deterministic: entries are emitted in `external_id` order, so * the `maxEntries` truncation is stable across runs. */ export const computeDesired = (params: { config: Config; scan: RomCandidate[]; detected: DetectedEmulator[]; art: Record; os: Os; }): DesiredResult => { const { config, scan, detected, art, os } = params; const defs = resolveEmulators(config.emulators); const detectedMap = new Map( detected.map((d) => [d.id, d]), ); const { survivors, skipped, excluded } = enumerateTitles(config, scan); const warnings: string[] = []; const entries: ProviderEntry[] = []; const perPlatform: Record = {}; for (const p of survivors) { const launch = effectiveLaunch(p.platform, config, p.override); const resolution = resolveLaunch({ platform: p.platform, candidate: p.candidate, launch, detected: detectedMap, defs, os, }); const title = p.override?.title ?? p.parsed.displayTitle; if (!resolution.ok) { skipped.push({ external_id: p.externalId, title, reason: resolution.reason, }); continue; } for (const w of resolution.warnings) warnings.push(`${title}: ${w}`); // Built mutable, collected as the (readonly) wire type. const entry: { -readonly [K in keyof ProviderEntry]: ProviderEntry[K]; } = { external_id: p.externalId, title, launch: { kind: "command", value: resolution.value }, }; const artwork = resolveArtwork(art[p.externalId], p.override); if (artwork && Object.keys(artwork).length > 0) entry.art = artwork; // Optional "close emulator when the stream ends" (default off). if (config.sync.closeOnEnd) { const prep = killPrep(detectedMap.get(launch.emulator), os); if (prep) entry.prep = [prep]; } entries.push(entry); perPlatform[p.platform.id] = (perPlatform[p.platform.id] ?? 0) + 1; } // Deterministic order + scale guard. entries.sort((a, b) => a.external_id.localeCompare(b.external_id)); let truncated = 0; if (entries.length > config.sync.maxEntries) { truncated = entries.length - config.sync.maxEntries; entries.length = config.sync.maxEntries; warnings.push( `entry count ${truncated + config.sync.maxEntries} exceeds maxEntries=${config.sync.maxEntries}; ${truncated} dropped`, ); } return { entries, report: { considered: survivors.length, included: entries.length, skipped, excluded, warnings, truncated, perPlatform, overWarn: entries.length >= config.sync.warnEntries, }, }; };