Skip to content

Commit fcbe7fe

Browse files
authored
Merge pull request #10 from rohal12/feat/seedable-prng
feat: add seedable PRNG with save/load support
2 parents 34fb9d1 + 4b149ce commit fcbe7fe

File tree

11 files changed

+656
-11
lines changed

11 files changed

+656
-11
lines changed

.github/workflows/claude.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jobs:
3535
- uses: anthropics/claude-code-action@v1
3636
with:
3737
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
38+
show_full_output: true
3839
prompt: |
3940
Review this PR for:
4041
- Security issues

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- Seedable PRNG (Mulberry32) with `Story.prng.init()`, `Story.random()`, `Story.randomInt()`
13+
- `random()` and `randomInt(min, max)` available in expressions
14+
- PRNG state survives save/load and history navigation via pull-counter approach
15+
816
## [0.3.2] - 2026-3-5
917

1018
### Added

docs/story-api.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,74 @@ Story.waitForActions().then(function (actions) {
231231
});
232232
```
233233

234+
## Random Numbers
235+
236+
Spindle includes a seedable pseudo-random number generator (PRNG) for reproducible randomness across save/load cycles. Initialize it in `StoryInit`, then use `random()` and `randomInt()` in expressions or via the Story API.
237+
238+
### `Story.prng.init(seed?, useEntropy?)`
239+
240+
Initialize the PRNG. Call this in `StoryInit` to enable seeded randomness.
241+
242+
| Parameter | Type | Default | Description |
243+
| ------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
244+
| `seed` | `string?` || Seed string. If omitted, a random seed is generated. |
245+
| `useEntropy` | `boolean` | `true` | Mix in `Date.now()` and `Math.random()` for unique playthroughs. Set to `false` for fully deterministic sequences. |
246+
247+
```
248+
:: StoryInit
249+
{do}
250+
Story.prng.init("my-seed");
251+
{/do}
252+
```
253+
254+
### `Story.prng.isEnabled()`
255+
256+
Returns `true` if the PRNG has been initialized.
257+
258+
### `Story.prng.seed`
259+
260+
The current seed string (read-only).
261+
262+
### `Story.prng.pull`
263+
264+
The number of times `random()` has been called since initialization (read-only).
265+
266+
### `Story.random()`
267+
268+
Returns a seeded random number in `[0, 1)`. Falls back to `Math.random()` if the PRNG is not initialized.
269+
270+
```
271+
{do}
272+
var roll = Story.random();
273+
{/do}
274+
```
275+
276+
### `Story.randomInt(min, max)`
277+
278+
Returns a random integer between `min` and `max` (inclusive).
279+
280+
```
281+
{do}
282+
var damage = Story.randomInt(1, 6);
283+
{/do}
284+
```
285+
286+
### Using in expressions
287+
288+
`random()` and `randomInt(min, max)` are available directly in expressions:
289+
290+
```
291+
{set $damage = randomInt(1, 6)}
292+
{if random() > 0.5}
293+
Critical hit!
294+
{/if}
295+
{print randomInt(1, 20)} on your perception check.
296+
```
297+
298+
### Save/load behavior
299+
300+
PRNG state is automatically saved and restored. After loading a save, the random sequence continues from exactly where it was when the save was made. History navigation (back/forward) also restores the PRNG state from that point in the story.
301+
234302
## Properties
235303

236304
### `Story.title`

docs/variables.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,18 @@ Standard JavaScript operators and built-in functions (`Math`, `Array` methods, s
127127

128128
The following functions are available in any expression to check passage visit and render history:
129129

130-
| Function | Returns | Description |
131-
| ------------------------------- | --------- | -------------------------------------------------- |
132-
| `visited("name")` | `number` | Times the passage was visited |
133-
| `hasVisited("name")` | `boolean` | Whether the passage was visited at least once |
134-
| `hasVisitedAny("a", "b", ...)` | `boolean` | Whether **any** of the passages were visited |
135-
| `hasVisitedAll("a", "b", ...)` | `boolean` | Whether **all** of the passages were visited |
136-
| `rendered("name")` | `number` | Times the passage was rendered (visits + includes) |
137-
| `hasRendered("name")` | `boolean` | Whether the passage was rendered at least once |
138-
| `hasRenderedAny("a", "b", ...)` | `boolean` | Whether **any** of the passages were rendered |
139-
| `hasRenderedAll("a", "b", ...)` | `boolean` | Whether **all** of the passages were rendered |
130+
| Function | Returns | Description |
131+
| ------------------------------- | --------- | ------------------------------------------------------------ |
132+
| `visited("name")` | `number` | Times the passage was visited |
133+
| `hasVisited("name")` | `boolean` | Whether the passage was visited at least once |
134+
| `hasVisitedAny("a", "b", ...)` | `boolean` | Whether **any** of the passages were visited |
135+
| `hasVisitedAll("a", "b", ...)` | `boolean` | Whether **all** of the passages were visited |
136+
| `rendered("name")` | `number` | Times the passage was rendered (visits + includes) |
137+
| `hasRendered("name")` | `boolean` | Whether the passage was rendered at least once |
138+
| `hasRenderedAny("a", "b", ...)` | `boolean` | Whether **any** of the passages were rendered |
139+
| `hasRenderedAll("a", "b", ...)` | `boolean` | Whether **all** of the passages were rendered |
140+
| `random()` | `number` | Seeded random number in [0, 1) (or `Math.random()` fallback) |
141+
| `randomInt(min, max)` | `number` | Seeded random integer between min and max (inclusive) |
140142

141143
Use these directly in `{if}` conditions or any expression:
142144

src/expression.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { StoryState } from './store';
22
import { useStoryStore } from './store';
3+
import { random, randomInt } from './prng';
34

45
interface ExpressionFns {
56
visited: (name: string) => number;
@@ -10,6 +11,8 @@ interface ExpressionFns {
1011
hasRendered: (name: string) => boolean;
1112
hasRenderedAny: (...names: string[]) => boolean;
1213
hasRenderedAll: (...names: string[]) => boolean;
14+
random: () => number;
15+
randomInt: (min: number, max: number) => number;
1316
}
1417

1518
type CompiledExpression = (
@@ -36,7 +39,7 @@ function transform(expr: string): string {
3639
}
3740

3841
const preamble =
39-
'const {visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll}=__fns;';
42+
'const {visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
4043

4144
function getOrCompile(key: string, body: string): CompiledExpression {
4245
const cached = fnCache.get(key);
@@ -100,6 +103,8 @@ export function buildExpressionFns() {
100103
hasRendered,
101104
hasRenderedAny,
102105
hasRenderedAll,
106+
random,
107+
randomInt,
103108
};
104109
cachedVisitCounts = visitCounts;
105110
cachedRenderCounts = renderCounts;

src/prng.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Seedable PRNG for Spindle — Mulberry32 algorithm.
3+
*
4+
* Provides deterministic random number generation that survives save/load
5+
* cycles via a pull-counter approach: recreate from seed, fast-forward N pulls.
6+
*/
7+
8+
// ---------------------------------------------------------------------------
9+
// Core algorithm
10+
// ---------------------------------------------------------------------------
11+
12+
/** Mulberry32: fast, high-quality 32-bit PRNG. */
13+
function mulberry32(seed: number): () => number {
14+
let t = seed | 0;
15+
return () => {
16+
t = (t + 0x6d2b79f5) | 0;
17+
let r = Math.imul(t ^ (t >>> 15), t | 1);
18+
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
19+
return ((r ^ (r >>> 14)) >>> 0) / 0x100000000;
20+
};
21+
}
22+
23+
/** FNV-1a hash — converts a string seed to a 32-bit integer. */
24+
function hashSeed(seed: string): number {
25+
let h = 0x811c9dc5;
26+
for (let i = 0; i < seed.length; i++) {
27+
h ^= seed.charCodeAt(i);
28+
h = Math.imul(h, 0x01000193);
29+
}
30+
return h;
31+
}
32+
33+
// ---------------------------------------------------------------------------
34+
// Module state
35+
// ---------------------------------------------------------------------------
36+
37+
let enabled = false;
38+
let currentSeed = '';
39+
let currentPull = 0;
40+
let generator: (() => number) | null = null;
41+
42+
// ---------------------------------------------------------------------------
43+
// Public API
44+
// ---------------------------------------------------------------------------
45+
46+
export interface PRNGSnapshot {
47+
readonly seed: string;
48+
readonly pull: number;
49+
}
50+
51+
/**
52+
* Initialize the PRNG.
53+
* @param seed Optional seed string. If omitted, a random seed is generated.
54+
* @param useEntropy If true (default), mix `Date.now()` and `Math.random()`
55+
* into the seed for uniqueness across playthroughs.
56+
* Set to false for fully deterministic sequences.
57+
*/
58+
export function initPRNG(seed?: string, useEntropy = true): void {
59+
let resolvedSeed: string;
60+
if (seed === undefined) {
61+
resolvedSeed = String(Date.now()) + String(Math.random());
62+
} else if (useEntropy) {
63+
resolvedSeed = seed + '|' + Date.now() + '|' + Math.random();
64+
} else {
65+
resolvedSeed = seed;
66+
}
67+
68+
currentSeed = resolvedSeed;
69+
currentPull = 0;
70+
generator = mulberry32(hashSeed(resolvedSeed));
71+
enabled = true;
72+
}
73+
74+
/**
75+
* Restore PRNG state from a snapshot (used on save/load).
76+
* Recreates the generator from the seed and fast-forwards to the saved pull count.
77+
*/
78+
export function restorePRNG(seed: string, pull: number): void {
79+
currentSeed = seed;
80+
currentPull = 0;
81+
generator = mulberry32(hashSeed(seed));
82+
enabled = true;
83+
84+
// Fast-forward
85+
for (let i = 0; i < pull; i++) {
86+
generator();
87+
}
88+
currentPull = pull;
89+
}
90+
91+
/** Disable the PRNG (used on restart before StoryInit re-enables it). */
92+
export function resetPRNG(): void {
93+
enabled = false;
94+
currentSeed = '';
95+
currentPull = 0;
96+
generator = null;
97+
}
98+
99+
/** Returns the current PRNG state for snapshotting, or null if disabled. */
100+
export function snapshotPRNG(): PRNGSnapshot | null {
101+
if (!enabled) return null;
102+
return { seed: currentSeed, pull: currentPull };
103+
}
104+
105+
/** Returns a seeded random number [0, 1), or falls back to Math.random(). */
106+
export function random(): number {
107+
if (!enabled || !generator) return Math.random();
108+
currentPull++;
109+
return generator();
110+
}
111+
112+
/** Returns a random integer between min and max (inclusive). */
113+
export function randomInt(min: number, max: number): number {
114+
if (min > max) [min, max] = [max, min];
115+
return Math.floor(random() * (max - min + 1)) + min;
116+
}
117+
118+
export function isPRNGEnabled(): boolean {
119+
return enabled;
120+
}
121+
122+
export function getPRNGSeed(): string {
123+
return currentSeed;
124+
}
125+
126+
export function getPRNGPull(): number {
127+
return currentPull;
128+
}

src/saves/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { HistoryMoment } from '../store';
2+
import type { PRNGSnapshot } from '../prng';
23

34
export interface SavePayload {
45
passage: string;
@@ -7,6 +8,7 @@ export interface SavePayload {
78
historyIndex: number;
89
visitCounts?: Record<string, number>;
910
renderCounts?: Record<string, number>;
11+
prng?: PRNGSnapshot | null;
1012
}
1113

1214
export interface SaveMeta {

0 commit comments

Comments
 (0)