Files
punktfunk-plugin-rom-manager/plugin/src/domain/reconcile.ts
T
enricobuehler 899028e20f feat: Effect services + HttpApi implementation + kit entry/CLI/dev server
- 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>
2026-07-20 19:04:25 +02:00

282 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
},
};
};