Add weighted text support

main
Mari 1 year ago
parent fcf2b8a463
commit ff5343e1ca
  1. 31
      .idea/workspace.xml
  2. 26899
      package-lock.json
  3. 5
      package.json
  4. 4
      patches/slash-create+5.10.0.patch
  5. 8
      src/state/gameData.ts
  6. 51
      src/state/weightedText.spec.ts
  7. 100
      src/state/weightedText.ts
  8. 8
      src/types/random.spec.ts
  9. 6
      src/types/random.ts
  10. 4
      tsconfig.json

@ -4,11 +4,18 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="84c57704-c34b-42d3-90dc-bc3746b4a30c" name="Changes" comment="Steppies project start">
<change afterPath="$PROJECT_DIR$/src/state/gameEvent.ts" afterDir="false" />
<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" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/gamestate.spec.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/state/gameData.spec.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/state/gamestate.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/state/gameData.ts" 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" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -94,7 +101,7 @@
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1669828165304</updated>
<workItem from="1669828166439" duration="32122000" />
<workItem from="1669828166439" duration="43038000" />
</task>
<task id="LOCAL-00001" summary="Steppies project start">
<created>1669853979470</created>
@ -110,7 +117,14 @@
<option name="project" value="LOCAL" />
<updated>1669854047768</updated>
</task>
<option name="localTasksCounter" value="3" />
<task id="LOCAL-00003" summary="Add events both incoming and outgoing">
<created>1669866587540</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1669866587540</updated>
</task>
<option name="localTasksCounter" value="4" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -129,9 +143,10 @@
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Steppies project start" />
<option name="LAST_COMMIT_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" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/steppies$All_Tests.info" NAME="All Tests Coverage Results" MODIFIED="1669853649183" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="JestJavaScriptTestRunnerCoverage" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" />
<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" />
</component>
</project>

26899
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -30,12 +30,13 @@
"slash-up": "^1.1.2",
"ts-jest": "^29.0.3",
"ts-loader": "^7.0.5",
"typescript": "^4.4.3",
"typescript": "^4.9.3",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.0",
"wrangler": "^2.0.5"
},
"dependencies": {
"patch-package": "^6.4.7"
"patch-package": "^6.4.7",
"random-seedable": "^1.0.8"
}
}

@ -1,8 +1,8 @@
diff --git a/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts b/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts
index 9ecfd7f..70ec168 100644
index df895c3..f2a5878 100644
--- a/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts
+++ b/node_modules/slash-create/lib/structures/interfaces/messageInteraction.d.ts
@@ -153,7 +153,7 @@ export interface EditMessageOptions {
@@ -132,7 +132,7 @@ export interface EditMessageOptions {
/** A file within {@link EditMessageOptions}. */
export interface MessageFile {
/** The attachment to send. */

@ -1,3 +1,5 @@
import { WeightedTextSelector } from './weightedText'
export enum DieFace {
FAIL = 'F',
STOP = 'S',
@ -200,6 +202,8 @@ export interface GameTheme {
readonly difficulties: readonly Difficulty[]
readonly commonText?: { readonly [key: string]: WeightedTextSelector | undefined }
readonly narratorName: string
readonly topText: PlayerText
readonly bottomText: PlayerText
@ -208,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?: readonly string[]
readonly dialogue?: WeightedTextSelector
// The descriptions that can be triggered for this action.
// Descriptions are always displayed, regardless of player.
readonly description?: readonly string[]
readonly description?: WeightedTextSelector
}
export interface ActionText {

@ -0,0 +1,51 @@
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')
})
})

@ -0,0 +1,100 @@
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)
},
)
}

@ -0,0 +1,8 @@
import { describe, test, expect } from '@jest/globals'
import { forbidRandomness } from './random'
describe('forbidRandomness', () => {
test(' throws', () => {
expect(forbidRandomness).toThrow()
})
})

@ -0,0 +1,6 @@
// 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')
}

@ -2,8 +2,8 @@
"compilerOptions": {
"outDir": "./dist",
"module": "commonjs",
"target": "es2020",
"lib": ["es2020", "webworker"],
"target": "es2022",
"lib": ["es2022", "webworker"],
"alwaysStrict": true,
"strict": true,
"preserveConstEnums": true,

Loading…
Cancel
Save