@cruncheevos/core
Version:
Parse and generate achievements and leaderboards for RetroAchievements.org
470 lines (469 loc) • 15 kB
JavaScript
import { Condition } from './condition.js';
import { stringToNumberLE } from './util.js';
function makeBuilder(flag) {
return function (...args) {
const builder = new ConditionBuilder();
pushArgsToBuilder.call(builder, flag, ...args);
return builder;
};
}
/**
* Function providing versatile way to define conditions,
* returns instance of ConditionBuilder class
*
* @example
* import { define as $ } from '@cruncheevos/core'
* let someParameter = false
*
* $(
* ['', 'Mem', '32bit', 0xCAFE, '=', 'Value', '', 1],
* '0=2'
* ).trigger(
* '0=3',
* // condition below will not be included because
* // the expression evaluated to falsy value
* someParameter && '0=4',
* ).toString() // 0xXcafe=1_0=2_T:0=3
*/
export 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 > 0xFFFFFF ? '32bit' :
value > 0xFFFF ? '24bit' :
value > 0xFF ? '16bit' :
'8bit', ['Value', '', value]);
if (index > 0) {
return c.withLast({
lvalue: { value: c.conditions[c.conditions.length - 1].lvalue.value + index * 4 },
});
}
return c;
}));
};
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with Trigger flag
*
* @example
* import { trigger } from '@cruncheevos/core'
* trigger('0=1', '0=2').toString() // T:0=1_T:0=2
*/
export const trigger = makeBuilder('Trigger');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with ResetIf flag
*
* @example
* import { resetIf } from '@cruncheevos/core'
* resetIf('0=1', '0=2').toString() // R:0=1_R:0=2
*/
export const resetIf = makeBuilder('ResetIf');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with PauseIf flag
*
* @example
* import { pauseIf } from '@cruncheevos/core'
* pauseIf('0=1', '0=2').toString() // P:0=1_P:0=2
*/
export const pauseIf = makeBuilder('PauseIf');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with AddHits flag
*
* @example
* import { addHits } from '@cruncheevos/core'
* addHits('0=1', '0=2').toString() // C:0=1_C:0=2
*/
export const addHits = makeBuilder('AddHits');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with SubHits flag
*
* @example
* import { subHits } from '@cruncheevos/core'
* subHits('0=1', '0=2').toString() // D:0=1_D:0=2
*/
export const subHits = makeBuilder('SubHits');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with Measured flag
*
* @example
* import { measured } from '@cruncheevos/core'
* measured('0=1', '0=2').toString() // M:0=1_M:0=2
*/
export const measured = makeBuilder('Measured');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with Measured% flag
*
* RAIntegration converts Measured flags to Measured% if *Track as %* checkbox is ticked
*
* @example
* import { measuredPercent } from '@cruncheevos/core'
* measuredPercent('0=1', '0=2').toString() // G:0=1_G:0=2
*/
export const measuredPercent = makeBuilder('Measured%');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with MeasuredIf flag
*
* @example
* import { measuredIf } from '@cruncheevos/core'
* measuredIf('0=1', '0=2').toString() // Q:0=1_Q:0=2
*/
export const measuredIf = makeBuilder('MeasuredIf');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with ResetNextIf flag
*
* @example
* import { resetNextIf } from '@cruncheevos/core'
* resetNextIf('0=1', '0=2').toString() // Z:0=1_Z:0=2
*/
export const resetNextIf = makeBuilder('ResetNextIf');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with AndNext flag
*
* The final condition will not have AndNext flag applied
* unless it's followed by a chained method call, otherwise
* codition will not work correctly that way
*
* @example
* import { andNext } from '@cruncheevos/core'
* andNext('0=1', '0=2').toString() // N:0=1_0=2
* andNext('0=1', '0=2').also('0=3').toString() // N:0=1_N:0=2_0=3
*/
export const andNext = makeBuilder('AndNext');
/**
* Same as {@link define}, but starts the condition chain
* by wrapping the passed conditions with OrNext flag
*
* The final condition will not have OrNext flag applied
* unless it's followed by a chained method call, otherwise
* codition will not work correctly that way
*
* @example
* import { orNext } from '@cruncheevos/core'
* orNext('0=1', '0=2').toString() // O:0=1_0=2
* orNext('0=1', '0=2').also('0=3').toString() // O:0=1_O:0=2_0=3
*/
export const orNext = makeBuilder('OrNext');
/**
* Same as {@link define}, but sets 1 hit to the final
* condition that was passed to the function
*
* @example
* $('0=1').once(
* andNext('0=2', '0=3')
* ).toString() // 0=1_N:0=2_0=3.1.
*/
export const once = (...args) => new ConditionBuilder().also('once', ...args);
const lastCallTypes = new WeakMap();
export 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);
}