export interface WeightedText { readonly wt: number readonly v: WeightedTextSelector } export type WeightedTextListItem = WeightedText | string | WeightedTextList export type WeightedTextList = readonly WeightedTextListItem[] export type WeightedTextSelector = string | WeightedTextList export interface TextSource { [key: string]: WeightedTextSelector } function isWeightedText(t: WeightedTextListItem): t is WeightedText { return typeof t === 'object' && 'wt' in t } interface TotalWeightedText { readonly minValue: number readonly maxValue: number readonly result: WeightedTextSelector } function totalWeightedText(minValue: number, t: WeightedTextListItem): TotalWeightedText { if (isWeightedText(t)) { return { minValue, maxValue: minValue + t.wt, result: t.v, } } return { minValue, maxValue: minValue + 1, result: t, } } export function selectAndFulfillWeightedText( text: WeightedTextSelector, data: TextSource, random: () => number, ): string { if (typeof text === 'string') { if (textRequiresFulfillment(text)) { return fulfillText(text, data, random) } else { return text } } else { if (text.length === 0) { return '' } else if (text.length === 1) { return selectAndFulfillWeightedText(isWeightedText(text[0]) ? text[0].v : text[0], data, random) } const totalWeightedTexts = text.reduce((accumulated, next) => { const weighted = accumulated.length === 0 ? totalWeightedText(0, next) : totalWeightedText(accumulated[accumulated.length - 1].maxValue, next) return [...accumulated, weighted] }, []) const maxValue = totalWeightedTexts[totalWeightedTexts.length - 1].maxValue const selectedValue = random() * maxValue const selectedItem = totalWeightedTexts.find((value) => { return value.minValue <= selectedValue && value.maxValue < selectedValue })?.result ?? '' return selectAndFulfillWeightedText(selectedItem, data, random) } } const textReplacementRE = /\\(.)|\{\{\s*([A-Za-z0-9_]+)\s*}}/g export function textRequiresFulfillment(text: string): boolean { return textReplacementRE.test(text) } export function fulfillText(text: string, data: TextSource, random: () => number): string { return text.replaceAll( textReplacementRE, (substring, escapedCharacter: string | undefined, fieldName: string | undefined): string => { if (typeof escapedCharacter === 'string') { if (escapedCharacter === '\\' || escapedCharacter === '{') { return escapedCharacter } else { throw Error('unknown escape ' + substring) } } if (typeof fieldName === 'string') { const substitution = data[fieldName] if (typeof substitution === 'undefined') { throw Error('unknown variable or commonText ' + fieldName) } return selectAndFulfillWeightedText(substitution, data, random) } // We know this can't happen because one of the capturing groups always captures, but Typescript and Istanbul don't. /* istanbul ignore next */ throw Error('unknown replacement ' + substring) }, ) }