@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/local.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { compact, decode, isRecord, jsonClone, jsonEqual } from "./values.js";
|
|
2
|
+
import { alwaysVisible } from "./ports.js";
|
|
3
|
+
import { createPublisher } from "./publisher.js";
|
|
4
|
+
import { advanceAi, aiDecisionSeed, aiRestore, defaultRestoreRng, deriveAiSeed } from "./rngs.js";
|
|
5
|
+
import { applySeatChange, parseEngineInput, rebuild, recordOf, safeApply } from "./replay.js";
|
|
6
|
+
import { isSeatInfo, loadSave } from "./saves.js";
|
|
7
|
+
import { createStopwatch } from "./stopwatch.js";
|
|
8
|
+
import { ASSIST_KINDS, err, gameError, isJson, ok, undoPolicyFor } from "@joystyk/contracts";
|
|
9
|
+
//#region src/local.ts
|
|
10
|
+
var notice = (code, message) => ({
|
|
11
|
+
code,
|
|
12
|
+
message
|
|
13
|
+
});
|
|
14
|
+
var UNDO_MESSAGES = {
|
|
15
|
+
UndoNotAllowed: "This game doesn't allow undo here.",
|
|
16
|
+
HistoryUnavailable: "Undo is unavailable for this saved game.",
|
|
17
|
+
GameOver: "The game is over.",
|
|
18
|
+
UndoLimit: "No undos left.",
|
|
19
|
+
NothingToUndo: "There is nothing to undo.",
|
|
20
|
+
UndoBarrier: "That move can't be undone.",
|
|
21
|
+
Paused: "The game is paused."
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The default AI runner: the engine's own `ai.choose`, after a delay on the clock, drawing
|
|
25
|
+
* from the task's stream. `restore` rebuilds it; default: the kit's `restoreRng`.
|
|
26
|
+
*/
|
|
27
|
+
var inlineAi = (ai, clock, delayMs, restore = defaultRestoreRng) => ({ choose: (task) => {
|
|
28
|
+
let cancel = () => {};
|
|
29
|
+
return {
|
|
30
|
+
result: new Promise((resolve, reject) => {
|
|
31
|
+
cancel = clock.after(delayMs, () => {
|
|
32
|
+
try {
|
|
33
|
+
resolve(ai.choose(task.view, task.seat, task.level, restore(task.rng)));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}),
|
|
39
|
+
cancel: () => cancel()
|
|
40
|
+
};
|
|
41
|
+
} });
|
|
42
|
+
/** The kinds in `ASSIST_KINDS` order, each once. */
|
|
43
|
+
var assistList = (used) => ASSIST_KINDS.filter((kind) => used.has(kind));
|
|
44
|
+
var checkSeats = (seats, levels) => {
|
|
45
|
+
if (seats.length === 0 || !seats.every((seat, i) => isSeatInfo(seat) && seat.id === i)) return gameError("InvalidSeats", "The seats must be numbered 0, 1, 2 and so on.");
|
|
46
|
+
const unknown = seats.find((seat) => seat.aiLevel !== void 0 && levels !== null && !levels.includes(seat.aiLevel));
|
|
47
|
+
return unknown === void 0 ? null : gameError("InvalidSeats", "An AI seat names a level the game lacks.", { seat: unknown.id });
|
|
48
|
+
};
|
|
49
|
+
/** Starts a new local game. Fails when the seats or config are invalid or `init` refuses them. */
|
|
50
|
+
var createLocalSession = (engine, options) => {
|
|
51
|
+
const seatProblem = checkSeats(options.seats, engine.ai?.levels ?? null);
|
|
52
|
+
if (seatProblem !== null) return err(seatProblem);
|
|
53
|
+
const config = decode(engine.schemas.config, options.config, "game setup");
|
|
54
|
+
if (!config.ok) return config;
|
|
55
|
+
const start = {
|
|
56
|
+
config: config.value,
|
|
57
|
+
seats: options.seats,
|
|
58
|
+
seed: options.seed
|
|
59
|
+
};
|
|
60
|
+
const built = rebuild(engine, start, [], options.restoreRng ?? defaultRestoreRng);
|
|
61
|
+
if (!built.ok) return built;
|
|
62
|
+
return ok(run(engine, options, {
|
|
63
|
+
state: built.value.state,
|
|
64
|
+
rng: built.value.rng,
|
|
65
|
+
seats: options.seats,
|
|
66
|
+
history: {
|
|
67
|
+
...start,
|
|
68
|
+
records: []
|
|
69
|
+
},
|
|
70
|
+
elapsedMs: 0,
|
|
71
|
+
started: false,
|
|
72
|
+
undosUsed: 0,
|
|
73
|
+
assists: [],
|
|
74
|
+
assistCounts: {},
|
|
75
|
+
aiRng: null
|
|
76
|
+
}));
|
|
77
|
+
};
|
|
78
|
+
var restoreHistory = (engine, envelope, state, restoreRng) => {
|
|
79
|
+
const lost = (why) => err(gameError("HistoryUnavailable", "The saved moves could not be replayed.", { why }));
|
|
80
|
+
const replay = envelope.replay;
|
|
81
|
+
if (!isRecord(replay)) return lost("no replay");
|
|
82
|
+
if (replay["layer"] !== 2 || replay["gameId"] !== envelope.gameId || replay["rulesVersion"] !== engine.rulesVersion) return lost("replay header");
|
|
83
|
+
const config = decode(engine.schemas.config, replay["config"], "saved setup");
|
|
84
|
+
if (!config.ok) return config;
|
|
85
|
+
const seats = replay["seats"];
|
|
86
|
+
if (!Array.isArray(seats) || checkSeats(seats, null) !== null) return lost("replay seats");
|
|
87
|
+
const seed = replay["seed"];
|
|
88
|
+
if (!isRecord(seed) || typeof seed["algorithm"] !== "string" || !Array.isArray(seed["words"])) return lost("replay seed");
|
|
89
|
+
const rawInputs = replay["inputs"];
|
|
90
|
+
if (!Array.isArray(rawInputs)) return lost("replay inputs");
|
|
91
|
+
const inputs = [];
|
|
92
|
+
for (const raw of rawInputs) {
|
|
93
|
+
const input = parseEngineInput(raw, engine.schemas.command);
|
|
94
|
+
if (!input.ok) return input;
|
|
95
|
+
inputs.push(input.value);
|
|
96
|
+
}
|
|
97
|
+
const start = {
|
|
98
|
+
config: config.value,
|
|
99
|
+
seats,
|
|
100
|
+
seed
|
|
101
|
+
};
|
|
102
|
+
const rebuilt = rebuild(engine, start, inputs, restoreRng);
|
|
103
|
+
if (!rebuilt.ok) return rebuilt;
|
|
104
|
+
if (!jsonEqual(rebuilt.value.state, state) || !jsonEqual(rebuilt.value.rng.save(), envelope.rng)) return lost("replay diverged");
|
|
105
|
+
return ok({
|
|
106
|
+
...start,
|
|
107
|
+
records: [...rebuilt.value.records]
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Resumes a saved game. `save` is whatever storage returned; it is parsed, migrated and
|
|
112
|
+
* validated here. When the save's replay is missing or no longer matches, the game still
|
|
113
|
+
* resumes, without undo or outstanding jobs, and the reason goes to `report`.
|
|
114
|
+
*/
|
|
115
|
+
var restoreLocalSession = (engine, save, options) => {
|
|
116
|
+
const loaded = loadSave(save, options.manifest, engine.rulesVersion, options.migrations, engine.schemas.state);
|
|
117
|
+
if (!loaded.ok) return loaded;
|
|
118
|
+
const { envelope, state } = loaded.value;
|
|
119
|
+
const seatProblem = checkSeats(envelope.seats, engine.ai?.levels ?? null);
|
|
120
|
+
if (seatProblem !== null) return err(seatProblem);
|
|
121
|
+
const restoreRng = options.restoreRng ?? defaultRestoreRng;
|
|
122
|
+
const history = restoreHistory(engine, envelope, state, restoreRng);
|
|
123
|
+
if (!history.ok) options.report?.(history.error, "Restored a save without its history.");
|
|
124
|
+
const assists = new Set(envelope.assists ?? []);
|
|
125
|
+
const fullRecords = history.ok ? history.value.records : [];
|
|
126
|
+
const records = fullRecords.slice(Math.max(0, fullRecords.findLastIndex((record) => record.restartsGame)));
|
|
127
|
+
for (const record of records) if (record.assist !== null) assists.add(record.assist);
|
|
128
|
+
const replayCounts = {};
|
|
129
|
+
for (const record of records) if (record.assist !== null) replayCounts[record.assist] = (replayCounts[record.assist] ?? 0) + 1;
|
|
130
|
+
const assistCounts = {};
|
|
131
|
+
for (const kind of ASSIST_KINDS) {
|
|
132
|
+
const count = Math.max(envelope.assistCounts?.[kind] ?? 0, replayCounts[kind] ?? 0, kind === "undo" ? envelope.undosUsed ?? 0 : 0, assists.has(kind) ? 1 : 0);
|
|
133
|
+
if (count > 0) {
|
|
134
|
+
assistCounts[kind] = count;
|
|
135
|
+
assists.add(kind);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const clockInput = engine.clockStart === "transition" ? records.some((record) => record.startsClock) : records.length > 0;
|
|
139
|
+
const started = envelope.clockStarted ?? ((envelope.elapsedMs ?? 0) > 0 || clockInput);
|
|
140
|
+
return ok(run(engine, options, {
|
|
141
|
+
state,
|
|
142
|
+
rng: restoreRng(envelope.rng),
|
|
143
|
+
seats: envelope.seats,
|
|
144
|
+
history: history.ok ? history.value : null,
|
|
145
|
+
elapsedMs: envelope.elapsedMs ?? 0,
|
|
146
|
+
started,
|
|
147
|
+
undosUsed: envelope.undosUsed ?? 0,
|
|
148
|
+
assists: assistList(assists),
|
|
149
|
+
assistCounts,
|
|
150
|
+
aiRng: envelope.aiRng ?? null
|
|
151
|
+
}));
|
|
152
|
+
};
|
|
153
|
+
var outstandingJobs = (records) => {
|
|
154
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
155
|
+
for (const record of records) {
|
|
156
|
+
if (record.restartsGame) jobs.clear();
|
|
157
|
+
if (record.input.kind === "job") jobs.delete(record.input.jobId);
|
|
158
|
+
for (const job of record.jobs) jobs.set(job.id, job);
|
|
159
|
+
}
|
|
160
|
+
return jobs;
|
|
161
|
+
};
|
|
162
|
+
var FAILED = {
|
|
163
|
+
outcome: {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: "failed"
|
|
166
|
+
},
|
|
167
|
+
presentation: null
|
|
168
|
+
};
|
|
169
|
+
var parseJobResult = (raw, spec) => {
|
|
170
|
+
if (!isRecord(raw)) return FAILED;
|
|
171
|
+
if (raw["ok"] === false && (raw["reason"] === "timeout" || raw["reason"] === "failed")) return {
|
|
172
|
+
outcome: {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: raw["reason"]
|
|
175
|
+
},
|
|
176
|
+
presentation: null
|
|
177
|
+
};
|
|
178
|
+
if (raw["ok"] !== true) return FAILED;
|
|
179
|
+
const value = decode(spec.result, raw["value"], "job result");
|
|
180
|
+
if (!value.ok || !isJson(value.value)) return FAILED;
|
|
181
|
+
const presentation = raw["presentation"];
|
|
182
|
+
return {
|
|
183
|
+
outcome: {
|
|
184
|
+
ok: true,
|
|
185
|
+
value: value.value
|
|
186
|
+
},
|
|
187
|
+
presentation: presentation !== void 0 && isJson(presentation) ? presentation : null
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
var run = (engine, options, start) => {
|
|
191
|
+
const { manifest, mode, clock } = options;
|
|
192
|
+
const restoreRng = options.restoreRng ?? defaultRestoreRng;
|
|
193
|
+
const report = options.report ?? (() => {});
|
|
194
|
+
const visibility = options.visibility ?? alwaysVisible;
|
|
195
|
+
const handoffScreen = mode === "pass-and-play" && manifest.handoff;
|
|
196
|
+
let state = start.state;
|
|
197
|
+
let rng = start.rng;
|
|
198
|
+
let seats = start.seats;
|
|
199
|
+
let history = start.history;
|
|
200
|
+
let revision = 0;
|
|
201
|
+
let undosUsed = start.undosUsed;
|
|
202
|
+
const assists = new Set(start.assists);
|
|
203
|
+
const assistCounts = { ...start.assistCounts };
|
|
204
|
+
const undoPolicy = undoPolicyFor(manifest.undo, mode);
|
|
205
|
+
let currentNotice = null;
|
|
206
|
+
let presentation = null;
|
|
207
|
+
let paused = false;
|
|
208
|
+
let disposed = false;
|
|
209
|
+
let outstanding = history === null ? /* @__PURE__ */ new Map() : outstandingJobs(history.records);
|
|
210
|
+
const jobTasks = /* @__PURE__ */ new Map();
|
|
211
|
+
const aiTasks = /* @__PURE__ */ new Map();
|
|
212
|
+
const restoreAi = aiRestore(restoreRng);
|
|
213
|
+
let aiState = start.aiRng ?? options.aiSeed ?? deriveAiSeed(rng.save(), restoreRng);
|
|
214
|
+
if (manifest.ai?.heavy === true && options.ai === void 0 && engine.ai !== void 0) report(/* @__PURE__ */ new Error(`${manifest.id} declares heavy AI but no worker AI runner was given.`), "Running heavy AI on the main thread.");
|
|
215
|
+
const aiRunner = options.ai ?? (engine.ai === void 0 ? null : inlineAi(engine.ai, clock, options.aiDelayMs ?? 500, restoreAi));
|
|
216
|
+
const stopwatch = createStopwatch(clock, {
|
|
217
|
+
elapsedMs: start.elapsedMs,
|
|
218
|
+
started: start.started,
|
|
219
|
+
finished: engine.outcome(state) !== null,
|
|
220
|
+
hidden: visibility.hidden()
|
|
221
|
+
});
|
|
222
|
+
const isHuman = (seat) => seats[seat]?.controller === "human";
|
|
223
|
+
const firstHuman = () => seats.find((seat) => seat.controller === "human")?.id ?? null;
|
|
224
|
+
let viewer = mode === "pass-and-play" ? null : options.seat === void 0 ? firstHuman() : options.seat;
|
|
225
|
+
let handoff = null;
|
|
226
|
+
let wasHandoffRequired = handoffScreen;
|
|
227
|
+
/**
|
|
228
|
+
* Pass-and-play: the viewer follows the human seat to act, through the hand-off screen. At
|
|
229
|
+
* the outcome a hand-off game shows the spectator's view, so the final board reveals no
|
|
230
|
+
* hand to whoever holds the device next; an engine whose rules reveal hands at the end
|
|
231
|
+
* shows them in `viewFor(state, null)`.
|
|
232
|
+
*/
|
|
233
|
+
const updateViewer = () => {
|
|
234
|
+
if (mode !== "pass-and-play") return;
|
|
235
|
+
if (engine.outcome(state) !== null) {
|
|
236
|
+
handoff = null;
|
|
237
|
+
if (handoffScreen) viewer = null;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const humans = engine.pending(state).filter(isHuman);
|
|
241
|
+
const current = handoff ?? viewer;
|
|
242
|
+
const required = handoffScreen && engine.handoffRequired?.(state) !== false;
|
|
243
|
+
const enteringPrivate = required && !wasHandoffRequired;
|
|
244
|
+
wasHandoffRequired = required;
|
|
245
|
+
if (humans.length === 0) {
|
|
246
|
+
if (!required && handoff !== null) {
|
|
247
|
+
handoff = null;
|
|
248
|
+
viewer = firstHuman();
|
|
249
|
+
}
|
|
250
|
+
if (viewer === null && handoff === null && !handoffScreen) viewer = firstHuman();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (current !== null && humans.includes(current) && !enteringPrivate) {
|
|
254
|
+
if (!required && handoff !== null) {
|
|
255
|
+
viewer = current;
|
|
256
|
+
handoff = null;
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const after = current ?? -1;
|
|
261
|
+
const target = enteringPrivate ? Math.min(...humans) : [...humans].sort((a, b) => a - b).find((seat) => seat > after) ?? Math.min(...humans);
|
|
262
|
+
if (required) {
|
|
263
|
+
viewer = null;
|
|
264
|
+
handoff = target;
|
|
265
|
+
} else {
|
|
266
|
+
viewer = target;
|
|
267
|
+
handoff = null;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const undoPlan = () => {
|
|
271
|
+
const policy = undoPolicy;
|
|
272
|
+
if (policy === "none" || mode !== "pass-and-play" && seats.filter((seat) => seat.controller === "human").length > 1) return { block: "UndoNotAllowed" };
|
|
273
|
+
if (history === null) return { block: "HistoryUnavailable" };
|
|
274
|
+
if (engine.outcome(state) !== null) return { block: "GameOver" };
|
|
275
|
+
if (paused) return { block: "Paused" };
|
|
276
|
+
if (typeof policy === "object" && undosUsed >= policy.limited) return { block: "UndoLimit" };
|
|
277
|
+
const cut = history.records.findLastIndex((record) => record.input.kind === "command" && record.seatsBefore[record.input.seat]?.controller === "human");
|
|
278
|
+
if (cut < 0) return { block: "NothingToUndo" };
|
|
279
|
+
if (history.records.slice(cut).some((record) => record.barrier)) return { block: "UndoBarrier" };
|
|
280
|
+
return { cut };
|
|
281
|
+
};
|
|
282
|
+
const undoState = () => {
|
|
283
|
+
const plan = undoPlan();
|
|
284
|
+
const policy = undoPolicy;
|
|
285
|
+
const remaining = policy === "none" ? 0 : policy === "unlimited" ? null : Math.max(0, policy.limited - undosUsed);
|
|
286
|
+
return "cut" in plan ? {
|
|
287
|
+
available: true,
|
|
288
|
+
remaining,
|
|
289
|
+
blockedBy: null
|
|
290
|
+
} : {
|
|
291
|
+
available: false,
|
|
292
|
+
remaining,
|
|
293
|
+
blockedBy: plan.block
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
const redact = (beats) => beats.flatMap((beat) => {
|
|
297
|
+
const event = engine.eventFor(beat.event, viewer);
|
|
298
|
+
if (event === null) return [];
|
|
299
|
+
return [beat.state === void 0 ? { event } : {
|
|
300
|
+
event,
|
|
301
|
+
view: engine.viewFor(beat.state, viewer)
|
|
302
|
+
}];
|
|
303
|
+
});
|
|
304
|
+
const snapshot = (beats) => {
|
|
305
|
+
const outcome = engine.outcome(state);
|
|
306
|
+
const elapsedMs = stopwatch.elapsed();
|
|
307
|
+
return {
|
|
308
|
+
meta: {
|
|
309
|
+
connection: "local",
|
|
310
|
+
seat: viewer,
|
|
311
|
+
revision,
|
|
312
|
+
seats,
|
|
313
|
+
outcome: outcome === null ? null : {
|
|
314
|
+
...outcome,
|
|
315
|
+
elapsedMs,
|
|
316
|
+
undosUsed,
|
|
317
|
+
assists: assistList(assists),
|
|
318
|
+
assistCounts: { ...assistCounts }
|
|
319
|
+
},
|
|
320
|
+
safePoint: engine.safePoint?.(state) ?? outcome !== null,
|
|
321
|
+
notice: currentNotice,
|
|
322
|
+
undosUsed,
|
|
323
|
+
assists: assistList(assists),
|
|
324
|
+
assistCounts: { ...assistCounts }
|
|
325
|
+
},
|
|
326
|
+
view: engine.viewFor(state, viewer),
|
|
327
|
+
beats: redact(beats),
|
|
328
|
+
pending: engine.pending(state),
|
|
329
|
+
presentation,
|
|
330
|
+
mode,
|
|
331
|
+
handoff: handoff === null ? null : {
|
|
332
|
+
seat: handoff,
|
|
333
|
+
name: seats[handoff]?.name ?? ""
|
|
334
|
+
},
|
|
335
|
+
undo: undoState(),
|
|
336
|
+
thinking: [...aiTasks.keys()].sort((a, b) => a - b),
|
|
337
|
+
paused,
|
|
338
|
+
elapsedMs
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
const replay = () => history === null ? null : {
|
|
342
|
+
layer: 2,
|
|
343
|
+
gameId: manifest.id,
|
|
344
|
+
rulesVersion: engine.rulesVersion,
|
|
345
|
+
config: history.config,
|
|
346
|
+
seats: history.seats,
|
|
347
|
+
seed: history.seed,
|
|
348
|
+
inputs: history.records.map((record) => record.input)
|
|
349
|
+
};
|
|
350
|
+
const envelope = () => compact({
|
|
351
|
+
gameId: manifest.id,
|
|
352
|
+
build: manifest.versions.build,
|
|
353
|
+
rulesVersion: engine.rulesVersion,
|
|
354
|
+
saveVersion: manifest.versions.save,
|
|
355
|
+
rng: rng.save(),
|
|
356
|
+
seats,
|
|
357
|
+
state,
|
|
358
|
+
elapsedMs: stopwatch.elapsed(),
|
|
359
|
+
clockStarted: stopwatch.started(),
|
|
360
|
+
replay: replay() ?? void 0,
|
|
361
|
+
aiRng: aiState,
|
|
362
|
+
undosUsed,
|
|
363
|
+
assists: assistList(assists),
|
|
364
|
+
assistCounts: { ...assistCounts }
|
|
365
|
+
});
|
|
366
|
+
/** The engine's `worthSaving`; an engine that throws is reported, and the game is saved. */
|
|
367
|
+
const worthSaving = () => {
|
|
368
|
+
try {
|
|
369
|
+
return engine.worthSaving?.(state) ?? true;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
report(error, "The engine threw in worthSaving.");
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
let saving = Promise.resolve();
|
|
376
|
+
let slotMayHoldSave = true;
|
|
377
|
+
/**
|
|
378
|
+
* Queues a write of the current position; resolves once it has landed (or failed). A
|
|
379
|
+
* position the engine says isn't worth saving is never written, and a save it replaces
|
|
380
|
+
* (after an undo back to the start) is cleared, so the launcher offers no "Continue".
|
|
381
|
+
*/
|
|
382
|
+
const persist = () => {
|
|
383
|
+
const store = options.storage;
|
|
384
|
+
if (store === void 0) return Promise.resolve();
|
|
385
|
+
if (!worthSaving()) {
|
|
386
|
+
if (!slotMayHoldSave || store.clear === void 0) return saving;
|
|
387
|
+
slotMayHoldSave = false;
|
|
388
|
+
const clear = store.clear.bind(store);
|
|
389
|
+
saving = saving.then(clear).catch((error) => report(error, "Clearing an untouched save failed."));
|
|
390
|
+
return saving;
|
|
391
|
+
}
|
|
392
|
+
slotMayHoldSave = true;
|
|
393
|
+
const save = envelope();
|
|
394
|
+
saving = saving.then(() => store.write(save)).catch((error) => {
|
|
395
|
+
report(error, "Saving the game failed.");
|
|
396
|
+
enqueue(() => {
|
|
397
|
+
currentNotice = notice("SaveFailed", "The game couldn't be saved on this device.");
|
|
398
|
+
publisher.publish(snapshot([]));
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
return saving;
|
|
402
|
+
};
|
|
403
|
+
const queue = [];
|
|
404
|
+
let draining = false;
|
|
405
|
+
/** Every change runs here, one at a time, so a listener that dispatches never sees a torn state. */
|
|
406
|
+
const enqueue = (work) => {
|
|
407
|
+
if (disposed) return;
|
|
408
|
+
queue.push(work);
|
|
409
|
+
if (draining) return;
|
|
410
|
+
draining = true;
|
|
411
|
+
try {
|
|
412
|
+
while (queue.length > 0 && !disposed) {
|
|
413
|
+
const next = queue.shift();
|
|
414
|
+
try {
|
|
415
|
+
next();
|
|
416
|
+
} catch (error) {
|
|
417
|
+
report(error, "The local session failed to process an input.");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} finally {
|
|
421
|
+
draining = false;
|
|
422
|
+
queue.length = 0;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const reject = (problem) => {
|
|
426
|
+
currentNotice = {
|
|
427
|
+
code: problem.code,
|
|
428
|
+
message: problem.message
|
|
429
|
+
};
|
|
430
|
+
publisher.publish(snapshot([]));
|
|
431
|
+
};
|
|
432
|
+
/** Publishes the new position, then saves it, then starts the jobs it requested (rule 1). */
|
|
433
|
+
const settle = (beats) => {
|
|
434
|
+
updateViewer();
|
|
435
|
+
scheduleAi();
|
|
436
|
+
publisher.publish(snapshot(beats));
|
|
437
|
+
persist().then(() => enqueue(startJobs));
|
|
438
|
+
};
|
|
439
|
+
/** Applies one input. On success it publishes; on failure it returns the engine's error. */
|
|
440
|
+
const accept = (input, jobPresentation = null) => {
|
|
441
|
+
const applied = safeApply(engine, state, input, rng, restoreRng);
|
|
442
|
+
rng = applied.rng;
|
|
443
|
+
if ("thrown" in applied) report(applied.thrown, "The engine threw in apply.");
|
|
444
|
+
if (!applied.result.ok) return applied.result.error;
|
|
445
|
+
const transition = applied.result.value;
|
|
446
|
+
const record = recordOf(input, transition, seats);
|
|
447
|
+
if (record.restartsGame) {
|
|
448
|
+
cancelAi();
|
|
449
|
+
cancelJobs();
|
|
450
|
+
outstanding.clear();
|
|
451
|
+
stopwatch.restart();
|
|
452
|
+
undosUsed = 0;
|
|
453
|
+
assists.clear();
|
|
454
|
+
for (const key of ASSIST_KINDS) delete assistCounts[key];
|
|
455
|
+
}
|
|
456
|
+
history?.records.push(record);
|
|
457
|
+
if (record.assist !== null) {
|
|
458
|
+
assists.add(record.assist);
|
|
459
|
+
assistCounts[record.assist] = (assistCounts[record.assist] ?? 0) + 1;
|
|
460
|
+
}
|
|
461
|
+
seats = applySeatChange(seats, input);
|
|
462
|
+
state = transition.state;
|
|
463
|
+
if (input.kind === "job") outstanding.delete(input.jobId);
|
|
464
|
+
for (const job of transition.jobs ?? []) outstanding.set(job.id, job);
|
|
465
|
+
revision += 1;
|
|
466
|
+
currentNotice = null;
|
|
467
|
+
presentation = jobPresentation;
|
|
468
|
+
if (engine.clockStart !== "transition" || record.startsClock) stopwatch.start();
|
|
469
|
+
if (engine.outcome(state) !== null) stopwatch.finish();
|
|
470
|
+
settle(transition.beats);
|
|
471
|
+
return null;
|
|
472
|
+
};
|
|
473
|
+
const cancelAi = () => {
|
|
474
|
+
for (const task of aiTasks.values()) task.cancel();
|
|
475
|
+
aiTasks.clear();
|
|
476
|
+
};
|
|
477
|
+
/** Starts a choice for every pending AI seat whose decision changed; cancels the rest. */
|
|
478
|
+
const scheduleAi = () => {
|
|
479
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
480
|
+
if (!paused && !disposed && aiRunner !== null && engine.outcome(state) === null) for (const seat of engine.pending(state)) {
|
|
481
|
+
if (seats[seat]?.controller !== "ai") continue;
|
|
482
|
+
wanted.set(seat, JSON.stringify(engine.decisionKey?.(state, seat) ?? engine.viewFor(state, seat)));
|
|
483
|
+
}
|
|
484
|
+
for (const [seat, task] of aiTasks) {
|
|
485
|
+
if (wanted.get(seat) === task.key) continue;
|
|
486
|
+
task.cancel();
|
|
487
|
+
aiTasks.delete(seat);
|
|
488
|
+
}
|
|
489
|
+
for (const [seat, key] of wanted) if (!aiTasks.has(seat)) startAi(seat, key);
|
|
490
|
+
};
|
|
491
|
+
const aiFailed = (seat, error) => {
|
|
492
|
+
report(error, `The AI for seat ${seat} failed.`);
|
|
493
|
+
reject(notice("AiFailed", `${seats[seat]?.name ?? "The computer"} couldn't move.`));
|
|
494
|
+
};
|
|
495
|
+
const startAi = (seat, key) => {
|
|
496
|
+
if (aiRunner === null) return;
|
|
497
|
+
const view = jsonClone(engine.viewFor(state, seat));
|
|
498
|
+
const level = seats[seat]?.aiLevel ?? engine.ai?.levels[0] ?? "default";
|
|
499
|
+
const task = aiRunner.choose({
|
|
500
|
+
view,
|
|
501
|
+
seat,
|
|
502
|
+
level,
|
|
503
|
+
rng: aiDecisionSeed(aiState, seat, restoreAi)
|
|
504
|
+
});
|
|
505
|
+
const entry = {
|
|
506
|
+
key,
|
|
507
|
+
cancel: () => task.cancel()
|
|
508
|
+
};
|
|
509
|
+
aiTasks.set(seat, entry);
|
|
510
|
+
task.result.then((raw) => enqueue(() => {
|
|
511
|
+
if (aiTasks.get(seat) !== entry) return;
|
|
512
|
+
aiTasks.delete(seat);
|
|
513
|
+
const command = decode(engine.schemas.command, raw, "AI move");
|
|
514
|
+
if (!command.ok) return aiFailed(seat, command.error);
|
|
515
|
+
const before = aiState;
|
|
516
|
+
aiState = advanceAi(aiState, seats.length, restoreAi);
|
|
517
|
+
const problem = accept({
|
|
518
|
+
kind: "command",
|
|
519
|
+
seat,
|
|
520
|
+
command: command.value
|
|
521
|
+
});
|
|
522
|
+
if (problem !== null) {
|
|
523
|
+
aiState = before;
|
|
524
|
+
aiFailed(seat, problem);
|
|
525
|
+
}
|
|
526
|
+
}), (error) => enqueue(() => {
|
|
527
|
+
if (aiTasks.get(seat) !== entry) return;
|
|
528
|
+
aiTasks.delete(seat);
|
|
529
|
+
aiFailed(seat, error);
|
|
530
|
+
}));
|
|
531
|
+
};
|
|
532
|
+
const cancelJobs = () => {
|
|
533
|
+
for (const task of jobTasks.values()) task.cancel();
|
|
534
|
+
jobTasks.clear();
|
|
535
|
+
};
|
|
536
|
+
const startJobs = () => {
|
|
537
|
+
for (const job of outstanding.values()) if (!jobTasks.has(job.id)) launch(job);
|
|
538
|
+
};
|
|
539
|
+
const launch = (job) => {
|
|
540
|
+
let stopTimer = () => {};
|
|
541
|
+
let task = null;
|
|
542
|
+
const entry = { cancel: () => (stopTimer(), task?.cancel()) };
|
|
543
|
+
jobTasks.set(job.id, entry);
|
|
544
|
+
const finish = (result) => enqueue(() => {
|
|
545
|
+
if (jobTasks.get(job.id) !== entry) return;
|
|
546
|
+
jobTasks.delete(job.id);
|
|
547
|
+
stopTimer();
|
|
548
|
+
if (!outstanding.has(job.id)) return;
|
|
549
|
+
const problem = accept({
|
|
550
|
+
kind: "job",
|
|
551
|
+
jobId: job.id,
|
|
552
|
+
outcome: result.outcome
|
|
553
|
+
}, result.presentation);
|
|
554
|
+
if (problem !== null) {
|
|
555
|
+
outstanding.delete(job.id);
|
|
556
|
+
report(problem, `The engine rejected the result of job ${job.id}.`);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
const spec = engine.jobs?.[job.kind];
|
|
560
|
+
const payload = spec === void 0 ? null : decode(spec.payload, job.payload, "job payload");
|
|
561
|
+
if (spec === void 0 || spec.where === "server" || options.jobs === void 0 || payload === null || !payload.ok) {
|
|
562
|
+
report(/* @__PURE__ */ new Error(`Job ${job.id} (${job.kind}) can't run locally.`), "Job unavailable.");
|
|
563
|
+
Promise.resolve().then(() => finish(FAILED));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const running = options.jobs.run(job, spec);
|
|
567
|
+
task = running;
|
|
568
|
+
stopTimer = clock.after(spec.timeoutMs, () => {
|
|
569
|
+
running.cancel();
|
|
570
|
+
finish({
|
|
571
|
+
outcome: {
|
|
572
|
+
ok: false,
|
|
573
|
+
reason: "timeout"
|
|
574
|
+
},
|
|
575
|
+
presentation: null
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
running.result.then((raw) => finish(parseJobResult(raw, spec)), (error) => {
|
|
579
|
+
report(error, `Job ${job.id} failed.`);
|
|
580
|
+
finish(FAILED);
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
const gameOver = () => engine.outcome(state) !== null;
|
|
584
|
+
const undo = () => {
|
|
585
|
+
const plan = undoPlan();
|
|
586
|
+
if ("block" in plan) return reject(notice(plan.block, UNDO_MESSAGES[plan.block]));
|
|
587
|
+
if (history === null) return;
|
|
588
|
+
const inputs = history.records.slice(0, plan.cut).map((record) => record.input);
|
|
589
|
+
const rebuilt = rebuild(engine, history, inputs, restoreRng);
|
|
590
|
+
if (!rebuilt.ok) {
|
|
591
|
+
report(rebuilt.error, "Undo could not replay the game.");
|
|
592
|
+
return reject(notice("InternalError", "Undo failed. The game continues from here."));
|
|
593
|
+
}
|
|
594
|
+
cancelAi();
|
|
595
|
+
cancelJobs();
|
|
596
|
+
state = rebuilt.value.state;
|
|
597
|
+
rng = rebuilt.value.rng;
|
|
598
|
+
seats = rebuilt.value.seats;
|
|
599
|
+
history.records = [...rebuilt.value.records];
|
|
600
|
+
outstanding = outstandingJobs(history.records);
|
|
601
|
+
undosUsed += 1;
|
|
602
|
+
assists.add("undo");
|
|
603
|
+
assistCounts.undo = undosUsed;
|
|
604
|
+
revision += 1;
|
|
605
|
+
currentNotice = null;
|
|
606
|
+
presentation = null;
|
|
607
|
+
settle([]);
|
|
608
|
+
};
|
|
609
|
+
/**
|
|
610
|
+
* The seat a command comes from. Without a named seat it is the viewer's. A named seat must
|
|
611
|
+
* be a person at this device who is pending or has an explicitly authorized untimed side
|
|
612
|
+
* command: in pass-and-play without a hand-off screen, any such human seat, so simultaneous games such as War take both players' moves in
|
|
613
|
+
* either order; everywhere else, only the viewer's own seat (gp-o2c).
|
|
614
|
+
*/
|
|
615
|
+
const commandSeat = (named, command) => {
|
|
616
|
+
const watching = () => err(handoff === null ? notice("Spectator", "Watchers cannot move.") : notice("HandoffPending", "Pass the device first."));
|
|
617
|
+
if (named === void 0) return viewer === null ? watching() : ok(viewer);
|
|
618
|
+
const notHere = err(notice("InvalidSeat", "That seat isn't played on this device."));
|
|
619
|
+
if (typeof named !== "number" || seats[named] === void 0 || !isHuman(named)) return notHere;
|
|
620
|
+
if (mode !== "pass-and-play") return named === viewer ? ok(named) : notHere;
|
|
621
|
+
if (handoffScreen && named !== viewer) return viewer === null ? watching() : err(notice("HandoffPending", "Pass the device first."));
|
|
622
|
+
if (gameOver()) return err(notice("GameOver", "The game is over."));
|
|
623
|
+
if (!engine.pending(state).includes(named) && engine.sideCommand?.(state, named, command) !== true) return err(notice("NotYourTurn", `It isn't ${seats[named]?.name ?? "that seat"}'s turn.`));
|
|
624
|
+
return ok(named);
|
|
625
|
+
};
|
|
626
|
+
const handle = (raw) => {
|
|
627
|
+
const invalid = notice("InvalidInput", "That action isn't recognized.");
|
|
628
|
+
if (!isRecord(raw)) return reject(invalid);
|
|
629
|
+
const kind = raw["kind"];
|
|
630
|
+
if ((kind === "command" || kind === "resign" || kind === "seat" || kind === "undo") && paused) return reject(notice("Paused", "The game is paused."));
|
|
631
|
+
switch (kind) {
|
|
632
|
+
case "command": {
|
|
633
|
+
const command = decode(engine.schemas.command, raw["command"], "move");
|
|
634
|
+
if (!command.ok) return reject(command.error);
|
|
635
|
+
const seat = commandSeat(raw["seat"], command.value);
|
|
636
|
+
if (!seat.ok) return reject(seat.error);
|
|
637
|
+
const problem = accept({
|
|
638
|
+
kind: "command",
|
|
639
|
+
seat: seat.value,
|
|
640
|
+
command: command.value
|
|
641
|
+
});
|
|
642
|
+
return problem === null ? void 0 : reject(problem);
|
|
643
|
+
}
|
|
644
|
+
case "resign":
|
|
645
|
+
case "seat": {
|
|
646
|
+
const seat = raw["seat"] === void 0 && kind === "resign" ? viewer : raw["seat"];
|
|
647
|
+
if (typeof seat !== "number" || seats[seat] === void 0) return reject(notice("InvalidSeat", "Choose a seat first."));
|
|
648
|
+
if (gameOver()) return reject(notice("GameOver", "The game is over."));
|
|
649
|
+
let input;
|
|
650
|
+
if (kind === "resign") {
|
|
651
|
+
if (!isHuman(seat)) return reject(notice("InvalidSeat", "Only a player can resign."));
|
|
652
|
+
input = {
|
|
653
|
+
kind: "resign",
|
|
654
|
+
seat
|
|
655
|
+
};
|
|
656
|
+
} else {
|
|
657
|
+
const parsed = parseEngineInput({
|
|
658
|
+
kind: "seat",
|
|
659
|
+
seat,
|
|
660
|
+
change: raw["change"]
|
|
661
|
+
}, engine.schemas.command);
|
|
662
|
+
if (!parsed.ok) return reject(invalid);
|
|
663
|
+
input = parsed.value;
|
|
664
|
+
const change = parsed.value.change;
|
|
665
|
+
if (change.type === "controller" && change.aiLevel !== void 0 && engine.ai !== void 0 && !engine.ai.levels.includes(change.aiLevel)) return reject(notice("InvalidSeat", "The game doesn't have that AI level."));
|
|
666
|
+
}
|
|
667
|
+
const problem = accept(input);
|
|
668
|
+
return problem === null ? void 0 : reject(problem);
|
|
669
|
+
}
|
|
670
|
+
case "undo": return undo();
|
|
671
|
+
case "reveal":
|
|
672
|
+
if (handoff === null) return reject(notice("NoHandoff", "There is nothing to reveal."));
|
|
673
|
+
viewer = handoff;
|
|
674
|
+
handoff = null;
|
|
675
|
+
revision += 1;
|
|
676
|
+
currentNotice = null;
|
|
677
|
+
return publisher.publish(snapshot([]));
|
|
678
|
+
case "pause":
|
|
679
|
+
case "resume":
|
|
680
|
+
if (paused === (kind === "pause")) return;
|
|
681
|
+
paused = kind === "pause";
|
|
682
|
+
stopwatch.setPaused(paused);
|
|
683
|
+
scheduleAi();
|
|
684
|
+
publisher.publish(snapshot([]));
|
|
685
|
+
if (paused) persist();
|
|
686
|
+
return;
|
|
687
|
+
default: return reject(invalid);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
updateViewer();
|
|
691
|
+
scheduleAi();
|
|
692
|
+
const publisher = createPublisher(snapshot([]), {
|
|
693
|
+
freeze: options.freeze ?? true,
|
|
694
|
+
report
|
|
695
|
+
});
|
|
696
|
+
const stopVisibility = visibility.subscribe((hidden) => enqueue(() => {
|
|
697
|
+
stopwatch.setHidden(hidden);
|
|
698
|
+
if (hidden) persist();
|
|
699
|
+
}));
|
|
700
|
+
persist().then(() => enqueue(startJobs));
|
|
701
|
+
return {
|
|
702
|
+
get: () => publisher.get(),
|
|
703
|
+
subscribe: (listener) => publisher.subscribe(listener),
|
|
704
|
+
dispatch: (input) => enqueue(() => handle(input)),
|
|
705
|
+
dispose: () => {
|
|
706
|
+
if (disposed) return;
|
|
707
|
+
persist();
|
|
708
|
+
disposed = true;
|
|
709
|
+
queue.length = 0;
|
|
710
|
+
cancelAi();
|
|
711
|
+
cancelJobs();
|
|
712
|
+
stopVisibility();
|
|
713
|
+
publisher.close();
|
|
714
|
+
},
|
|
715
|
+
elapsedMs: () => stopwatch.elapsed(),
|
|
716
|
+
save: envelope,
|
|
717
|
+
replay
|
|
718
|
+
};
|
|
719
|
+
};
|
|
720
|
+
//#endregion
|
|
721
|
+
export { createLocalSession, inlineAi, restoreLocalSession };
|
|
722
|
+
|
|
723
|
+
//# sourceMappingURL=local.js.map
|