UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

249 lines (248 loc) • 13.3 kB
import { ProgressBarSymbol } from '../../console/progressBar.js'; import Game from '../../types/dats/game.js'; import Internationalization from '../../types/internationalization.js'; import Module from '../module.js'; /** * Infer {@link Parent}s for all {@link DAT}s, even those that already have some parents. */ export default class DATParentInferrer extends Module { options; constructor(options, progressBar) { super(progressBar, DATParentInferrer.name); this.options = options; } /** * Infer {@link Parent}s from {@link Game}s. */ infer(dat) { if (dat.hasParentCloneInfo() && !this.options.getDatIgnoreParentClone()) { this.progressBar.logTrace(`${dat.getName()}: DAT has parent/clone info, skipping`); return dat; } if (dat.getGames().length === 0) { this.progressBar.logTrace(`${dat.getName()}: no games to process`); return dat; } this.progressBar.logTrace(`${dat.getName()}: inferring parents for ${dat.getGames().length.toLocaleString()} game${dat.getGames().length === 1 ? '' : 's'}`); this.progressBar.setSymbol(ProgressBarSymbol.DAT_GROUPING_SIMILAR); this.progressBar.resetProgress(dat.getGames().length); // Group games by their stripped names const strippedNamesToGames = dat.getGames().reduce((map, game) => { let strippedGameName = game.getName(); strippedGameName = DATParentInferrer.stripGameRegionAndLanguage(strippedGameName); strippedGameName = DATParentInferrer.stripGameVariants(strippedGameName); if (map.has(strippedGameName)) { map.get(strippedGameName)?.push(game); } else { map.set(strippedGameName, [game]); } return map; }, new Map()); const groupedGames = [...strippedNamesToGames.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([, games]) => games); const newGames = groupedGames.flatMap((games) => DATParentInferrer.electParent(games)); const inferredDat = dat.withGames(newGames); this.progressBar.logTrace(`${inferredDat.getName()}: grouped to ${inferredDat.getParents().length.toLocaleString()} parent${inferredDat.getParents().length === 1 ? '' : 's'}`); this.progressBar.logTrace('done inferring parents'); return inferredDat; } static stripGameRegionAndLanguage(name) { let strippedName = name // ***** Regions ***** .replace(new RegExp(`\\(((${Internationalization.REGION_CODES.join('|')})[,+-]? ?)+\\)`, 'i'), '') .replace(new RegExp(`\\(((${Internationalization.REGION_NAMES.join('|')})[,+-]? ?)+\\)`, 'i'), '') .replace(/\(Latin America\)/i, ''); Internationalization.REGION_REGEX.forEach((regex) => { strippedName = strippedName.replace(regex, ''); }); // ***** Languages ***** return (strippedName .replace(new RegExp(`\\(((${Internationalization.LANGUAGES.join('|')})[,+-]? ?)+\\)`, 'i'), '') // ***** Cleanup ***** .replaceAll(/ +/g, ' ') .trim()); } static stripGameVariants(name) { return (name // ***** Retail types ***** .replace(/\(Alt( [a-z0-9. ]*)?\)/i, '') .replace(/\([^)]*Collector's Edition\)/i, '') .replace(/\(Digital Release\)/i, '') .replace(/\(Disney Classic Games\)/i, '') .replace(/\(Evercade\)/i, '') .replace(/\(Extra Box\)/i, '') .replace(/ - European Version/i, '') .replace(/\(Fukkokuban\)/i, '') // "reprint" .replace(/\([^)]*Genteiban\)/i, '') // "limited edition" .replace(/\(Limited[^)]+Edition\)/i, '') .replace(/\(Limited Run Games\)/i, '') .replace(/\(LodgeNet\)/i, '') .replace(/\(Made in [^)]+\)/i, '') .replace(/\(Major Wave\)/i, '') .replace(/\((Midway Classics)\)/i, '') .replace(/\([^)]*Premium [^)]+\)/i, '') .replace(/\([^)]*Preview Disc\)/i, '') .replace(/\(QUByte Classics\)/i, '') .replace(/\(Recalled\)/i, '') .replace(/\(Renkaban\)/i, '') // "cheap edition" .replace(/\(Reprint\)/i, '') .replace(/\(Rerelease\)/i, '') .replace(/\(Retro-Bit\)/i, '') .replace(/\((Rev|Version)\s*[a-z0-9.-]*\)/i, '') .replace(/\([^)]*Seisanban\)/i, '') // "production version" .replace(/\(Shotenban\)/i, '') // "bookstore edition" .replace(/\(Special Pack\)/i, '') .replace(/\(Steam\)/i, '') .replace(/\(Switch Online\)/i, '') .replace(/\([^)]+ the Best\)/i, '') .replace(/\([^)]*Taiouban[^)]*\)/i, '') // "compatible version" .replace(/\([^)]*Tokubetsu-?ban[^)]*\)/i, '') // "special edition" .replace(/\([^)]*Virtual Console\)/i, '') // ***** Non-retail types ***** .replace(/\([0-9]{4}-[0-9]{2}-[0-9]{2}\)/, '') // YYYY-MM-DD .replace(/\(Aftermarket[a-z0-9. ]*\)/i, '') .replace(/\(Alpha[a-z0-9. ]*\)/i, '') .replace(/\(Beta[a-z0-9. ]*\)/i, '') .replace(/\(Build [a-z0-9. ]+\)/i, '') .replace(/\(Bung\)/i, '') .replace(/\(Debug[a-z0-9. ]*\)/i, '') .replace(Game.DEMO_REGEX, '') .replace(/\(Hack\)/i, '') .replace(/\(Homebrew[a-z0-9. ]*\)/i, '') .replace(/\(Kiosk[^)]*\)/i, '') .replace(/\(Not for Resale\)/i, '') .replace(/\(PD\)/i, '') // "public domain" .replace(/\(Pirate[a-z0-9. ]*\)/i, '') .replace(/\([a-z0-9. ]*Program\)|(Check|Sample) Program/i, '') .replace(/\([^)]*Proto[a-z0-9. ]*\)/i, '') .replace(/\([^)]*Sample[a-z0-9. ]*\)/i, '') .replace(/\(Spaceworld[a-z0-9. ]*\)/i, '') .replace(/\(Unl[a-z0-9. ]*\)/i, '') .replace(/\(v[0-9.-]+[a-z]*\)/i, '') .replace(/\(Version [0-9.]+[a-z]*\)/i, '') // ***** Good Tools ***** .replace(/\[!\]/, '') .replace(/\[a[0-9]*\]/, '') .replace(/\[b[0-9]*\]/, '') .replace(/\[bf\]/, '') .replace(/\[c\]/, '') .replace(/\[f[0-9]*\]/, '') .replace(/\[h[a-zA-Z90-9+]*\]/, '') .replace(/\[MIA\]/, '') .replace(/\[o[0-9]*\]/, '') .replace(/\[!p\]/, '') .replace(/\[p[0-9]*\]/, '') .replace(/\[t[0-9]*\]/, '') .replace(/\[T[+-][^\]]+\]/, '') .replace(/\[x\]/, '') .replace(/\(Wxn\)/i, '') .replace(/\((SC-3000|SG-1000|SF-7000|GG2SMS|MSX2SMS|SG2GG)\)/, '') // GoodSMS .replace(/\[(v|eb|eba|ebb|f125|f126)\]/, '') // GoodGBA .replace(/\((IQue|MB|MB2GBA)\)/, '') // GoodGBA .replace(/\[(C|S|BF)\]/, '') // GoodGBx .replace(/\((1|4|5|8|F|B|J-Cart|SN|REVXB|REVSC02|MP|MD Bundle|Alt Music)\)/, '') // GoodGen .replace(/\[(c|x)\]/, '') // GoodGen .replace(/\((RU|PC10|VS|Aladdin|Sachen|KC|FamiStudio|PRG0|PRG1|FDS Hack|GBA E-reader|E-GC|J-GC)\)/, '') // GoodNES .replace(/\[(FDS|FCN|U)\]/, '') // GoodNES .replace(/\((BS|ST|NP|NSS)\)/, '') // GoodSNES .replace(/\((Beta-WIP|Debug Version|GC|Save|Save-PAL|Z64-Save)\)/, '') // GoodN64 // ***** TOSEC ***** .replace(/\((AE|AL|AS|AT|AU|BA|BE|BG|BR|CA|CH|CL|CN|CS|CY|CZ|DE|DK|EE|EG|ES|EU|FI|FR|GB|GR|HK|HR|HU|ID|IE|IL|IN|IR|IS|IT|JO|JP|KR|LT|LU|LV|MN|MX|MY|NL|NO|NP|NZ|OM|PE|PH|PL|PT|QA|RO|RU|SE|SG|SI|SK|TH|TR|TW|US|VN|YU|ZA)\)/, '') // region .replace(/\((ar|bg|bs|cs|cy|da|de|el|en|eo|es|et|fa|fi|fr|ga|gu|he|hi|hr|hu|is|it|ja|ko|lt|lv|ms|nl|no|pl|pt|ro|ru|sk|sl|sq|sr|sv|th|tr|ur|vi|yi|zh)\)/, '') // language .replace(/\((demo|demo-kiosk|demo-playable|demo-rolling|demo-slideshow)\)/, '') // demo .replace(/\([0-9x]{4}(-[0-9x]{2}(-[0-9x]{2})?)?\)/, '') // YYYY-MM-DD .replace(/\((CGA|EGA|HGC|MCGA|MDA|NTSC|NTSC-PAL|PAL|PAL-60|PAL-NTSC|SVGA|VGA|XGA)\)/i, '') // video .replace(/\(M[0-9]+\)/, '') // language .replace(/\((CW|CW-R|FW|GW|GW-R|LW|PD|SW|SW-R)\)/i, '') // copyright .replace(/\((alpha|beta|preview|pre-release|proto)\)/i, '') // development .replace(/(\[(cr|f|h|m|p|t|tr|o|u|v|b|a|!)([0-9]+| [^\]]+)?\])+/i, '') .replace(/(\W)v[0-9]+\.[0-9]+(\W)/i, '$1 $2') // ***** Specific cases ***** .replace(/'([0-9][0-9])/, '$1') // year abbreviations // ***** Console-specific ***** // Nintendo - Game Boy .replace(/\(SGB Enhanced\)/i, '') // Nintendo - Game Boy Color .replace(/\(GB Compatible\)/i, '') // Nintendo - GameCube .replace(/\(GameCube\)/i, '') // Nintendo - Super Nintendo Entertainment System .replace(/\(NP\)/i, '') // "Nintendo Power" // Sega - Dreamcast .replace(/\[([0-9A-Z ]+(, )?)+\]$/, '') // TOSEC boxcode .replace(/\[[0-9]+S\]/, '') // TOSEC ring code .replaceAll(/\[(compilation|data identical to retail|fixed version|keyboard|limited edition|req\. microphone|scrambled|unscrambled|white label)\]/gi, '') // TOSEC .replace(/for Dreamcast/i, '') // Sega - Mega Drive / Genesis .replace(/\(MP\)/i, '') // "MegaPlay version" // Sega - Sega/Mega CD .replace(/\(RE?-?[0-9]*\)/, '') // Sony - PlayStation 1 .replace(/\(EDC\)/i, '') // copy protection .replace(/\(PSone Books\)/i, '') .replace(/[(\]](SCES|SCUS|SLES|SLUS)-[0-9]+[(\]]/i, '') // Sony - PlayStation 3 .replace(/\((Arcade|AVTool|Debug|Disc|Patch|Shop|Tool)\)/, '') // BIOS // Sony - PlayStation Portable .replace(/[(\]][UN][CLP][AEJKU][BFGHJMSXZ]-[0-9]+[(\]]/i, '') // ***** Cleanup ***** .replaceAll(/ +/g, ' ') .trim()); // ***** EXPLICITLY LEFT ALONE ***** // (Bonus Disc .*) // (Disc [0-9A-Z]) // (Mega-CD 32X) / (Sega CD 32X) } static electParent(games) { // Index games by their name without the region and language const strippedNamesToGames = games.reduce((map, game) => { let strippedGameName = game.getName(); strippedGameName = DATParentInferrer.stripGameRegionAndLanguage(strippedGameName); if (!map.has(strippedGameName)) { // If there is a conflict after stripping the region & language, then we know the two games // only differ by region & language. Assume the first one seen in the DAT should be the // parent. map.set(strippedGameName, game); } return map; }, new Map()); return games.map((game, idx) => { // Search for this game's retail parent. // Retail games do not have variants such as "(Demo)", so if we fully strip the game name and // find a match, then we have reasonable confidence that match is this game's parent. let strippedGameName = game.getName(); strippedGameName = DATParentInferrer.stripGameRegionAndLanguage(strippedGameName); strippedGameName = DATParentInferrer.stripGameVariants(strippedGameName); const retailParent = strippedNamesToGames.get(strippedGameName); if (retailParent) { if (retailParent.hashCode() === game.hashCode()) { // This game is the parent return game.withProps({ cloneOf: undefined, cloneOfId: undefined }); } if (retailParent.getId() !== undefined) { // id/cloneofid-based DAT return game.withProps({ cloneOfId: retailParent.getId(), cloneOf: undefined }); } // name/cloneof-based DAT return game.withProps({ cloneOfId: undefined, cloneOf: retailParent.getName() }); } // Assume this game's non-retail parent. // If we got here, then we know these games share the same fully-stripped name. Assume the // first game seen in the DAT should be the parent. // The only danger with this assumption is it will affect `--prefer-parent`, but that's not // likely a commonly used option. if (idx === 0) { // This game is the parent return game.withProps({ cloneOf: undefined, cloneOfId: undefined }); } if (games[0].getId() !== undefined) { // id/cloneofid-based DAT return game.withProps({ cloneOfId: games[0].getId(), cloneOf: undefined }); } // name/cloneof-based DAT return game.withProps({ cloneOfId: undefined, cloneOf: games[0].getName() }); }); } }