parent
ff5343e1ca
commit
279169be79
@ -1,51 +0,0 @@ |
||||
import { describe, expect, test } from '@jest/globals' |
||||
import { fulfillText, TextSource } from './weightedText' |
||||
import { forbidRandomness } from '../types/random' |
||||
|
||||
const source: TextSource = { |
||||
empty: '', |
||||
emptyList: [], |
||||
example: 'basic string value', |
||||
singletonList: ['singleton value'], |
||||
singleWeightedItem: [{ wt: 5, v: 'weighted item' }], |
||||
nestedItem: [[[['layered value']]]], |
||||
nestedWeightedItem: [[{ wt: 5, v: [[['innermost text']]] }]], |
||||
} |
||||
|
||||
describe('fulfillText', () => { |
||||
test.each<[string, string, string]>([ |
||||
[' returns blank string for blank string', '', ''], |
||||
[' returns constant string for no-substitution string', 'candy', 'candy'], |
||||
[' does not replace empty brackets', '{{}}', '{{}}'], |
||||
[' does not replace escaped brackets', '\\{{empty}}', '{{empty}}'], |
||||
[' does not replace half started brackets', '{{ empty', '{{ empty'], |
||||
[' does not replace brackets without only word characters inside', '{{ ??? }}', '{{ ??? }}'], |
||||
[' escapes backslash and replaces brackets after', '\\\\{{empty}}', '\\'], |
||||
[' replaces empty reference with empty value', '{{empty}}', ''], |
||||
[' replaces empty list reference with empty value', '{{emptyList}}', ''], |
||||
[' replaces string reference with value', '{{example}}', 'basic string value'], |
||||
[' replaces singleton list reference with value', '{{singletonList}}', 'singleton value'], |
||||
[' replaces singleton list reference with weighted value', '{{singleWeightedItem}}', 'weighted item'], |
||||
[' replaces multilevel singleton list reference with inner value', '{{nestedItem}}', 'layered value'], |
||||
[ |
||||
' replaces multilevel singleton list reference through weighted item with inner value', |
||||
'{{nestedWeightedItem}}', |
||||
'innermost text', |
||||
], |
||||
])('%s (%p -> %p)', (caseName, input, result) => { |
||||
expect(fulfillText(input, source, forbidRandomness)).toEqual(result) |
||||
}) |
||||
|
||||
test.each<[string, string]>([ |
||||
[' forbids \\ not followed by { or \\', '\\c'], |
||||
[' forbids reference to nonexistent value', '{{ nonexistentKey }}'], |
||||
])('%s (%p -> throws)', (caseName, input) => { |
||||
expect(() => fulfillText(input, source, forbidRandomness)).toThrow() |
||||
}) |
||||
}) |
||||
|
||||
describe('selectAndFulfillWeightedText', () => { |
||||
test(' needs tests', () => { |
||||
expect('tests').toBe('written') |
||||
}) |
||||
}) |
@ -1,100 +0,0 @@ |
||||
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<readonly TotalWeightedText[]>((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) |
||||
}, |
||||
) |
||||
} |
@ -1,6 +0,0 @@ |
||||
// Function that returns a floating point number x such that 0 <= x < 1
|
||||
export type RandomFunction = () => number |
||||
|
||||
export function forbidRandomness(): number { |
||||
throw Error('bad random') |
||||
} |
@ -1,8 +1,8 @@ |
||||
import { describe, test, expect } from '@jest/globals' |
||||
import { forbidRandomness } from './random' |
||||
import { forbiddenRNG } from './random' |
||||
|
||||
describe('forbidRandomness', () => { |
||||
test(' throws', () => { |
||||
expect(forbidRandomness).toThrow() |
||||
expect(() => forbiddenRNG()).toThrow() |
||||
}) |
||||
}) |
@ -0,0 +1,14 @@ |
||||
import { PRNG } from 'seedrandom' |
||||
|
||||
export const UsedForbiddenRNG = Error('used random number generation and was not supposed to') |
||||
|
||||
function forbidRNG(): never { |
||||
throw UsedForbiddenRNG |
||||
} |
||||
|
||||
export const forbiddenRNG: PRNG = Object.assign(forbidRNG, { |
||||
double: forbidRNG, |
||||
int32: forbidRNG, |
||||
quick: forbidRNG, |
||||
state: forbidRNG, |
||||
}) |
@ -0,0 +1,45 @@ |
||||
import { describe, expect, test } from '@jest/globals' |
||||
import { renderText, TextSource } from './renderableText' |
||||
import { forbiddenRNG } from './random' |
||||
|
||||
const source: TextSource = { |
||||
empty: '', |
||||
emptyList: [], |
||||
example: 'basic string value', |
||||
singletonList: ['singleton value'], |
||||
singleWeightedItem: [{ wt: 5, v: 'weighted item' }], |
||||
nestedItem: [[[['layered value']]]], |
||||
nestedWeightedItem: [[{ wt: 5, v: [[['innermost text']]] }]], |
||||
} |
||||
|
||||
describe('renderText', () => { |
||||
test.each<[string, string, string]>([ |
||||
['returns blank string for blank string', '', ''], |
||||
['returns constant string for no-substitution string', 'candy', 'candy'], |
||||
['does not replace empty brackets', '{{}}', '{{}}'], |
||||
['does not replace escaped brackets', '\\{{empty}}', '{{empty}}'], |
||||
['does not replace half started brackets', '{{ empty', '{{ empty'], |
||||
['does not replace brackets without only word characters inside', '{{ ??? }}', '{{ ??? }}'], |
||||
['escapes backslash and replaces brackets after', '\\\\{{empty}}', '\\'], |
||||
['replaces empty reference with empty value', '{{empty}}', ''], |
||||
['replaces empty list reference with empty value', '{{emptyList}}', ''], |
||||
['replaces string reference with value', '{{example}}', 'basic string value'], |
||||
['replaces singleton list reference with value', '{{singletonList}}', 'singleton value'], |
||||
['replaces singleton list reference with weighted value', '{{singleWeightedItem}}', 'weighted item'], |
||||
['replaces multilevel singleton list reference with inner value', '{{nestedItem}}', 'layered value'], |
||||
[ |
||||
'replaces multilevel singleton list reference through weighted item with inner value', |
||||
'{{nestedWeightedItem}}', |
||||
'innermost text', |
||||
], |
||||
])(' %s (%j -> %j)', (caseName, input, result) => { |
||||
expect(renderText(input, source, forbiddenRNG)).toEqual(result) |
||||
}) |
||||
|
||||
test.each<[string, string]>([ |
||||
['forbids \\ not followed by { or \\', '\\c'], |
||||
['forbids reference to nonexistent value', '{{ nonexistentKey }}'], |
||||
])(' %s (%j -> throws)', (caseName, input) => { |
||||
expect(() => renderText(input, source, forbiddenRNG)).toThrow() |
||||
}) |
||||
}) |
@ -0,0 +1,58 @@ |
||||
import { shuffleWeightedList, WeightedList } from './weightedList' |
||||
import { PRNG } from 'seedrandom' |
||||
export type RenderableText = string | WeightedList<string> |
||||
|
||||
export interface TextSource { |
||||
[key: string]: RenderableText |
||||
} |
||||
|
||||
export function renderText(text: RenderableText, data: TextSource, rng: PRNG): string { |
||||
const result = shuffleWeightedList(text, '', rng) |
||||
if (needsSubstitution(result)) { |
||||
return substituteText(result, data, rng) |
||||
} else { |
||||
return result |
||||
} |
||||
} |
||||
|
||||
const textReplacementRE = /\\(.)|\{\{\s*([A-Za-z0-9_]+)\s*}}/g |
||||
|
||||
function needsSubstitution(text: string): boolean { |
||||
return textReplacementRE.test(text) |
||||
} |
||||
|
||||
function substituteText(text: string, data: TextSource, random: PRNG): string { |
||||
return text.replaceAll( |
||||
textReplacementRE, |
||||
(substring, escapedCharacter: string | undefined, fieldName: string | undefined): string => { |
||||
let result: undefined | RenderableText = undefined |
||||
let errType = 'substitution' |
||||
|
||||
if (typeof escapedCharacter === 'string') { |
||||
if (escapedCharacter === '\\' || escapedCharacter === '{') { |
||||
result = escapedCharacter |
||||
} else { |
||||
errType = 'escape' |
||||
} |
||||
} |
||||
if (typeof fieldName === 'string') { |
||||
errType = 'variable or commonText' |
||||
result = data[fieldName] |
||||
if (typeof result === 'string') { |
||||
if (needsSubstitution(result)) { |
||||
return substituteText(result, data, random) |
||||
} |
||||
} else if (typeof result === 'undefined') { |
||||
errType = 'variable or commonText' |
||||
} else { |
||||
return renderText(result, data, random) |
||||
} |
||||
} |
||||
|
||||
if (typeof result === 'undefined') { |
||||
throw Error(`unknown ${errType} ${substring}`) |
||||
} |
||||
return result |
||||
}, |
||||
) |
||||
} |
@ -0,0 +1,101 @@ |
||||
import { describe, expect, test } from '@jest/globals' |
||||
import { shuffleWeightedList, WeightableItem, WeightedList } from './weightedList' |
||||
import { forbiddenRNG } from './random' |
||||
import { default as seedrandom } from 'seedrandom' |
||||
|
||||
type NonRandomTestCase<ItemT extends WeightableItem> = readonly [string, ItemT | WeightedList<ItemT>, ItemT, ItemT] |
||||
// type RandomTestCase<ItemT extends WeightableItem> = readonly [string, ItemT | WeightedList<ItemT>, ItemT, number, ItemT]
|
||||
|
||||
describe('shuffleWeightedList', () => { |
||||
test.each<NonRandomTestCase<string>>([ |
||||
['returns empty value for empty list', [], 'empty', 'empty'], |
||||
['returns item value for lone item', 'text', 'empty', 'text'], |
||||
['returns single item for singleton list', ['wrapped'], 'empty', 'wrapped'], |
||||
['returns single weighted item for singleton list', [{ wt: 10, v: 'found' }], 'empty', 'found'], |
||||
[ |
||||
'returns lone item with nonzero weight', |
||||
[ |
||||
{ wt: 0, v: 'zero' }, |
||||
{ wt: 2, v: 'goal' }, |
||||
{ wt: 0, v: 'none' }, |
||||
], |
||||
'empty', |
||||
'goal', |
||||
], |
||||
[ |
||||
'returns lone item without zero weight', |
||||
[{ wt: 0, v: 'zero' }, 'where am i', { wt: 0, v: 'none' }], |
||||
'empty', |
||||
'where am i', |
||||
], |
||||
[ |
||||
'returns empty for list with all zero weights', |
||||
[ |
||||
{ wt: 0, v: 'zero' }, |
||||
{ wt: 0, v: 'goal??' }, |
||||
{ wt: 0, v: 'none' }, |
||||
], |
||||
'its basically empty', |
||||
'its basically empty', |
||||
], |
||||
[ |
||||
'returns nested singleton for list with all zero weights save nested list', |
||||
[ |
||||
{ wt: 0, v: 'zero' }, |
||||
{ wt: 1, v: [{ wt: 1, v: ['goal!!!'] }] }, |
||||
{ wt: 0, v: 'none' }, |
||||
], |
||||
'its basically empty', |
||||
'goal!!!', |
||||
], |
||||
[ |
||||
'returns nested singleton for list with all zero weights save nested list without weight', |
||||
[{ wt: 0, v: 'zero' }, [{ wt: 1, v: [{ wt: 1, v: 'goal!?!' }] }], { wt: 0, v: 'none' }], |
||||
'hell', |
||||
'goal!?!', |
||||
], |
||||
])(' %s (%j with default %j -> %j)', (caseName, input, empty, result) => { |
||||
expect(shuffleWeightedList(input, empty, forbiddenRNG)).toEqual(result) |
||||
}) |
||||
|
||||
test(' chooses a value roughly evenly at random from the options for an unweighted list', () => { |
||||
const input: WeightedList<'a' | 'b' | 'c' | 'd' | ''> = ['a', 'b', 'c', 'd'] |
||||
const random = seedrandom('poppy') |
||||
const counts = { a: 0, b: 0, c: 0, d: 0, '': 0 } |
||||
const totalRounds = 10_000 |
||||
for (let roundsLeft = totalRounds; roundsLeft > 0; roundsLeft -= 1) { |
||||
const result = shuffleWeightedList(input, '', random) |
||||
counts[result] = (counts[result] ?? 0) + 1 |
||||
} |
||||
expect(counts['']).toBe(0) |
||||
for (const key of ['a', 'b', 'c', 'd'] as ('a' | 'b' | 'c' | 'd')[]) { |
||||
// Confirm everything is between 24-26% after 10,000 rounds
|
||||
expect(Math.abs(25 - (100 * counts[key]) / totalRounds)).toBeLessThan(1) |
||||
} |
||||
}) |
||||
|
||||
test(' chooses a value roughly evenly at random from the options for a weighted list', () => { |
||||
const input: WeightedList<'a' | 'b' | 'c' | 'd'> = [ |
||||
{ wt: 2, v: 'a' }, |
||||
{ wt: 5, v: 'b' }, |
||||
{ wt: 0, v: 'c' }, |
||||
'd', |
||||
] |
||||
const random = seedrandom('sesame') |
||||
const counts = { a: 0, b: 0, c: 0, d: 0 } |
||||
const totalRounds = 10_000 |
||||
for (let roundsLeft = totalRounds; roundsLeft > 0; roundsLeft -= 1) { |
||||
const result = shuffleWeightedList(input, 'c', random) |
||||
counts[result] = (counts[result] ?? 0) + 1 |
||||
} |
||||
expect(counts['c']).toBe(0) |
||||
for (const [expected, key] of [ |
||||
[25, 'a'], |
||||
[62.5, 'b'], |
||||
[12.5, 'd'], |
||||
] as [number, 'a' | 'b' | 'd'][]) { |
||||
// Confirm everything is within 1% of its expected weight after 10,000 rounds
|
||||
expect(Math.abs(expected - (100 * counts[key]) / totalRounds)).toBeLessThan(1) |
||||
} |
||||
}) |
||||
}) |
@ -0,0 +1,96 @@ |
||||
import { PRNG } from 'seedrandom' |
||||
|
||||
// Weighted items do not support as values:
|
||||
// * Individual weighted items (what does it mean for a weighted item to have another weight inside?)
|
||||
// * Arrays (we don't know the difference between an array that is an item and an array that is)
|
||||
// * Items with the length or wt property as a number (that's what we use to detect an array or weighted item)
|
||||
export type WeightableItem = |
||||
| { |
||||
readonly wt?: undefined | null | string | boolean | bigint | symbol | object | never |
||||
readonly length?: undefined | null | string | boolean | bigint | symbol | object | never |
||||
} |
||||
| string |
||||
| boolean |
||||
| number |
||||
| bigint |
||||
| null |
||||
| undefined |
||||
| symbol |
||||
export interface WeightedItem<ItemT extends WeightableItem> { |
||||
readonly wt: number |
||||
readonly v: ItemT | WeightedList<ItemT> |
||||
} |
||||
type WeightedListItem<ItemT extends WeightableItem> = WeightedItem<ItemT> | ItemT | WeightedList<ItemT> |
||||
export type WeightedList<ItemT extends WeightableItem> = readonly WeightedListItem<ItemT>[] |
||||
|
||||
function isWeightedItem<ItemT extends WeightableItem>(x: WeightedListItem<ItemT>): x is WeightedItem<ItemT> { |
||||
return typeof x === 'object' && x !== null && 'wt' in x && typeof x['wt'] === 'number' |
||||
} |
||||
|
||||
function getItemValue<ItemT extends WeightableItem>(x: WeightedListItem<ItemT>): WeightedList<ItemT> | ItemT { |
||||
if (isWeightedItem(x)) { |
||||
return x.v |
||||
} else { |
||||
return x |
||||
} |
||||
} |
||||
|
||||
type TotalWeightedItem<ItemT extends WeightableItem> = { |
||||
readonly minWt: number |
||||
readonly maxWt: number |
||||
readonly v: WeightedList<ItemT> | ItemT |
||||
} |
||||
|
||||
type TotalWeightedList<ItemT extends WeightableItem> = readonly TotalWeightedItem<ItemT>[] |
||||
|
||||
function isWeightedList<ItemT extends WeightableItem>(x: ItemT | WeightedList<ItemT>): x is WeightedList<ItemT> { |
||||
return typeof x === 'object' && x !== null && 'length' in x && typeof x['length'] === 'number' |
||||
} |
||||
|
||||
export function shuffleWeightedList<ItemT extends WeightableItem>( |
||||
x: WeightedList<ItemT> | ItemT, |
||||
emptyValue: ItemT, |
||||
rng: PRNG, |
||||
): ItemT { |
||||
if (!isWeightedList(x)) { |
||||
return x |
||||
} |
||||
if (x.length === 0) { |
||||
return emptyValue |
||||
} |
||||
if (x.length === 1) { |
||||
return shuffleWeightedList(getItemValue(x[0]), emptyValue, rng) |
||||
} |
||||
const totals = x.reduce<TotalWeightedList<ItemT>>((accumulated, next) => { |
||||
const minWeight = accumulated.length === 0 ? 0 : accumulated[accumulated.length - 1].maxWt |
||||
const newWeight = isWeightedItem(next) ? next.wt : 1 |
||||
if (minWeight + newWeight === minWeight) { |
||||
// weightless items get ignored
|
||||
// this includes items whose weights are so small relative to the list that they are effectively weightless
|
||||
return accumulated |
||||
} |
||||
return [...accumulated, { minWt: minWeight, maxWt: minWeight + newWeight, v: getItemValue(next) }] |
||||
}, []) |
||||
if (totals.length === 0) { |
||||
return emptyValue |
||||
} |
||||
if (totals.length === 1) { |
||||
return shuffleWeightedList(totals[0].v, emptyValue, rng) |
||||
} |
||||
// Here we have a weighted list with no items with any weight.
|
||||
if (totals[totals.length - 1].maxWt <= 0) { |
||||
return emptyValue |
||||
} |
||||
// Now we pick a value out of the stacked weights and binary search it up
|
||||
const target = rng.double() * totals[totals.length - 1].maxWt |
||||
for (let start = 0, end = totals.length; ; ) { |
||||
const nextIndex = start + Math.floor((end - start) / 2) |
||||
if (target >= totals[nextIndex].maxWt) { |
||||
start = nextIndex + 1 |
||||
} else if (target < totals[nextIndex].minWt) { |
||||
end = nextIndex |
||||
} else { |
||||
return shuffleWeightedList(totals[nextIndex].v, emptyValue, rng) |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue