Skip to content

Commit 46418bc

Browse files
committed
Progress on set fork rendering
1 parent 9c91c50 commit 46418bc

File tree

9 files changed

+158
-13
lines changed

9 files changed

+158
-13
lines changed

src/choiceFork.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { WeightedChoice, Choice } from './weightedChoice'
1+
import { WeightedChoice, Choice, sumWeights } from './weightedChoice'
22
import {
33
normalizeWeights,
44
weightedChoose
55
} from './rand';
6+
import { NoPossibleChoiceError, InvalidForkWeightsError } from './errors';
67

78
export type ChoiceForkCallResult = {
89
replacement: Choice,
@@ -11,12 +12,15 @@ export type ChoiceForkCallResult = {
1112

1213
export class ChoiceFork {
1314
weights: WeightedChoice[];
15+
initWeights: number[];
1416
identifier: string | null;
1517
isSilent: boolean;
1618
isSet: boolean;
1719

1820
constructor(weights: WeightedChoice[], identifier: string | null, isSilent: boolean, isSet: boolean) {
1921
this.weights = normalizeWeights(weights);
22+
this.validateWeights();
23+
this.initWeights = this.weights.map((w) => (w.weight!));
2024
this.identifier = identifier;
2125
this.isSilent = isSilent;
2226
this.isSet = isSet;
@@ -26,12 +30,38 @@ export class ChoiceFork {
2630
* returns an object of the form {replacement: String, choiceIndex: Int}
2731
*/
2832
call(): ChoiceForkCallResult {
29-
let result = weightedChoose(this.weights);
33+
let result;
34+
try {
35+
result = weightedChoose(this.weights);
36+
} catch (error) {
37+
if (error instanceof NoPossibleChoiceError && this.isSet) {
38+
console.warn(`Set '${this.identifier}' is exhausted; resetting weights.`)
39+
this.resetWeights();
40+
return this.call();
41+
} else {
42+
throw error;
43+
}
44+
}
45+
if (this.isSet) {
46+
this.weights[result.choiceIndex].weight = 0;
47+
}
3048
return { replacement: result.choice, choiceIndex: result.choiceIndex };
3149
}
3250

51+
private resetWeights() {
52+
for (let [idx, val] of this.weights.entries()) {
53+
val.weight = this.initWeights[idx];
54+
}
55+
}
56+
57+
private validateWeights() {
58+
if (sumWeights(this.weights) === 0) {
59+
throw new InvalidForkWeightsError();
60+
}
61+
}
62+
3363
toString(): string {
3464
return `ChoiceFork{weights: ${this.weights}, `
35-
+ `identifier: ${this.identifier}, isSilent: ${this.isSilent}}`;
65+
+ `identifier: ${this.identifier}, isSilent: ${this.isSilent}, isSet: ${this.isSet}}`;
3666
}
3767
}

src/errors.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,23 @@ export class BMLDuplicatedRefError extends Error {
5353

5454
export class EvalBoundSettingsError extends Error {
5555
constructor(field: string, value: any) {
56-
super(`Eval binding of settings field '${field}' of '${value}' is invalid`)
56+
super(`Eval binding of settings field '${field}' of '${value}' is invalid`);
5757
this.name = 'EvalBoundSettingsError';
5858
Object.setPrototypeOf(this, EvalBoundSettingsError.prototype);
5959
}
6060
}
6161

6262
export class EvalBindingError extends Error {
6363
constructor(key: string) {
64-
super(`Eval binding ${key} was bound multiple times.`)
64+
super(`Eval binding ${key} was bound multiple times.`);
6565
this.name = 'EvalBindingError';
6666
Object.setPrototypeOf(this, EvalBindingError.prototype);
6767
}
6868
}
6969

7070
export class EvalDisabledError extends Error {
7171
constructor() {
72-
super(`This document includes eval blocks and cannot be rendered with allowEval=false.`)
72+
super(`This document includes eval blocks and cannot be rendered with allowEval=false.`);
7373
this.name = 'EvalDisabledError';
7474
Object.setPrototypeOf(this, EvalDisabledError.prototype);
7575
}
@@ -82,3 +82,21 @@ export class IncludeError extends Error {
8282
Object.setPrototypeOf(this, IncludeError.prototype);
8383
}
8484
}
85+
86+
export class InvalidForkWeightsError extends Error {
87+
constructor() {
88+
super('Fork has invalid weights');
89+
this.name = 'InvalidForkWeightsError';
90+
Object.setPrototypeOf(this, InvalidForkWeightsError.prototype);
91+
}
92+
}
93+
94+
export class NoPossibleChoiceError extends Error {
95+
constructor() {
96+
super('Cannot perform weighted choice where the given weights have a sum of zero');
97+
this.name = 'NoPossibleWeightsError';
98+
Object.setPrototypeOf(this, NoPossibleChoiceError.prototype);
99+
}
100+
}
101+
102+

src/rand.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { WeightedChoice, Choice } from './weightedChoice';
1+
import { WeightedChoice, Choice, sumWeights } from './weightedChoice';
2+
import { NoPossibleChoiceError } from './errors';
23
import seedrandom from 'seedrandom';
34

5+
46
// A module-local seedable random number generator
57
// The selected seed will be random unless `setRandomSeed()` is called.
68
// @ts-ignore
@@ -63,9 +65,9 @@ export function randomInt(min: number, max: number): number {
6365
* Returns an object of the form {choice, choiceIndex}
6466
*/
6567
export function weightedChoose(weights: WeightedChoice[]): { choice: Choice, choiceIndex: number } {
66-
let sum = 0;
67-
for (let wc of weights) {
68-
sum += wc.weight ?? 0;
68+
let sum = sumWeights(weights);
69+
if (sum === 0) {
70+
throw new NoPossibleChoiceError();
6971
}
7072
let progress = 0;
7173
let pickedValue = randomFloat(0, sum);

src/renderer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ export class Renderer {
8686
if (isStr(node)) {
8787
output += node;
8888
} else if (node instanceof ChoiceFork) {
89+
if (node.isSet && node.isSilent) {
90+
// Silent set declarations are *not* immediately executed.
91+
this.executedForkMap.set(node.identifier!,
92+
{ choiceFork: node, choiceIndex: -1, renderedOutput: '' });
93+
continue;
94+
}
8995
let { replacement, choiceIndex } = node.call();
9096
let renderedOutput = this.renderChoice(replacement);
9197
if (node.isSilent) {

src/weightedChoice.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,9 @@ export class WeightedChoice {
2525
return new WeightedChoice(this.choice, this.weight);
2626
}
2727
}
28+
29+
export function sumWeights(weights: WeightedChoice[]) {
30+
// Note that if weights have been normalized, as they are in `ChoiceFork`s,
31+
// `wc.weight` will always be non-null here so the default should never occur.
32+
return weights.reduce((acc, val) => acc + (val.weight ?? 0), 0);
33+
}

test/testReplacer.ts renamed to test/testChoiceFork.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('ChoiceFork', function() {
2828
let choiceFork = new ChoiceFork(weights, 'identifier', true, false);
2929
expect(choiceFork.toString()).toBe(
3030
'ChoiceFork{weights: WeightedChoice{choice: foo, weight: 40},'
31-
+ 'WeightedChoice{choice: bar, weight: 60}, identifier: identifier, isSilent: true}');
31+
+ 'WeightedChoice{choice: bar, weight: 60}, identifier: identifier, '
32+
+ 'isSilent: true, isSet: false}');
3233
});
3334
});

test/testRand.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import sha from 'sha.js';
33
import { Buffer } from 'buffer';
44

55
import { WeightedChoice } from '../src/weightedChoice';
6+
import { NoPossibleChoiceError } from '../src/errors';
67
let rand = require('../src/rand.ts');
78

89
type RandomFunction = (a: number, b: number) => number;
@@ -108,6 +109,7 @@ describe('weightedChoose', function() {
108109
beforeEach(function() {
109110
rand.setRandomSeed(0); // pin seed for reproducibility
110111
});
112+
111113
it('behaves on well-formed weights', function() {
112114
let weights = [
113115
new WeightedChoice(['foo'], 40),
@@ -125,4 +127,23 @@ describe('weightedChoose', function() {
125127
expect(result.choice).toStrictEqual(['bar']);
126128
expect(result.choiceIndex).toBe(1);
127129
});
130+
131+
it('errors when given no weights', function() {
132+
expect(() =>
133+
rand.weightedChoose([])
134+
).toThrow('Cannot perform weighted choice where the given weights have a sum of zero');
135+
// For reasons beyond me, toThrow here fails when I reference the actual error type
136+
});
137+
138+
it('errors when given all-zero weights', function() {
139+
expect(() => {
140+
let weights = [
141+
new WeightedChoice(['foo'], 0),
142+
new WeightedChoice(['bar'], 0),
143+
];
144+
rand.weightedChoose(weights);
145+
}).toThrow('Cannot perform weighted choice where the given weights have a sum of zero');
146+
// For reasons beyond me, toThrow here fails when I reference the actual error type
147+
});
148+
128149
});

test/testRenderer.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,43 @@ describe('render', function() {
6767
expect(render(testString, null, null)).toEqual('Foo fff\nfoo fff\nbar bbb\nbar bbb\n');
6868
});
6969

70+
it('supports initial set fork declarations', function() {
71+
let testString = 'foo {$id: (bar), (biz)}';
72+
expect(render(testString, null, null)).toEqual('Foo bar\n');
73+
});
74+
75+
it('supports initial silent set fork declarations', function() {
76+
let testString = 'foo {#$id: (bar), (biz)}';
77+
expect(render(testString, null, null)).toEqual('Foo\n');
78+
});
79+
80+
it('supports re-executing set forks', function() {
81+
let testString = '{$id: (A), (B), (C), (D)} {@!id} {@!id} {@!id}';
82+
expect(render(testString, null, null)).toEqual('A B D C\n');
83+
});
84+
85+
it('supports re-executing silent set forks', function() {
86+
// Note that silent set forks are *not* immediately executed,
87+
// so the initial declaration does not cause a set member to be exhausted
88+
let testString = '{#$id: (A), (B), (C), (D)} {@!id} {@!id} {@!id} {@!id}';
89+
expect(render(testString, null, null)).toEqual(' A B D C\n');
90+
});
91+
92+
it('gracefully errors trying to map unexecuted silent set forks', function() {
93+
// TODO
94+
});
95+
96+
it('resets weights on exhausted sets', function() {
97+
let originalConsoleWarn = console.warn;
98+
console.warn = jest.fn(); // Mock console.warn with a Jest mock function
99+
try {
100+
let testString = '{#$id: (A), (B), (C), (D)} {@!id} {@!id} {@!id} {@!id} {@!id}';
101+
expect(render(testString, null, null)).toEqual(' A B D C D\n');
102+
} finally {
103+
console.warn = originalConsoleWarn;
104+
}
105+
});
106+
70107
it('preserves plaintext parentheses', function() {
71108
let testString = 'foo (bar)';
72109
expect(render(testString, null, null)).toEqual('Foo (bar)\n');

test/testWeightedChoice.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
import expect from 'expect';
22

3-
import { WeightedChoice } from '../src/weightedChoice';
3+
import { WeightedChoice, sumWeights } from '../src/weightedChoice';
44
import { EvalBlock } from '../src/evalBlock';
55

6-
describe('ChoiceFork', function() {
6+
describe('WeightedChoice', function() {
77
it('has a useful toString for all choice types', function() {
88
expect(new WeightedChoice(['foo'], 1).toString())
99
.toBe('WeightedChoice{choice: foo, weight: 1}');
1010
expect(new WeightedChoice(new EvalBlock('foo'), 1).toString())
1111
.toBe('WeightedChoice{choice: EvalBlock(\'foo\'), weight: 1}');
1212
});
1313
});
14+
15+
describe('sumWeights', function() {
16+
it('Converts null weights to 0', function() {
17+
let weights = [
18+
new WeightedChoice(['foo'], null),
19+
new WeightedChoice(['bar'], 5),
20+
];
21+
expect(sumWeights(weights)).toEqual(5);
22+
});
23+
24+
it('Accepts empty arrays', function() {
25+
expect(sumWeights([])).toEqual(0);
26+
});
27+
28+
it('Works on simple cases', function() {
29+
let weights = [
30+
new WeightedChoice(['foo'], 2),
31+
new WeightedChoice(['bar'], 5),
32+
new WeightedChoice(['biz'], 6),
33+
];
34+
expect(sumWeights(weights)).toEqual(13);
35+
});
36+
37+
});

0 commit comments

Comments
 (0)