src/types/datStatus.ts
import { writeToString } from '@fast-csv/format';
import chalk, { ChalkInstance } from 'chalk';
import ArrayPoly from '../polyfill/arrayPoly.js';
import DAT from './dats/dat.js';
import Game from './dats/game.js';
import Parent from './dats/parent.js';
import File from './files/file.js';
import Options from './options.js';
import ReleaseCandidate from './releaseCandidate.js';
enum ROMType {
GAME = 'games',
BIOS = 'BIOSes',
DEVICE = 'devices',
RETAIL = 'retail releases',
PATCHED = 'patched games',
}
export enum GameStatus {
// The Game wanted to be written, and it has no ROMs or every ROM was found
FOUND = 1,
// Only some of the Game's ROMs were found
INCOMPLETE,
// The Game was ignored due to 1G1R rules, and it is unknown if there was a matching
// ReleaseCandidate
IGNORED,
// The Game wanted to be written, but there was no matching ReleaseCandidate
MISSING,
// The input file was not used in any ReleaseCandidate, but a duplicate file was
DUPLICATE,
// The input File was not used in any ReleaseCandidate, and neither was any duplicate file
UNUSED,
// The output File was not from any ReleaseCandidate, so it was deleted
DELETED,
}
/**
* Parse and hold information about every {@link Game} in a {@link DAT}, as well as which
* {@link Game}s were found (had a {@link ReleaseCandidate} created for it).
*/
export default class DATStatus {
private readonly dat: DAT;
private readonly allRomTypesToGames = new Map<ROMType, Game[]>();
// eslint-disable-next-line no-spaced-func
private readonly foundRomTypesToReleaseCandidates = new Map<
ROMType,
(ReleaseCandidate | undefined)[]
>();
private readonly incompleteRomTypesToReleaseCandidates = new Map<ROMType, ReleaseCandidate[]>();
private readonly ignoredHashCodesToGames = new Map<string, Game>();
constructor(
dat: DAT,
options: Options,
parentsToReleaseCandidates: Map<Parent, ReleaseCandidate[]>,
) {
this.dat = dat;
// Un-patched ROMs
[...parentsToReleaseCandidates.entries()]
.filter(([, releaseCandidates]) => releaseCandidates.every((rc) => !rc.isPatched()))
.forEach(([parent, releaseCandidates]) => {
parent.getGames().forEach((game, gameIdx) => {
DATStatus.pushValueIntoMap(this.allRomTypesToGames, game, game);
const gameReleaseCandidates = releaseCandidates
.filter((rc) => !rc.isPatched())
.filter((rc) => rc.getGame().hashCode() === game.hashCode());
if (gameReleaseCandidates.length > 0 || game.getRoms().length === 0) {
// The only reason there may be multiple ReleaseCandidates for a Game is if it has
// multiple regions, but DATStatus doesn't care about regions.
const gameReleaseCandidate = gameReleaseCandidates.find(() => true);
if (
gameReleaseCandidate &&
gameReleaseCandidate.getRomsWithFiles().length !== game.getRoms().length
) {
// The found ReleaseCandidate is incomplete
DATStatus.pushValueIntoMap(
this.incompleteRomTypesToReleaseCandidates,
game,
gameReleaseCandidate,
);
return;
}
// The found ReleaseCandidate is complete
DATStatus.pushValueIntoMap(
this.foundRomTypesToReleaseCandidates,
game,
gameReleaseCandidate,
);
return;
}
// When running in 1G1R mode, if no ReleaseCandidate was found for this Game (would have
// already returned above), and:
// 1. This Parent has at least one ReleaseCandidate, then one of the other Games must
// have a ReleaseCandidate, so this Game should be considered IGNORED
// 2. This parent has zero ReleaseCandidates and this Game is not the first Game, then
// consider this Game IGNORED (only the first Game should be considered MISSING)
// We can't know if this Game had matching input files, they would have already been
// discarded, so those files will be reported as UNUSED.
if (options.getSingle() && (releaseCandidates.length > 0 || gameIdx > 0)) {
this.ignoredHashCodesToGames.set(game.hashCode(), game);
}
});
});
// Patched ROMs
[...parentsToReleaseCandidates.entries()]
.filter(([, releaseCandidates]) => releaseCandidates.some((rc) => rc.isPatched()))
.forEach(([, releaseCandidates]) => {
// Patched ROMs
releaseCandidates
.filter((rc) => rc.isPatched())
.forEach((releaseCandidate) => {
const game = releaseCandidate.getGame();
DATStatus.append(this.allRomTypesToGames, ROMType.PATCHED, game);
DATStatus.append(
this.foundRomTypesToReleaseCandidates,
ROMType.PATCHED,
releaseCandidate,
);
});
});
}
private static pushValueIntoMap<T>(map: Map<ROMType, T[]>, game: Game, value: T): void {
DATStatus.append(map, ROMType.GAME, value);
if (game.isBios()) {
DATStatus.append(map, ROMType.BIOS, value);
}
if (game.isDevice()) {
DATStatus.append(map, ROMType.DEVICE, value);
}
if (game.isRetail()) {
DATStatus.append(map, ROMType.RETAIL, value);
}
}
private static append<T>(map: Map<ROMType, T[]>, romType: ROMType, val: T): void {
if (!map.has(romType)) {
map.set(romType, [val]);
} else {
map.get(romType)?.push(val);
}
}
getDATName(): string {
return this.dat.getNameShort();
}
getInputFiles(): File[] {
return [
...this.foundRomTypesToReleaseCandidates.values(),
...this.incompleteRomTypesToReleaseCandidates.values(),
]
.flat()
.filter((releaseCandidate) => releaseCandidate !== undefined)
.flatMap((releaseCandidate) => releaseCandidate.getRomsWithFiles())
.map((romWithFiles) => romWithFiles.getInputFile());
}
/**
* If any {@link Game} in the entire {@link DAT} was found in the input files.
*/
anyGamesFound(options: Options): boolean {
return DATStatus.getAllowedTypes(options).reduce((result, romType) => {
const foundReleaseCandidates =
this.foundRomTypesToReleaseCandidates.get(romType)?.length ?? 0;
return result || foundReleaseCandidates > 0;
}, false);
}
/**
* Return a string of CLI-friendly output to be printed by a {@link Logger}.
*/
toConsole(options: Options): string {
return `${DATStatus.getAllowedTypes(options)
.filter((type) => this.allRomTypesToGames.get(type)?.length)
.map((type) => {
const found = this.foundRomTypesToReleaseCandidates.get(type) ?? [];
const all = (this.allRomTypesToGames.get(type) ?? [])
// Do not report ignored 1G1R games in the CLI total
.filter((game) => !this.ignoredHashCodesToGames.has(game.hashCode()));
if (!options.usingDats()) {
return `${found.length.toLocaleString()} ${type}`;
}
const percentage = (found.length / all.length) * 100;
let color: ChalkInstance;
if (percentage >= 100) {
color = chalk.rgb(0, 166, 0); // macOS terminal green
} else if (percentage >= 75) {
color = chalk.rgb(153, 153, 0); // macOS terminal yellow
} else if (percentage >= 50) {
color = chalk.rgb(160, 124, 0);
} else if (percentage >= 25) {
color = chalk.rgb(162, 93, 0);
} else if (percentage > 0) {
color = chalk.rgb(160, 59, 0);
} else {
color = chalk.rgb(153, 0, 0); // macOS terminal red
}
// Patched ROMs are always found===all
if (type === ROMType.PATCHED) {
return `${color(all.length.toLocaleString())} ${type}`;
}
return `${color(found.length.toLocaleString())}/${all.length.toLocaleString()} ${type}`;
})
.filter((str) => str)
.join(', ')} ${options.shouldWrite() ? 'written' : 'found'}`;
}
/**
* Return the file contents of a CSV with status information for every {@link Game}.
*/
async toCsv(options: Options): Promise<string> {
const foundReleaseCandidates = DATStatus.getValuesForAllowedTypes(
options,
this.foundRomTypesToReleaseCandidates,
);
const incompleteReleaseCandidates = DATStatus.getValuesForAllowedTypes(
options,
this.incompleteRomTypesToReleaseCandidates,
);
const rows = DATStatus.getValuesForAllowedTypes(options, this.allRomTypesToGames)
.reduce(ArrayPoly.reduceUnique(), [])
.sort((a, b) => a.getName().localeCompare(b.getName()))
.map((game) => {
let status = GameStatus.MISSING;
if (this.ignoredHashCodesToGames.has(game.hashCode())) {
status = GameStatus.IGNORED;
}
const incompleteReleaseCandidate = incompleteReleaseCandidates.find((rc) =>
rc.getGame().equals(game),
);
if (incompleteReleaseCandidate) {
status = GameStatus.INCOMPLETE;
}
const foundReleaseCandidate = foundReleaseCandidates.find(
(rc) => rc && rc.getGame().equals(game),
);
if (foundReleaseCandidate !== undefined || game.getRoms().length === 0) {
status = GameStatus.FOUND;
}
const filePaths = [
...(incompleteReleaseCandidate ? incompleteReleaseCandidate.getRomsWithFiles() : []),
...(foundReleaseCandidate ? foundReleaseCandidate.getRomsWithFiles() : []),
]
.map((romWithFiles) =>
options.shouldWrite() ? romWithFiles.getOutputFile() : romWithFiles.getInputFile(),
)
.map((file) => file.getFilePath())
.reduce(ArrayPoly.reduceUnique(), []);
return DATStatus.buildCsvRow(
this.getDATName(),
game.getName(),
status,
filePaths,
foundReleaseCandidate?.isPatched() ?? false,
game.isBios(),
game.isRetail(),
game.isUnlicensed(),
game.isDebug(),
game.isDemo(),
game.isBeta(),
game.isSample(),
game.isPrototype(),
game.isProgram(),
game.isAftermarket(),
game.isHomebrew(),
game.isBad(),
);
});
return writeToString(rows, {
headers: [
'DAT Name',
'Game Name',
'Status',
'ROM Files',
'Patched',
'BIOS',
'Retail Release',
'Unlicensed',
'Debug',
'Demo',
'Beta',
'Sample',
'Prototype',
'Program',
'Aftermarket',
'Homebrew',
'Bad',
],
});
}
/**
* Return a string of CSV rows without headers for a certain {@link GameStatus}.
*/
static async filesToCsv(filePaths: string[], status: GameStatus): Promise<string> {
return writeToString(filePaths.map((filePath) => this.buildCsvRow('', '', status, [filePath])));
}
private static buildCsvRow(
datName: string,
gameName: string,
status: GameStatus,
filePaths: string[] = [],
patched = false,
bios = false,
retail = false,
unlicensed = false,
debug = false,
demo = false,
beta = false,
sample = false,
prototype = false,
test = false,
aftermarket = false,
homebrew = false,
bad = false,
): string[] {
return [
datName,
gameName,
GameStatus[status],
filePaths.join('|'),
String(patched),
String(bios),
String(retail),
String(unlicensed),
String(debug),
String(demo),
String(beta),
String(sample),
String(prototype),
String(test),
String(aftermarket),
String(homebrew),
String(bad),
];
}
private static getValuesForAllowedTypes<T>(
options: Options,
romTypesToValues: Map<ROMType, T[]>,
): T[] {
return DATStatus.getAllowedTypes(options)
.flatMap((type) => romTypesToValues.get(type))
.filter((value) => value !== undefined)
.reduce(ArrayPoly.reduceUnique(), [])
.sort();
}
private static getAllowedTypes(options: Options): ROMType[] {
return [
!options.getOnlyBios() && !options.getOnlyDevice() && !options.getOnlyRetail()
? ROMType.GAME
: undefined,
options.getOnlyBios() || (!options.getNoBios() && !options.getOnlyDevice())
? ROMType.BIOS
: undefined,
options.getOnlyDevice() || (!options.getOnlyBios() && !options.getNoDevice())
? ROMType.DEVICE
: undefined,
options.getOnlyRetail() || (!options.getOnlyBios() && !options.getOnlyDevice())
? ROMType.RETAIL
: undefined,
ROMType.PATCHED,
].filter((romType) => romType !== undefined);
}
}