Finish renderable text implementation+tests

main
Mari 2 years ago
parent ff5343e1ca
commit 279169be79
  1. 36
      .idea/codeStyles/Project.xml
  2. 87
      .idea/workspace.xml
  3. 2
      jest.config.js
  4. 33
      package-lock.json
  5. 4
      package.json
  6. 10
      src/game/gameData.spec.ts
  7. 8
      src/game/gameData.ts
  8. 0
      src/game/gameEvent.ts
  9. 51
      src/state/weightedText.spec.ts
  10. 100
      src/state/weightedText.ts
  11. 6
      src/types/random.ts
  12. 4
      src/util/random.spec.ts
  13. 14
      src/util/random.ts
  14. 45
      src/util/renderableText.spec.ts
  15. 58
      src/util/renderableText.ts
  16. 101
      src/util/weightedList.spec.ts
  17. 96
      src/util/weightedList.ts

@ -2,26 +2,21 @@
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
<option name="HTML_QUOTE_STYLE" value="Single" />
<option name="HTML_ENFORCE_QUOTES" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
@ -30,22 +25,35 @@
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="120" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="120" />
<option name="BLOCK_COMMENT_ADD_SPACE" value="true" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="120" />
<option name="BLOCK_COMMENT_ADD_SPACE" value="true" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="120" />
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="4" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>

@ -4,18 +4,23 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="84c57704-c34b-42d3-90dc-bc3746b4a30c" name="Changes" comment="Add events both incoming and outgoing">
<change afterPath="$PROJECT_DIR$/patches/slash-create+5.10.0.patch" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/state/weightedText.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/state/weightedText.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/types/random.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/types/random.ts" afterDir="false" />
<list default="true" id="84c57704-c34b-42d3-90dc-bc3746b4a30c" name="Changes" comment="Add weighted text support">
<change afterPath="$PROJECT_DIR$/src/util/renderableText.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/util/renderableText.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/util/weightedList.spec.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/util/weightedList.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/codeStyles/Project.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/codeStyles/Project.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/jest.config.js" beforeDir="false" afterPath="$PROJECT_DIR$/jest.config.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/patches/slash-create+5.5.2.patch" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/gameData.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/state/gameData.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/tsconfig.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/gameData.spec.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/game/gameData.spec.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/gameData.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/game/gameData.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/gameEvent.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/game/gameEvent.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/weightedText.spec.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/weightedText.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/random.spec.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/util/random.spec.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/random.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/util/random.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -41,29 +46,34 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"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"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;WebServerToolWindowFactoryState&quot;: &quot;false&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/reya/WebstormProjects/steppies/node_modules/ts-jest&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.standard&quot;: &quot;true&quot;,
&quot;node.js.detected.package.stylelint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.standard&quot;: &quot;&quot;,
&quot;node.js.selected.package.stylelint&quot;: &quot;&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs.jest.jest_package&quot;: &quot;/home/reya/WebstormProjects/steppies/node_modules/jest&quot;,
&quot;nodejs_interpreter_path&quot;: &quot;node&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;/home/reya/WebstormProjects/steppies/node_modules/prettier&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;configurable.group.appearance&quot;,
&quot;ts.external.directory.path&quot;: &quot;/home/reya/WebstormProjects/steppies/node_modules/typescript/lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/types" />
</key>
</component>
<component name="RunManager" selected="Jest.All Tests">
<configuration name="All Tests" type="JavaScriptTestRunnerJest" nameIsGenerated="true">
<node-interpreter value="project" />
@ -101,7 +111,8 @@
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1669828165304</updated>
<workItem from="1669828166439" duration="43038000" />
<workItem from="1669828166439" duration="43199000" />
<workItem from="1669906819433" duration="20772000" />
</task>
<task id="LOCAL-00001" summary="Steppies project start">
<created>1669853979470</created>
@ -124,7 +135,14 @@
<option name="project" value="LOCAL" />
<updated>1669866587540</updated>
</task>
<option name="localTasksCounter" value="4" />
<task id="LOCAL-00004" summary="Add weighted text support">
<created>1669877633888</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1669877633888</updated>
</task>
<option name="localTasksCounter" value="5" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -144,7 +162,8 @@
<component name="VcsManagerConfiguration">
<MESSAGE value="Steppies project start" />
<MESSAGE value="Add events both incoming and outgoing" />
<option name="LAST_COMMIT_MESSAGE" value="Add events both incoming and outgoing" />
<MESSAGE value="Add weighted text support" />
<option name="LAST_COMMIT_MESSAGE" value="Add weighted text support" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/steppies$All_Tests.info" NAME="All Tests Coverage Results" MODIFIED="1669877408435" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="JestJavaScriptTestRunnerCoverage" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" />

@ -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}'],

33
package-lock.json generated

@ -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",

@ -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"
}
}

@ -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)
})
})

@ -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 {

@ -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…
Cancel
Save