@rohal12/spindle 0.52.0 → 0.52.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +241 -104
- package/package.json +4 -1
- package/src/action-registry.ts +70 -18
- package/src/components/macros/Computed.tsx +13 -5
- package/src/components/macros/Set.tsx +5 -4
- package/src/components/macros/Unset.tsx +5 -4
- package/src/execute-mutation.ts +1 -1
- package/src/expression.ts +5 -4
- package/src/hooks/use-action.ts +10 -4
- package/src/index.tsx +2 -1
- package/src/markup/ast.ts +7 -2
- package/src/prng.ts +33 -0
- package/src/saves/save-manager.ts +7 -2
- package/src/settings.ts +12 -9
- package/src/store.ts +119 -32
- package/src/story-api.ts +7 -6
- package/src/triggers.ts +4 -7
- package/src/utils/counts.ts +45 -0
package/src/store.ts
CHANGED
|
@@ -46,15 +46,17 @@ import {
|
|
|
46
46
|
clearAllData as smClearAllData,
|
|
47
47
|
deletePlaythroughData as smDeletePlaythroughData,
|
|
48
48
|
} from './saves/save-manager';
|
|
49
|
-
import { deepClone, serialize } from './class-registry';
|
|
49
|
+
import { deepClone, deepEqual, serialize } from './class-registry';
|
|
50
50
|
import {
|
|
51
51
|
snapshotPRNG,
|
|
52
52
|
restorePRNG,
|
|
53
53
|
resetPRNG,
|
|
54
|
+
withoutDraws,
|
|
54
55
|
type PRNGSnapshot,
|
|
55
56
|
} from './prng';
|
|
56
57
|
import { errorMessage } from './utils/error-message';
|
|
57
58
|
import {
|
|
59
|
+
isMergeable,
|
|
58
60
|
routeStoreUpdate,
|
|
59
61
|
runWithCommittedMutations,
|
|
60
62
|
} from './execute-mutation';
|
|
@@ -64,6 +66,7 @@ import {
|
|
|
64
66
|
isNamespace,
|
|
65
67
|
type Namespace,
|
|
66
68
|
} from './utils/namespace';
|
|
69
|
+
import { createCounts, type Counts } from './utils/counts';
|
|
67
70
|
|
|
68
71
|
enablePatches();
|
|
69
72
|
// Story state holds Map and Set values: Immer must be able to draft them
|
|
@@ -531,13 +534,14 @@ function loadedEntryMoment(
|
|
|
531
534
|
}
|
|
532
535
|
|
|
533
536
|
/**
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
537
|
+
* Write the changes between `before` and `after` (the live variables around
|
|
538
|
+
* the `beforesave` hooks) into the payload's snapshot of the saved moment. A
|
|
539
|
+
* load restores that snapshot, the state on entering the passage, and runs
|
|
540
|
+
* the passage again, so data a hook adds to a save would otherwise be lost
|
|
541
|
+
* on load (#227). Only the property paths the hooks changed are written: a
|
|
542
|
+
* whole variable would bring along what the passage did to the rest of it,
|
|
543
|
+
* which the passage then does again on load (#232). Only the payload's copy
|
|
544
|
+
* changes: the live history keeps the recorded snapshot (#159).
|
|
541
545
|
*/
|
|
542
546
|
function keepHookWrites(
|
|
543
547
|
payload: SavePayload,
|
|
@@ -546,15 +550,100 @@ function keepHookWrites(
|
|
|
546
550
|
): void {
|
|
547
551
|
const moment = payload.history[payload.historyIndex];
|
|
548
552
|
if (!moment) return;
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
553
|
+
mergeHookWrites(moment.variables, before, after, new Set());
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const hasOwn = (obj: object, key: string): boolean =>
|
|
557
|
+
Object.prototype.hasOwnProperty.call(obj, key);
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Whether changes between `a` and `b` merge key by key: both are arrays,
|
|
561
|
+
* or both are objects of one class (see isMergeable).
|
|
562
|
+
*/
|
|
563
|
+
function mergeable(a: unknown, b: unknown): boolean {
|
|
564
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
565
|
+
return Array.isArray(a) && Array.isArray(b);
|
|
566
|
+
}
|
|
567
|
+
return (
|
|
568
|
+
isMergeable(a) &&
|
|
569
|
+
isMergeable(b) &&
|
|
570
|
+
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Apply the hooks' change of a value, from `before` to `after`, to the
|
|
576
|
+
* snapshot's copy of it, `target[key]`. Store updates are immutable, so a
|
|
577
|
+
* value no hook touched keeps its identity; one rebuilt with equal content
|
|
578
|
+
* (mutation code commits whole values) is no change either. Where `target`
|
|
579
|
+
* lacks the key or holds another kind of value (the passage created or
|
|
580
|
+
* replaced it), the hooks' whole value is written.
|
|
581
|
+
*/
|
|
582
|
+
function mergeHookWrite(
|
|
583
|
+
target: Record<string, unknown>,
|
|
584
|
+
key: string,
|
|
585
|
+
before: unknown,
|
|
586
|
+
after: unknown,
|
|
587
|
+
ancestors: Set<object>,
|
|
588
|
+
): void {
|
|
589
|
+
if (Object.is(before, after)) return;
|
|
590
|
+
if (
|
|
591
|
+
mergeable(before, after) &&
|
|
592
|
+
hasOwn(target, key) &&
|
|
593
|
+
mergeable(after, target[key]) &&
|
|
594
|
+
// Stop at cycles: deepEqual() and deepClone() handle them
|
|
595
|
+
!ancestors.has(after as object)
|
|
596
|
+
) {
|
|
597
|
+
mergeHookWrites(
|
|
598
|
+
target[key] as Record<string, unknown>,
|
|
599
|
+
before as Record<string, unknown>,
|
|
600
|
+
after as Record<string, unknown>,
|
|
601
|
+
ancestors,
|
|
602
|
+
);
|
|
603
|
+
} else if (!deepEqual(before, after)) {
|
|
604
|
+
target[key] = deepClone(after);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Apply the changes between `before` and `after`, two objects or two arrays
|
|
610
|
+
* (see mergeable()), to the snapshot's `target`. Array elements merge by
|
|
611
|
+
* index. Elements the hooks removed from an array's end are removed from
|
|
612
|
+
* the snapshot's array at the same indices, and elements they added are
|
|
613
|
+
* appended to it: where the passage resized the array, its indices do not
|
|
614
|
+
* line up with the snapshot's, and the passage resizes it again on load.
|
|
615
|
+
*/
|
|
616
|
+
function mergeHookWrites(
|
|
617
|
+
target: Record<string, unknown>,
|
|
618
|
+
before: Record<string, unknown>,
|
|
619
|
+
after: Record<string, unknown>,
|
|
620
|
+
ancestors: Set<object>,
|
|
621
|
+
): void {
|
|
622
|
+
ancestors.add(after);
|
|
623
|
+
if (Array.isArray(target)) {
|
|
624
|
+
const b = before as unknown as unknown[];
|
|
625
|
+
const a = after as unknown as unknown[];
|
|
626
|
+
const shared = Math.min(b.length, a.length, target.length);
|
|
627
|
+
for (let i = 0; i < shared; i++) {
|
|
628
|
+
mergeHookWrite(target, String(i), b[i], a[i], ancestors);
|
|
629
|
+
}
|
|
630
|
+
if (a.length < b.length) {
|
|
631
|
+
target.length = Math.min(target.length, a.length);
|
|
632
|
+
}
|
|
633
|
+
for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
|
|
634
|
+
} else {
|
|
635
|
+
for (const key of Object.keys(before)) {
|
|
636
|
+
if (!hasOwn(after, key)) delete target[key];
|
|
637
|
+
}
|
|
638
|
+
for (const key of Object.keys(after)) {
|
|
639
|
+
if (hasOwn(before, key)) {
|
|
640
|
+
mergeHookWrite(target, key, before[key], after[key], ancestors);
|
|
641
|
+
} else {
|
|
642
|
+
target[key] = deepClone(after[key]);
|
|
643
|
+
}
|
|
556
644
|
}
|
|
557
645
|
}
|
|
646
|
+
ancestors.delete(after);
|
|
558
647
|
}
|
|
559
648
|
|
|
560
649
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
@@ -594,8 +683,8 @@ export interface StoryState {
|
|
|
594
683
|
temporary: Record<string, unknown>;
|
|
595
684
|
history: HistoryMoment[];
|
|
596
685
|
historyIndex: number;
|
|
597
|
-
visitCounts:
|
|
598
|
-
renderCounts:
|
|
686
|
+
visitCounts: Counts;
|
|
687
|
+
renderCounts: Counts;
|
|
599
688
|
knownSaves: Record<string, true>;
|
|
600
689
|
/**
|
|
601
690
|
* The playthrough the running game is in: its saves are grouped under it.
|
|
@@ -801,8 +890,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
801
890
|
temporary: createNamespace(),
|
|
802
891
|
history: [],
|
|
803
892
|
historyIndex: -1,
|
|
804
|
-
visitCounts:
|
|
805
|
-
renderCounts:
|
|
893
|
+
visitCounts: createCounts(),
|
|
894
|
+
renderCounts: createCounts(),
|
|
806
895
|
knownSaves: {},
|
|
807
896
|
playthroughId: '',
|
|
808
897
|
maxHistory: 40,
|
|
@@ -870,8 +959,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
870
959
|
},
|
|
871
960
|
];
|
|
872
961
|
state.historyIndex = 0;
|
|
873
|
-
state.visitCounts =
|
|
874
|
-
state.renderCounts =
|
|
962
|
+
state.visitCounts = createCounts(null, startPassage.name);
|
|
963
|
+
state.renderCounts = createCounts(null, startPassage.name);
|
|
875
964
|
});
|
|
876
965
|
|
|
877
966
|
// Update lastNavigationVars to the Immer-produced reference
|
|
@@ -955,10 +1044,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
955
1044
|
state.historyIndex = state.history.length - 1;
|
|
956
1045
|
// Trim oldest entries if over the limit
|
|
957
1046
|
trimHistory(state);
|
|
958
|
-
state.visitCounts
|
|
959
|
-
|
|
960
|
-
state.renderCounts[passageName] =
|
|
961
|
-
(state.renderCounts[passageName] ?? 0) + 1;
|
|
1047
|
+
state.visitCounts = createCounts(state.visitCounts, passageName);
|
|
1048
|
+
state.renderCounts = createCounts(state.renderCounts, passageName);
|
|
962
1049
|
});
|
|
963
1050
|
|
|
964
1051
|
// Watchers react to the completed transition (visit counts, cleared
|
|
@@ -1094,8 +1181,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1094
1181
|
|
|
1095
1182
|
trackRender: (passageName: string) => {
|
|
1096
1183
|
set((state) => {
|
|
1097
|
-
state.renderCounts
|
|
1098
|
-
(state.renderCounts[passageName] ?? 0) + 1;
|
|
1184
|
+
state.renderCounts = createCounts(state.renderCounts, passageName);
|
|
1099
1185
|
});
|
|
1100
1186
|
},
|
|
1101
1187
|
|
|
@@ -1144,8 +1230,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1144
1230
|
},
|
|
1145
1231
|
];
|
|
1146
1232
|
state.historyIndex = 0;
|
|
1147
|
-
state.visitCounts =
|
|
1148
|
-
state.renderCounts =
|
|
1233
|
+
state.visitCounts = createCounts(null, startPassage.name);
|
|
1234
|
+
state.renderCounts = createCounts(null, startPassage.name);
|
|
1149
1235
|
if (!keepDeferred) {
|
|
1150
1236
|
state.renderDeferred = false;
|
|
1151
1237
|
}
|
|
@@ -1492,8 +1578,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1492
1578
|
);
|
|
1493
1579
|
// A save made under a higher limit keeps no more than the limit
|
|
1494
1580
|
trimHistory(state);
|
|
1495
|
-
state.visitCounts = payload.visitCounts
|
|
1496
|
-
state.renderCounts = payload.renderCounts
|
|
1581
|
+
state.visitCounts = createCounts(payload.visitCounts);
|
|
1582
|
+
state.renderCounts = createCounts(payload.renderCounts);
|
|
1497
1583
|
state.temporary = createNamespace();
|
|
1498
1584
|
state.transient = createNamespace(deepClone(get().transientDefaults));
|
|
1499
1585
|
});
|
|
@@ -1517,7 +1603,8 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1517
1603
|
// Write the loaded game to the session so a refresh restores it
|
|
1518
1604
|
persistSession(get);
|
|
1519
1605
|
|
|
1520
|
-
|
|
1606
|
+
// Draws made by the handlers would shift the passage's replayed rolls
|
|
1607
|
+
withoutDraws(() => emit('afterload', slot));
|
|
1521
1608
|
},
|
|
1522
1609
|
|
|
1523
1610
|
getHistoryVariables: (index: number): Record<string, unknown> => {
|
package/src/story-api.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { registerClass } from './class-registry';
|
|
|
24
24
|
import { frozenCopy, getActiveMutationScope } from './execute-mutation';
|
|
25
25
|
import { getByPath, setByPath } from './utils/object-path';
|
|
26
26
|
import { checkVariableName } from './utils/namespace';
|
|
27
|
+
import { countOf } from './utils/counts';
|
|
27
28
|
import { defineMacro } from './define-macro';
|
|
28
29
|
import type { MacroDefinition } from './define-macro';
|
|
29
30
|
import { getMacroRegistry as _getMacroRegistry } from './registry';
|
|
@@ -358,7 +359,7 @@ function createStoryAPI(): StoryAPI {
|
|
|
358
359
|
|
|
359
360
|
visited(name?: string): number {
|
|
360
361
|
const state = useStoryStore.getState();
|
|
361
|
-
return state.visitCounts
|
|
362
|
+
return countOf(state.visitCounts, name ?? state.currentPassage);
|
|
362
363
|
},
|
|
363
364
|
|
|
364
365
|
hasVisited(name?: string): boolean {
|
|
@@ -367,17 +368,17 @@ function createStoryAPI(): StoryAPI {
|
|
|
367
368
|
|
|
368
369
|
hasVisitedAny(...names: string[]): boolean {
|
|
369
370
|
const { visitCounts } = useStoryStore.getState();
|
|
370
|
-
return names.some((n) => (visitCounts
|
|
371
|
+
return names.some((n) => countOf(visitCounts, n) > 0);
|
|
371
372
|
},
|
|
372
373
|
|
|
373
374
|
hasVisitedAll(...names: string[]): boolean {
|
|
374
375
|
const { visitCounts } = useStoryStore.getState();
|
|
375
|
-
return names.every((n) => (visitCounts
|
|
376
|
+
return names.every((n) => countOf(visitCounts, n) > 0);
|
|
376
377
|
},
|
|
377
378
|
|
|
378
379
|
rendered(name?: string): number {
|
|
379
380
|
const state = useStoryStore.getState();
|
|
380
|
-
return state.renderCounts
|
|
381
|
+
return countOf(state.renderCounts, name ?? state.currentPassage);
|
|
381
382
|
},
|
|
382
383
|
|
|
383
384
|
hasRendered(name?: string): boolean {
|
|
@@ -386,12 +387,12 @@ function createStoryAPI(): StoryAPI {
|
|
|
386
387
|
|
|
387
388
|
hasRenderedAny(...names: string[]): boolean {
|
|
388
389
|
const { renderCounts } = useStoryStore.getState();
|
|
389
|
-
return names.some((n) => (renderCounts
|
|
390
|
+
return names.some((n) => countOf(renderCounts, n) > 0);
|
|
390
391
|
},
|
|
391
392
|
|
|
392
393
|
hasRenderedAll(...names: string[]): boolean {
|
|
393
394
|
const { renderCounts } = useStoryStore.getState();
|
|
394
|
-
return names.every((n) => (renderCounts
|
|
395
|
+
return names.every((n) => countOf(renderCounts, n) > 0);
|
|
395
396
|
},
|
|
396
397
|
|
|
397
398
|
currentPassage(): Passage | undefined {
|
package/src/triggers.ts
CHANGED
|
@@ -19,7 +19,6 @@ export interface QueuedDialog {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface Trigger {
|
|
22
|
-
id: number;
|
|
23
22
|
name?: string;
|
|
24
23
|
condition: string;
|
|
25
24
|
callback?: () => void;
|
|
@@ -30,7 +29,6 @@ interface Trigger {
|
|
|
30
29
|
macroKey?: string;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
let nextId = 0;
|
|
34
32
|
let triggers: Trigger[] = [];
|
|
35
33
|
let checking = false;
|
|
36
34
|
|
|
@@ -67,13 +65,11 @@ function registerTrigger(
|
|
|
67
65
|
callbackOrOptions: (() => void) | WatchOptions,
|
|
68
66
|
macroKey?: string,
|
|
69
67
|
): () => void {
|
|
70
|
-
const id = nextId++;
|
|
71
68
|
const isCallback = typeof callbackOrOptions === 'function';
|
|
72
69
|
const options = isCallback ? undefined : callbackOrOptions;
|
|
73
70
|
const callback = isCallback ? callbackOrOptions : undefined;
|
|
74
71
|
|
|
75
72
|
const trigger: Trigger = {
|
|
76
|
-
id,
|
|
77
73
|
name: options?.name,
|
|
78
74
|
condition,
|
|
79
75
|
callback,
|
|
@@ -86,8 +82,10 @@ function registerTrigger(
|
|
|
86
82
|
triggers.push(trigger);
|
|
87
83
|
triggers.sort((a, b) => b.priority - a.priority);
|
|
88
84
|
|
|
85
|
+
// By identity: an unsubscribe kept across a restart (resetTriggers) must
|
|
86
|
+
// not remove a watcher added since
|
|
89
87
|
return () => {
|
|
90
|
-
triggers = triggers.filter((t) => t
|
|
88
|
+
triggers = triggers.filter((t) => t !== trigger);
|
|
91
89
|
};
|
|
92
90
|
}
|
|
93
91
|
|
|
@@ -177,7 +175,7 @@ function runCheckLoop(): void {
|
|
|
177
175
|
anyFired = true;
|
|
178
176
|
|
|
179
177
|
if (trigger.options?.once) {
|
|
180
|
-
triggers = triggers.filter((t) => t
|
|
178
|
+
triggers = triggers.filter((t) => t !== trigger);
|
|
181
179
|
}
|
|
182
180
|
|
|
183
181
|
fireTrigger(trigger);
|
|
@@ -267,7 +265,6 @@ export function reinitTriggerState(): void {
|
|
|
267
265
|
export function resetTriggers(): void {
|
|
268
266
|
triggers = [];
|
|
269
267
|
dialogQueue = [];
|
|
270
|
-
nextId = 0;
|
|
271
268
|
dialogHostCallbacks?.closeAll();
|
|
272
269
|
}
|
|
273
270
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passage counters: the visit and render counts of the story state, keyed
|
|
3
|
+
* by passage name.
|
|
4
|
+
*
|
|
5
|
+
* A passage may have any name, including `constructor`, `toString` or
|
|
6
|
+
* `__proto__`, so the counters only ever read their own entries: a passage
|
|
7
|
+
* that was never counted counts 0, never an Object.prototype member. The
|
|
8
|
+
* store keeps them without a prototype, so writing the count of a passage
|
|
9
|
+
* named `__proto__` stores an entry instead of replacing the prototype.
|
|
10
|
+
* Counters from outside the store (a loaded save) go through
|
|
11
|
+
* createCounts(), which keeps only their own entries that are counts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type Counts = Record<string, number>;
|
|
15
|
+
|
|
16
|
+
const isCount = (value: unknown): value is number =>
|
|
17
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
18
|
+
|
|
19
|
+
/** The count of `name` in `counts`: its own entry, or 0. */
|
|
20
|
+
export function countOf(
|
|
21
|
+
counts: object | null | undefined,
|
|
22
|
+
name: string,
|
|
23
|
+
): number {
|
|
24
|
+
if (!counts || !Object.prototype.hasOwnProperty.call(counts, name)) return 0;
|
|
25
|
+
const value = (counts as Record<string, unknown>)[name];
|
|
26
|
+
return isCount(value) ? value : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Counters with no prototype holding the own entries of `source` that are
|
|
31
|
+
* counts, with `name` (if given) counted once more. Immer copies the
|
|
32
|
+
* counters on every write anyway, so counting into a fresh copy costs no
|
|
33
|
+
* more than counting in place.
|
|
34
|
+
*/
|
|
35
|
+
export function createCounts(source?: object | null, name?: string): Counts {
|
|
36
|
+
const counts = Object.create(null) as Counts;
|
|
37
|
+
if (source) {
|
|
38
|
+
for (const key of Object.keys(source)) {
|
|
39
|
+
const value = (source as Record<string, unknown>)[key];
|
|
40
|
+
if (isCount(value)) counts[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (name !== undefined) counts[name] = countOf(source, name) + 1;
|
|
44
|
+
return counts;
|
|
45
|
+
}
|