@cruncheevos/core
Version:
Parse and generate achievements and leaderboards for RetroAchievements.org
1,592 lines • 74.6 kB
JavaScript
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`
);
}
});
})