899028e20f
- RomConfig/RomCache on the kit's ConfigService/CacheStore; RomSync wires the pure domain into the kit SyncEngine (poll/watch/coalesce/fingerprint) and ProviderClient; EngineStatus assembly shared by REST + SSE - makeApi: HttpApiBuilder groups over live service values (handler runtime shares the plugin runtime's singletons) + sseRoute status feed - index.ts: definePluginKit entry (async-main boundary); headless-first (UI serve failure logged, engine continues) - cli.ts on the kit dispatcher: scan/detect/preview offline, sync/uninstall online; set-password is gone with the standalone server - dev.ts: auth-free loopback API server (:5885) — the dev:live proxy target; never bundled - verified live host-less: raw config round-trip on disk, status/platforms/ preview endpoints, soft-fail reconcile without a host - CI: workspace install, per-package typecheck, publish from plugin/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
282 lines
8.9 KiB
TypeScript
282 lines
8.9 KiB
TypeScript
// 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<string, number>;
|
||
/** 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<string, Map<string, number>> => {
|
||
const perPlatform = new Map<string, string[]>();
|
||
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<string, Map<string, number>>();
|
||
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: `<platform>/<relpath>`, with a `<root-ordinal>/` segment
|
||
* inserted only when the platform has multiple roots (design §4).
|
||
*/
|
||
const externalIdFor = (
|
||
candidate: RomCandidate,
|
||
ordinals: Map<string, Map<string, number>>,
|
||
): 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<string, EnumeratedTitle[]>();
|
||
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<string, ArtVerdict>;
|
||
os: Os;
|
||
}): DesiredResult => {
|
||
const { config, scan, detected, art, os } = params;
|
||
const defs = resolveEmulators(config.emulators);
|
||
const detectedMap = new Map<string, DetectedEmulator>(
|
||
detected.map((d) => [d.id, d]),
|
||
);
|
||
|
||
const { survivors, skipped, excluded } = enumerateTitles(config, scan);
|
||
const warnings: string[] = [];
|
||
|
||
const entries: ProviderEntry[] = [];
|
||
const perPlatform: Record<string, number> = {};
|
||
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,
|
||
},
|
||
};
|
||
};
|