@joystyk/launcher 0.0.0 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +245 -2
  3. package/dist/browser/daily-prefetch.d.ts +8 -0
  4. package/dist/browser/daily-prefetch.js +32 -0
  5. package/dist/browser/daily-prefetch.js.map +1 -0
  6. package/dist/browser/index.d.ts +3 -0
  7. package/dist/browser/index.js +4 -0
  8. package/dist/browser/rooms.d.ts +2 -0
  9. package/dist/browser/rooms.js +49 -0
  10. package/dist/browser/rooms.js.map +1 -0
  11. package/dist/browser/services.d.ts +127 -0
  12. package/dist/browser/services.js +95 -0
  13. package/dist/browser/services.js.map +1 -0
  14. package/dist/browser/stage.d.ts +28 -0
  15. package/dist/browser/stage.js +1195 -0
  16. package/dist/browser/stage.js.map +1 -0
  17. package/dist/fixture/engine.d.ts +60 -0
  18. package/dist/fixture/engine.js +164 -0
  19. package/dist/fixture/engine.js.map +1 -0
  20. package/dist/fixture/index.d.ts +52 -0
  21. package/dist/fixture/index.js +63 -0
  22. package/dist/fixture/index.js.map +1 -0
  23. package/dist/fixture/module.d.ts +2 -0
  24. package/dist/fixture/module.js +135 -0
  25. package/dist/fixture/module.js.map +1 -0
  26. package/dist/fixture/probe.d.ts +10 -0
  27. package/dist/fixture/probe.js +19 -0
  28. package/dist/fixture/probe.js.map +1 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.js +4 -0
  31. package/dist/launcher.d.ts +190 -0
  32. package/dist/launcher.js +4614 -0
  33. package/dist/launcher.js.map +1 -0
  34. package/dist/model.d.ts +431 -0
  35. package/dist/model.js +566 -0
  36. package/dist/model.js.map +1 -0
  37. package/dist/report-problem.d.ts +39 -0
  38. package/dist/report-problem.js +359 -0
  39. package/dist/report-problem.js.map +1 -0
  40. package/dist/report.d.ts +18 -0
  41. package/dist/report.js +32 -0
  42. package/dist/report.js.map +1 -0
  43. package/dist/room-drivers.d.ts +26 -0
  44. package/dist/room-drivers.js +92 -0
  45. package/dist/room-drivers.js.map +1 -0
  46. package/dist/safe-point.d.ts +33 -0
  47. package/dist/safe-point.js +44 -0
  48. package/dist/safe-point.js.map +1 -0
  49. package/dist/save-slots.d.ts +6 -0
  50. package/dist/save-slots.js +18 -0
  51. package/dist/save-slots.js.map +1 -0
  52. package/dist/theme.d.ts +35 -0
  53. package/dist/theme.js +111 -0
  54. package/dist/theme.js.map +1 -0
  55. package/package.json +67 -11
