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:
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user