refactor: workspace restructure — contract package + pure domain ported, tests green

- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
  domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
  imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
  hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
  gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:54:38 +02:00
parent e78df91925
commit d6cf5f3d36
30 changed files with 1938 additions and 181 deletions
+281
View File
@@ -0,0 +1,281 @@
// 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 { Config, GameOverride } from "@rom-manager/contract";
import { type DetectedEmulator, resolveEmulators } from "./emulators.js";
import { type Platform, resolvePlatforms } from "./platforms.js";
import type { Os } from "./quote.js";
import type { ArtVerdict } from "../art/provider.js";
import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { killPrep } from "./close.js";
import { type EffectiveLaunch, resolveLaunch } from "./launch.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,
},
};
};