@cruncheevos/core
Version:
Parse and generate achievements and leaderboards for RetroAchievements.org
498 lines (497 loc) • 18.7 kB
JavaScript
import { Condition, normalizedConditionGroupSetFromString } from './condition.js';
import { ConditionBuilder } from './define.js';
import { eatSymbols, formatNumberAsHex, isNumber, wrappedError } from './util.js';
const richLookup = Symbol('isRichLookupOrFormat');
const allowedFormatTypes = new Set([
'VALUE',
'SCORE',
'POINTS',
'TIME',
'FRAMES',
'MILLISECS',
'SECS',
'MINUTES',
'SECS_AS_MINS',
'FLOAT1',
'FLOAT2',
'FLOAT3',
'FLOAT4',
'FLOAT5',
'FLOAT6',
]);
function compressRange(input) {
const { numbers, notNumbers } = input.reduce((prev, cur) => {
const numberMaybe = Number(cur);
if (Number.isNaN(numberMaybe)) {
prev.notNumbers.push(cur);
}
else {
prev.numbers.push(numberMaybe);
}
return prev;
}, { numbers: [], notNumbers: [] });
if (numbers.length === 0) {
return { formattedRanges: '', notNumbers };
}
const ranges = [];
let start = numbers[0];
let end = numbers[0];
for (let i = 1; i < numbers.length; i++) {
if (numbers[i] - end === 1) {
end = numbers[i];
}
else {
ranges.push([start, end]);
start = numbers[i];
end = numbers[i];
}
}
ranges.push([start, end]);
return {
notNumbers,
formattedRanges: ranges
.map(([start, end]) => (start === end ? `${start}` : `${start}-${end}`))
.join(','),
};
}
function isRichLookup(input) {
return input && typeof input !== 'string' && input[richLookup];
}
function taggedDisplayString(strings, ...args) {
return strings
.map((str, i) => {
let val = i === strings.length - 1 ? '' : args[i];
if (isRichLookup(val)) {
val = val.at();
}
return `${str}${val}`;
})
.join('');
}
function doesntHaveMeasuredTrail(condition) {
// prettier-ignore
const conditions = condition instanceof ConditionBuilder ? condition.conditions :
Array.isArray(condition) ? condition :
[condition];
return conditions[conditions.length - 1].flag !== 'Measured';
}
function shortenMaybe(input) {
const conditions = input instanceof ConditionBuilder ? input.conditions : [input];
if (conditions.length === 1) {
return input.toString().replace('M:', '').replace(' ', '');
}
return input.toString();
}
function makeRichPresenceDisplay(condition, displayString) {
return `?${condition}?${displayString}`;
}
function _makeRichPresenceFormat(params) {
const { name, type } = params;
return {
/** Name of this Rich Presence Format */
name,
/** Type of this Rich Presence Format, such as SCORE, FRAMES, etc. */
type: type,
/**
* Returns string representation of Rich Presence Format definition
*
* @example
* import { RichPresence } from '@cruncheevos/core'
* const format = RichPresence.format({ name: 'Score', type: 'VALUE' })
* format.toString() // 'Format:Score\nFormatType=VALUE'
*/
toString() {
return `Format:${name}\nFormatType=${type}`;
},
/**
* Returns string representation of Rich PresenceFormat macro call
*
* If there's only one condition - output may be shortened.
*
* When passing Condition or ConditionBuilder - you must have
* at least one condition marked as Measured.
*
* Legacy value format is only supported by passing a string.
*
* @example
* import { define as $, RichPresence } from '@cruncheevos/core'
*
* const format = RichPresence.format({ name: 'Score', type: 'VALUE' })
* format.at('0xcafe_v1') // '@Car(0xcafe_v1)'
* format.at($(['Measured', 'Mem', 'Float', 0xCAFE])) // '@Car(fFcafe)'
*/
at(input) {
if (typeof input === 'string') {
try {
normalizedConditionGroupSetFromString(input, {
considerLegacyValueFormat: true,
});
}
catch (err) {
throw wrappedError(err, eatSymbols `Rich Presence Format ${name} got invalid string input: ${err.message}`);
}
return `@${name}(${input})`;
}
if (input instanceof Condition || input instanceof ConditionBuilder) {
if (doesntHaveMeasuredTrail(input)) {
throw new Error(eatSymbols `Rich Presence Format ${name} got invalid input: must have at least one condition with Measured flag, but got ${input.toString()}`);
}
return `@${name}(${shortenMaybe(input)})`;
}
throw new Error(eatSymbols `Rich Presence Format ${name} got invalid input: ${input}`);
},
};
}
function makeRichPresenceFormat(params) {
const { name, type } = params;
if (typeof name !== 'string') {
throw new Error(eatSymbols `Rich Presence Format expected to have a name as string, but got ${name}`);
}
if (allowedFormatTypes.has(type) === false) {
throw new Error(eatSymbols `Rich Presence Format ${name} got unexpected type: ${type}`);
}
return _makeRichPresenceFormat(params);
}
function makeRichPresenceLookup(params) {
const { name, values, defaultAt, compressRanges = true } = params;
let parsedDefaultAt = '';
if (typeof name !== 'string') {
throw new Error(eatSymbols `Rich Presence Lookup expected to have a name as string, but got ${name}`);
}
const entries = Object.entries(values || {});
if (entries.length === 0) {
throw new Error(eatSymbols `Rich Presence Lookup ${name} must define at least one key-value pair`);
}
for (const [key, value] of entries) {
if (key === '*') {
continue;
}
if (isNumber(key, { isInteger: true, isPositive: true }) === false) {
throw new Error(eatSymbols `Rich Presence Lookup ${name} got invalid key-value pair ${key}: ${value}, value must be positive integer or "*"`);
}
}
if (defaultAt !== undefined) {
if (typeof defaultAt === 'string') {
try {
var conditions = normalizedConditionGroupSetFromString(defaultAt, {
considerLegacyValueFormat: true,
});
}
catch (err) {
throw wrappedError(err, eatSymbols `Rich Presence Lookup ${name} got invalid defaultAt: ${err.message}`);
}
if (doesntHaveMeasuredTrail(conditions[0])) {
throw new Error(eatSymbols `Rich Presence Lookup ${name} got invalid input: must have at least one condition with Measured flag, but got ${defaultAt}`);
}
parsedDefaultAt = `@${name}(${defaultAt})`;
}
else if (defaultAt instanceof Condition || defaultAt instanceof ConditionBuilder) {
if (doesntHaveMeasuredTrail(defaultAt)) {
throw new Error(eatSymbols `Rich Presence Lookup ${name} got invalid defaultAt: must have at least one condition with Measured flag, but got ${defaultAt}`);
}
parsedDefaultAt = `@${name}(${shortenMaybe(defaultAt)})`;
}
else {
throw new Error(eatSymbols `Rich Presence Lookup ${name} defaultAt expected to be a string, Condition or ConditionBuilder, but got ${defaultAt}`);
}
}
let finalValues = values;
if (compressRanges) {
const valueRanges = entries.reduce((prev, [key, value]) => {
if (!prev[value]) {
prev[value] = [];
}
prev[value].push(key);
return prev;
}, {});
finalValues = Object.entries(valueRanges).reduce((prev, [value, keys]) => {
const { notNumbers, formattedRanges } = compressRange(keys);
if (formattedRanges) {
prev[formattedRanges] = value;
}
for (const bad of notNumbers) {
prev[bad] = value;
}
return prev;
}, {});
}
return {
[richLookup]: true,
/** Name of this Rich Presence Lookup */
name,
/**
* Returns string representation of Rich Presence Lookup definition
*
* @param {RichPresence.LookupKeyFormat} keyFormat defines how to format Lookup keys, defaults to `'dec'`
*
* @example
* import { RichPresence } from '@cruncheevos/core'
* const format = RichPresence.format({ name: 'Score', type: 'VALUE' })
* lookup.toString() // `Lookup:Car\n0x1=First!\n0x2=Second!\n0x4-0x5=Same'
*/
toString(keyFormat = 'dec') {
let rich = `Lookup:${name}`;
for (const inputKey in finalValues) {
let key = inputKey;
if (key !== '*' && keyFormat.startsWith('hex')) {
key = key.replace(/\d+/g, num => formatNumberAsHex(Number(num), keyFormat !== 'hex-lowercase'));
}
rich += `\n${key}=${finalValues[inputKey]}`;
}
return rich;
},
at: function at(input) {
if (input === undefined) {
if (parsedDefaultAt) {
return parsedDefaultAt;
}
throw new Error(`Rich Presence Lookup ${name} got no input, neither defaultAt specified`);
}
if (typeof input === 'string') {
try {
var conditions = normalizedConditionGroupSetFromString(input, {
considerLegacyValueFormat: true,
});
}
catch (err) {
throw wrappedError(err, eatSymbols `Rich Presence Lookup ${name} got error when parsing input: ${err.message}`);
}
if (doesntHaveMeasuredTrail(conditions[0])) {
throw new Error(eatSymbols `Rich Presence Lookup ${name} got invalid input: must have at least one condition with Measured flag, but got ${input}`);
}
return `@${name}(${input})`;
}
else if (input instanceof Condition || input instanceof ConditionBuilder) {
if (doesntHaveMeasuredTrail(input)) {
throw new Error(eatSymbols `Rich Presence Lookup ${name} got invalid input: must have at least one condition with Measured flag, but got ${input}`);
}
return `@${name}(${shortenMaybe(input)})`;
}
else {
throw new Error(eatSymbols `Rich Presence Lookup ${name} got invalid input: ${input}`);
}
},
};
}
/**
* Provides declarative API to produce Rich Presence string
*
* @example
* import { define as $, RichPresence } from '@cruncheevos/core'
*
* RichPresence({
* lookupDefaultParameters: { keyFormat: 'hex', compressRanges: true },
* // Wraps calls to RichPresence.format
* format: {
* Score: 'VALUE',
* },
* // Wraps calls to RichPresence.lookup
* lookup: {
* Song: {
* // No need to specify name, it's taken from object
* values: {
* '*': 'Unknown value',
* 1: 'Same value',
* 2: 'Same value',
* 3: 'Same value',
* },
* // overrides lookupDefaultParameters.keyFormat
* keyFormat: 'dec',
* defaultAt: 0x100,
* },
* Mode: {
* values: {
* 1: 'Mode 1',
* 2: 'Mode 2',
* },
* // overrides lookupDefaultParameters.compressRanges
* compressRanges: false
* },
* },
* // Callback function that must return an array of display strings.
* // All the previously specified Lookups and Formats are provided
* // through `lookup` and `format` objects respectively,
* // along with the `tag` function to inject lookups into display strings.
* displays: ({ lookup, format, macro, tag }) => [
* [
* $(['', 'Mem', '16bit', 0xcafe, '=', 'Value', '', 1]),
*
* // Passing lookup.Song to this tagged template literal function causes
* // `lookup.Song.at()` call with previosly set `defaultAt` value
* tag`Cafe at value 1, Song: ${lookup.Song}, Mode: ${lookup.Mode.at(0x990)}`,
* ],
*
* ['0xCAFE=2', tag`Cafe at value 2, format example: ${format.Score.at(0x600)}`],
*
* // `macro` is an object providing several pre-existing Formats
* ['0xCAFE=3', tag`Default macro test ${macro.Score.at('0xfeed')}`],
* 'Playing a good game',
* ],
* })
*
* `Format:Score
* FormatType=VALUE
*
* Lookup:Song
* 1-3=Same value
* *=Unknown value
*
* Lookup:Mode
* 0x1=Mode 1
* 0x2=Mode 2
*
* Display:
* ?0x cafe=1?Cafe at value 1, Song: @Song(0x100), Mode: @Mode(0x990)
* ?0xCAFE=2?Cafe at value 2, format example: @Score(0x600)
* ?0xCAFE=3?Default macro test @Score(0xfeed)
* Playing a good game`
*/
export const RichPresence = (params) => {
const { format = {}, lookup = {}, lookupDefaultParameters = {} } = params;
const mappedFormat = Object.keys(format).reduce((prev, key) => {
prev[key] = makeRichPresenceFormat({
name: key,
type: format[key],
});
return prev;
}, {});
const mappedLookup = Object.keys(lookup).reduce((prev, key) => {
prev[key] = makeRichPresenceLookup({
compressRanges: lookupDefaultParameters.compressRanges,
...lookup[key],
name: key,
});
return prev;
}, {});
const displays = params.displays({
lookup: mappedLookup,
format: mappedFormat,
tag: taggedDisplayString,
macro: RichPresence.macro,
});
if (displays.length === 0) {
throw new Error(`Rich Presence displays must return at least one display string`);
}
const mappedDisplays = displays.map((display, i) => {
if (typeof display === 'string') {
return display;
}
if (Array.isArray(display)) {
if (display.length !== 2) {
throw new Error(`Rich Presence displays[${i}] must be either a string or an array with two strings`);
}
return makeRichPresenceDisplay(display[0], display[1]);
}
throw new Error(`Rich Presence displays[${i}] must be either a string or an array with two strings`);
});
return {
lookup: mappedLookup,
format: mappedFormat,
displayStrings: mappedDisplays,
macro: RichPresence.macro,
toString() {
return ([
Object.values(mappedFormat).join('\n\n'),
Object.values(mappedLookup)
.map(l => l.toString(lookup[l.name].keyFormat || lookupDefaultParameters.keyFormat))
.join('\n\n'),
]
.join('\n\n')
.trim() +
'\n\nDisplay:\n' +
mappedDisplays.join('\n'));
},
};
};
/**
* Returns a string representing Rich Presence Display line
*
* Does not check if provided arguments are of correct type
*
* @example
* import { RichPresence } from '@cruncheevos/core'
* RichPresence.display('0=1', 'Nothing is happening'))
* // '?0=1?Nothing is happening'
*/
RichPresence.display = makeRichPresenceDisplay;
/**
* Creates an object representing Rich Presence Format
*
* @example
* import { RichPresence } from '@cruncheevos/core'
*
* const format = RichPresence.format({
* name: 'Score',
* type: 'VALUE',
* })
*
* format.at('0xCAFE_v1') // '@Score(0xCAFE_v1)'
* format.at($(['Measured', 'Mem', '16bit', 0xCAFE])) // '@Score(0xcafe)'
* format.toString() // 'Format:Score\nFormatType=VALUE'
*/
RichPresence.format = makeRichPresenceFormat;
/**
* Creates an object representing Rich Presence Lookup
*
* @example
* import { RichPresence } from '@cruncheevos/core'
*
* const lookup = RichPresence.lookup({
* name: 'Car',
* keyFormat: 'hex',
* values: {
* 1: 'First!',
* 2: 'Second!',
* 4: 'Same',
* 5: 'Same',
* },
* defaultAt: 0xfeed,
* compressRanges: true
* })
*
* lookup.at() // '@Car(0xfeed)'
* lookup.at('0xCAFE_v1') // '@Score(0xCAFE_v1)'
* lookup.at($(['Measured', 'Mem', 'Float', 0xCAFE])) // '@Car(fFcafe)'
* lookup.toString() // `Lookup:Car\n0x1=First!\n0x2=Second!\n0x4-0x5=Same'
*/
RichPresence.lookup = makeRichPresenceLookup;
/**
* Tagged template literal function which can accept Rich Presence Lookup instances.
* This allows for less noisy display strings.
*
* @example
* import { RichPresence } from '@cruncheevos/core'
*
* const lookup = RichPresence.lookup({ name: 'Song', defaultAddress: 0xfeed, values: { ... } })
*
* RichPresence.tag`${lookup} - now playing` // '@Song(0xfeed) - now playing'
*/
RichPresence.tag = taggedDisplayString;
/**
* Provides an object containing default Rich Presence Macros
*
* @example
* import { RichPresence } from '@cruncheevos/core'
*
* RichPresence.macro.Score.at('0xCAFE') // '@Score(0xCAFE)'
* RichPresence.macro.ASCIIChar.at('0xCAFE') // '@ASCIIChar(0xCAFE)'
*/
RichPresence.macro = {
Number: _makeRichPresenceFormat({ name: 'Number', type: 'VALUE' }),
Unsigned: _makeRichPresenceFormat({ name: 'Unsigned', type: 'UNSIGNED' }),
Score: _makeRichPresenceFormat({ name: 'Score', type: 'SCORE' }),
Centiseconds: _makeRichPresenceFormat({ name: 'Centiseconds', type: 'MILLISECS' }),
Seconds: _makeRichPresenceFormat({ name: 'Seconds', type: 'SECS' }),
Minutes: _makeRichPresenceFormat({ name: 'Minutes', type: 'MINUTES' }),
Fixed1: _makeRichPresenceFormat({ name: 'Fixed1', type: 'FIXED1' }),
Fixed2: _makeRichPresenceFormat({ name: 'Fixed2', type: 'FIXED2' }),
Fixed3: _makeRichPresenceFormat({ name: 'Fixed3', type: 'FIXED3' }),
Float1: _makeRichPresenceFormat({ name: 'Float1', type: 'FLOAT1' }),
Float2: _makeRichPresenceFormat({ name: 'Float2', type: 'FLOAT2' }),
Float3: _makeRichPresenceFormat({ name: 'Float3', type: 'FLOAT3' }),
Float4: _makeRichPresenceFormat({ name: 'Float4', type: 'FLOAT4' }),
Float5: _makeRichPresenceFormat({ name: 'Float5', type: 'FLOAT5' }),
Float6: _makeRichPresenceFormat({ name: 'Float6', type: 'FLOAT6' }),
ASCIIChar: _makeRichPresenceFormat({ name: 'ASCIIChar', type: 'ASCIIChar' }),
UnicodeChar: _makeRichPresenceFormat({ name: 'UnicodeChar', type: 'UnicodeChar' }),
};