diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index cb106f8..9fb0edc 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -2,26 +2,21 @@
-
-
-
-
+
-
-
-
+
@@ -30,22 +25,35 @@
-
+
-
+
+
+
-
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
-
+
-
-
+
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 0427ccc..5f92bb3 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,18 +4,23 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
@@ -41,29 +46,34 @@
- {
+ "keyToString": {
+ "RunOnceActivity.OpenProjectViewOnStart": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "WebServerToolWindowFactoryState": "false",
+ "last_opened_file_path": "/home/reya/WebstormProjects/steppies/node_modules/ts-jest",
+ "node.js.detected.package.eslint": "true",
+ "node.js.detected.package.standard": "true",
+ "node.js.detected.package.stylelint": "true",
+ "node.js.detected.package.tslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "node.js.selected.package.standard": "",
+ "node.js.selected.package.stylelint": "",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "nodejs.jest.jest_package": "/home/reya/WebstormProjects/steppies/node_modules/jest",
+ "nodejs_interpreter_path": "node",
+ "nodejs_package_manager_path": "npm",
+ "prettierjs.PrettierConfiguration.Package": "/home/reya/WebstormProjects/steppies/node_modules/prettier",
+ "settings.editor.selected.configurable": "configurable.group.appearance",
+ "ts.external.directory.path": "/home/reya/WebstormProjects/steppies/node_modules/typescript/lib",
+ "vue.rearranger.settings.migration": "true"
}
-}]]>
+}
+
+
+
+
+
@@ -101,7 +111,8 @@
1669828165304
-
+
+
1669853979470
@@ -124,7 +135,14 @@
1669866587540
-
+
+ 1669877633888
+
+
+
+ 1669877633888
+
+
@@ -144,7 +162,8 @@
-
+
+
diff --git a/jest.config.js b/jest.config.js
index 2d2ef14..dc82c69 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,5 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
-module.exports = {
+export default {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: ['src/**/*.{ts,js}', '!src/index.ts', '!src/types.d.ts', '!src/shim/**/*.{ts,js}'],
diff --git a/package-lock.json b/package-lock.json
index b1a3129..7b17ef9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,8 +7,9 @@
"name": "temptress-bot",
"hasInstallScript": true,
"dependencies": {
+ "@types/seedrandom": "^3.0.2",
"patch-package": "^6.4.7",
- "random-seedable": "^1.0.8"
+ "seedrandom": "^3.0.5"
},
"devDependencies": {
"@cloudflare/workers-types": "^2.0.0",
@@ -1770,6 +1771,11 @@
"integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==",
"dev": true
},
+ "node_modules/@types/seedrandom": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.2.tgz",
+ "integrity": "sha512-YPLqEOo0/X8JU3rdiq+RgUKtQhQtrppE766y7vMTu8dGML7TVtZNiiiaC/hhU9Zqw9UYopXxhuWWENclMVBwKQ=="
+ },
"node_modules/@types/stack-trace": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.29.tgz",
@@ -6195,11 +6201,6 @@
}
]
},
- "node_modules/random-seedable": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/random-seedable/-/random-seedable-1.0.8.tgz",
- "integrity": "sha512-f6gzvNhAnZBht1Prn0e/tpukUNhkANntFF42uIdWDPriyEATYaRpyH8A9bYaGecUB3AL+dXeYtBUggy18fe3rw=="
- },
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -6443,6 +6444,11 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/seedrandom": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
+ "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
+ },
"node_modules/selfsigned": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz",
@@ -9204,6 +9210,11 @@
"integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==",
"dev": true
},
+ "@types/seedrandom": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.2.tgz",
+ "integrity": "sha512-YPLqEOo0/X8JU3rdiq+RgUKtQhQtrppE766y7vMTu8dGML7TVtZNiiiaC/hhU9Zqw9UYopXxhuWWENclMVBwKQ=="
+ },
"@types/stack-trace": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.29.tgz",
@@ -12397,11 +12408,6 @@
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true
},
- "random-seedable": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/random-seedable/-/random-seedable-1.0.8.tgz",
- "integrity": "sha512-f6gzvNhAnZBht1Prn0e/tpukUNhkANntFF42uIdWDPriyEATYaRpyH8A9bYaGecUB3AL+dXeYtBUggy18fe3rw=="
- },
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -12579,6 +12585,11 @@
"ajv-keywords": "^3.5.2"
}
},
+ "seedrandom": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
+ "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
+ },
"selfsigned": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz",
diff --git a/package.json b/package.json
index 888e525..042b999 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"author": "Reya C.",
"main": "dist/worker.js",
"private": true,
+ "type": "module",
"scripts": {
"build": "webpack",
"dev": "wrangler dev -l",
@@ -36,7 +37,8 @@
"wrangler": "^2.0.5"
},
"dependencies": {
+ "@types/seedrandom": "^3.0.2",
"patch-package": "^6.4.7",
- "random-seedable": "^1.0.8"
+ "seedrandom": "^3.0.5"
}
}
diff --git a/src/state/gameData.spec.ts b/src/game/gameData.spec.ts
similarity index 87%
rename from src/state/gameData.spec.ts
rename to src/game/gameData.spec.ts
index 3414f55..c567f9c 100644
--- a/src/state/gameData.spec.ts
+++ b/src/game/gameData.spec.ts
@@ -8,7 +8,7 @@ describe('isHeldState', () => {
[DieState.HELD, true],
[DieState.HELD_SELECTED, true],
[DieState.SCORED, false],
- ])('(%p) returns %p', (state, value): void => {
+ ])('(%j) returns %j', (state, value): void => {
expect(isHeldState(state)).toEqual(value)
})
})
@@ -20,7 +20,7 @@ describe('isSelectedState', () => {
[DieState.HELD, false],
[DieState.HELD_SELECTED, true],
[DieState.SCORED, false],
- ])('(%p) returns %p', (state, value): void => {
+ ])('(%j) returns %j', (state, value): void => {
expect(isSelectedState(state)).toEqual(value)
})
})
@@ -32,7 +32,7 @@ describe('setSelected', () => {
[DieState.HELD, DieState.HELD_SELECTED],
[DieState.HELD_SELECTED, DieState.HELD_SELECTED],
[DieState.SCORED, DieState.SCORED],
- ])('(%p) returns %p', (state, value): void => {
+ ])('(%j) returns %j', (state, value): void => {
expect(setSelected(state)).toEqual(value)
})
})
@@ -44,7 +44,7 @@ describe('setDeselected', () => {
[DieState.HELD, DieState.HELD],
[DieState.HELD_SELECTED, DieState.HELD],
[DieState.SCORED, DieState.SCORED],
- ])('(%p) returns %p', (state, value): void => {
+ ])('(%j) returns %j', (state, value): void => {
expect(setDeselected(state)).toEqual(value)
})
})
@@ -56,7 +56,7 @@ describe('toggleSelected', () => {
[DieState.HELD, DieState.HELD_SELECTED],
[DieState.HELD_SELECTED, DieState.HELD],
[DieState.SCORED, DieState.SCORED],
- ])('(%p) returns %p', (state, value): void => {
+ ])('(%j) returns %j', (state, value): void => {
expect(toggleSelected(state)).toEqual(value)
})
})
diff --git a/src/state/gameData.ts b/src/game/gameData.ts
similarity index 98%
rename from src/state/gameData.ts
rename to src/game/gameData.ts
index 863b893..cf80250 100644
--- a/src/state/gameData.ts
+++ b/src/game/gameData.ts
@@ -1,4 +1,4 @@
-import { WeightedTextSelector } from './weightedText'
+import { RenderableText } from '../util/renderableText'
export enum DieFace {
FAIL = 'F',
@@ -202,7 +202,7 @@ export interface GameTheme {
readonly difficulties: readonly Difficulty[]
- readonly commonText?: { readonly [key: string]: WeightedTextSelector | undefined }
+ readonly commonText?: { readonly [key: string]: RenderableText | undefined }
readonly narratorName: string
readonly topText: PlayerText
@@ -212,10 +212,10 @@ export interface GameTheme {
export interface TriggeredText {
// The dialogues that can be triggered for this action.
// Dialogue can be disabled for characters run by human players.
- readonly dialogue?: WeightedTextSelector
+ readonly dialogue?: RenderableText
// The descriptions that can be triggered for this action.
// Descriptions are always displayed, regardless of player.
- readonly description?: WeightedTextSelector
+ readonly description?: RenderableText
}
export interface ActionText {
diff --git a/src/state/gameEvent.ts b/src/game/gameEvent.ts
similarity index 100%
rename from src/state/gameEvent.ts
rename to src/game/gameEvent.ts
diff --git a/src/state/weightedText.spec.ts b/src/state/weightedText.spec.ts
deleted file mode 100644
index 4228543..0000000
--- a/src/state/weightedText.spec.ts
+++ /dev/null
@@ -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')
- })
-})
diff --git a/src/state/weightedText.ts b/src/state/weightedText.ts
deleted file mode 100644
index 6f1adc1..0000000
--- a/src/state/weightedText.ts
+++ /dev/null
@@ -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((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)
- },
- )
-}
diff --git a/src/types/random.ts b/src/types/random.ts
deleted file mode 100644
index 020ebf6..0000000
--- a/src/types/random.ts
+++ /dev/null
@@ -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')
-}
diff --git a/src/types/random.spec.ts b/src/util/random.spec.ts
similarity index 60%
rename from src/types/random.spec.ts
rename to src/util/random.spec.ts
index 7513a8d..daa8130 100644
--- a/src/types/random.spec.ts
+++ b/src/util/random.spec.ts
@@ -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()
})
})
diff --git a/src/util/random.ts b/src/util/random.ts
new file mode 100644
index 0000000..f9b4461
--- /dev/null
+++ b/src/util/random.ts
@@ -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,
+})
diff --git a/src/util/renderableText.spec.ts b/src/util/renderableText.spec.ts
new file mode 100644
index 0000000..893b1d7
--- /dev/null
+++ b/src/util/renderableText.spec.ts
@@ -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()
+ })
+})
diff --git a/src/util/renderableText.ts b/src/util/renderableText.ts
new file mode 100644
index 0000000..750ff7d
--- /dev/null
+++ b/src/util/renderableText.ts
@@ -0,0 +1,58 @@
+import { shuffleWeightedList, WeightedList } from './weightedList'
+import { PRNG } from 'seedrandom'
+export type RenderableText = string | WeightedList
+
+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
+ },
+ )
+}
diff --git a/src/util/weightedList.spec.ts b/src/util/weightedList.spec.ts
new file mode 100644
index 0000000..c9122c6
--- /dev/null
+++ b/src/util/weightedList.spec.ts
@@ -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 = readonly [string, ItemT | WeightedList, ItemT, ItemT]
+// type RandomTestCase = readonly [string, ItemT | WeightedList, ItemT, number, ItemT]
+
+describe('shuffleWeightedList', () => {
+ test.each>([
+ ['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)
+ }
+ })
+})
diff --git a/src/util/weightedList.ts b/src/util/weightedList.ts
new file mode 100644
index 0000000..1304ade
--- /dev/null
+++ b/src/util/weightedList.ts
@@ -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 {
+ readonly wt: number
+ readonly v: ItemT | WeightedList
+}
+type WeightedListItem = WeightedItem | ItemT | WeightedList
+export type WeightedList = readonly WeightedListItem[]
+
+function isWeightedItem(x: WeightedListItem): x is WeightedItem {
+ return typeof x === 'object' && x !== null && 'wt' in x && typeof x['wt'] === 'number'
+}
+
+function getItemValue(x: WeightedListItem): WeightedList | ItemT {
+ if (isWeightedItem(x)) {
+ return x.v
+ } else {
+ return x
+ }
+}
+
+type TotalWeightedItem = {
+ readonly minWt: number
+ readonly maxWt: number
+ readonly v: WeightedList | ItemT
+}
+
+type TotalWeightedList = readonly TotalWeightedItem[]
+
+function isWeightedList(x: ItemT | WeightedList): x is WeightedList {
+ return typeof x === 'object' && x !== null && 'length' in x && typeof x['length'] === 'number'
+}
+
+export function shuffleWeightedList(
+ x: WeightedList | 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>((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)
+ }
+ }
+}