@@ -0,0 +1,1195 @@
1
+ import { findLauncherServices } from "./services.js";
2
+ import { SAVE_REQUEST_EVENT } from "../safe-point.js";
3
+ import { pageRooms } from "./rooms.js";
4
+ import { accessGate, clockOptions } from "../model.js";
5
+ import { canvasTheme } from "../theme.js";
6
+ import { saveDescription } from "../save-slots.js";
7
+ import { restoreRng } from "@joystyk/kit/rng";
8
+ import { createGameAudio, watchGameAudio, workerAiRunner, workerJobRunner } from "@joystyk/session/browser";
9
+ import { OUTPUT_EVENT } from "@gyral/core";
10
+ import { err, gameError, ok, setupOptions } from "@joystyk/contracts";
11
+ import { creditDate, dailyWordStats, parsePuzzleId, shareText, utcDate } from "@joystyk/daily";
12
+ import { contentHashOf, createDailyClient, createDailyResults, nextReleaseLocal } from "@joystyk/daily/browser";
13
+ import { createFixedStepRunner, createLocalSession, createRemoteSession, restoreFixedStepRunner, restoreLocalSession } from "@joystyk/session";
14
+ import { DEFAULT_PREFERENCES, resolvePreferences } from "@joystyk/storage";
15
+ //#region src/browser/stage.ts
16
+ var STAGE_TAG = "jy-game-stage";
17
+ var STAGE_STYLES = `
18
+ :host { display: block; }
19
+ :host([hidden]) { display: none; }
20
+ .canvas {
21
+ display: block;
22
+ min-block-size: inherit;
23
+ border-radius: inherit;
24
+ background: var(--game-surface, transparent);
25
+ color: var(--game-ink, inherit);
26
+ color-scheme: inherit;
27
+ }
28
+ `;
29
+ var sheet = null;
30
+ var stageSheet = () => {
31
+ if (sheet === null) {
32
+ sheet = new CSSStyleSheet();
33
+ sheet.replaceSync(STAGE_STYLES);
34
+ }
35
+ return sheet;
36
+ };
37
+ var noRematch = () => null;
38
+ var noState = () => null;
39
+ var json = (value) => JSON.stringify(value);
40
+ /** Runs a synchronous Standard Schema; an async one is refused. */
41
+ var decodeSync = (schema, value) => {
42
+ const result = schema["~standard"].validate(value);
43
+ if (result instanceof Promise) return err(gameError("AsyncSchema", "The game setup could not be checked."));
44
+ return result.issues === void 0 ? ok(result.value) : err(gameError("InvalidSetup", "The game setup is not valid.", { issues: result.issues.map((i) => i.message) }));
45
+ };
46
+ var describeSave = (describe, state) => {
47
+ try {
48
+ return decodeSync(saveDescription, describe(state));
49
+ } catch {
50
+ return err(gameError("InvalidSaveSlot", "The saved game could not be grouped."));
51
+ }
52
+ };
53
+ /**
54
+ * A save slot per local mode, adapted to the runner's port. After the game ends, writes are
55
+ * dropped and the slot is cleared, so a finished game is never offered for resuming. The
56
+ * runner clears it too while the game isn't worth saving (the engine's `worthSaving`).
57
+ */
58
+ var saveSlot = (player, gameId, slot, groupOf, initialGroup = null) => {
59
+ let finished = false;
60
+ let group = initialGroup;
61
+ let chain = Promise.resolve();
62
+ return {
63
+ port: {
64
+ write: (save) => {
65
+ if (finished) return Promise.resolve();
66
+ const write = chain.then(async () => {
67
+ group = groupOf?.(save) ?? null;
68
+ if (group !== null) {
69
+ const grouped = await player.storage.saves.write(gameId, group, save);
70
+ if (!grouped.ok) throw new Error(grouped.error.message);
71
+ }
72
+ const written = await player.storage.saves.write(gameId, slot, save);
73
+ if (!written.ok) throw new Error(written.error.message);
74
+ });
75
+ chain = write.catch(() => {});
76
+ return write;
77
+ },
78
+ clear: () => {
79
+ if (finished) return Promise.resolve();
80
+ const cleared = chain.then(async () => {
81
+ if (group !== null) {
82
+ await player.storage.saves.delete(gameId, group);
83
+ group = null;
84
+ }
85
+ const deleted = await player.storage.saves.delete(gameId, slot);
86
+ if (!deleted.ok) throw new Error(deleted.error.message);
87
+ });
88
+ chain = cleared.catch(() => {});
89
+ return cleared;
90
+ }
91
+ },
92
+ clear: () => {
93
+ chain = chain.then(async () => {
94
+ if (group !== null) {
95
+ await player.storage.saves.delete(gameId, group);
96
+ group = null;
97
+ }
98
+ await player.storage.saves.delete(gameId, slot);
99
+ });
100
+ return chain;
101
+ },
102
+ finish: () => {
103
+ finished = true;
104
+ chain = chain.then(async () => {
105
+ if (group !== null) {
106
+ await player.storage.saves.delete(gameId, group);
107
+ group = null;
108
+ }
109
+ await player.storage.saves.delete(gameId, slot);
110
+ });
111
+ return chain;
112
+ }
113
+ };
114
+ };
115
+ var handoffOf = (snapshot) => {
116
+ const handoff = snapshot.handoff;
117
+ if (typeof handoff !== "object" || handoff === null) return null;
118
+ const { seat, name } = handoff;
119
+ return typeof seat === "number" && typeof name === "string" ? {
120
+ seat,
121
+ name
122
+ } : null;
123
+ };
124
+ /** What the frame needs from a snapshot, as plain data. */
125
+ var frameOf = (snapshot) => ({
126
+ seats: snapshot.meta.seats.map((seat) => ({ ...seat })),
127
+ handoff: handoffOf(snapshot),
128
+ outcome: snapshot.meta.outcome === null ? null : JSON.parse(JSON.stringify(snapshot.meta.outcome)),
129
+ safePoint: snapshot.meta.safePoint,
130
+ notice: snapshot.meta.notice?.message ?? null
131
+ });
132
+ /** The room's clock setting, as the lobby shows it. */
133
+ var clockOf = (settings) => settings.bank !== null ? {
134
+ kind: "bank",
135
+ initialMs: settings.bank.initialMs,
136
+ incrementMs: settings.bank.incrementMs
137
+ } : settings.decisionMs !== null ? {
138
+ kind: "move",
139
+ ms: settings.decisionMs
140
+ } : { kind: "off" };
141
+ var seatName = (seats, seat) => seats.find((s) => s.id === seat)?.name ?? `Seat ${String(seat + 1)}`;
142
+ /**
143
+ * The decision clock that is running, counted down from when the room reported it; or, on a
144
+ * paused table (D-58), the clock that waits, holding its time.
145
+ */
146
+ var timerOf = (snapshot, seats, now) => {
147
+ const room = snapshot.room;
148
+ const clocks = snapshot.meta.clocks ?? [];
149
+ const shown = clocks.find((clock) => clock.running) ?? clocks.find((clock) => clock.paused === true);
150
+ if (room === null || shown === void 0 || room.phase !== "playing") return null;
151
+ const left = shown.running ? Math.max(0, shown.remainingMs - (now - room.clocksReadAt)) : shown.remainingMs;
152
+ return {
153
+ seat: shown.seat,
154
+ name: seatName(seats, shown.seat),
155
+ seconds: Math.ceil(left / 1e3),
156
+ paused: !shown.running
157
+ };
158
+ };
159
+ /** The room's offer, with the players it waits for and an end offer's seconds left. */
160
+ var offerOf = (snapshot, seats, now) => {
161
+ const room = snapshot.room;
162
+ const offer = room?.offer ?? null;
163
+ if (room === null || offer === null) return null;
164
+ const voters = seats.filter((s) => s.claimed && s.controller === "human" && (offer.kind === "rematch" || s.id !== offer.by && s.connected));
165
+ const elapsed = room.paused ? 0 : Math.max(0, now - room.clocksReadAt);
166
+ return {
167
+ id: offer.id,
168
+ kind: offer.kind,
169
+ by: offer.by,
170
+ byName: seatName(seats, offer.by),
171
+ status: offer.status,
172
+ accepted: [...offer.accepted],
173
+ declinedBy: offer.declinedBy,
174
+ declinedName: offer.declinedBy === null ? null : seatName(seats, offer.declinedBy),
175
+ waiting: offer.status === "open" ? voters.filter((s) => !offer.accepted.includes(s.id)).map((s) => s.name) : [],
176
+ seconds: offer.endsInMs === null ? null : Math.ceil(Math.max(0, offer.endsInMs - elapsed) / 1e3)
177
+ };
178
+ };
179
+ /** What the lobby and the bar need from a room snapshot, as plain data. */
180
+ var roomFrameOf = (snapshot, context) => {
181
+ const room = snapshot.room;
182
+ const seats = (room?.seats ?? []).map((seat) => ({
183
+ id: seat.id,
184
+ name: seat.name,
185
+ controller: seat.controller,
186
+ aiLevel: seat.aiLevel,
187
+ claimed: seat.claimed,
188
+ ready: seat.ready,
189
+ connected: room?.connected[seat.id] ?? false
190
+ }));
191
+ const { manifest } = context;
192
+ return {
193
+ code: context.code,
194
+ link: context.link,
195
+ phase: room?.phase ?? (snapshot.meta.connection === "closed" ? "closed" : "connecting"),
196
+ connection: snapshot.meta.connection === "local" ? "online" : snapshot.meta.connection,
197
+ seat: snapshot.meta.seat,
198
+ host: room?.host ?? null,
199
+ seats,
200
+ minSeats: Math.max(2, manifest.players.min),
201
+ clock: room === null ? { kind: "off" } : clockOf(room.settings),
202
+ clockOptions: clockOptions(manifest.clock),
203
+ aiLevel: room?.settings.aiLevel ?? null,
204
+ aiLevels: manifest.ai === null ? [] : [...manifest.ai.levels],
205
+ spectators: room?.spectators ?? 0,
206
+ timer: timerOf(snapshot, seats, context.now),
207
+ notice: snapshot.meta.notice?.message ?? null,
208
+ noticeCode: snapshot.meta.notice?.code ?? null,
209
+ match: room?.match ?? 0,
210
+ offer: offerOf(snapshot, seats, context.now)
211
+ };
212
+ };
213
+ /** A finished game's result as a daily result records it. */
214
+ var dailyOutcome = (result) => result === "won" ? "won" : result === "lost" || result === "abandoned" ? "lost" : "completed";
215
+ /** The game page's address for share text: no query string or fragment (a room's code, say). */
216
+ var pageUrl = () => {
217
+ const url = `${location.origin}${location.pathname}`;
218
+ return /^https?:\/\/[^\s?#]+$/.test(url) ? url : void 0;
219
+ };
220
+ /** The results panel's daily part: share text in both forms, streaks and the next release. */
221
+ var dailyFrame = (title, result, streaks, headline, now, earlier, statistics) => {
222
+ const date = creditDate(result);
223
+ const rows = result.shareRows.map((symbols, i) => ({
224
+ symbols,
225
+ words: result.shareWords?.[i] ?? `Row ${String(i + 1)}`,
226
+ ...result.shareDetails?.[i] == null ? {} : { detail: result.shareDetails[i] }
227
+ }));
228
+ const url = pageUrl();
229
+ const input = {
230
+ title,
231
+ date,
232
+ headline,
233
+ rows,
234
+ ...url === void 0 ? {} : { url }
235
+ };
236
+ let share = "";
237
+ let shareWords = "";
238
+ try {
239
+ share = shareText(input, "symbols");
240
+ shareWords = shareText(input, "words");
241
+ } catch {}
242
+ const next = nextReleaseLocal({ now });
243
+ const canLose = result.outcome !== "completed";
244
+ return {
245
+ date,
246
+ ...statistics === void 0 ? {} : { statistics },
247
+ share,
248
+ shareWords,
249
+ streak: streaks?.current ?? 0,
250
+ bestStreak: streaks?.best ?? 0,
251
+ winStreak: canLose ? streaks?.winCurrent ?? 0 : null,
252
+ bestWinStreak: canLose ? streaks?.winBest ?? 0 : null,
253
+ next: `New puzzle at ${next.time} (${next.countdown})`,
254
+ earlier: earlier ? {
255
+ result: result.outcome,
256
+ summary: result.summary
257
+ } : null
258
+ };
259
+ };
260
+ /** One game on one stage, from loading to disposal. */
261
+ var run = (host, canvas, first) => {
262
+ let control = first;
263
+ let disposed = false;
264
+ const preparations = /* @__PURE__ */ new Set();
265
+ let loaded = null;
266
+ let begun = false;
267
+ let running = null;
268
+ let mounted = null;
269
+ let stopAudio = null;
270
+ let slot = null;
271
+ let paused = false;
272
+ let reveals = control.reveals;
273
+ let lastPreferences = "";
274
+ let lastFrame = "";
275
+ let finished = false;
276
+ const stops = [];
277
+ const services = findLauncherServices(host);
278
+ let platform = null;
279
+ let saveChoices = [];
280
+ /** Online with friends: the room, once open. */
281
+ let remote = null;
282
+ const emit = (out) => {
283
+ if (!disposed) host.dispatchEvent(new CustomEvent(OUTPUT_EVENT, {
284
+ detail: out,
285
+ bubbles: true
286
+ }));
287
+ };
288
+ const fail = (message, error) => {
289
+ if (error !== void 0) services?.ports.report(error, message);
290
+ emit({
291
+ _tag: "Failed",
292
+ message
293
+ });
294
+ };
295
+ const prepare = async (svc) => {
296
+ const loader = svc.games[control.gameId];
297
+ if (loader === void 0) return fail("This game isn't available here yet.");
298
+ const entry = await loader.entry();
299
+ if (disposed) return;
300
+ const { manifest } = entry;
301
+ if (manifest.id !== control.gameId || !manifest.modes.includes(control.mode)) return fail("This game can't be played in that mode.");
302
+ if (control.mode === "server" && (control.room === null || manifest.players.max < 2)) return fail("This game can't be played online here.");
303
+ if (accessGate(manifest.access) !== "open") return fail("This game needs a pass to play.");
304
+ const [module, player] = await Promise.all([entry.load(), svc.openPlayer()]);
305
+ if (disposed) return;
306
+ const stored = await player.storage.preferences.get();
307
+ const preferences = stored.ok ? stored.value : DEFAULT_PREFERENCES;
308
+ loaded = {
309
+ loader,
310
+ entry,
311
+ module,
312
+ player,
313
+ preferences,
314
+ save: await findSave(entry, module, player)
315
+ };
316
+ if (disposed) return;
317
+ lastPreferences = json(preferences);
318
+ stops.push(player.storage.preferences.subscribe((next, source) => {
319
+ if (source !== "other-tab" || json(next) === lastPreferences) return;
320
+ lastPreferences = json(next);
321
+ emit({
322
+ _tag: "Preferences",
323
+ preferences: next
324
+ });
325
+ }));
326
+ const save = loaded.save;
327
+ emit({
328
+ _tag: "Prepared",
329
+ preferences,
330
+ save: save === null ? null : {
331
+ savedAt: save.savedAt,
332
+ elapsedMs: save.envelope.elapsedMs ?? 0,
333
+ players: save.envelope.seats.map((seat) => seat.name),
334
+ ...saveChoices.length === 0 ? {} : {
335
+ slot: save.slot,
336
+ choices: saveChoices
337
+ }
338
+ }
339
+ });
340
+ sync();
341
+ };
342
+ /** The saved game in this mode's slot, if it can still be played on. Others are cleared. */
343
+ const findSave = async (entry, module, player, requested = control.mode, one = false) => {
344
+ const { manifest } = entry;
345
+ if (control.mode === "server") return null;
346
+ const rules = module.engine ?? module.simulation;
347
+ if (!one && module.engine?.saveSlot !== void 0) {
348
+ const listed = await player.storage.saves.list(manifest.id);
349
+ if (!listed.ok) return null;
350
+ const groups = /* @__PURE__ */ new Map();
351
+ for (const saved of listed.value) {
352
+ if (saved.slot !== control.mode && !saved.slot.startsWith(`${control.mode}-`)) continue;
353
+ const checked = await findSave(entry, module, player, saved.slot, true);
354
+ if (checked === null) continue;
355
+ const description = describeSave(module.engine.saveSlot, checked.envelope.state);
356
+ if (!description.ok) continue;
357
+ const key = `${control.mode}-${description.value.key}`;
358
+ if (saved.slot !== control.mode && saved.slot !== key) continue;
359
+ const old = groups.get(key);
360
+ if (old === void 0 || checked.savedAt > old.save.savedAt) groups.set(key, {
361
+ save: checked,
362
+ label: description.value.label
363
+ });
364
+ }
365
+ const offered = [];
366
+ for (const [key, group] of groups) {
367
+ if (group.save.slot !== key) {
368
+ if (!(await player.storage.saves.write(manifest.id, key, group.save.envelope)).ok) continue;
369
+ }
370
+ offered.push({
371
+ ...group,
372
+ save: {
373
+ ...group.save,
374
+ slot: key
375
+ }
376
+ });
377
+ }
378
+ offered.sort((a, b) => b.save.savedAt - a.save.savedAt || a.save.slot.localeCompare(b.save.slot));
379
+ saveChoices = offered.map(({ save, label }) => ({
380
+ slot: save.slot,
381
+ label,
382
+ savedAt: save.savedAt,
383
+ elapsedMs: save.envelope.elapsedMs ?? 0,
384
+ players: save.envelope.seats.map((seat) => seat.name)
385
+ }));
386
+ return offered[0]?.save ?? null;
387
+ }
388
+ const found = rules === void 0 ? await player.storage.saves.read(manifest.id, requested) : await player.storage.saves.load(manifest.id, requested, {
389
+ saveVersion: manifest.versions.save,
390
+ migrations: module.migrations,
391
+ state: rules.schemas.state
392
+ });
393
+ if (!found.ok || found.value === null) return null;
394
+ const save = found.value;
395
+ let over = false;
396
+ if (rules !== void 0) {
397
+ if (save.envelope.rulesVersion !== rules.rulesVersion) {
398
+ emit({
399
+ _tag: "Notice",
400
+ message: "Your saved game was played under older rules, so it was set aside. A new game starts instead."
401
+ });
402
+ over = true;
403
+ } else try {
404
+ over = (module.engine?.outcome(save.envelope.state) ?? module.simulation?.outcome(save.envelope.state) ?? null) !== null;
405
+ over ||= module.engine?.worthSaving?.(save.envelope.state) === false;
406
+ } catch (error) {
407
+ services?.ports.report(error, "Checking a saved game failed.");
408
+ over = true;
409
+ }
410
+ }
411
+ if (!over) return save;
412
+ await player.storage.saves.delete(manifest.id, requested);
413
+ return null;
414
+ };
415
+ const seatsWithNames = (player) => control.seats.map((seat) => seat.controller === "human" && seat.name === "" ? {
416
+ ...seat,
417
+ name: player.displayName
418
+ } : seat);
419
+ /**
420
+ * The engine's Config: a rematch's, the preparer's output, or the setup itself (empty
421
+ * until setup screens exist, so a game's config schema supplies its defaults).
422
+ */
423
+ const configFor = async (svc, entry) => {
424
+ if (control.config !== null) return ok(control.config);
425
+ const raw = control.setup ?? {};
426
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return err(gameError("InvalidSetup", "Choose valid game options."));
427
+ const selected = raw;
428
+ const { level: aiLevel, ...options } = selected;
429
+ if (control.mode === "ai" && entry.manifest.players.max === 1 && aiLevel !== void 0 && (typeof aiLevel !== "string" || !entry.manifest.ai?.levels.includes(aiLevel))) return err(gameError("InvalidSetup", "Choose a valid computer level."));
430
+ const decodedSetup = decodeSync(setupOptions(entry.manifest.options ?? [], control.mode), control.mode === "ai" && entry.manifest.players.max === 1 ? options : selected);
431
+ if (!decodedSetup.ok) return decodedSetup;
432
+ const setup = control.mode === "ai" && entry.manifest.players.max === 1 ? {
433
+ ...decodedSetup.value,
434
+ level: aiLevel ?? entry.manifest.ai?.levels[0] ?? "medium"
435
+ } : decodedSetup.value;
436
+ if (loaded?.module.configFromSetup !== void 0) return loaded.module.configFromSetup(setup, control.mode);
437
+ if (entry.loadAsyncPreparer === void 0 && entry.loadPreparer === void 0) return ok(setup);
438
+ const execution = entry.loadAsyncPreparer === void 0 ? "main" : "worker";
439
+ const started = performance.now();
440
+ try {
441
+ if (entry.loadAsyncPreparer !== void 0) {
442
+ const bridge = await entry.loadAsyncPreparer();
443
+ if (disposed) {
444
+ bridge.dispose();
445
+ return err(gameError("Disposed", "The game was closed."));
446
+ }
447
+ preparations.add(bridge);
448
+ try {
449
+ const decoded = decodeSync(bridge.setup, setup);
450
+ return decoded.ok ? await bridge.prepare(decoded.value, restoreRng(svc.newSeed())) : decoded;
451
+ } finally {
452
+ bridge.dispose();
453
+ preparations.delete(bridge);
454
+ }
455
+ }
456
+ const preparer = await entry.loadPreparer();
457
+ const decoded = decodeSync(preparer.setup, setup);
458
+ return decoded.ok ? preparer.prepare(decoded.value, restoreRng(svc.newSeed())) : decoded;
459
+ } finally {
460
+ const duration = performance.now() - started;
461
+ const name = `joystyk:prepare:${entry.manifest.id}:${execution}`;
462
+ performance.clearMeasures(name);
463
+ performance.measure(name, {
464
+ start: started,
465
+ duration
466
+ });
467
+ }
468
+ };
469
+ const start = async (svc, game, how) => {
470
+ const mode = control.mode;
471
+ if (mode === "server") return startRoom(svc, game);
472
+ const { entry, player, loader } = game;
473
+ const { manifest } = entry;
474
+ let resume = how === "resume" ? game.save : null;
475
+ if (how === "resume" && control.resumeSlot !== void 0) {
476
+ if (!saveChoices.some((choice) => choice.slot === control.resumeSlot)) return fail("That saved game is no longer available.");
477
+ resume = await findSave(entry, game.module, player, control.resumeSlot, true);
478
+ if (resume === null) return fail("That saved game could not be resumed.");
479
+ }
480
+ const groupOf = game.module.engine?.saveSlot === void 0 ? void 0 : (save) => {
481
+ const described = describeSave(game.module.engine.saveSlot, save.state);
482
+ if (!described.ok) {
483
+ svc.ports.report(described.error, "The engine supplied an invalid save slot.");
484
+ return null;
485
+ }
486
+ return `${mode}-${described.value.key}`;
487
+ };
488
+ const saves = saveSlot(player, manifest.id, mode, groupOf, resume !== null && game.module.engine?.saveSlot !== void 0 ? resume.slot : null);
489
+ const write = saves.port.write;
490
+ saves.port.write = (save) => write(mode === "daily" && daily !== null ? {
491
+ ...save,
492
+ daily: {
493
+ puzzleId: daily.puzzleId,
494
+ contentHash: daily.contentHash
495
+ }
496
+ } : save);
497
+ slot = saves;
498
+ let given = null;
499
+ if (mode === "daily") {
500
+ if (resume !== null) await keepDaily(svc, game, resume);
501
+ else {
502
+ const today = await openDaily(svc, game);
503
+ if (today === null || disposed) return;
504
+ given = today;
505
+ }
506
+ }
507
+ if (resume === null && game.save !== null) await saves.clear();
508
+ if (disposed) return;
509
+ const seats = seatsWithNames(player);
510
+ const created = await createSession(svc, game, mode, seats, resume, loader, given);
511
+ if (disposed) {
512
+ if (created.ok) created.value.session.dispose();
513
+ return;
514
+ }
515
+ if (!created.ok) {
516
+ if (resume === null) return fail(created.error.message, created.error);
517
+ emit({
518
+ _tag: "Notice",
519
+ message: `${created.error.message} A new game starts instead.`
520
+ });
521
+ await saves.clear();
522
+ game.save = null;
523
+ return start(svc, game, "new");
524
+ }
525
+ running = created.value;
526
+ mount(svc, game, created.value.session);
527
+ };
528
+ const createSession = async (svc, game, mode, seats, resume, loader, given = null) => {
529
+ const { entry, module } = game;
530
+ const { manifest } = entry;
531
+ const storage = slot?.port;
532
+ const common = {
533
+ manifest,
534
+ report: svc.ports.report,
535
+ visibility: svc.ports.visibility,
536
+ ...storage === void 0 ? {} : { storage },
537
+ freeze: svc.freeze ?? true
538
+ };
539
+ if (module.engine !== void 0) {
540
+ const heavy = manifest.ai?.heavy === true && loader.aiWorker !== void 0;
541
+ const local = {
542
+ ...common,
543
+ mode,
544
+ clock: svc.ports.clock,
545
+ aiSeed: svc.newSeed(),
546
+ aiDelayMs: svc.aiDelayMs ?? 500,
547
+ ...heavy && loader.aiWorker !== void 0 ? { ai: workerAiRunner(loader.aiWorker) } : {},
548
+ ...loader.jobWorker === void 0 ? {} : { jobs: workerJobRunner(loader.jobWorker) }
549
+ };
550
+ const engine = module.engine;
551
+ const wrap = (session) => ({
552
+ session,
553
+ inputs: (input) => session.dispatch(input),
554
+ state: () => session.save().state,
555
+ checkpoint: () => session.save(),
556
+ rematch: () => {
557
+ const replay = session.replay();
558
+ if (engine.rematch === void 0 || replay === null) return null;
559
+ try {
560
+ return engine.rematch(replay.config, session.save().state);
561
+ } catch (error) {
562
+ svc.ports.report(error, "The engine failed to name a rematch config.");
563
+ return null;
564
+ }
565
+ }
566
+ });
567
+ if (resume !== null) {
568
+ const restored = restoreLocalSession(engine, resume.envelope, {
569
+ ...local,
570
+ migrations: module.migrations
571
+ });
572
+ return restored.ok ? ok(wrap(restored.value)) : restored;
573
+ }
574
+ const choices = given === null ? null : decodeSync(setupOptions(manifest.options ?? [], mode), control.setup ?? {});
575
+ if (choices !== null && !choices.ok) return choices;
576
+ const config = given === null ? await configFor(svc, entry) : module.configFromSetup !== void 0 && choices?.ok ? module.configFromSetup(choices.value, mode, given) : ok(given);
577
+ if (!config.ok) return config;
578
+ const created = createLocalSession(engine, {
579
+ ...local,
580
+ seats,
581
+ config: config.value,
582
+ seed: svc.newSeed()
583
+ });
584
+ return created.ok ? ok(wrap(created.value)) : created;
585
+ }
586
+ if (module.simulation !== void 0) {
587
+ const fixed = {
588
+ ...common,
589
+ frames: svc.ports.frames
590
+ };
591
+ const pausable = (runner) => (input) => {
592
+ if (input.kind !== "reveal") runner.dispatch({ kind: input.kind });
593
+ };
594
+ if (resume !== null) {
595
+ const restored = restoreFixedStepRunner(module.simulation, resume.envelope, {
596
+ ...fixed,
597
+ startPaused: true,
598
+ migrations: module.migrations
599
+ });
600
+ return restored.ok ? ok({
601
+ session: restored.value,
602
+ inputs: pausable(restored.value),
603
+ rematch: noRematch,
604
+ state: () => restored.value.save().state,
605
+ checkpoint: () => restored.value.save()
606
+ }) : restored;
607
+ }
608
+ const config = given === null ? await configFor(svc, entry) : ok(given);
609
+ if (!config.ok) return config;
610
+ const created = createFixedStepRunner(module.simulation, {
611
+ ...fixed,
612
+ seats,
613
+ config: config.value,
614
+ seed: svc.newSeed()
615
+ });
616
+ return created.ok ? ok({
617
+ session: created.value,
618
+ inputs: pausable(created.value),
619
+ rematch: noRematch,
620
+ state: () => created.value.save().state,
621
+ checkpoint: () => created.value.save()
622
+ }) : created;
623
+ }
624
+ if (module.createSession !== void 0) {
625
+ const config = await configFor(svc, entry);
626
+ if (!config.ok) return config;
627
+ try {
628
+ const session = module.createSession({
629
+ mode,
630
+ seats,
631
+ seat: seats.find((seat) => seat.controller === "human")?.id ?? null,
632
+ config: config.value,
633
+ seed: svc.newSeed(),
634
+ save: resume?.envelope ?? null
635
+ });
636
+ return ok({
637
+ session,
638
+ inputs: null,
639
+ rematch: noRematch,
640
+ state: noState,
641
+ checkpoint: null
642
+ });
643
+ } catch (error) {
644
+ return err(gameError("SessionFailed", "The game couldn't start.", { error: String(error) }));
645
+ }
646
+ }
647
+ return err(gameError("NoSession", "This game can't be played yet."));
648
+ };
649
+ /** The daily mode's content client and results, once today's puzzle is open. */
650
+ let daily = null;
651
+ /**
652
+ * A resumed daily game finishes on the puzzle its save holds, even after the next release
653
+ * (D-46: a puzzle started is a puzzle kept); its result counts for that puzzle's date.
654
+ */
655
+ const keepDaily = async (svc, game, resume) => {
656
+ const { player } = game;
657
+ if (player.device === void 0) return;
658
+ if (resume.envelope.daily !== void 0 && parsePuzzleId(resume.envelope.daily.puzzleId)?.gameId === game.entry.manifest.id) {
659
+ daily = {
660
+ results: createDailyResults(player.device, player.playerId),
661
+ now: svc.daily?.now ?? Date.now,
662
+ ...resume.envelope.daily
663
+ };
664
+ emit({
665
+ _tag: "Puzzle",
666
+ puzzleId: daily.puzzleId
667
+ });
668
+ return;
669
+ }
670
+ const config = resume.envelope.replay?.config ?? null;
671
+ if (config === null || typeof config !== "object" || Array.isArray(config)) return;
672
+ const id = config["puzzleId"];
673
+ const hash = await contentHashOf(config);
674
+ if (typeof id !== "string" || parsePuzzleId(id) === null || hash === null) return;
675
+ daily = {
676
+ results: createDailyResults(player.device, player.playerId),
677
+ now: svc.daily?.now ?? Date.now,
678
+ puzzleId: id,
679
+ contentHash: hash
680
+ };
681
+ emit({
682
+ _tag: "Puzzle",
683
+ puzzleId: id
684
+ });
685
+ };
686
+ /**
687
+ * Today's puzzle for a new daily game. Returns the config to start with, or null after
688
+ * reporting why not: no puzzle (offline with nothing cached, or not published yet), or
689
+ * today's puzzle already finished, whose result it reports instead.
690
+ */
691
+ const openDaily = async (svc, game) => {
692
+ const { entry, module, player } = game;
693
+ const rules = module.engine ?? module.simulation;
694
+ if (rules === void 0 || player.device === void 0) {
695
+ fail("This game's daily puzzle can't be played here.");
696
+ return null;
697
+ }
698
+ const now = svc.daily?.now ?? Date.now;
699
+ const results = createDailyResults(player.device, player.playerId);
700
+ const client = createDailyClient({
701
+ baseUrl: svc.daily?.baseUrl ?? "/daily",
702
+ gameId: entry.manifest.id,
703
+ config: rules.schemas.config,
704
+ storage: player.device,
705
+ ...svc.daily?.fetch === void 0 ? {} : { fetch: svc.daily.fetch }
706
+ });
707
+ const today = utcDate(now());
708
+ const puzzle = await client.puzzle(today);
709
+ if (disposed) return null;
710
+ if (!puzzle.ok) {
711
+ fail(puzzle.error.message);
712
+ return null;
713
+ }
714
+ const variants = entry.manifest.dailyVariants ?? [null];
715
+ let earlier = null;
716
+ for (const variant of variants) {
717
+ const found = await results.get(puzzle.value.puzzleId, variant);
718
+ if (disposed) return null;
719
+ earlier = found.ok ? found.value : null;
720
+ if (earlier === null) break;
721
+ }
722
+ if (earlier !== null) {
723
+ const streaks = await results.streaks(entry.manifest.id, today);
724
+ if (disposed) return null;
725
+ emit({
726
+ _tag: "Daily",
727
+ daily: dailyFrame(entry.manifest.name, earlier, streaks.ok ? streaks.value : null, earlier.summary, now, true, entry.manifest.id === "daily-word" ? await wordStatistics(results, today) : void 0)
728
+ });
729
+ return null;
730
+ }
731
+ daily = {
732
+ results,
733
+ now,
734
+ puzzleId: puzzle.value.puzzleId,
735
+ contentHash: puzzle.value.contentHash
736
+ };
737
+ emit({
738
+ _tag: "Puzzle",
739
+ puzzleId: puzzle.value.puzzleId
740
+ });
741
+ client.prune(today).catch(() => {});
742
+ return puzzle.value.config;
743
+ };
744
+ const wordStatistics = async (results, today) => {
745
+ const all = await results.list("daily-word");
746
+ return all.ok ? dailyWordStats(all.value, today) : void 0;
747
+ };
748
+ /** Records the finished daily puzzle, then reports its streaks and share text. */
749
+ const recordDaily = async (svc, game, outcome) => {
750
+ const open = daily;
751
+ const engine = game.module.engine;
752
+ const simulation = game.module.simulation;
753
+ if (open === null || engine === void 0 && simulation === void 0) return;
754
+ let summary = null;
755
+ try {
756
+ const state = running?.state() ?? null;
757
+ summary = state === null ? null : engine?.share?.(state, outcome) ?? simulation?.share?.(state) ?? null;
758
+ } catch (error) {
759
+ svc.ports.report(error, "The engine failed to summarize the daily result for sharing.");
760
+ }
761
+ const rows = summary?.rows ?? [];
762
+ const today = utcDate(open.now());
763
+ const result = {
764
+ puzzleId: open.puzzleId,
765
+ variant: summary?.variant !== void 0 && (game.entry.manifest.dailyVariants ?? []).includes(summary.variant) ? summary.variant : null,
766
+ contentHash: open.contentHash,
767
+ outcome: dailyOutcome(outcome.result),
768
+ assisted: summary?.assisted ?? ((outcome.assists?.length ?? 0) > 0 || (outcome.undosUsed ?? 0) > 0),
769
+ summary: outcome.summary.slice(0, 200),
770
+ stats: { ...outcome.stats ?? {} },
771
+ shareRows: rows.map((row) => row.symbols),
772
+ shareWords: rows.map((row) => row.words),
773
+ ...rows.some((row) => row.detail !== void 0) ? { shareDetails: rows.map((row) => row.detail ?? null) } : {},
774
+ finishedOn: today
775
+ };
776
+ const recorded = await open.results.record(result, today);
777
+ if (disposed) return;
778
+ if (!recorded.ok) {
779
+ emit({
780
+ _tag: "Notice",
781
+ message: "Your result couldn't be saved on this device."
782
+ });
783
+ return;
784
+ }
785
+ const kept = recorded.value.result;
786
+ emit({
787
+ _tag: "Daily",
788
+ daily: dailyFrame(game.entry.manifest.name, kept, recorded.value.streaks, recorded.value.isNew ? summary?.headline ?? kept.summary : kept.summary, open.now, false, game.entry.manifest.id === "daily-word" ? await wordStatistics(open.results, today) : void 0)
789
+ });
790
+ };
791
+ /** Opens the room: creates it (the creator holds seat 0) or checks the code, then connects. */
792
+ const startRoom = async (svc, game) => {
793
+ const { entry, module, player } = game;
794
+ const { manifest } = entry;
795
+ const plan = control.room;
796
+ const engine = module.engine;
797
+ if (plan === null || engine === void 0) return fail("This game can't be played online yet.");
798
+ const rooms = svc.rooms ?? pageRooms();
799
+ let code;
800
+ if (plan.action === "create") {
801
+ const created = await rooms.create(manifest.id, player.displayName);
802
+ if (disposed) return;
803
+ if (!created.ok) return fail(created.message);
804
+ rooms.tokens.set(created.value.room, created.value.token);
805
+ code = created.value.room;
806
+ if (created.value.notice !== null) emit({
807
+ _tag: "Notice",
808
+ message: created.value.notice
809
+ });
810
+ } else {
811
+ const found = await rooms.find(plan.code);
812
+ if (disposed) return;
813
+ if (!found.ok) return fail(found.message);
814
+ if (found.value.gameId !== manifest.id) return fail("That code belongs to a room for another game. Open the invite link to reach its page.");
815
+ code = plan.code;
816
+ }
817
+ const session = createRemoteSession({
818
+ game: {
819
+ id: manifest.id,
820
+ build: manifest.versions.build,
821
+ rulesVersion: engine.rulesVersion,
822
+ schemas: engine.schemas
823
+ },
824
+ room: code,
825
+ name: player.displayName,
826
+ watch: plan.action === "join" && plan.watch,
827
+ connect: rooms.connect,
828
+ tokens: rooms.tokens,
829
+ clock: svc.ports.clock,
830
+ actionId: () => rooms.actionId(),
831
+ freeze: svc.freeze ?? true,
832
+ report: svc.ports.report
833
+ });
834
+ running = {
835
+ session,
836
+ inputs: null,
837
+ rematch: noRematch,
838
+ state: noState,
839
+ checkpoint: null
840
+ };
841
+ remote = {
842
+ session,
843
+ code,
844
+ link: rooms.inviteLink(code),
845
+ mountedMatch: null,
846
+ lastRoom: "",
847
+ ticker: null
848
+ };
849
+ platform = platformFor(svc, game);
850
+ stops.push(session.subscribe((snapshot) => onRoom(svc, game, snapshot)));
851
+ sync();
852
+ };
853
+ /** Reports the room, ticks its clock, and mounts the game when a match is there to show. */
854
+ const onRoom = (svc, game, snapshot) => {
855
+ const open = remote;
856
+ if (open === null || disposed) return;
857
+ reportRoom(svc, game);
858
+ const match = snapshot.room?.match ?? 0;
859
+ if (snapshot.view !== null && open.mountedMatch !== match) {
860
+ try {
861
+ mounted?.dispose();
862
+ } finally {
863
+ mounted = null;
864
+ canvas.replaceChildren();
865
+ }
866
+ open.mountedMatch = match;
867
+ finished = false;
868
+ lastFrame = "";
869
+ mountCanvas(game.module, open.session);
870
+ }
871
+ if (open.mountedMatch === null) return;
872
+ const frame = frameOf(snapshot);
873
+ if (frame.outcome !== null && !finished) {
874
+ finished = true;
875
+ game.player.storage.stats.record(game.entry.manifest.id, frame.outcome);
876
+ }
877
+ const text = json(frame);
878
+ if (text === lastFrame) return;
879
+ lastFrame = text;
880
+ emit({
881
+ _tag: "Frame",
882
+ frame
883
+ });
884
+ };
885
+ /** Emits the room when it changed; while a clock runs, again as its seconds tick. */
886
+ const reportRoom = (svc, game) => {
887
+ const open = remote;
888
+ if (open === null || disposed) return;
889
+ const room = roomFrameOf(open.session.get(), {
890
+ code: open.code,
891
+ link: open.link,
892
+ manifest: game.entry.manifest,
893
+ now: svc.ports.clock.now()
894
+ });
895
+ const text = json(room);
896
+ if (text !== open.lastRoom) {
897
+ open.lastRoom = text;
898
+ emit({
899
+ _tag: "Room",
900
+ room
901
+ });
902
+ }
903
+ open.ticker?.();
904
+ open.ticker = room.timer !== null && !room.timer.paused || room.offer?.status === "open" && room.offer.seconds !== null ? svc.ports.clock.after(250, () => reportRoom(svc, game)) : null;
905
+ };
906
+ /** Turns a lobby request into the room control (or session input) it stands for. */
907
+ const perform = (session, request) => {
908
+ switch (request.kind) {
909
+ case "ready": return session.dispatch({
910
+ kind: "control",
911
+ control: {
912
+ kind: "ready",
913
+ ready: request.ready
914
+ }
915
+ });
916
+ case "clock": {
917
+ const { setting } = request;
918
+ const control = setting.kind === "off" ? {
919
+ kind: "settings",
920
+ decisionMs: null,
921
+ bank: null
922
+ } : setting.kind === "move" ? {
923
+ kind: "settings",
924
+ decisionMs: setting.ms
925
+ } : {
926
+ kind: "settings",
927
+ bank: {
928
+ initialMs: setting.initialMs,
929
+ incrementMs: setting.incrementMs
930
+ }
931
+ };
932
+ return session.dispatch({
933
+ kind: "control",
934
+ control
935
+ });
936
+ }
937
+ case "ai-level": return session.dispatch({
938
+ kind: "control",
939
+ control: {
940
+ kind: "settings",
941
+ aiLevel: request.level
942
+ }
943
+ });
944
+ case "fill-with-ai":
945
+ case "remove-seat": return session.dispatch({
946
+ kind: "control",
947
+ control: {
948
+ kind: request.kind,
949
+ seat: request.seat
950
+ }
951
+ });
952
+ case "start": return session.dispatch({
953
+ kind: "control",
954
+ control: { kind: "start" }
955
+ });
956
+ case "offer": return session.dispatch({
957
+ kind: "control",
958
+ control: {
959
+ kind: "offer",
960
+ offer: request.offer
961
+ }
962
+ });
963
+ case "answer": return session.dispatch({
964
+ kind: "control",
965
+ control: {
966
+ kind: request.answer,
967
+ offer: request.offer
968
+ }
969
+ });
970
+ case "reconnect": return session.dispatch({ kind: "reconnect" });
971
+ case "leave":
972
+ session.dispatch({ kind: "leave" });
973
+ emit({ _tag: "ExitRequested" });
974
+ return;
975
+ }
976
+ };
977
+ const mount = (svc, game, session) => {
978
+ const { entry, player } = game;
979
+ const { manifest } = entry;
980
+ platform = platformFor(svc, game);
981
+ stops.push(session.subscribe((snapshot) => {
982
+ const frame = frameOf(snapshot);
983
+ if (frame.outcome !== null && !finished) {
984
+ finished = true;
985
+ slot?.finish();
986
+ player.storage.stats.record(manifest.id, frame.outcome);
987
+ if (control.mode === "daily") recordDaily(svc, game, frame.outcome).catch((error) => fail("Your result couldn't be saved.", error));
988
+ else {
989
+ const rematch = running?.rematch() ?? null;
990
+ if (rematch !== null) emit({
991
+ _tag: "Rematch",
992
+ config: rematch
993
+ });
994
+ }
995
+ }
996
+ const text = json(frame);
997
+ if (text === lastFrame) return;
998
+ lastFrame = text;
999
+ emit({
1000
+ _tag: "Frame",
1001
+ frame
1002
+ });
1003
+ }));
1004
+ mountCanvas(game.module, session);
1005
+ };
1006
+ /** Draws the game into the canvas with the stage's PlatformApi. */
1007
+ const mountCanvas = (module, session) => {
1008
+ if (platform === null) return;
1009
+ stopAudio?.();
1010
+ const audio = createGameAudio(platform.preferences, control.gameId);
1011
+ const withAudio = {
1012
+ ...platform,
1013
+ audio
1014
+ };
1015
+ const stopSounds = watchGameAudio(session, withAudio, control.gameId);
1016
+ stopAudio = () => {
1017
+ stopSounds();
1018
+ audio.dispose();
1019
+ };
1020
+ try {
1021
+ mounted = module.mount(canvas, session, withAudio);
1022
+ } catch (error) {
1023
+ stopAudio();
1024
+ stopAudio = null;
1025
+ fail("The game couldn't start.", error);
1026
+ }
1027
+ sync();
1028
+ };
1029
+ /** The PlatformApi for this stage's game: its theme on the canvas, live preferences, storage. */
1030
+ const platformFor = (svc, game) => {
1031
+ const { entry, player } = game;
1032
+ const { manifest } = entry;
1033
+ const theme = canvasTheme(manifest.theme);
1034
+ canvas.style.setProperty("--game-accent", theme.accent);
1035
+ canvas.style.setProperty("--game-surface", theme.surface);
1036
+ canvas.style.setProperty("--game-ink", theme.ink);
1037
+ let stored = game.preferences;
1038
+ let resolved = resolvePreferences(stored, svc.system.get());
1039
+ const listeners = /* @__PURE__ */ new Set();
1040
+ const publish = () => {
1041
+ const next = resolvePreferences(stored, svc.system.get());
1042
+ canvas.style.colorScheme = next.colorScheme;
1043
+ if (json(next) === json(resolved)) return;
1044
+ resolved = next;
1045
+ for (const listener of [...listeners]) listener(next);
1046
+ };
1047
+ canvas.style.colorScheme = resolved.colorScheme;
1048
+ stops.push(player.storage.preferences.subscribe((next) => {
1049
+ stored = next;
1050
+ publish();
1051
+ }), svc.system.subscribe(publish));
1052
+ return {
1053
+ storage: player.storage.gameStorage(manifest.id),
1054
+ preferences: {
1055
+ get: () => resolved,
1056
+ subscribe: (listener) => {
1057
+ const entry = (preferences) => listener(preferences);
1058
+ listeners.add(entry);
1059
+ return () => {
1060
+ listeners.delete(entry);
1061
+ };
1062
+ }
1063
+ },
1064
+ requestExit: () => {
1065
+ if (remote !== null) remote.session.dispatch({ kind: "leave" });
1066
+ emit({ _tag: "ExitRequested" });
1067
+ },
1068
+ announce: (message, politeness) => emit({
1069
+ _tag: "Announce",
1070
+ message,
1071
+ politeness
1072
+ })
1073
+ };
1074
+ };
1075
+ /** Acts on what changed in `control`: begin, pause, reveals and preferences. */
1076
+ const sync = () => {
1077
+ if (disposed || services === null || loaded === null) return;
1078
+ if (control.preferences !== null && json(control.preferences) !== lastPreferences) {
1079
+ lastPreferences = json(control.preferences);
1080
+ loaded.player.storage.preferences.update(control.preferences);
1081
+ }
1082
+ if (!begun && control.begin !== null) {
1083
+ begun = true;
1084
+ start(services, loaded, control.begin).catch((error) => fail("The game couldn't start.", error));
1085
+ return;
1086
+ }
1087
+ if (running?.inputs === void 0 || running.inputs === null) return;
1088
+ if (control.paused !== paused) {
1089
+ paused = control.paused;
1090
+ running.inputs({ kind: paused ? "pause" : "resume" });
1091
+ }
1092
+ while (reveals < control.reveals) {
1093
+ reveals += 1;
1094
+ running.inputs({ kind: "reveal" });
1095
+ }
1096
+ };
1097
+ /**
1098
+ * Stops play and writes the position now, for a forced security update (`requestSave`):
1099
+ * a real-time game pauses first, which also checkpoints it. The write queues behind the
1100
+ * session's own, so whatever lands last is this position or a later one. A finished game
1101
+ * has nothing to keep; rooms keep their state on the server.
1102
+ */
1103
+ const saveNow = async () => {
1104
+ const game = running;
1105
+ const saves = slot;
1106
+ if (game === null || game.checkpoint === null || saves === null || finished) return;
1107
+ game.inputs?.({ kind: "pause" });
1108
+ await saves.port.write(game.checkpoint());
1109
+ };
1110
+ const onSaveRequest = (event) => {
1111
+ event.detail?.waitUntil?.(saveNow());
1112
+ };
1113
+ const doc = host.ownerDocument;
1114
+ doc.addEventListener(SAVE_REQUEST_EVENT, onSaveRequest);
1115
+ stops.push(() => doc.removeEventListener(SAVE_REQUEST_EVENT, onSaveRequest));
1116
+ if (services === null) queueMicrotask(() => fail("This page can't start games: the launcher has no services. Call provideLauncherServices above it."));
1117
+ else {
1118
+ stops.push(services.onNotice((message) => emit({
1119
+ _tag: "Notice",
1120
+ message
1121
+ })));
1122
+ prepare(services).catch((error) => fail("The game couldn't load. Check your connection, then try again.", error));
1123
+ }
1124
+ return {
1125
+ roomRequest: (request) => {
1126
+ if (disposed || remote === null) throw new Error("The room is not open.");
1127
+ perform(remote.session, request);
1128
+ },
1129
+ update: (next) => {
1130
+ control = next;
1131
+ sync();
1132
+ },
1133
+ dispose: () => {
1134
+ if (disposed) return;
1135
+ disposed = true;
1136
+ for (const bridge of preparations) bridge.dispose();
1137
+ preparations.clear();
1138
+ remote?.ticker?.();
1139
+ for (const stop of stops.splice(0)) stop();
1140
+ stopAudio?.();
1141
+ stopAudio = null;
1142
+ try {
1143
+ mounted?.dispose();
1144
+ } finally {
1145
+ running?.session.dispose();
1146
+ canvas.replaceChildren();
1147
+ }
1148
+ }
1149
+ };
1150
+ };
1151
+ /** The stage element. Set `control`; listen for `gyral-output` events. */
1152
+ var GameStage = class extends HTMLElement {
1153
+ #control = null;
1154
+ #game = null;
1155
+ get control() {
1156
+ return this.#control;
1157
+ }
1158
+ set control(value) {
1159
+ this.#control = value;
1160
+ if (value === null) return;
1161
+ if (this.#game !== null) this.#game.update(value);
1162
+ else if (this.isConnected) this.#start(value);
1163
+ }
1164
+ /** A one-shot command from the launcher; the remote room is the authority. */
1165
+ roomRequest(request) {
1166
+ if (this.#game === null) throw new Error("The game stage is not running.");
1167
+ this.#game.roomRequest(request);
1168
+ }
1169
+ connectedCallback() {
1170
+ if (Object.hasOwn(this, "control")) {
1171
+ const value = this.control;
1172
+ delete this.control;
1173
+ this.control = value;
1174
+ }
1175
+ if (this.#game === null && this.#control !== null) this.#start(this.#control);
1176
+ }
1177
+ disconnectedCallback() {
1178
+ this.#game?.dispose();
1179
+ this.#game = null;
1180
+ }
1181
+ #start(control) {
1182
+ const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
1183
+ root.adoptedStyleSheets = [stageSheet()];
1184
+ const canvas = document.createElement("div");
1185
+ canvas.className = "canvas";
1186
+ canvas.setAttribute("part", "canvas");
1187
+ root.replaceChildren(canvas);
1188
+ this.#game = run(this, canvas, control);
1189
+ }
1190
+ };
1191
+ if (customElements.get("jy-game-stage") === void 0) customElements.define(STAGE_TAG, GameStage);
1192
+ //#endregion
1193
+ export { GameStage, STAGE_TAG, frameOf, roomFrameOf };
1194
+
1195
+ //# sourceMappingURL=stage.js.map