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 { describe, test, expect } from '@jest/globals' |
||||||
import { forbidRandomness } from './random' |
import { forbiddenRNG } from './random' |
||||||
|
|
||||||
describe('forbidRandomness', () => { |
describe('forbidRandomness', () => { |
||||||
test(' throws', () => { |
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