UNPKG

@cruncheevos/core

Version:

Parse and generate achievements and leaderboards for RetroAchievements.org

1,592 lines 74.6 kB
function invertObject(obj) { return Object.entries(obj).reduce((prev, cur) => { prev[cur[1]] = cur[0]; return prev; }, {}); } function capitalizeWord(word) { return word[0].toUpperCase() + word.slice(1); } function formatNumberAsHex(num, upperCase = false) { let rvalue = Math.abs(num).toString(16); if (upperCase) { rvalue = rvalue.toUpperCase(); } return `${num < 0 ? "-" : ""}0x` + rvalue; } function eatSymbols(str, ...args) { return str.map((x, i) => { let arg = args[i]; if (typeof arg === "string") { arg = `"${arg}"`; } else if (typeof arg === "symbol") { arg = String(arg); } else if (i >= args.length) { arg = ""; } return x + arg; }).join(""); } function isObject(val) { return Object.prototype.toString.call(val) === "[object Object]"; } function isNumber(val, opts = {}) { if (val === null || typeof val === "symbol" || typeof val === "boolean" || typeof val === "string" && val.trim().length === 0) { return false; } val = Number(val); if (Number.isNaN(val) || Number.isFinite(val) === false) { return false; } if (opts.isInteger && Number.isInteger(val) === false) { return false; } if (opts.isPositive && val < 0) { return false; } return true; } function deepFreeze(obj) { for (const key in obj) { const value = obj[key]; if (isObject(value)) { deepFreeze(value); } else if (Array.isArray(value)) { for (const x of value) { if (isObject(x) || Array.isArray(x)) { deepFreeze(x); } } Object.freeze(value); } } return Object.freeze(obj); } function wrappedError(err, message) { const wrappedError2 = new Error(message); wrappedError2.cause = err; return wrappedError2; } function parseCSV(str) { const arr = []; let inQuotes = false; let col = 0; for (let i = 0; i < str.length; i++) { const cur = str[i]; arr[col] = arr[col] || ""; if (inQuotes && cur == "\\" && str[i + 1] == '"') { arr[col] += '"'; i++; continue; } if (cur == '"') { inQuotes = !inQuotes; continue; } if (cur == ":" && !inQuotes) { col++; continue; } arr[col] += cur; } return arr; } function quoteIfHaveTo(str) { return str.match(/[:"]/g) ? `"${str.replace(/"/g, '\\"')}"` : str; } const validate$3 = { andNormalizeId(id, propertyName = "id") { const origId = id; if (typeof id === "string") { if (id.trim().length === 0) { throw new Error(`expected ${propertyName} as unsigned integer, but got ""`); } id = Number(id); } if (Number.isInteger(id) === false) { throw new Error( `expected ${propertyName} as unsigned integer, but got ` + eatSymbols`${origId}` ); } if (id < 0 || id >= Number.MAX_SAFE_INTEGER) { throw new Error( `expected ${propertyName} to be within the range of 0x0 .. 0xFFFFFFFF, but got ` + eatSymbols`${origId}` ); } return id; }, title(title, propertyName = "title") { if (typeof title !== "string" || title.trim().length === 0) { throw new Error( `expected ${propertyName} as non-empty string, but got ` + eatSymbols`${title}` ); } }, andNormalizeDescription(description) { if (description === void 0 || description === null) { return ""; } if (typeof description !== "string") { throw new Error(eatSymbols`expected description as string, but got ${description}`); } return description; } }; function indexToConditionGroupName(index) { return index === 0 ? "Core" : `Alt ${index}`; } function stringToNumberLE(input) { const bytes = new TextEncoder().encode(input); const values = []; for (let i = 0; i < bytes.length; i += 4) { const value = [...bytes.slice(i, i + 4)].reverse().map((x) => x.toString(16).padStart(2, "0")).join(""); values.push(parseInt(value, 16)); } return values; } function makeBuilder(flag) { return function(...args) { const builder = new ConditionBuilder(); pushArgsToBuilder.call(builder, flag, ...args); return builder; }; } const define = makeBuilder(""); define.one = function(arg) { if (arguments.length > 1) { throw new Error("expected only one condition argument, but got " + arguments.length); } return new Condition(arg); }; define.str = function(input, cb) { return andNext( ...stringToNumberLE(input).map((value, index) => { let c = cb( // prettier-ignore value > 16777215 ? "32bit" : value > 65535 ? "24bit" : value > 255 ? "16bit" : "8bit", ["Value", "", value] ); if (index > 0) { return c.withLast({ lvalue: { value: c.conditions[c.conditions.length - 1].lvalue.value + index * 4 } }); } return c; }) ); }; const trigger = makeBuilder("Trigger"); const resetIf = makeBuilder("ResetIf"); const pauseIf = makeBuilder("PauseIf"); const addHits = makeBuilder("AddHits"); const subHits = makeBuilder("SubHits"); const measured = makeBuilder("Measured"); const measuredPercent = makeBuilder("Measured%"); const measuredIf = makeBuilder("MeasuredIf"); const resetNextIf = makeBuilder("ResetNextIf"); const andNext = makeBuilder("AndNext"); const orNext = makeBuilder("OrNext"); const once = (...args) => new ConditionBuilder().also("once", ...args); const lastCallTypes = /* @__PURE__ */ new WeakMap(); class ConditionBuilder { constructor() { this.conditions = []; lastCallTypes.set(this, ""); } /** * Adds conditions wrapped with Trigger flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').trigger('0=2', '0=3').toString() // 0=1_T:0=2_T:0=3 */ trigger(...args) { pushArgsToBuilder.call(this, "Trigger", ...args); return this; } /** * Adds conditions wrapped with ResetIf flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').resetIf('0=2', '0=3').toString() // 0=1_R:0=2_R:0=3 */ resetIf(...args) { pushArgsToBuilder.call(this, "ResetIf", ...args); return this; } /** * Adds conditions wrapped with PauseIf flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').pauseIf('0=2', '0=3').toString() // 0=1_P:0=2_P:0=3 */ pauseIf(...args) { pushArgsToBuilder.call(this, "PauseIf", ...args); return this; } /** * Adds conditions wrapped with AddHits flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').addHits('0=2', '0=3').toString() // 0=1_C:0=2_C:0=3 */ addHits(...args) { pushArgsToBuilder.call(this, "AddHits", ...args); return this; } /** * Adds conditions wrapped with SubHits flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').subHits('0=2', '0=3').toString() // 0=1_D:0=2_D:0=3 */ subHits(...args) { pushArgsToBuilder.call(this, "SubHits", ...args); return this; } /** * Adds conditions wrapped with Measured flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').measured('0=2', '0=3').toString() // 0=1_M:0=2_M:0=3 */ measured(...args) { pushArgsToBuilder.call(this, "Measured", ...args); return this; } /** * Adds conditions wrapped with Measured% flag to the chain * * RAIntegration converts Measured flags to Measured% if *Track as %* checkbox is ticked * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').measuredPercent('0=2', '0=3').toString() // 0=1_G:0=2_G:0=3 */ measuredPercent(...args) { pushArgsToBuilder.call(this, "Measured%", ...args); return this; } /** * Adds conditions wrapped with Measured flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').measuredIf('0=2', '0=3').toString() // 0=1_Q:0=2_Q:0=3 */ measuredIf(...args) { pushArgsToBuilder.call(this, "MeasuredIf", ...args); return this; } /** * Adds conditions wrapped with ResetNextIf flag to the chain * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').resetNextIf('0=2', '0=3').toString() // 0=1_Z:0=2_Z:0=3 */ resetNextIf(...args) { pushArgsToBuilder.call(this, "ResetNextIf", ...args); return this; } /** * Adds conditions wrapped with AndNext flag to the chain * * The final condition in the chain will not have AndNext flag * applied, because the condition will not work correctly that way * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').andNext('0=2', '0=3').toString() // 0=1_N:0=2_0=3 * $('0=1') * .andNext('0=2', '0=3') * .resetIf('0=4').toString() // 0=1_N:0=2_N:0=3_R:0=4 */ andNext(...args) { pushArgsToBuilder.call(this, "AndNext", ...args); return this; } /** * Adds conditions wrapped with OrNext flag to the chain * * The final condition in the chain will not have OrNext flag * applied, because the condition will not work correctly that way * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1').orNext('0=2', '0=3').toString() // 0=1_O:0=2_0=3 * $('0=1') * .orNext('0=2', '0=3') * .resetIf('0=4').toString() // 0=1_O:0=2_O:0=3_R:0=4 */ orNext(...args) { pushArgsToBuilder.call(this, "OrNext", ...args); return this; } /** * Adds conditions to the chain as is * * @example * import { define as $, resetIf } from '@cruncheevos/core' * resetIf('0=1', '0=2') * .also('0=3') * .toString() // R:0=1_R:0=2_0=3 */ also(...args) { pushArgsToBuilder.call(this, "", ...args); return this; } /** * Adds conditions as is with final condition set to have 1 hit * * @example * import { define as $ } from '@cruncheevos/core' * $('0=1') * .once( * andNext('0=2', '0=3') * ).toString() // 0=1_N:0=2_0=3.1. */ once(...args) { pushArgsToBuilder.call(this, "", "once", ...args); return this; } *[Symbol.iterator]() { for (const piece of this.conditions) { yield piece; } } /** * Returns new instance of ConditionBuilder with mapped conditions * * Accepts a callback function that acts similar to Array.prototype.map * * If any conditional condition was ignored, it will not appear in the callback * * @example * $('0=1', false && '0=2', '0=3') * .map((c, i) => c.with({ hits: i + 1 })) * .toString() // 0=1.1._0=3.2. */ map(cb) { const mappedConditions = this.conditions.map(cb); return new ConditionBuilder().also(...mappedConditions); } /** * Returns new instance of ConditionBuilder with different * values merged into last condition * * `lvalue` and `rvalue` can be specified as partial array, which can be less verbose * * Useful when combined with pointer chains * * @param {Condition.PartialMergedData} data Condition.PartialMergedData * * @example * $( * ['AddAddress', 'Mem', '32bit', 0xcafe], * ['AddAddress', 'Mem', '32bit', 0xbeef], * ['', 'Mem', '32bit', 0, '=', 'Value', '', 120], * ).withLast({ cmp: '!=', rvalue: { value: 9 } }) * .toString() // I:0xXcafe_I:0xXbeef_0xX0!=9 * * $( * ['AddAddress', 'Mem', '32bit', 0xcafe], * ['AddAddress', 'Mem', '32bit', 0xbeef], * ['', 'Mem', '32bit', 0, '=', 'Value', '', 120], * ).withLast({ cmp: '!=', rvalue: rvalue: ['Delta', '32bit', 0] }) * .toString() // I:0xXcafe_I:0xXbeef_0xX0!=d0xX0 */ withLast(data) { return this.map((c, idx, array) => { if (idx !== array.length - 1) { return c; } return c.with(data); }); } /** * Returns a string with raw condition code * * @example * $( * ['AndNext', 'Mem', '32bit', 0xCAFE, '=', 'Value', '', 5], * ['', 'Delta', '32bit', 0xCAFE, '=', 'Value', '', 4] * ).toString() // N:0xXcafe=5_d0xXcafe=4 */ toString() { return this.conditions.join("_"); } /** * Same as {@link ConditionBuilder.prototype.toString toString()} * * @example * JSON.stringify({ conditions: $('0=1', '0=2') }) * // {"conditions":"0=1_0=2"} */ toJSON() { return this.toString(); } } const whiteSpaceRegex = /\s+/; function pushArgsToBuilder(flag, ...args) { let hits = 0; const filteredArgs = args.filter((arg, i) => { if (typeof arg === "string" && (arg === "once" || arg.startsWith("hits"))) { if (i > 0) { throw new Error(`strings 'once' and 'hits %number%' must be placed before any conditions`); } if (arg === "once") { hits = 1; } if (arg.startsWith("hits")) { hits = parseInt(arg.split(whiteSpaceRegex)[1]); } return false; } if (arg instanceof ConditionBuilder && arg.conditions.length === 0) { return false; } return Boolean(arg); }); if (filteredArgs.length === 0) { return; } const lastCallType = lastCallTypes.get(this); if (lastCallType === "AndNext" || lastCallType === "OrNext") { const lastCondition = this.conditions[this.conditions.length - 1]; if (lastCondition.flag === "") { this.conditions[this.conditions.length - 1] = lastCondition.with({ flag: lastCallType }); } } for (let i = 0; i < filteredArgs.length; i++) { const variantArg = filteredArgs[i]; if (variantArg instanceof ConditionBuilder) { filteredArgs.splice(i, 1, ...variantArg); i--; continue; } let arg = new Condition(variantArg); const isLastArgument = i === filteredArgs.length - 1; const settingOperatorOnFinalCondition = isLastArgument && (flag === "AndNext" || flag === "OrNext"); if (isLastArgument && hits > 0) { arg = arg.with({ hits }); } if (flag && arg.flag === "" && settingOperatorOnFinalCondition === false) { arg = arg.with({ flag }); } this.conditions.push(arg); } lastCallTypes.set(this, flag); } function parseUnderflow(num) { const result = num < 0 ? num + 1 + 4294967295 : num; if (num < -2147483648) { throw new Error( `${num} (${formatNumberAsHex(num)}) underflows into positive ${result} (${formatNumberAsHex( result )}), it's very unlikely you intended for that to happen` ); } return result; } const flags = (() => { const forReadingRaw = { "": "", PauseIf: "P", ResetIf: "R", ResetNextIf: "Z", AddHits: "C", SubHits: "D", AndNext: "N", OrNext: "O", Measured: "M", "Measured%": "G", MeasuredIf: "Q", Trigger: "T" }; const forCalcRaw = { AddSource: "A", SubSource: "B", AddAddress: "I", Remember: "K" }; const toRaw = { ...forReadingRaw, ...forCalcRaw }; const fromRaw = invertObject(toRaw); delete fromRaw[""]; return { forReading: { toRaw: forReadingRaw }, forCalc: { toRaw: forCalcRaw }, toRaw, fromRaw }; })(); const types = (() => { const toRaw = { Mem: "", Delta: "d", Prior: "p", BCD: "b", Invert: "~" }; return { withSize: { toRaw, array: Object.keys(toRaw), fromRaw: invertObject(toRaw) }, withoutSize: { array: ["Value", "Float", "Recall"] } }; })(); const sizesRegular = (() => { const toRaw = { "": "", Bit0: "M", Bit1: "N", Bit2: "O", Bit3: "P", Bit4: "Q", Bit5: "R", Bit6: "S", Bit7: "T", Lower4: "L", Upper4: "U", "8bit": "H", "16bit": " ", "24bit": "W", "32bit": "X", "16bitBE": "I", "24bitBE": "J", "32bitBE": "G", BitCount: "K" }; return { toRaw, fromRaw: invertObject(toRaw) }; })(); const sizesExt = (() => { const toRaw = { Float: "F", FloatBE: "B", Double32: "H", Double32BE: "I", MBF32: "M", MBF32LE: "L" }; return { toRaw, fromRaw: invertObject(toRaw) }; })(); const cmp = { forReading: ["=", "!=", "<", "<=", ">", ">="], forCalc: ["+", "-", "*", "/", "%", "&", "^"], isLegalForReading(cmp2) { return typeof cmp2 === "string" && this.forReading.includes(cmp2); }, isLegalForCalc(cmp2) { return typeof cmp2 === "string" && this.forCalc.includes(cmp2); }, isLegal(cmp2) { return this.isLegalForReading(cmp2) || this.isLegalForCalc(cmp2); } }; function flagNeedsComparisonOperator(def) { return flags.forReading.toRaw.hasOwnProperty(def.flag); } function flagNeedsCalculationOperator(def) { return flags.forCalc.toRaw.hasOwnProperty(def.flag); } function isBareRecall(def) { return def.lvalue.type === "Recall" && hasRValueDefined(def) === false; } function isBareMeasured(def) { return def.flag === "Measured" && hasRValueDefined(def) === false; } function isMeasuredLeaderboardValue(def) { return def.flag === "Measured" && hasRValueDefined(def) && cmp.isLegalForCalc(def.cmp); } function hasRValueDefined(def) { const hasType = Boolean(def.rvalue.type); const hasSize = Boolean(def.rvalue.size); if (!hasSize && !hasType) { return false; } if (hasSize) { return hasType; } const mustHaveSizeDefined = types.withSize.toRaw.hasOwnProperty(def.rvalue.type); return mustHaveSizeDefined ? hasSize : true; } const validateAndNormalize = { value(value, placement) { if (types.withSize.array.some((x) => x === value.type)) { if (Number.isInteger(value.value) === false) { throw new Error( `expected ${placement} memory address as unsigned integer, but got ` + eatSymbols`${value.value}` ); } if (value.value < 0 || value.value > 4294967295) { throw new Error( `expected ${placement} memory address to be within the range of 0x0 .. 0xFFFFFFFF, but got ` + eatSymbols`${value.value}` ); } } else if (value.type === "Recall") { if (value.value !== 0 && (placement === "lvalue" ? true : value.size !== void 0)) { throw new Error( `expected Recall ${placement} value to be 0, but got ` + eatSymbols`${value.value}` ); } } else if (value.type === "Value") { if (Number.isInteger(value.value) === false) { throw new Error(`expected ${placement} as integer, but got ` + eatSymbols`${value.value}`); } try { var finalValue = parseUnderflow(value.value); } catch (err) { throw wrappedError(err, `${placement}: ${err.message}`); } if (finalValue > 4294967295) { throw new Error( `expected ${placement} to be within the range of 0x0 .. 0xFFFFFFFF, but got ` + eatSymbols`${value.value}` ); } } else if (value.type === "Float") { if (Number.isNaN(value.value) || Number.isFinite(value.value) === false) { throw new Error(`expected ${placement} as float, but got ` + eatSymbols`${value.value}`); } const lowerLimit = -294967040; const upperLimit = 4294967040; if (value.value < lowerLimit || value.value > upperLimit) { throw new Error( `expected ${placement} to be within the range of ${lowerLimit} .. ${upperLimit}, but got ` + eatSymbols`${value.value}` ); } } else if (value.type !== "") { throw new Error(`expected valid ${placement} type, but got ` + eatSymbols`${value.type}`); } if (types.withoutSize.array.some((x) => x === value.type)) { if (value.size) { throw new Error( `${placement} value cannot have size specified, but got ` + eatSymbols`${value.size}` ); } } if (value.type === "Value") { return { ...value, value: parseUnderflow(value.value) }; } else if (value.type === "Recall" && placement === "rvalue") { return { ...value, size: value.size === void 0 ? "" : value.size, value: value.value === void 0 ? 0 : value.value }; } else { return value; } }, calculations(def) { if (flagNeedsCalculationOperator(def) === false) { return; } if (def.cmp) { if (cmp.isLegalForCalc(def.cmp) === false) { throw new Error( `expected an accumulation operator (${cmp.forCalc.join(" ")}), but got ` + eatSymbols`${def.cmp}` ); } if (hasRValueDefined(def) === false) { throw new Error("rvalue must be fully provided if operator is specified"); } def.rvalue = validateAndNormalize.value(def.rvalue, "rvalue"); } else if (def.cmp === "" && hasRValueDefined(def)) { throw new Error(`expected an accumulation operator (${cmp.forCalc.join(" ")}), but got ""`); } }, memoryComparisons(def) { if (flagNeedsComparisonOperator(def) === false || isBareRecall(def) || isBareMeasured(def) || isMeasuredLeaderboardValue(def)) { return; } def.rvalue = validateAndNormalize.value(def.rvalue, "rvalue"); if (cmp.isLegalForReading(def.cmp) === false || !def.cmp) { throw new Error( `expected comparison operator (${cmp.forReading.join(" ")}), but got ` + eatSymbols`${def.cmp}` ); } } }; const validate$2 = { enums(def) { if (flags.toRaw.hasOwnProperty(def.flag) === false) { throw new Error(eatSymbols`expected valid condition flag, but got ${def.flag}`); } for (const [value, valueSide] of [ [def.lvalue, "lvalue"], [def.rvalue, "rvalue"] ]) { if (value.type === "Recall") { if (value.size !== "" && (valueSide === "lvalue" ? true : value.size !== void 0)) { throw new Error( `expected Recall ${valueSide} size to be empty string, but got ` + eatSymbols`${value.size}` ); } continue; } if (sizesRegular.toRaw.hasOwnProperty(value.size) === false && sizesExt.toRaw.hasOwnProperty(value.size) === false) { throw new Error(`expected valid ${valueSide} size, but got ` + eatSymbols`${value.size}`); } } if (def.cmp && cmp.isLegal(def.cmp) === false) { throw new Error(eatSymbols`expected an operator or lack of it, but got ${def.cmp}`); } }, hits(def) { if (Number.isInteger(def.hits) === false) { throw new Error(eatSymbols`expected hits as unsigned integer, but got ${def.hits}`); } if (def.hits < 0 || def.hits > 4294967295) { throw new Error( `expected hits to be within the range of 0x0 .. 0xFFFFFFFF, but got ${def.hits}` ); } if (flagNeedsCalculationOperator(def) && def.hits > 0) { throw new Error(`hits value cannot be specified with ${def.flag} condition flag`); } } }; const consume = { flag(str) { const match = str.match(regExes.flag); if (!match) { return ["", str]; } const flag = match[1]; if (flags.fromRaw.hasOwnProperty(flag.toUpperCase()) === false) { throw new Error(eatSymbols`expected a legal condition flag, but got ${match[0]}`); } return [flags.fromRaw[flag.toUpperCase()], str.slice(match[0].length)]; }, value(str) { const def = { type: "Mem", size: "", value: 0 }; let match = null; let integersAllowed = true; if (match = str.match(regExes.type)) { str = str.slice(match[0].length); def.type = types.withSize.fromRaw[match[1].toLowerCase()]; integersAllowed = false; } if (str.startsWith("{recall}")) { str = str.slice("{recall}".length); def.type = "Recall"; } else if (match = str.match(regExes.valueFloat)) { str = str.slice(match[0].length); def.type = "Float"; def.value = Number(match[1]); } else if (match = str.match(regExes.memAddress)) { str = str.slice(match[0].length); if (match[1].toLowerCase() === "0x") { if (match = str.match(regExes.sizesRegular)) { str = str.slice(match[0].length); def.size = sizesRegular.fromRaw[match[1].toUpperCase()]; } else if (str.match(regExes.hexValue)) { def.size = "16bit"; } else { throw new Error(eatSymbols`expected valid size specifier, but got ${str.slice(0, 6)}`); } } else { if (match = str.match(regExes.sizesExt)) { str = str.slice(match[0].length); def.size = sizesExt.fromRaw[match[1].toUpperCase()]; } else { throw new Error(eatSymbols`expected valid size specifier, but got ${str.slice(0, 6)}`); } } if (match = str.match(regExes.hexValue)) { str = str.slice(match[0].length); const value = match[1]; def.value = Number("0x" + value); } else { throw new Error( eatSymbols`expected memory address as hex number, but got ${str.slice(0, 6)}` ); } } else if (integersAllowed && (match = str.match(regExes.valueHex))) { str = str.slice(match[0].length); def.type = "Value"; def.value = parseUnderflow(parseInt(match[1].replace(regExes.hexPrefix, "0x"))); } else if (integersAllowed && (match = str.match(regExes.valueInteger))) { str = str.slice(match[0].length); def.type = "Value"; def.value = parseUnderflow(Number(match[1])); } else { throw new Error(eatSymbols`expected proper definition, but got ${str.slice(0, 6)}`); } return [def, str]; }, cmp(str) { const match = str.match(regExes.cmp); if (!match) { throw new Error(eatSymbols`expected an operator, but got ${str.slice(0, 6)}`); } return [match[1], str.slice(match[0].length)]; }, hits(str) { const match = str.match(regExes.hits); if (!match) { throw new Error(eatSymbols`expected hits definition, but got ${str}`); } const hitsString = match[1]; if (isNumber(hitsString, { isInteger: true, isPositive: true })) { const hits = Number(hitsString); if (hits > 4294967295) { throw new Error( `expected hits to be within the range of 0x0 .. 0xFFFFFFFF, but got ${hitsString}` ); } return [hits, str.slice(match[0].length)]; } else { throw new Error(eatSymbols`expected hits as unsigned integer, but got ${hitsString}`); } } }; function fromString(str) { str = str.trim(); const def = { flag: "", lvalue: { type: "", size: "", value: 0 }, cmp: "", rvalue: { type: "", size: "", value: 0 }, hits: 0 }; [def.flag, str] = consume.flag(str); try { ; [def.lvalue, str] = consume.value(str); } catch (err) { throw wrappedError(err, `lvalue: ${err.message}`); } if (str) { [def.cmp, str] = consume.cmp(str); const comparisonOperatorExpected = flagNeedsComparisonOperator(def); const hasValidComparisonOperator = cmp.isLegalForReading(def.cmp); const hasValidOperator = hasValidComparisonOperator || cmp.isLegalForCalc(def.cmp); if (hasValidOperator === false) { if (comparisonOperatorExpected) { throw new Error( `expected comparison operator (${cmp.forReading.join(" ")}), but got ` + eatSymbols`${def.cmp}` ); } else { throw new Error( `expected calculation operator (${cmp.forCalc.join(" ")}), but got ` + eatSymbols`${def.cmp}` ); } } try { ; [def.rvalue, str] = consume.value(str); } catch (err) { throw wrappedError(err, `rvalue: ${err.message}`); } if (str) { [def.hits, str] = consume.hits(str); } if (comparisonOperatorExpected === false && hasValidComparisonOperator) { def.cmp = ""; def.rvalue = { type: "", size: "", value: 0 }; } } return def; } function conditionValueValueToString(def) { if (def.type === "Value") { const diff = def.value - 4294967295 - 1; if (diff >= -4096 && diff < 0) { return diff.toString(); } else { return def.value >= 1e5 ? formatNumberAsHex(def.value) : def.value.toString(); } } else if (def.type) { const shouldFormatAsHex = def.type !== "Float" || def.value >= 1e5; return shouldFormatAsHex ? formatNumberAsHex(def.value) : def.value.toString(); } } function conditionDataFromArray(def) { const shouldFallbackRValue = def[4] === void 0 && def[5] === void 0 && def[6] === void 0 && def[7] === void 0; return { flag: def[0], lvalue: { type: def[1], size: def[2], value: def[3] }, cmp: shouldFallbackRValue ? "" : def[4], rvalue: { type: shouldFallbackRValue ? "" : def[5], size: shouldFallbackRValue ? "" : def[6], value: shouldFallbackRValue ? 0 : def[7] }, hits: def[8] === void 0 ? 0 : def[8] }; } function conditionValueToString(def) { let res = ""; if (def.type === "Value") { res += def.value; } else if (def.type === "Float") { res += "f"; res += def.value; if (Number.isInteger(def.value)) { res += ".0"; } } else if (def.type === "Recall") { res += "{recall}"; } else { res += types.withSize.toRaw[def.type]; if (sizesExt.toRaw.hasOwnProperty(def.size)) { res += "f"; res += sizesExt.toRaw[def.size]; } else { res += `0x`; res += sizesRegular.toRaw[def.size]; } res += def.value.toString(16); } return res; } function validateRegularMeasuredConditions(conditions) { conditions.forEach((group, groupIndex) => { const groupName = indexToConditionGroupName(groupIndex); group.forEach((condition, conditionIndex) => { if (isBareMeasured(condition)) { throw new Error( `${groupName}, condition ${conditionIndex + 1}: cannot have Measured condition without rvalue specified` ); } if (isMeasuredLeaderboardValue(condition)) { throw new Error( `${groupName}, condition ${conditionIndex + 1}: expected comparison operator (${cmp.forReading.join(" ")}), but got ` + eatSymbols`${condition.cmp}` ); } }); }); } function normalizeMergedValue(value) { if (Array.isArray(value)) { return Object.assign( {}, typeof value[0] === "string" && { type: value[0] }, typeof value[1] === "string" && { size: value[1] }, typeof value[2] === "number" && { value: value[2] } ); } return value; } class Condition { constructor(def) { if (def instanceof Condition) { return def; } if (typeof def === "string") { Object.assign(this, fromString(def)); } else if (Array.isArray(def)) { Object.assign(this, conditionDataFromArray(def)); } else if (isObject(def)) { this.flag = def.flag; this.cmp = def.cmp; this.rvalue = { ...def.rvalue }; this.lvalue = { ...def.lvalue }; this.hits = def.hits; } else { throw new Error( eatSymbols`condition data must be an array, object or string with condition code, but got ${def}` ); } validate$2.enums(this); this.lvalue = validateAndNormalize.value(this.lvalue, "lvalue"); validateAndNormalize.memoryComparisons(this); validateAndNormalize.calculations(this); validate$2.hits(this); deepFreeze(this); } /** * Returns new Condition instance with different values merged. * * `lvalue` and `rvalue` can be specified as partial array, which can be less verbose * * @param {DeepPartial<Condition.Data>} data DeepPartial<Condition.Data> * * @example * new Condition('0=1') * .with({ cmp: '!=', rvalue: { value: 47 } }) * .toString() // 0!=47 * * new Condition('0xXcafe=0xXfeed') * .with({ rvalue: ['Delta', '16bit', 0xabcd] }) * .toString() // 0xXcafe=d0x abcd * * new Condition('0xXcafe=0xXfeed') * .with({ rvalue: ['Delta'] }) * .toString() // 0xXcafe=d0xXfeed */ with(data) { return new Condition({ ...this, ...data, lvalue: { ...this.lvalue, ...normalizeMergedValue(data.lvalue) }, rvalue: { ...this.rvalue, ...normalizeMergedValue(data.rvalue) } }); } /** * Returns string representation of Condition * suitable for RetroAchievements and local files. * @example * new Condition(['ResetIf', 'Mem', 'Bit0', 71, '>', 'Delta', 'Bit1', 71, 3]).toString() // 'R:0xM47>d0xN47.3.' */ toString() { let res = ""; if (this.flag !== "") { res += flags.toRaw[this.flag] + ":"; } res += conditionValueToString(this.lvalue); if (hasRValueDefined(this)) { res += this.cmp; res += conditionValueToString(this.rvalue); if (this.hits) { res += "." + this.hits + "."; } } return res; } /** * Returns direct Array representation of Condition, * values are exactly same as properties of Condition. * * @example * new Condition(['Measured', 'Mem', '8bit', 4]).toArray() * // [ "Measured", "Mem", "8bit", 4, "", "", "", 0, 0 ] */ toArray() { return [ this.flag, this.lvalue.type, this.lvalue.size, this.lvalue.value, this.cmp, this.rvalue.type, this.rvalue.size, this.rvalue.value, this.hits ]; } /** * Returns prettier Array representation of Condition, which is more suitable for display: * * * Everything is a string * * Values are formatted as hexadecimal if they are greater or equal to 100000 * * Negative values are formatted as decimal if they are greater or equal to -4096, otherwise formatted as hexadecimal with underflow correction * * Hits are empty string if equal to zero * * @example * new cruncheevos.Condition(['ResetIf', 'Mem', '32bit', 0xfeedcafe, '>', 'Value', '', 71]).toArrayPretty() * // [ "ResetIf", "Mem", "32bit", "0xfeedcafe", ">", "Value", "", "71", "" ] * * new cruncheevos.Condition(['', 'Value', '', -4097, '>', 'Value', '', -1]).toArrayPretty() * // [ "", "Value", "", "0xffffefff", ">", "Value", "", "-1", "" ] */ toArrayPretty() { const rValueIsRecall = this.rvalue.type === "Recall"; const rValueIsDefined = hasRValueDefined(this); return [ this.flag, this.lvalue.type, this.lvalue.size, conditionValueValueToString(this.lvalue), this.cmp, rValueIsDefined ? this.rvalue.type : "", // prettier-ignore rValueIsRecall ? "" : rValueIsDefined ? this.rvalue.size : "", // prettier-ignore rValueIsRecall ? "" : rValueIsDefined ? conditionValueValueToString(this.rvalue) : "", this.hits > 0 ? this.hits.toString() : "" ]; } } function normalizedConditionGroupSetFromString(str, options = {}) { const { considerLegacyValueFormat = false } = options; const conditionStrings = str.split(considerLegacyValueFormat ? "$" : new RegExp("(?<!0x)S")).map((group) => group.trim().length > 0 ? group.split("_") : []); const parseAsLegacy = considerLegacyValueFormat && conditionStrings.every( (group) => group.every((conditionString) => conditionString.match(regExes.flag) === null) ); return conditionStrings.map( (group, groupIndex) => group.map((conditionString, conditionIndex) => { if (parseAsLegacy) { if (conditionString.match(regExes.legacyTrailingFloat)) { conditionString = conditionString.replace( regExes.legacyTrailingFloat, (match) => "f" + match ); } const valueMatch = conditionString.match(regExes.legacyValue); if (valueMatch) { const sign = valueMatch[1] || "+"; let value = Number(valueMatch[2]); if (value < 0) { value = parseUnderflow(value); } if (value > 2147483647) { value = 2147483647; } if (sign === "-" && value > 0) { value = -value; } conditionString = value.toString(); } const isLastElement = conditionIndex === group.length - 1; conditionString = (isLastElement ? "M" : "A") + ":" + conditionString; } try { return new Condition(conditionString); } catch (err) { const groupName = indexToConditionGroupName(groupIndex); throw wrappedError(err, `${groupName}, condition ${conditionIndex + 1}: ${err.message}`); } }) ); } function normalizedConditionGroupSet(def, options = {}) { const res = []; if (typeof def === "string") { return normalizedConditionGroupSetFromString(def, options); } else if (Array.isArray(def)) { const subRes = []; for (let i = 0; i < def.length; i++) { const x = def[i]; try { if (x instanceof ConditionBuilder) { subRes.push(...x); } else { subRes.push(new Condition(x)); } } catch (err) { throw wrappedError(err, `conditions[${i}]: ${err.message}`); } } res.push(subRes); } else if (def instanceof ConditionBuilder) { res.push([...def]); } else if (isObject(def)) { let coreDefined = false; const altNumbers = []; for (const key in def) { const match = key.match(/^(?:core|alt([1-9]\d*))$/); if (match) { if (match[0] === "core") { coreDefined = true; } else if (match[1]) { altNumbers.push(Number(match[1])); } } else { throw new Error(`conditions.${key}: group name must be "core" or "alt1", "alt2"...`); } } if (!coreDefined) { throw new Error(`conditions: expected "core" group`); } altNumbers.sort((a, b) => a - b).forEach((num, index) => { if (num !== index + 1) { throw new Error( `conditions: expected "alt${index + 1}" group, but got "alt${num}", make sure there are no gaps` ); } }); const groups = ["core", ...altNumbers.map((x) => `alt${x}`)]; for (const groupName of groups) { const group = def[groupName]; if (typeof group === "string") { try { res.push(...normalizedConditionGroupSetFromString(group, options)); } catch (err) { throw wrappedError(err, `conditions.${groupName}: ${err.message}`); } } else if (group instanceof ConditionBuilder) { res.push([...group]); } else if (Array.isArray(group)) { const subRes = []; for (let i = 0; i < group.length; i++) { try { const x = group[i]; if (x instanceof ConditionBuilder) { subRes.push(...x); } else { subRes.push(new Condition(x)); } } catch (err) { throw wrappedError(err, `conditions.${groupName}[${i}]: ${err.message}`); } } res.push(subRes); } else { throw new Error( `conditions.${groupName}: expected an array of conditions or string, but got ` + eatSymbols`${group}` ); } } } else { throw new Error( eatSymbols`expected conditions as object, array of arrays or string, but got ${def}` ); } return res; } const regExes = (() => { const cmps = [...cmp.forCalc, ...cmp.forReading].sort((a, b) => { return b.length - a.length; }).map( (x) => x.split("").map((x2) => `\\${x2}`).join("") ); return { cmp: new RegExp(`^(${cmps.join("|")})`), flag: /^(.*?):/i, hits: /^\.(.*)\./, hexPrefix: /h/i, hexValue: /^([\dabcdef]+)/i, sizesRegular: new RegExp( "^(" + Object.values(sizesRegular.toRaw).filter(Boolean).join("|") + ")", "i" ), sizesExt: new RegExp("^(" + Object.values(sizesExt.toRaw).filter(Boolean).join("|") + ")", "i"), memAddress: /^(0x|f)/i, type: new RegExp( "^(" + Object.values(types.withSize.toRaw).filter(Boolean).join("|") + ")", "i" ), valueHex: /^(-?h[\dabcdef]+)/i, valueInteger: /^(-?\d+)/, valueFloat: /^f(-?\d+\.\d+)/i, legacyTrailingFloat: /(-?\d+\.\d+)$/, legacyValue: /^v([-+])?([-+]?\d+)$/i }; })(); const allowedAchievementTypesForDisplay = ["missable", "progression", "win_condition"]; const allowedAchievementTypes = /* @__PURE__ */ new Set(["", ...allowedAchievementTypesForDisplay]); const validate$1 = { points(points) { if (isNumber(points, { isInteger: true, isPositive: true }) === false) { throw new Error( `expected points value to be a positive integer, but got ` + eatSymbols`${points}` ); } }, measuredConditionsMixing(conditions) { const measuredConditions = []; const measuredPercentConditions = []; conditions.forEach((group, groupIndex) => { const groupName = indexToConditionGroupName(groupIndex); group.forEach((condition, conditionIndex) => { if (condition.flag === "Measured") { measuredConditions.push([groupName, conditionIndex]); } if (condition.flag === "Measured%") { measuredPercentConditions.push([groupName, conditionIndex]); } }); }); if (measuredPercentConditions.length > 0 && measuredConditions.length > 0) { const m = measuredConditions[0]; const mp = measuredPercentConditions[0]; throw new Error( `${m[0]}, condition ${m[1] + 1}: Measured conflicts with ${mp[0]}, condition ${mp[1] + 1} Measured%, make sure you exclusively use Measured or Measured%` ); } }, andNormalizeAuthor(author) { if (author === void 0 || author === null) { author = ""; } if (typeof author !== "string") { throw new Error(eatSymbols`expected author as string, but got ${author}`); } return author || "cruncheevos"; }, andNormalizeAchievementType(type) { type = type === void 0 ? "" : type; if (allowedAchievementTypes.has(type) === false) { throw new Error( `expected type to be one of: [${[...allowedAchievementTypesForDisplay].join(", ")}], or empty string, or undefined, but got ` + eatSymbols`${type}` ); } return type; }, andNormalizeBadge(badge) { const errMessage = eatSymbols`expected badge as unsigned integer or filepath starting with local\\\\ and going strictly down, but got ${badge}`; if (badge === void 0 || badge === null) { return "00000"; } if (isNumber(badge, { isInteger: true })) { const num = Number(badge); if (num < 0 || num > 4294967295) { throw new Error( `expected badge id to be within the range of 0x0 .. 0xFFFFFFFF, but got ${badge}` ); } return badge.toString().padStart(5, "0"); } else if (typeof badge === "string") { const pieces = badge.split("\\\\"); if (pieces.length < 2 || pieces[0] !== "local") { throw new Error(errMessage); } for (const piece of pieces) { if (/^\.+$/.test(piece)) { throw new Error(`encountered ${piece} within ${badge}, path can only go down`); } } const fileName = pieces[pieces.length - 1]; if (/^.+\.(png|jpe?g|gif)$/.test(fileName) === false) { throw new Error(`expected badge filename to be *.(png|jpg|jpeg|gif) but got "${fileName}"`); } return badge; } else { throw new Error(errMessage); } } }; const entireLineIsWhitespace = /^\s+$/; function achievementDataFromString(str) { const col = parseCSV(str); if (col.length !== 13 && col.length !== 14) { throw new Error( `got an unexpected amount of data when parsing raw achievement string, either there's not enough data or it's not escaped/quoted correctly` ); } let type = col[6]; if (type.match(entireLineIsWhitespace)) { type = ""; } const def = { id: validate$3.andNormalizeId(col[0]), title: col[2], description: col[3], type, author: col[7], points: Number(col[8]), badge: validate$1.andNormalizeBadge(col[13] || ""), conditions: normalizedConditionGroupSetFromString(col[1]) }; return def; } const moveConditions$1 = Symbol(); class Achievement { constructor(def) { const isAchievementInstance = def instanceof Achievement; if (typeof def === "string") { Object.assign(this, achievementDataFromString(def)); } else if (isObject(def) && isAchievementInstance === false) { let conditions = def.conditions; if (!def[moveConditions$1]) { conditions = normalizedConditionGroupSet(def.conditions); } Object.assign(this, { id: validate$3.andNormalizeId(def.id), title: def.title, description: def.description, author: def.author, points: def.points, type: def.type, badge: validate$1.andNormalizeBadge(def.badge), conditions }); } else { throw new Error( "achievement data must be an object or string with achievement code, but got " + (isAchievementInstance ? "another Achievement instance" : eatSymbols`${def}`) ); } validate$3.title(this.title); this.description = validate$3.andNormalizeDescription(this.description); this.author = validate$1.andNormalizeAuthor(this.author); validate$1.points(this.points); this.type = validate$1.andNormalizeAchievementType(this.type); validateRegularMeasuredConditions(this.conditions); validate$1.measuredConditionsMixing(this.conditions); deepFreeze(this); } /** * Returns new Achievement instance with different values merged. * * @param {DeepPartial<Achievement.InputObject>} data DeepPartial<Achievement.InputObject> * * @example * someAchievement * .with({ title: someAchievement.title + 'suffix' }) */ with(data) { return new Achievement({ ...this, ...data, [moveConditions$1]: data.hasOwnProperty("conditions") === false }); } /** * Returns string representation of Achievement suitable * for `RACache/Data/GameId-User.txt` file. * * @param desiredData optional parameter, set this to `'achievement'` or `'conditions'` to have corresponding string returned. Default option is `'achievement'`. * * @example * * someAchievement.toString() * someAchievement.toString('achievement') * // '58:"0=1":My Achievement:Do something funny::::cruncheevos:5:::::00000' * * someAchievement.toString('conditions') // '0=1' */ toString(desiredData = "achievement") { const conditions = this.conditions.map((x) => x.map((x2) => x2.toString()).join("_")).join("S"); if (desiredData === "conditions") { return conditions; } else if (desiredData === "achievement") { let res = ""; res += this.id + ":"; res += `"${conditions}":`; res += quoteIfHaveTo(this.title) + ":"; res += quoteIfHaveTo(this.description); res += ":::"; res += this.type + ":"; res += quoteIfHaveTo(this.author) + ":"; res += this.points; res += ":::::"; res += this.badge.startsWith("local\\\\") ? `"${this.badge}"` : this.badge; return res; } else { throw new Error(eatSymbols`unexpected achievement data toString request: ${desiredData}`); } } } const allowedLeaderboardConditionGroups = /* @__PURE__ */ new Set(["start", "cancel", "submit", "value"]); const allowedLeaderboardTypes = /* @__PURE__ */ new Set([ "SCORE", "TIME", "FRAMES", "MILLISECS", "SECS", "TIMESECS", "MINUTES", "SECS_AS_MINS", "VALUE", "UNSIGNED", "TENS", "HUNDREDS", "THOUSANDS", "FIXED1", "FIXED2", "FIXED3" ]); const validate = { andNormalizeLeaderboardId(id) { if (typeof id === "string") { if (id.startsWith("L")) { id = id.slice(1); } else { throw new Error(`expected id to start with L, but got "${id}"`); } } return validate$3.andNormalizeId(id); }, andNormalizeConditions(conditions) { let result; if (typeof conditions === "string") { result = leaderboardConditionsFromLegacyString(conditions); } else if (isObject(conditions)) { result = Object.keys(conditions).reduce((obj, key) => { if (allowedLeaderboardConditionGroups.has(key) === false) { throw new Error( `expected leaderboard condition group name to be one of: [${[ ...allowedLeaderboardConditionGroups ].join(", ")}], but got ` + eatSymbols`${key}` ); } obj[key] = normalizedConditionGroupSet(conditions[key], { considerLegacyValueFormat: key === "value" }); return obj; }, {}); } else { throw new Error(eatSymbols`expected conditions to be an object, but got ${conditions}`); } for (const group of result.value) { const hasMeasuredFlag = group.some((x) => x.flag === "Measured"); if (hasMeasuredFlag === false) { for (let i = 0; i < group.length; i++) { const condition = group[i]; if (condition.flag === "") { group[i] = condition.with({ flag: "Measured" }); break; } } } } return result; }, leaderboardType(type) { if (allowedLeaderboardTypes.has(type) === false) { throw new Error( `expected type to be one of: [${[...allowedLeaderboardTypes].join(", ")}], but got ` + eatSymbols`${type}` ); } }, measuredConditions(conditions) { for (const group of ["start", "cancel", "submit", "value"]) { try { if (group !== "value") { validateRegularMeasuredConditions(conditions[group]); } validate.lackOfMeasuredPercent(conditions[group]); } catch (err) { throw wrappedError(err, `${capitalizeWord(group)}, ` + err.message); } } }, lackOfMeasuredPercent(conditions) { conditions.forEach((group, groupIndex) => { group.forEach((condition, conditionIndex) => { if (condition.flag === "Measured%") { const groupName = indexToConditionGroupName(groupIndex); throw new Error( `${groupName}, condition ${conditionIndex + 1}: Measured% conditions are not allowed in leaderboards` ); } }); })