@joystyk/session 0.0.0-stage → 0.2.0
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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/NOTICE +36 -0
- package/README.md +266 -2
- package/dist/assistance.d.ts +3 -0
- package/dist/assistance.js +20 -0
- package/dist/assistance.js.map +1 -0
- package/dist/browser/audio-sounds.d.ts +19 -0
- package/dist/browser/audio-sounds.js +413 -0
- package/dist/browser/audio-sounds.js.map +1 -0
- package/dist/browser/audio.d.ts +16 -0
- package/dist/browser/audio.js +182 -0
- package/dist/browser/audio.js.map +1 -0
- package/dist/browser/index.d.ts +48 -0
- package/dist/browser/index.js +140 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/preparer.d.ts +3 -0
- package/dist/browser/preparer.js +73 -0
- package/dist/browser/preparer.js.map +1 -0
- package/dist/browser/rooms.d.ts +35 -0
- package/dist/browser/rooms.js +140 -0
- package/dist/browser/rooms.js.map +1 -0
- package/dist/fixed-step.d.ts +66 -0
- package/dist/fixed-step.js +280 -0
- package/dist/fixed-step.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/local.d.ts +130 -0
- package/dist/local.js +723 -0
- package/dist/local.js.map +1 -0
- package/dist/ports.d.ts +70 -0
- package/dist/ports.js +10 -0
- package/dist/ports.js.map +1 -0
- package/dist/publisher.d.ts +11 -0
- package/dist/publisher.js +44 -0
- package/dist/publisher.js.map +1 -0
- package/dist/remote.d.ts +128 -0
- package/dist/remote.js +474 -0
- package/dist/remote.js.map +1 -0
- package/dist/replay.d.ts +65 -0
- package/dist/replay.js +199 -0
- package/dist/replay.js.map +1 -0
- package/dist/rngs.d.ts +22 -0
- package/dist/rngs.js +34 -0
- package/dist/rngs.js.map +1 -0
- package/dist/saves.d.ts +20 -0
- package/dist/saves.js +59 -0
- package/dist/saves.js.map +1 -0
- package/dist/stopwatch.d.ts +22 -0
- package/dist/stopwatch.js +51 -0
- package/dist/stopwatch.js.map +1 -0
- package/dist/values.d.ts +19 -0
- package/dist/values.js +66 -0
- package/dist/values.js.map +1 -0
- package/package.json +56 -3
package/dist/replay.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { compact, decode, isRecord } from "./values.js";
|
|
2
|
+
import { defaultRestoreRng } from "./rngs.js";
|
|
3
|
+
import { err, gameError, ok } from "@joystyk/contracts";
|
|
4
|
+
//#region src/replay.ts
|
|
5
|
+
var ENGINE_ASSISTS = [
|
|
6
|
+
"hint",
|
|
7
|
+
"check",
|
|
8
|
+
"reveal",
|
|
9
|
+
"shuffle"
|
|
10
|
+
];
|
|
11
|
+
/** The assistance a transition flagged. Anything but a kind an engine may report is ignored. */
|
|
12
|
+
var assistOf = (transition) => ENGINE_ASSISTS.includes(transition.assist) ? transition.assist : null;
|
|
13
|
+
/** What the runner keeps of one accepted input and its transition. */
|
|
14
|
+
var recordOf = (input, transition, seatsBefore) => ({
|
|
15
|
+
input,
|
|
16
|
+
jobs: transition.jobs ?? [],
|
|
17
|
+
barrier: transition.undoBarrier === true,
|
|
18
|
+
assist: assistOf(transition),
|
|
19
|
+
startsClock: transition.startsClock === true,
|
|
20
|
+
restartsGame: transition.restartsGame === true,
|
|
21
|
+
seatsBefore
|
|
22
|
+
});
|
|
23
|
+
/** Applies a seat change to the seat list, as the runner shows it in `meta.seats`. */
|
|
24
|
+
var applySeatChange = (seats, input) => {
|
|
25
|
+
if (input.kind !== "seat" || input.change.type !== "controller") return seats;
|
|
26
|
+
const { change } = input;
|
|
27
|
+
return seats.map((seat) => seat.id === input.seat ? compact({
|
|
28
|
+
id: seat.id,
|
|
29
|
+
name: change.name,
|
|
30
|
+
controller: change.controller,
|
|
31
|
+
aiLevel: change.aiLevel
|
|
32
|
+
}) : seat);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Calls `apply` the way a runner must: a throw becomes an `EngineThrew` error, and on any
|
|
36
|
+
* failure the random source is restored, so a rejected input never consumes randomness that
|
|
37
|
+
* a replay (which skips rejected inputs) would not.
|
|
38
|
+
*/
|
|
39
|
+
var safeApply = (engine, state, input, rng, restoreRng) => {
|
|
40
|
+
const before = rng.save();
|
|
41
|
+
try {
|
|
42
|
+
const result = engine.apply(state, input, rng);
|
|
43
|
+
if (result.ok && result.value.restartsGame === true && (input.kind !== "command" || result.value.undoBarrier !== true || engine.outcome(result.value.state) !== null)) return {
|
|
44
|
+
result: err(gameError("BadRestart", "A new game needs a setup command, an undo barrier and an unfinished state.")),
|
|
45
|
+
rng: restoreRng(before)
|
|
46
|
+
};
|
|
47
|
+
return result.ok ? {
|
|
48
|
+
result,
|
|
49
|
+
rng
|
|
50
|
+
} : {
|
|
51
|
+
result,
|
|
52
|
+
rng: restoreRng(before)
|
|
53
|
+
};
|
|
54
|
+
} catch (thrown) {
|
|
55
|
+
return {
|
|
56
|
+
result: err(gameError("EngineThrew", "Something went wrong. Your last action was not applied.")),
|
|
57
|
+
rng: restoreRng(before),
|
|
58
|
+
thrown
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** Runs `init` with the seed, then every input. Any rejection fails the whole replay. */
|
|
63
|
+
var rebuild = (engine, start, inputs, restoreRng) => {
|
|
64
|
+
let rng = restoreRng(start.seed);
|
|
65
|
+
let initialized;
|
|
66
|
+
try {
|
|
67
|
+
initialized = engine.init(start.config, start.seats, rng);
|
|
68
|
+
} catch {
|
|
69
|
+
return err(gameError("EngineThrew", "The game could not be set up."));
|
|
70
|
+
}
|
|
71
|
+
if (!initialized.ok) return initialized;
|
|
72
|
+
let state = initialized.value;
|
|
73
|
+
let seats = start.seats;
|
|
74
|
+
const records = [];
|
|
75
|
+
for (const [index, input] of inputs.entries()) {
|
|
76
|
+
const applied = safeApply(engine, state, input, rng, restoreRng);
|
|
77
|
+
rng = applied.rng;
|
|
78
|
+
if (!applied.result.ok) return err(gameError("ReplayDiverged", "The saved moves no longer replay.", {
|
|
79
|
+
index,
|
|
80
|
+
code: applied.result.error.code
|
|
81
|
+
}));
|
|
82
|
+
const transition = applied.result.value;
|
|
83
|
+
records.push(recordOf(input, transition, seats));
|
|
84
|
+
seats = applySeatChange(seats, input);
|
|
85
|
+
state = transition.state;
|
|
86
|
+
}
|
|
87
|
+
return ok({
|
|
88
|
+
state,
|
|
89
|
+
rng,
|
|
90
|
+
seats,
|
|
91
|
+
records
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Replays a recorded Layer 2 game and returns its final state and random state. The
|
|
96
|
+
* conformance tests compare these with the recorded game's.
|
|
97
|
+
*/
|
|
98
|
+
var replayEngine = (engine, replay, restoreRng = defaultRestoreRng) => {
|
|
99
|
+
if (replay.rulesVersion !== engine.rulesVersion) return err(gameError("RulesChanged", "This replay was recorded under different rules."));
|
|
100
|
+
const rebuilt = rebuild(engine, replay, replay.inputs, restoreRng);
|
|
101
|
+
if (!rebuilt.ok) return rebuilt;
|
|
102
|
+
const barriers = rebuilt.value.records.flatMap((record, i) => record.barrier ? [i] : []);
|
|
103
|
+
return ok({
|
|
104
|
+
state: rebuilt.value.state,
|
|
105
|
+
rng: rebuilt.value.rng.save(),
|
|
106
|
+
barriers
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Replays a recorded Layer 3 game: `init` with the seed, then `step` up to `endTick`, feeding
|
|
111
|
+
* each tick its inputs in array order.
|
|
112
|
+
*/
|
|
113
|
+
var replaySimulation = (simulation, replay, restoreRng = defaultRestoreRng) => {
|
|
114
|
+
if (replay.rulesVersion !== simulation.rulesVersion) return err(gameError("RulesChanged", "This replay was recorded under different rules."));
|
|
115
|
+
const rng = restoreRng(replay.seed);
|
|
116
|
+
const initialized = simulation.init(replay.config, rng);
|
|
117
|
+
if (!initialized.ok) return initialized;
|
|
118
|
+
let state = initialized.value;
|
|
119
|
+
let next = 0;
|
|
120
|
+
for (let tick = 0; tick < replay.endTick; tick += 1) {
|
|
121
|
+
const inputs = [];
|
|
122
|
+
while (next < replay.inputs.length && replay.inputs[next].tick === tick) {
|
|
123
|
+
inputs.push(replay.inputs[next].input);
|
|
124
|
+
next += 1;
|
|
125
|
+
}
|
|
126
|
+
state = simulation.step(state, inputs, rng).state;
|
|
127
|
+
}
|
|
128
|
+
return ok({
|
|
129
|
+
state,
|
|
130
|
+
rng: rng.save()
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
var isSeat = (value) => Number.isInteger(value) && value >= 0;
|
|
134
|
+
var parseJobOutcome = (value) => {
|
|
135
|
+
if (!isRecord(value)) return null;
|
|
136
|
+
if (value["ok"] === true && Object.hasOwn(value, "value")) return {
|
|
137
|
+
ok: true,
|
|
138
|
+
value: value["value"]
|
|
139
|
+
};
|
|
140
|
+
if (value["ok"] === false && (value["reason"] === "timeout" || value["reason"] === "failed")) return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: value["reason"]
|
|
143
|
+
};
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
/** Parses one input from a stored replay; commands go through the engine's schema. */
|
|
147
|
+
var parseEngineInput = (value, command) => {
|
|
148
|
+
const bad = err(gameError("SaveUnreadable", "This saved game could not be read.", { why: "replay input" }));
|
|
149
|
+
if (!isRecord(value)) return bad;
|
|
150
|
+
switch (value["kind"]) {
|
|
151
|
+
case "command": {
|
|
152
|
+
if (!isSeat(value["seat"])) return bad;
|
|
153
|
+
const decoded = decode(command, value["command"], "saved command");
|
|
154
|
+
return decoded.ok ? ok({
|
|
155
|
+
kind: "command",
|
|
156
|
+
seat: value["seat"],
|
|
157
|
+
command: decoded.value
|
|
158
|
+
}) : decoded;
|
|
159
|
+
}
|
|
160
|
+
case "seat": {
|
|
161
|
+
const change = value["change"];
|
|
162
|
+
if (!isSeat(value["seat"]) || !isRecord(change)) return bad;
|
|
163
|
+
if (change["type"] === "left") return ok({
|
|
164
|
+
kind: "seat",
|
|
165
|
+
seat: value["seat"],
|
|
166
|
+
change: { type: "left" }
|
|
167
|
+
});
|
|
168
|
+
if (change["type"] !== "controller" || change["controller"] !== "human" && change["controller"] !== "ai" || typeof change["name"] !== "string" || change["aiLevel"] !== void 0 && typeof change["aiLevel"] !== "string") return bad;
|
|
169
|
+
return ok({
|
|
170
|
+
kind: "seat",
|
|
171
|
+
seat: value["seat"],
|
|
172
|
+
change: compact({
|
|
173
|
+
type: "controller",
|
|
174
|
+
controller: change["controller"],
|
|
175
|
+
name: change["name"],
|
|
176
|
+
aiLevel: change["aiLevel"]
|
|
177
|
+
})
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
case "timeout":
|
|
181
|
+
case "resign": return isSeat(value["seat"]) ? ok({
|
|
182
|
+
kind: value["kind"],
|
|
183
|
+
seat: value["seat"]
|
|
184
|
+
}) : bad;
|
|
185
|
+
case "job": {
|
|
186
|
+
const outcome = parseJobOutcome(value["outcome"]);
|
|
187
|
+
return typeof value["jobId"] === "string" && outcome !== null ? ok({
|
|
188
|
+
kind: "job",
|
|
189
|
+
jobId: value["jobId"],
|
|
190
|
+
outcome
|
|
191
|
+
}) : bad;
|
|
192
|
+
}
|
|
193
|
+
default: return bad;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
//#endregion
|
|
197
|
+
export { applySeatChange, assistOf, parseEngineInput, rebuild, recordOf, replayEngine, replaySimulation, safeApply };
|
|
198
|
+
|
|
199
|
+
//# sourceMappingURL=replay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replay.js","names":[],"sources":["../src/replay.ts"],"sourcesContent":["// Replaying a discrete engine or a simulation from its seed. The local runner rebuilds its\n// state this way for undo and restore, and tests use it to prove replay equality.\nimport {\n err,\n gameError,\n ok,\n type DiscreteEngine,\n type EngineAssist,\n type EngineInput,\n type EngineReplay,\n type FixedStepSimulation,\n type GameError,\n type JobOutcome,\n type JobRequest,\n type Json,\n type Result,\n type Rng,\n type RngState,\n type SeatInfo,\n type SimulationReplay,\n type Transition,\n type Validator,\n} from '@joystyk/contracts';\nimport type { RngFactory } from './ports.ts';\nimport { defaultRestoreRng } from './rngs.ts';\nimport { compact, decode, isRecord } from './values.ts';\n\n/** One accepted input and what its transition reported. */\nexport type InputRecord<Command> = {\n readonly input: EngineInput<Command>;\n /** Jobs the transition requested. */\n readonly jobs: readonly JobRequest[];\n readonly barrier: boolean;\n /** The assistance the transition flagged, if any. */\n readonly assist: EngineAssist | null;\n /** The transition set `startsClock`. */\n readonly startsClock: boolean;\n readonly restartsGame: boolean;\n /** The seats as they were when the input arrived, to tell human commands from AI ones. */\n readonly seatsBefore: readonly SeatInfo[];\n};\n\nconst ENGINE_ASSISTS: readonly unknown[] = ['hint', 'check', 'reveal', 'shuffle'];\n\n/** The assistance a transition flagged. Anything but a kind an engine may report is ignored. */\nexport const assistOf = (transition: { readonly assist?: EngineAssist }): EngineAssist | null =>\n ENGINE_ASSISTS.includes(transition.assist) ? (transition.assist as EngineAssist) : null;\n\n/** What the runner keeps of one accepted input and its transition. */\nexport const recordOf = <Command, State, Event>(\n input: EngineInput<Command>,\n transition: Transition<State, Event>,\n seatsBefore: readonly SeatInfo[],\n): InputRecord<Command> => ({\n input,\n jobs: transition.jobs ?? [],\n barrier: transition.undoBarrier === true,\n assist: assistOf(transition),\n startsClock: transition.startsClock === true,\n restartsGame: transition.restartsGame === true,\n seatsBefore,\n});\n\n/** Applies a seat change to the seat list, as the runner shows it in `meta.seats`. */\nexport const applySeatChange = <Command>(\n seats: readonly SeatInfo[],\n input: EngineInput<Command>,\n): readonly SeatInfo[] => {\n if (input.kind !== 'seat' || input.change.type !== 'controller') return seats;\n const { change } = input;\n return seats.map((seat) =>\n seat.id === input.seat\n ? compact({ id: seat.id, name: change.name, controller: change.controller, aiLevel: change.aiLevel })\n : seat,\n );\n};\n\n/**\n * Calls `apply` the way a runner must: a throw becomes an `EngineThrew` error, and on any\n * failure the random source is restored, so a rejected input never consumes randomness that\n * a replay (which skips rejected inputs) would not.\n */\nexport const safeApply = <\n State extends Json,\n Command extends Json,\n View extends Json,\n Event extends Json,\n Config extends Json,\n>(\n engine: DiscreteEngine<State, Command, View, Event, Config>,\n state: State,\n input: EngineInput<Command>,\n rng: Rng,\n restoreRng: RngFactory,\n): { readonly result: Result<Transition<State, Event>>; readonly rng: Rng; readonly thrown?: unknown } => {\n const before = rng.save();\n try {\n const result = engine.apply(state, input, rng);\n if (\n result.ok &&\n result.value.restartsGame === true &&\n (input.kind !== 'command' || result.value.undoBarrier !== true || engine.outcome(result.value.state) !== null)\n )\n return {\n result: err(\n gameError('BadRestart', 'A new game needs a setup command, an undo barrier and an unfinished state.'),\n ),\n rng: restoreRng(before),\n };\n return result.ok ? { result, rng } : { result, rng: restoreRng(before) };\n } catch (thrown) {\n return {\n result: err(gameError('EngineThrew', 'Something went wrong. Your last action was not applied.')),\n rng: restoreRng(before),\n thrown,\n };\n }\n};\n\nexport type Rebuilt<State, Command> = {\n readonly state: State;\n readonly rng: Rng;\n readonly seats: readonly SeatInfo[];\n readonly records: readonly InputRecord<Command>[];\n};\n\n/** Runs `init` with the seed, then every input. Any rejection fails the whole replay. */\nexport const rebuild = <\n State extends Json,\n Command extends Json,\n View extends Json,\n Event extends Json,\n Config extends Json,\n>(\n engine: DiscreteEngine<State, Command, View, Event, Config>,\n start: { readonly config: Config; readonly seats: readonly SeatInfo[]; readonly seed: RngState },\n inputs: readonly EngineInput<Command>[],\n restoreRng: RngFactory,\n): Result<Rebuilt<State, Command>, GameError> => {\n let rng = restoreRng(start.seed);\n let initialized: Result<State>;\n try {\n initialized = engine.init(start.config, start.seats, rng);\n } catch {\n return err(gameError('EngineThrew', 'The game could not be set up.'));\n }\n if (!initialized.ok) return initialized;\n let state = initialized.value;\n let seats = start.seats;\n const records: InputRecord<Command>[] = [];\n for (const [index, input] of inputs.entries()) {\n const applied = safeApply(engine, state, input, rng, restoreRng);\n rng = applied.rng;\n if (!applied.result.ok) {\n return err(\n gameError('ReplayDiverged', 'The saved moves no longer replay.', { index, code: applied.result.error.code }),\n );\n }\n const transition = applied.result.value;\n records.push(recordOf(input, transition, seats));\n seats = applySeatChange(seats, input);\n state = transition.state;\n }\n return ok({ state, rng, seats, records });\n};\n\n/**\n * Replays a recorded Layer 2 game and returns its final state and random state. The\n * conformance tests compare these with the recorded game's.\n */\nexport const replayEngine = <\n State extends Json,\n Command extends Json,\n View extends Json,\n Event extends Json,\n Config extends Json,\n>(\n engine: DiscreteEngine<State, Command, View, Event, Config>,\n replay: EngineReplay<Command, Config>,\n restoreRng: RngFactory = defaultRestoreRng,\n): Result<{ readonly state: State; readonly rng: RngState; readonly barriers: readonly number[] }, GameError> => {\n if (replay.rulesVersion !== engine.rulesVersion) {\n return err(gameError('RulesChanged', 'This replay was recorded under different rules.'));\n }\n const rebuilt = rebuild(engine, replay, replay.inputs, restoreRng);\n if (!rebuilt.ok) return rebuilt;\n const barriers = rebuilt.value.records.flatMap((record, i) => (record.barrier ? [i] : []));\n return ok({ state: rebuilt.value.state, rng: rebuilt.value.rng.save(), barriers });\n};\n\n/**\n * Replays a recorded Layer 3 game: `init` with the seed, then `step` up to `endTick`, feeding\n * each tick its inputs in array order.\n */\nexport const replaySimulation = <State extends Json, Input extends Json, View, Event extends Json, Config extends Json>(\n simulation: FixedStepSimulation<State, Input, View, Event, Config>,\n replay: SimulationReplay<Input, Config>,\n restoreRng: RngFactory = defaultRestoreRng,\n): Result<{ readonly state: State; readonly rng: RngState }, GameError> => {\n if (replay.rulesVersion !== simulation.rulesVersion) {\n return err(gameError('RulesChanged', 'This replay was recorded under different rules.'));\n }\n const rng = restoreRng(replay.seed);\n const initialized = simulation.init(replay.config, rng);\n if (!initialized.ok) return initialized;\n let state = initialized.value;\n let next = 0;\n for (let tick = 0; tick < replay.endTick; tick += 1) {\n const inputs: Input[] = [];\n while (next < replay.inputs.length && replay.inputs[next]!.tick === tick) {\n inputs.push(replay.inputs[next]!.input);\n next += 1;\n }\n state = simulation.step(state, inputs, rng).state;\n }\n return ok({ state, rng: rng.save() });\n};\n\nconst isSeat = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;\n\nconst parseJobOutcome = (value: unknown): JobOutcome | null => {\n if (!isRecord(value)) return null;\n if (value['ok'] === true && Object.hasOwn(value, 'value')) return { ok: true, value: value['value'] as Json };\n if (value['ok'] === false && (value['reason'] === 'timeout' || value['reason'] === 'failed')) {\n return { ok: false, reason: value['reason'] };\n }\n return null;\n};\n\n/** Parses one input from a stored replay; commands go through the engine's schema. */\nexport const parseEngineInput = <Command>(\n value: unknown,\n command: Validator<Command>,\n): Result<EngineInput<Command>, GameError> => {\n const bad = err(gameError('SaveUnreadable', 'This saved game could not be read.', { why: 'replay input' }));\n if (!isRecord(value)) return bad;\n switch (value['kind']) {\n case 'command': {\n if (!isSeat(value['seat'])) return bad;\n const decoded = decode(command, value['command'], 'saved command');\n return decoded.ok ? ok({ kind: 'command', seat: value['seat'], command: decoded.value }) : decoded;\n }\n case 'seat': {\n const change = value['change'];\n if (!isSeat(value['seat']) || !isRecord(change)) return bad;\n if (change['type'] === 'left') return ok({ kind: 'seat', seat: value['seat'], change: { type: 'left' } });\n if (\n change['type'] !== 'controller' ||\n (change['controller'] !== 'human' && change['controller'] !== 'ai') ||\n typeof change['name'] !== 'string' ||\n (change['aiLevel'] !== undefined && typeof change['aiLevel'] !== 'string')\n ) {\n return bad;\n }\n return ok({\n kind: 'seat',\n seat: value['seat'],\n change: compact({\n type: 'controller',\n controller: change['controller'],\n name: change['name'],\n aiLevel: change['aiLevel'],\n }),\n });\n }\n case 'timeout':\n case 'resign':\n return isSeat(value['seat']) ? ok({ kind: value['kind'], seat: value['seat'] }) : bad;\n case 'job': {\n const outcome = parseJobOutcome(value['outcome']);\n return typeof value['jobId'] === 'string' && outcome !== null\n ? ok({ kind: 'job', jobId: value['jobId'], outcome })\n : bad;\n }\n default:\n return bad;\n }\n};\n"],"mappings":"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"}
|
package/dist/rngs.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RngState, SeatId } from '@joystyk/contracts';
|
|
2
|
+
import type { RngFactory } from './ports.js';
|
|
3
|
+
/** The default factory: the kit's SFC32. */
|
|
4
|
+
export declare const defaultRestoreRng: RngFactory;
|
|
5
|
+
/**
|
|
6
|
+
* Restores a state from the AI stream. Forks are always the kit's SFC32, whatever algorithm
|
|
7
|
+
* the game's seed names; anything else goes to the game's factory.
|
|
8
|
+
*/
|
|
9
|
+
export declare const aiRestore: (game: RngFactory) => RngFactory;
|
|
10
|
+
/** An AI seed derived from a game's random state: a fork of a copy, so the game's stream doesn't move. */
|
|
11
|
+
export declare const deriveAiSeed: (state: RngState, restore: RngFactory) => RngState;
|
|
12
|
+
/**
|
|
13
|
+
* The child stream for one decision of `seat`: fork number `seat` of the AI stream's state.
|
|
14
|
+
* Seats choosing at the same time get different children; a choice that is cancelled and
|
|
15
|
+
* started again gets the same one, because the stream only moves when a move is accepted.
|
|
16
|
+
*/
|
|
17
|
+
export declare const aiDecisionSeed: (state: RngState, seat: SeatId, restore: RngFactory) => RngState;
|
|
18
|
+
/**
|
|
19
|
+
* The AI stream after an accepted AI move: one fork per seat, past every child the old state
|
|
20
|
+
* handed out, so no later decision reuses a child.
|
|
21
|
+
*/
|
|
22
|
+
export declare const advanceAi: (state: RngState, seats: number, restore: RngFactory) => RngState;
|
package/dist/rngs.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SFC32, forkRng, restoreRng } from "@joystyk/kit";
|
|
2
|
+
//#region src/rngs.ts
|
|
3
|
+
/** The default factory: the kit's SFC32. */
|
|
4
|
+
var defaultRestoreRng = restoreRng;
|
|
5
|
+
/**
|
|
6
|
+
* Restores a state from the AI stream. Forks are always the kit's SFC32, whatever algorithm
|
|
7
|
+
* the game's seed names; anything else goes to the game's factory.
|
|
8
|
+
*/
|
|
9
|
+
var aiRestore = (game) => (state) => state.algorithm === SFC32 ? restoreRng(state) : game(state);
|
|
10
|
+
/** An AI seed derived from a game's random state: a fork of a copy, so the game's stream doesn't move. */
|
|
11
|
+
var deriveAiSeed = (state, restore) => forkRng(restore(state)).save();
|
|
12
|
+
/**
|
|
13
|
+
* The child stream for one decision of `seat`: fork number `seat` of the AI stream's state.
|
|
14
|
+
* Seats choosing at the same time get different children; a choice that is cancelled and
|
|
15
|
+
* started again gets the same one, because the stream only moves when a move is accepted.
|
|
16
|
+
*/
|
|
17
|
+
var aiDecisionSeed = (state, seat, restore) => {
|
|
18
|
+
const parent = restore(state);
|
|
19
|
+
for (let i = 0; i < seat; i += 1) forkRng(parent);
|
|
20
|
+
return forkRng(parent).save();
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The AI stream after an accepted AI move: one fork per seat, past every child the old state
|
|
24
|
+
* handed out, so no later decision reuses a child.
|
|
25
|
+
*/
|
|
26
|
+
var advanceAi = (state, seats, restore) => {
|
|
27
|
+
const parent = restore(state);
|
|
28
|
+
for (let i = 0; i < seats; i += 1) forkRng(parent);
|
|
29
|
+
return parent.save();
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { advanceAi, aiDecisionSeed, aiRestore, defaultRestoreRng, deriveAiSeed };
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=rngs.js.map
|
package/dist/rngs.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rngs.js","names":[],"sources":["../src/rngs.ts"],"sourcesContent":["// The runners' random sources. The game's stream is restored with the factory the caller\n// passes (the kit's `restoreRng` by default). The AI's stream is the platform's own: each AI\n// decision gets a child forked with the kit's `forkRng`, so the stream moves by a fixed\n// amount per accepted AI move however much a search draws, and a worker can receive the\n// child as plain `RngState` (game-contract.md, \"AI: runner-driven or engine-driven\").\nimport type { RngState, SeatId } from '@joystyk/contracts';\nimport { forkRng, restoreRng, SFC32 } from '@joystyk/kit';\nimport type { RngFactory } from './ports.ts';\n\n/** The default factory: the kit's SFC32. */\nexport const defaultRestoreRng: RngFactory = restoreRng;\n\n/**\n * Restores a state from the AI stream. Forks are always the kit's SFC32, whatever algorithm\n * the game's seed names; anything else goes to the game's factory.\n */\nexport const aiRestore =\n (game: RngFactory): RngFactory =>\n (state) =>\n state.algorithm === SFC32 ? restoreRng(state) : game(state);\n\n/** An AI seed derived from a game's random state: a fork of a copy, so the game's stream doesn't move. */\nexport const deriveAiSeed = (state: RngState, restore: RngFactory): RngState => forkRng(restore(state)).save();\n\n/**\n * The child stream for one decision of `seat`: fork number `seat` of the AI stream's state.\n * Seats choosing at the same time get different children; a choice that is cancelled and\n * started again gets the same one, because the stream only moves when a move is accepted.\n */\nexport const aiDecisionSeed = (state: RngState, seat: SeatId, restore: RngFactory): RngState => {\n const parent = restore(state);\n for (let i = 0; i < seat; i += 1) forkRng(parent);\n return forkRng(parent).save();\n};\n\n/**\n * The AI stream after an accepted AI move: one fork per seat, past every child the old state\n * handed out, so no later decision reuses a child.\n */\nexport const advanceAi = (state: RngState, seats: number, restore: RngFactory): RngState => {\n const parent = restore(state);\n for (let i = 0; i < seats; i += 1) forkRng(parent);\n return parent.save();\n};\n"],"mappings":";;;AAUA,IAAa,oBAAgC;;;;;AAM7C,IAAa,aACV,UACA,UACC,MAAM,cAAc,QAAQ,WAAW,KAAK,IAAI,KAAK,KAAK;;AAG9D,IAAa,gBAAgB,OAAiB,YAAkC,QAAQ,QAAQ,KAAK,CAAC,CAAC,CAAC,KAAK;;;;;;AAO7G,IAAa,kBAAkB,OAAiB,MAAc,YAAkC;CAC9F,MAAM,SAAS,QAAQ,KAAK;CAC5B,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,KAAK,GAAG,QAAQ,MAAM;CAChD,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK;AAC9B;;;;;AAMA,IAAa,aAAa,OAAiB,OAAe,YAAkC;CAC1F,MAAM,SAAS,QAAQ,KAAK;CAC5B,KAAK,IAAI,IAAI,GAAG,IAAI,OAAO,KAAK,GAAG,QAAQ,MAAM;CACjD,OAAO,OAAO,KAAK;AACrB"}
|
package/dist/saves.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type GameError, type GameManifest, type Json, type Migration, type Result, type SaveEnvelope, type SeatInfo, type Validator } from '@joystyk/contracts';
|
|
2
|
+
/** The parts of a game's manifest a runner reads. `ai` matters only for its `heavy` flag. */
|
|
3
|
+
export type RunnerManifest = Pick<GameManifest, 'id' | 'versions' | 'undo' | 'handoff'> & {
|
|
4
|
+
readonly ai?: GameManifest['ai'];
|
|
5
|
+
};
|
|
6
|
+
export declare const isSeatInfo: (value: unknown) => value is SeatInfo;
|
|
7
|
+
/** Checks the envelope's shape. The replay is checked by the runner that understands it. */
|
|
8
|
+
export declare const parseEnvelope: (value: unknown) => Result<SaveEnvelope, GameError>;
|
|
9
|
+
/**
|
|
10
|
+
* Brings a save up to date and validates its state. Fails with `SaveUnreadable`,
|
|
11
|
+
* `SaveWrongGame`, `RulesChanged` (the rules version differs, so the game can't continue on
|
|
12
|
+
* this code), a migration error, or `InvalidInput` from the state schema. The launcher
|
|
13
|
+
* archives the save on any failure.
|
|
14
|
+
*/
|
|
15
|
+
export declare const loadSave: <State>(value: unknown, manifest: RunnerManifest, rulesVersion: number, migrations: readonly Migration[], stateSchema: Validator<State>) => Result<{
|
|
16
|
+
readonly envelope: SaveEnvelope;
|
|
17
|
+
readonly state: State;
|
|
18
|
+
}, GameError>;
|
|
19
|
+
/** True when a value is a list of JSON inputs, the least a replay's `inputs` must be. */
|
|
20
|
+
export declare const isJsonList: (value: unknown) => value is readonly Json[];
|
package/dist/saves.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { decode, isRecord } from "./values.js";
|
|
2
|
+
import { assistCountsSchema } from "./assistance.js";
|
|
3
|
+
import { ASSIST_KINDS, err, gameError, isJson, ok, runMigrations } from "@joystyk/contracts";
|
|
4
|
+
//#region src/saves.ts
|
|
5
|
+
var unreadable = (why) => gameError("SaveUnreadable", "This saved game could not be read.", { why });
|
|
6
|
+
var isRngState = (value) => isRecord(value) && typeof value["algorithm"] === "string" && Array.isArray(value["words"]) && value["words"].every((word) => typeof word === "number" && Number.isFinite(word));
|
|
7
|
+
var isSeatInfo = (value) => isRecord(value) && Number.isInteger(value["id"]) && typeof value["name"] === "string" && (value["controller"] === "human" || value["controller"] === "ai") && (value["aiLevel"] === void 0 || typeof value["aiLevel"] === "string");
|
|
8
|
+
var isWholeNumber = (value) => Number.isInteger(value) && value >= 0;
|
|
9
|
+
/** Checks the envelope's shape. The replay is checked by the runner that understands it. */
|
|
10
|
+
var parseEnvelope = (value) => {
|
|
11
|
+
if (!isRecord(value) || !isJson(value)) return err(unreadable("not a JSON object"));
|
|
12
|
+
if (typeof value["gameId"] !== "string" || typeof value["build"] !== "string") return err(unreadable("game id or build"));
|
|
13
|
+
if (!isWholeNumber(value["rulesVersion"]) || !isWholeNumber(value["saveVersion"])) return err(unreadable("versions"));
|
|
14
|
+
if (!isRngState(value["rng"])) return err(unreadable("rng"));
|
|
15
|
+
const seats = value["seats"];
|
|
16
|
+
if (!Array.isArray(seats) || !seats.every(isSeatInfo) || seats.some((seat, i) => seat.id !== i)) return err(unreadable("seats"));
|
|
17
|
+
if (value["daily"] !== void 0 && (!isRecord(value["daily"]) || typeof value["daily"]["puzzleId"] !== "string" || typeof value["daily"]["contentHash"] !== "string")) return err(unreadable("daily"));
|
|
18
|
+
if (!Object.hasOwn(value, "state")) return err(unreadable("state"));
|
|
19
|
+
if (value["tick"] !== void 0 && !isWholeNumber(value["tick"])) return err(unreadable("tick"));
|
|
20
|
+
if (value["elapsedMs"] !== void 0 && !isWholeNumber(value["elapsedMs"])) return err(unreadable("elapsedMs"));
|
|
21
|
+
if (value["clockStarted"] !== void 0 && typeof value["clockStarted"] !== "boolean") return err(unreadable("clockStarted"));
|
|
22
|
+
if (value["replay"] !== void 0 && !isRecord(value["replay"])) return err(unreadable("replay"));
|
|
23
|
+
if (value["aiRng"] !== void 0 && !isRngState(value["aiRng"])) return err(unreadable("aiRng"));
|
|
24
|
+
if (value["undosUsed"] !== void 0 && !isWholeNumber(value["undosUsed"])) return err(unreadable("undosUsed"));
|
|
25
|
+
const assists = value["assists"];
|
|
26
|
+
if (assists !== void 0 && !(Array.isArray(assists) && assists.every((kind) => ASSIST_KINDS.includes(kind)))) return err(unreadable("assists"));
|
|
27
|
+
if (value["assistCounts"] !== void 0 && !decode(assistCountsSchema, value["assistCounts"], "assistance counts").ok) return err(unreadable("assistCounts"));
|
|
28
|
+
return ok(value);
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Brings a save up to date and validates its state. Fails with `SaveUnreadable`,
|
|
32
|
+
* `SaveWrongGame`, `RulesChanged` (the rules version differs, so the game can't continue on
|
|
33
|
+
* this code), a migration error, or `InvalidInput` from the state schema. The launcher
|
|
34
|
+
* archives the save on any failure.
|
|
35
|
+
*/
|
|
36
|
+
var loadSave = (value, manifest, rulesVersion, migrations, stateSchema) => {
|
|
37
|
+
const parsed = parseEnvelope(value);
|
|
38
|
+
if (!parsed.ok) return parsed;
|
|
39
|
+
const envelope = parsed.value;
|
|
40
|
+
if (envelope.gameId !== manifest.id) return err(gameError("SaveWrongGame", "This saved game belongs to a different game.", { gameId: envelope.gameId }));
|
|
41
|
+
if (envelope.rulesVersion !== rulesVersion) return err(gameError("RulesChanged", "This saved game was played under older rules and can no longer continue.", {
|
|
42
|
+
saved: envelope.rulesVersion,
|
|
43
|
+
current: rulesVersion
|
|
44
|
+
}));
|
|
45
|
+
const migrated = runMigrations(migrations, envelope.state, envelope.saveVersion, manifest.versions.save);
|
|
46
|
+
if (!migrated.ok) return migrated;
|
|
47
|
+
const state = decode(stateSchema, migrated.value, "saved state");
|
|
48
|
+
if (!state.ok) return state;
|
|
49
|
+
return ok({
|
|
50
|
+
envelope,
|
|
51
|
+
state: state.value
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
/** True when a value is a list of JSON inputs, the least a replay's `inputs` must be. */
|
|
55
|
+
var isJsonList = (value) => Array.isArray(value) && isJson(value);
|
|
56
|
+
//#endregion
|
|
57
|
+
export { isJsonList, isSeatInfo, loadSave, parseEnvelope };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=saves.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saves.js","names":[],"sources":["../src/saves.ts"],"sourcesContent":["// Reading a save from storage: parse the envelope, check it belongs to this game and these\n// rules, run the migration chain, then validate the state (game-contract.md, \"Saves\").\nimport {\n ASSIST_KINDS,\n err,\n gameError,\n isJson,\n ok,\n runMigrations,\n type GameError,\n type GameManifest,\n type Json,\n type Migration,\n type Result,\n type RngState,\n type SaveEnvelope,\n type SeatInfo,\n type Validator,\n} from '@joystyk/contracts';\nimport { decode, isRecord } from './values.ts';\nimport { assistCountsSchema } from './assistance.ts';\n\n/** The parts of a game's manifest a runner reads. `ai` matters only for its `heavy` flag. */\nexport type RunnerManifest = Pick<GameManifest, 'id' | 'versions' | 'undo' | 'handoff'> & {\n readonly ai?: GameManifest['ai'];\n};\n\nconst unreadable = (why: string): GameError =>\n gameError('SaveUnreadable', 'This saved game could not be read.', { why });\n\nconst isRngState = (value: unknown): value is RngState =>\n isRecord(value) &&\n typeof value['algorithm'] === 'string' &&\n Array.isArray(value['words']) &&\n value['words'].every((word) => typeof word === 'number' && Number.isFinite(word));\n\nexport const isSeatInfo = (value: unknown): value is SeatInfo =>\n isRecord(value) &&\n Number.isInteger(value['id']) &&\n typeof value['name'] === 'string' &&\n (value['controller'] === 'human' || value['controller'] === 'ai') &&\n (value['aiLevel'] === undefined || typeof value['aiLevel'] === 'string');\n\nconst isWholeNumber = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;\n\n/** Checks the envelope's shape. The replay is checked by the runner that understands it. */\nexport const parseEnvelope = (value: unknown): Result<SaveEnvelope, GameError> => {\n if (!isRecord(value) || !isJson(value)) return err(unreadable('not a JSON object'));\n if (typeof value['gameId'] !== 'string' || typeof value['build'] !== 'string')\n return err(unreadable('game id or build'));\n if (!isWholeNumber(value['rulesVersion']) || !isWholeNumber(value['saveVersion'])) return err(unreadable('versions'));\n if (!isRngState(value['rng'])) return err(unreadable('rng'));\n const seats = value['seats'];\n if (!Array.isArray(seats) || !seats.every(isSeatInfo) || seats.some((seat, i) => seat.id !== i)) {\n return err(unreadable('seats'));\n }\n if (\n value['daily'] !== undefined &&\n (!isRecord(value['daily']) ||\n typeof value['daily']['puzzleId'] !== 'string' ||\n typeof value['daily']['contentHash'] !== 'string')\n )\n return err(unreadable('daily'));\n if (!Object.hasOwn(value, 'state')) return err(unreadable('state'));\n if (value['tick'] !== undefined && !isWholeNumber(value['tick'])) return err(unreadable('tick'));\n if (value['elapsedMs'] !== undefined && !isWholeNumber(value['elapsedMs'])) return err(unreadable('elapsedMs'));\n if (value['clockStarted'] !== undefined && typeof value['clockStarted'] !== 'boolean')\n return err(unreadable('clockStarted'));\n if (value['replay'] !== undefined && !isRecord(value['replay'])) return err(unreadable('replay'));\n if (value['aiRng'] !== undefined && !isRngState(value['aiRng'])) return err(unreadable('aiRng'));\n if (value['undosUsed'] !== undefined && !isWholeNumber(value['undosUsed'])) return err(unreadable('undosUsed'));\n const assists = value['assists'];\n if (\n assists !== undefined &&\n !(Array.isArray(assists) && assists.every((kind) => (ASSIST_KINDS as readonly unknown[]).includes(kind)))\n ) {\n return err(unreadable('assists'));\n }\n if (value['assistCounts'] !== undefined && !decode(assistCountsSchema, value['assistCounts'], 'assistance counts').ok)\n return err(unreadable('assistCounts'));\n return ok(value as unknown as SaveEnvelope);\n};\n\n/**\n * Brings a save up to date and validates its state. Fails with `SaveUnreadable`,\n * `SaveWrongGame`, `RulesChanged` (the rules version differs, so the game can't continue on\n * this code), a migration error, or `InvalidInput` from the state schema. The launcher\n * archives the save on any failure.\n */\nexport const loadSave = <State>(\n value: unknown,\n manifest: RunnerManifest,\n rulesVersion: number,\n migrations: readonly Migration[],\n stateSchema: Validator<State>,\n): Result<{ readonly envelope: SaveEnvelope; readonly state: State }, GameError> => {\n const parsed = parseEnvelope(value);\n if (!parsed.ok) return parsed;\n const envelope = parsed.value;\n if (envelope.gameId !== manifest.id) {\n return err(gameError('SaveWrongGame', 'This saved game belongs to a different game.', { gameId: envelope.gameId }));\n }\n if (envelope.rulesVersion !== rulesVersion) {\n return err(\n gameError('RulesChanged', 'This saved game was played under older rules and can no longer continue.', {\n saved: envelope.rulesVersion,\n current: rulesVersion,\n }),\n );\n }\n const migrated = runMigrations(migrations, envelope.state, envelope.saveVersion, manifest.versions.save);\n if (!migrated.ok) return migrated;\n const state = decode(stateSchema, migrated.value, 'saved state');\n if (!state.ok) return state;\n return ok({ envelope, state: state.value });\n};\n\n/** True when a value is a list of JSON inputs, the least a replay's `inputs` must be. */\nexport const isJsonList = (value: unknown): value is readonly Json[] => Array.isArray(value) && isJson(value);\n"],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Clock } from './ports.js';
|
|
2
|
+
export type Stopwatch = {
|
|
3
|
+
/** Milliseconds of active play so far, rounded to a whole number. */
|
|
4
|
+
elapsed(): number;
|
|
5
|
+
/** Starts the clock. Later calls do nothing. */
|
|
6
|
+
start(): void;
|
|
7
|
+
/** True once `start` was called (or the game was restored with its clock started). */
|
|
8
|
+
started(): boolean;
|
|
9
|
+
/** A confirmed new game: clear elapsed time and wait for its first meaningful input. */
|
|
10
|
+
restart(): void;
|
|
11
|
+
/** Stops for good (the game is over). */
|
|
12
|
+
finish(): void;
|
|
13
|
+
setHidden(hidden: boolean): void;
|
|
14
|
+
setPaused(paused: boolean): void;
|
|
15
|
+
running(): boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare const createStopwatch: (clock: Clock, initial: {
|
|
18
|
+
readonly elapsedMs: number;
|
|
19
|
+
readonly started: boolean;
|
|
20
|
+
readonly finished: boolean;
|
|
21
|
+
readonly hidden: boolean;
|
|
22
|
+
}) => Stopwatch;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region src/stopwatch.ts
|
|
2
|
+
var createStopwatch = (clock, initial) => {
|
|
3
|
+
let banked = initial.elapsedMs;
|
|
4
|
+
let since = null;
|
|
5
|
+
let started = initial.started;
|
|
6
|
+
let finished = initial.finished;
|
|
7
|
+
let hidden = initial.hidden;
|
|
8
|
+
let paused = false;
|
|
9
|
+
const shouldRun = () => started && !finished && !hidden && !paused;
|
|
10
|
+
const sync = () => {
|
|
11
|
+
const run = shouldRun();
|
|
12
|
+
if (run && since === null) since = clock.now();
|
|
13
|
+
if (!run && since !== null) {
|
|
14
|
+
banked += Math.max(0, clock.now() - since);
|
|
15
|
+
since = null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
sync();
|
|
19
|
+
return {
|
|
20
|
+
elapsed: () => Math.round(banked + (since === null ? 0 : Math.max(0, clock.now() - since))),
|
|
21
|
+
start: () => {
|
|
22
|
+
started = true;
|
|
23
|
+
sync();
|
|
24
|
+
},
|
|
25
|
+
started: () => started,
|
|
26
|
+
restart: () => {
|
|
27
|
+
banked = 0;
|
|
28
|
+
since = null;
|
|
29
|
+
started = false;
|
|
30
|
+
finished = false;
|
|
31
|
+
sync();
|
|
32
|
+
},
|
|
33
|
+
finish: () => {
|
|
34
|
+
finished = true;
|
|
35
|
+
sync();
|
|
36
|
+
},
|
|
37
|
+
setHidden: (value) => {
|
|
38
|
+
hidden = value;
|
|
39
|
+
sync();
|
|
40
|
+
},
|
|
41
|
+
setPaused: (value) => {
|
|
42
|
+
paused = value;
|
|
43
|
+
sync();
|
|
44
|
+
},
|
|
45
|
+
running: () => since !== null
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { createStopwatch };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=stopwatch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stopwatch.js","names":[],"sources":["../src/stopwatch.ts"],"sourcesContent":["// Active play time (game-contract.md, \"Saves\"): it starts at the input that starts the clock\n// (the first accepted one, or the engine's `startsClock` transition), pauses while the page is\n// hidden or the game is paused, and stops at the outcome.\nimport type { Clock } from './ports.ts';\n\nexport type Stopwatch = {\n /** Milliseconds of active play so far, rounded to a whole number. */\n elapsed(): number;\n /** Starts the clock. Later calls do nothing. */\n start(): void;\n /** True once `start` was called (or the game was restored with its clock started). */\n started(): boolean;\n /** A confirmed new game: clear elapsed time and wait for its first meaningful input. */\n restart(): void;\n /** Stops for good (the game is over). */\n finish(): void;\n setHidden(hidden: boolean): void;\n setPaused(paused: boolean): void;\n running(): boolean;\n};\n\nexport const createStopwatch = (\n clock: Clock,\n initial: {\n readonly elapsedMs: number;\n readonly started: boolean;\n readonly finished: boolean;\n readonly hidden: boolean;\n },\n): Stopwatch => {\n let banked = initial.elapsedMs;\n let since: number | null = null;\n let started = initial.started;\n let finished = initial.finished;\n let hidden = initial.hidden;\n let paused = false;\n\n const shouldRun = (): boolean => started && !finished && !hidden && !paused;\n const sync = (): void => {\n const run = shouldRun();\n if (run && since === null) since = clock.now();\n if (!run && since !== null) {\n banked += Math.max(0, clock.now() - since);\n since = null;\n }\n };\n sync();\n\n return {\n elapsed: () => Math.round(banked + (since === null ? 0 : Math.max(0, clock.now() - since))),\n start: () => {\n started = true;\n sync();\n },\n started: () => started,\n restart: () => {\n banked = 0;\n since = null;\n started = false;\n finished = false;\n sync();\n },\n finish: () => {\n finished = true;\n sync();\n },\n setHidden: (value) => {\n hidden = value;\n sync();\n },\n setPaused: (value) => {\n paused = value;\n sync();\n },\n running: () => since !== null,\n };\n};\n"],"mappings":";AAqBA,IAAa,mBACX,OACA,YAMc;CACd,IAAI,SAAS,QAAQ;CACrB,IAAI,QAAuB;CAC3B,IAAI,UAAU,QAAQ;CACtB,IAAI,WAAW,QAAQ;CACvB,IAAI,SAAS,QAAQ;CACrB,IAAI,SAAS;CAEb,MAAM,kBAA2B,WAAW,CAAC,YAAY,CAAC,UAAU,CAAC;CACrE,MAAM,aAAmB;EACvB,MAAM,MAAM,UAAU;EACtB,IAAI,OAAO,UAAU,MAAM,QAAQ,MAAM,IAAI;EAC7C,IAAI,CAAC,OAAO,UAAU,MAAM;GAC1B,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI,IAAI,KAAK;GACzC,QAAQ;EACV;CACF;CACA,KAAK;CAEL,OAAO;EACL,eAAe,KAAK,MAAM,UAAU,UAAU,OAAO,IAAI,KAAK,IAAI,GAAG,MAAM,IAAI,IAAI,KAAK,EAAE;EAC1F,aAAa;GACX,UAAU;GACV,KAAK;EACP;EACA,eAAe;EACf,eAAe;GACb,SAAS;GACT,QAAQ;GACR,UAAU;GACV,WAAW;GACX,KAAK;EACP;EACA,cAAc;GACZ,WAAW;GACX,KAAK;EACP;EACA,YAAY,UAAU;GACpB,SAAS;GACT,KAAK;EACP;EACA,YAAY,UAAU;GACpB,SAAS;GACT,KAAK;EACP;EACA,eAAe,UAAU;CAC3B;AACF"}
|
package/dist/values.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type GameError, type Json, type Result, type Validator } from '@joystyk/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Decodes `value` with a Standard Schema. Runners decode on their ordered input path, so the
|
|
4
|
+
* schema must validate synchronously; an asynchronous schema fails with `AsyncSchema`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const decode: <T>(schema: Validator<T>, value: unknown, what: string) => Result<T, GameError>;
|
|
7
|
+
/**
|
|
8
|
+
* Freezes plain objects and arrays all the way down, so an accidental mutation throws.
|
|
9
|
+
* Anything else (typed arrays, class instances in a simulation's view) is left alone:
|
|
10
|
+
* freezing a typed array with elements throws.
|
|
11
|
+
*/
|
|
12
|
+
export declare const deepFreeze: <T>(value: T) => T;
|
|
13
|
+
/** Deep equality for JSON values, ignoring key order. */
|
|
14
|
+
export declare const jsonEqual: (a: Json | undefined, b: Json | undefined) => boolean;
|
|
15
|
+
/** A deep copy through JSON, as a value would arrive from a worker or a socket. */
|
|
16
|
+
export declare const jsonClone: <T extends Json>(value: T) => T;
|
|
17
|
+
/** Builds an object without the keys whose value is undefined, so it stays JSON. */
|
|
18
|
+
export declare const compact: <T extends Record<string, unknown>>(value: T) => T;
|
|
19
|
+
export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
|
package/dist/values.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { err, gameError, ok } from "@joystyk/contracts";
|
|
2
|
+
//#region src/values.ts
|
|
3
|
+
/**
|
|
4
|
+
* Decodes `value` with a Standard Schema. Runners decode on their ordered input path, so the
|
|
5
|
+
* schema must validate synchronously; an asynchronous schema fails with `AsyncSchema`.
|
|
6
|
+
*/
|
|
7
|
+
var decode = (schema, value, what) => {
|
|
8
|
+
let result;
|
|
9
|
+
try {
|
|
10
|
+
result = schema["~standard"].validate(value);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return err(gameError("InvalidInput", `The ${what} could not be read.`, { reason: String(error) }));
|
|
13
|
+
}
|
|
14
|
+
if (result instanceof Promise) {
|
|
15
|
+
result.catch(() => {});
|
|
16
|
+
return err(gameError("AsyncSchema", `The ${what} schema must validate synchronously.`));
|
|
17
|
+
}
|
|
18
|
+
if (result.issues !== void 0) {
|
|
19
|
+
const messages = result.issues.map((issue) => issue.message);
|
|
20
|
+
return err(gameError("InvalidInput", `The ${what} is not valid.`, { issues: messages }));
|
|
21
|
+
}
|
|
22
|
+
return ok(result.value);
|
|
23
|
+
};
|
|
24
|
+
var isPlain = (value) => {
|
|
25
|
+
if (Array.isArray(value)) return true;
|
|
26
|
+
const proto = Object.getPrototypeOf(value);
|
|
27
|
+
return proto === Object.prototype || proto === null;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Freezes plain objects and arrays all the way down, so an accidental mutation throws.
|
|
31
|
+
* Anything else (typed arrays, class instances in a simulation's view) is left alone:
|
|
32
|
+
* freezing a typed array with elements throws.
|
|
33
|
+
*/
|
|
34
|
+
var deepFreeze = (value) => {
|
|
35
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value) || !isPlain(value)) return value;
|
|
36
|
+
Object.freeze(value);
|
|
37
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
/** Deep equality for JSON values, ignoring key order. */
|
|
41
|
+
var jsonEqual = (a, b) => {
|
|
42
|
+
if (a === b) return true;
|
|
43
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
44
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
45
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
46
|
+
return a.every((item, i) => jsonEqual(item, b[i]));
|
|
47
|
+
}
|
|
48
|
+
const ao = a;
|
|
49
|
+
const bo = b;
|
|
50
|
+
const keys = Object.keys(ao);
|
|
51
|
+
if (keys.length !== Object.keys(bo).length) return false;
|
|
52
|
+
return keys.every((key) => Object.hasOwn(bo, key) && jsonEqual(ao[key], bo[key]));
|
|
53
|
+
};
|
|
54
|
+
/** A deep copy through JSON, as a value would arrive from a worker or a socket. */
|
|
55
|
+
var jsonClone = (value) => JSON.parse(JSON.stringify(value));
|
|
56
|
+
/** Builds an object without the keys whose value is undefined, so it stays JSON. */
|
|
57
|
+
var compact = (value) => {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const [key, item] of Object.entries(value)) if (item !== void 0) out[key] = item;
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
//#endregion
|
|
64
|
+
export { compact, decode, deepFreeze, isRecord, jsonClone, jsonEqual };
|
|
65
|
+
|
|
66
|
+
//# sourceMappingURL=values.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"values.js","names":[],"sources":["../src/values.ts"],"sourcesContent":["// Small value helpers the runners share: schema decoding, deep freezing, JSON equality.\nimport { err, gameError, ok, type GameError, type Json, type Result, type Validator } from '@joystyk/contracts';\n\n/**\n * Decodes `value` with a Standard Schema. Runners decode on their ordered input path, so the\n * schema must validate synchronously; an asynchronous schema fails with `AsyncSchema`.\n */\nexport const decode = <T>(schema: Validator<T>, value: unknown, what: string): Result<T, GameError> => {\n let result: ReturnType<Validator<T>['~standard']['validate']>;\n try {\n result = schema['~standard'].validate(value);\n } catch (error) {\n return err(gameError('InvalidInput', `The ${what} could not be read.`, { reason: String(error) }));\n }\n if (result instanceof Promise) {\n // Keep an unhandled rejection from surfacing; the value is rejected either way.\n result.catch(() => {});\n return err(gameError('AsyncSchema', `The ${what} schema must validate synchronously.`));\n }\n if (result.issues !== undefined) {\n const messages = result.issues.map((issue) => issue.message);\n return err(gameError('InvalidInput', `The ${what} is not valid.`, { issues: messages }));\n }\n return ok(result.value);\n};\n\nconst isPlain = (value: object): boolean => {\n if (Array.isArray(value)) return true;\n const proto: unknown = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n};\n\n/**\n * Freezes plain objects and arrays all the way down, so an accidental mutation throws.\n * Anything else (typed arrays, class instances in a simulation's view) is left alone:\n * freezing a typed array with elements throws.\n */\nexport const deepFreeze = <T>(value: T): T => {\n if (typeof value !== 'object' || value === null || Object.isFrozen(value) || !isPlain(value)) return value;\n Object.freeze(value);\n for (const key of Object.keys(value)) deepFreeze((value as Record<string, unknown>)[key]);\n return value;\n};\n\n/** Deep equality for JSON values, ignoring key order. */\nexport const jsonEqual = (a: Json | undefined, b: Json | undefined): boolean => {\n if (a === b) return true;\n if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((item, i) => jsonEqual(item, (b as readonly Json[])[i]));\n }\n const ao = a as { readonly [key: string]: Json };\n const bo = b as { readonly [key: string]: Json };\n const keys = Object.keys(ao);\n if (keys.length !== Object.keys(bo).length) return false;\n return keys.every((key) => Object.hasOwn(bo, key) && jsonEqual(ao[key], bo[key]));\n};\n\n/** A deep copy through JSON, as a value would arrive from a worker or a socket. */\nexport const jsonClone = <T extends Json>(value: T): T => JSON.parse(JSON.stringify(value)) as T;\n\n/** Builds an object without the keys whose value is undefined, so it stays JSON. */\nexport const compact = <T extends Record<string, unknown>>(value: T): T => {\n const out: Record<string, unknown> = {};\n for (const [key, item] of Object.entries(value)) if (item !== undefined) out[key] = item;\n return out as T;\n};\n\nexport const isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n"],"mappings":";;;;;;AAOA,IAAa,UAAa,QAAsB,OAAgB,SAAuC;CACrG,IAAI;CACJ,IAAI;EACF,SAAS,OAAO,YAAY,CAAC,SAAS,KAAK;CAC7C,SAAS,OAAO;EACd,OAAO,IAAI,UAAU,gBAAgB,OAAO,KAAK,sBAAsB,EAAE,QAAQ,OAAO,KAAK,EAAE,CAAC,CAAC;CACnG;CACA,IAAI,kBAAkB,SAAS;EAE7B,OAAO,YAAY,CAAC,CAAC;EACrB,OAAO,IAAI,UAAU,eAAe,OAAO,KAAK,qCAAqC,CAAC;CACxF;CACA,IAAI,OAAO,WAAW,KAAA,GAAW;EAC/B,MAAM,WAAW,OAAO,OAAO,KAAK,UAAU,MAAM,OAAO;EAC3D,OAAO,IAAI,UAAU,gBAAgB,OAAO,KAAK,iBAAiB,EAAE,QAAQ,SAAS,CAAC,CAAC;CACzF;CACA,OAAO,GAAG,OAAO,KAAK;AACxB;AAEA,IAAM,WAAW,UAA2B;CAC1C,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO;CACjC,MAAM,QAAiB,OAAO,eAAe,KAAK;CAClD,OAAO,UAAU,OAAO,aAAa,UAAU;AACjD;;;;;;AAOA,IAAa,cAAiB,UAAgB;CAC5C,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,OAAO,SAAS,KAAK,KAAK,CAAC,QAAQ,KAAK,GAAG,OAAO;CACrG,OAAO,OAAO,KAAK;CACnB,KAAK,MAAM,OAAO,OAAO,KAAK,KAAK,GAAG,WAAY,MAAkC,IAAI;CACxF,OAAO;AACT;;AAGA,IAAa,aAAa,GAAqB,MAAiC;CAC9E,IAAI,MAAM,GAAG,OAAO;CACpB,IAAI,OAAO,MAAM,YAAY,OAAO,MAAM,YAAY,MAAM,QAAQ,MAAM,MAAM,OAAO;CACvF,IAAI,MAAM,QAAQ,CAAC,KAAK,MAAM,QAAQ,CAAC,GAAG;EACxC,IAAI,CAAC,MAAM,QAAQ,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,QAAQ,OAAO;EAC5E,OAAO,EAAE,OAAO,MAAM,MAAM,UAAU,MAAO,EAAsB,EAAE,CAAC;CACxE;CACA,MAAM,KAAK;CACX,MAAM,KAAK;CACX,MAAM,OAAO,OAAO,KAAK,EAAE;CAC3B,IAAI,KAAK,WAAW,OAAO,KAAK,EAAE,CAAC,CAAC,QAAQ,OAAO;CACnD,OAAO,KAAK,OAAO,QAAQ,OAAO,OAAO,IAAI,GAAG,KAAK,UAAU,GAAG,MAAM,GAAG,IAAI,CAAC;AAClF;;AAGA,IAAa,aAA6B,UAAgB,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;;AAG1F,IAAa,WAA8C,UAAgB;CACzE,MAAM,MAA+B,CAAC;CACtC,KAAK,MAAM,CAAC,KAAK,SAAS,OAAO,QAAQ,KAAK,GAAG,IAAI,SAAS,KAAA,GAAW,IAAI,OAAO;CACpF,OAAO;AACT;AAEA,IAAa,YAAY,UACvB,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK"}
|