@memizy/host-sdk 0.0.0-stage → 1.0.0-rc.6

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.
@@ -0,0 +1,548 @@
1
+ import "./version.js";
2
+ import { prepareSetForPlugin as e } from "./plugin.js";
3
+ import { leitner as t } from "./learning.js";
4
+ import { MemoryStorage as n } from "./storage.js";
5
+ import { BOARD_ADDRESS as r, LIMITS as i, PROTOCOL_VERSION as a, ProtocolError as o, SETTINGS_ADDRESS as s, negotiateProtocolVersion as c, resolveSettings as l, toProtocolError as u } from "@memizy/protocol";
6
+ import { resolveAsset as d } from "@memizy/oqse";
7
+ import { RateLimiter as f, assertJsonWithin as p, parseHostCall as m } from "@memizy/protocol/internal";
8
+ //#region src/session.ts
9
+ function h(e) {
10
+ try {
11
+ return structuredClone(e);
12
+ } catch {
13
+ return JSON.parse(JSON.stringify(e));
14
+ }
15
+ }
16
+ var g = class {
17
+ id;
18
+ plugin;
19
+ runtime;
20
+ mode;
21
+ hostAs;
22
+ authority;
23
+ prepared;
24
+ roster;
25
+ settingValues;
26
+ settingsOk = !0;
27
+ hostConfig;
28
+ isStarted = !1;
29
+ isEnded = !1;
30
+ pauseState = {
31
+ pausedMs: 0,
32
+ pausedAt: null
33
+ };
34
+ options;
35
+ storage;
36
+ algorithm;
37
+ instances = /* @__PURE__ */ new Map();
38
+ listeners = /* @__PURE__ */ new Set();
39
+ authorityConnected = !0;
40
+ startPromise = null;
41
+ resumeStartPending = !1;
42
+ pendingSnapshot = void 0;
43
+ finished = !1;
44
+ hasResult = !1;
45
+ finishedSnapshot = null;
46
+ snapshotTimer;
47
+ pendingData = /* @__PURE__ */ new Map();
48
+ get players() {
49
+ return this.roster;
50
+ }
51
+ get settings() {
52
+ return this.settingValues;
53
+ }
54
+ get settingsValid() {
55
+ return this.settingsOk;
56
+ }
57
+ get config() {
58
+ return this.hostConfig;
59
+ }
60
+ get started() {
61
+ return this.isStarted;
62
+ }
63
+ get ended() {
64
+ return this.isEnded;
65
+ }
66
+ constructor(i) {
67
+ if (this.options = i, this.id = i.sessionId ?? `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, this.plugin = i.plugin, this.runtime = i.plugin.runtime, this.mode = i.mode, this.storage = i.storage ?? new n(), this.algorithm = i.algorithm ?? t, this.hostConfig = {
68
+ locale: i.locale ?? "en",
69
+ theme: i.theme ?? "light"
70
+ }, i.mode === "solo") {
71
+ if (!this.runtime.solo) throw Error("The plugin does not support solo mode.");
72
+ this.hostAs = null;
73
+ } else {
74
+ if (!this.runtime.multiplayer) throw Error("The plugin does not support multiplayer.");
75
+ let e = i.hostAs ?? this.runtime.multiplayer.hostAs[0];
76
+ if (!this.runtime.multiplayer.hostAs.includes(e)) throw Error(`The plugin does not support hostAs "${e}".`);
77
+ this.hostAs = e;
78
+ }
79
+ let a = i.players ?? (i.mode === "solo" ? [{
80
+ id: "player",
81
+ name: "Player",
82
+ isHost: !0
83
+ }] : []);
84
+ if (i.mode === "solo" && a.length !== 1) throw Error("A solo session has exactly one player.");
85
+ if (new Set(a.map((e) => e.id)).size !== a.length) throw Error("Player IDs must be unique.");
86
+ if (a.some((e) => e.id === r || e.id === s || e.id === "server")) throw Error("Player IDs \"board\", \"settings\" and \"server\" are reserved.");
87
+ if (this.roster = a.map((e) => ({
88
+ id: e.id,
89
+ name: e.name,
90
+ isHost: i.mode === "solo" ? !0 : e.isHost ?? !1,
91
+ connected: !0
92
+ })), i.mode === "solo") this.authority = this.roster[0].id;
93
+ else if (this.hostAs === "presenter") this.authority = r;
94
+ else {
95
+ let e = this.roster.find((e) => e.isHost);
96
+ if (!e) throw Error("With hostAs \"player\", one player must have isHost: true.");
97
+ this.authority = e.id;
98
+ }
99
+ let o = l(this.runtime.settings, i.settings ?? {});
100
+ if (o.errors.length > 0) throw Error(`Invalid settings: ${o.errors.join("; ")}`);
101
+ this.settingValues = o.values;
102
+ let c = i.shuffleSeed === null ? void 0 : i.shuffleSeed ?? this.id;
103
+ this.prepared = e(h(i.set), i.plugin.manifest, { seed: c }), i.restore && (this.isStarted = !0, this.resumeStartPending = !0), i.clock && (this.pauseState = {
104
+ pausedMs: i.clock.pausedMs ?? 0,
105
+ pausedAt: i.clock.pausedAt ?? null
106
+ });
107
+ }
108
+ on(e) {
109
+ return this.listeners.add(e), () => this.listeners.delete(e);
110
+ }
111
+ emit(e) {
112
+ for (let t of this.listeners) try {
113
+ t(e);
114
+ } catch (e) {
115
+ console.error("[memizy host] session listener failed:", e);
116
+ }
117
+ }
118
+ viewOf(e) {
119
+ return e === s ? "settings" : this.mode === "solo" ? "solo" : e === r ? "board" : "controller";
120
+ }
121
+ addresses() {
122
+ return [...this.hostAs === "presenter" ? [r] : [], ...this.roster.map((e) => e.id)];
123
+ }
124
+ async connect(e, t, n = {}) {
125
+ let r = this.viewOf(e);
126
+ if (r === "settings") {
127
+ if (!this.runtime.multiplayer || !this.runtime.settingsScreen) throw Error("The plugin has no settings screen (settingsScreen).");
128
+ if (this.isStarted) throw Error("The settings screen is only available before the game starts.");
129
+ } else if (!this.addresses().includes(e)) throw Error(`Unknown address "${e}" in this session.`);
130
+ if (this.isEnded) throw Error("The session has ended.");
131
+ this.disposeInstance(e);
132
+ let a = {
133
+ address: e,
134
+ view: r,
135
+ hostApi: void 0,
136
+ endpoint: t,
137
+ onDispose: n.onDispose,
138
+ plugin: null,
139
+ ready: !1,
140
+ connected: !0,
141
+ disposed: !1,
142
+ resumed: !1,
143
+ greeted: !1,
144
+ limiter: new f(i.messagesPerSecond, i.messageBurst),
145
+ serviceLimiter: new f(i.servicesPerMinute / 60, 10),
146
+ answerLimiter: new f(i.answersPerSecond, i.answerBurst),
147
+ assetLimiter: new f(i.assetsPerSecond, i.assetBurst),
148
+ errorLimiter: new f(i.errorReportsPerSecond, i.errorReportBurst)
149
+ };
150
+ a.hostApi = this.createHostApi(a), this.instances.set(e, a), e === this.authority && this.isStarted && this.setAuthorityConnected(!1);
151
+ try {
152
+ a.plugin = await t(a.hostApi);
153
+ } catch (e) {
154
+ if (a.disposed) return a;
155
+ throw e;
156
+ }
157
+ return a;
158
+ }
159
+ async reload(e) {
160
+ let t = this.instances.get(e);
161
+ if (!t) throw Error(`No instance at "${e}".`);
162
+ return e === this.authority && await this.flushSnapshot(), this.connect(e, t.endpoint, { onDispose: t.onDispose });
163
+ }
164
+ disconnect(e) {
165
+ this.disposeInstance(e), e === this.authority && this.isStarted && this.setAuthorityConnected(!1);
166
+ }
167
+ disposeInstance(e) {
168
+ let t = this.instances.get(e);
169
+ if (t) {
170
+ t.disposed = !0, this.instances.delete(e);
171
+ try {
172
+ t.onDispose?.();
173
+ } catch (e) {
174
+ console.warn("[memizy host] dispose failed:", e);
175
+ }
176
+ }
177
+ }
178
+ getInstance(e) {
179
+ return this.instances.get(e);
180
+ }
181
+ start() {
182
+ if (this.mode === "solo" || this.isStarted && !this.startPromise) return Promise.resolve();
183
+ if (!this.settingsOk) return Promise.reject(/* @__PURE__ */ Error("The settings are not valid."));
184
+ let e = this.roster.length, t = this.runtime.multiplayer.players;
185
+ return e < t.min || e > t.max ? Promise.reject(/* @__PURE__ */ Error(`This plugin needs ${t.min}–${t.max} players (now ${e}).`)) : (this.startPromise ??= this.runStart(), this.startPromise);
186
+ }
187
+ async runStart() {
188
+ this.disposeInstance(s);
189
+ let e = Date.now() + (this.options.readyTimeoutMs ?? 15e3), t = () => [...this.instances.values()].every((e) => e.ready), n = () => this.instances.get(this.authority)?.ready === !0;
190
+ for (; !(t() && n()) && (Date.now() < e || !n());) {
191
+ if (this.isEnded) return;
192
+ await _(25);
193
+ }
194
+ let r = this.options.countdownMs ?? 3e3;
195
+ for (let e = Math.ceil(r / 1e3); e > 0; e--) this.emit({
196
+ type: "countdown",
197
+ secondsLeft: e
198
+ }), await _(Math.min(1e3, r - (Math.ceil(r / 1e3) - e) * 1e3));
199
+ this.isEnded || (this.isStarted = !0, this.emit({ type: "started" }), await this.callPlugin(this.authority, (e) => e.start()));
200
+ }
201
+ addPlayer(e) {
202
+ if (this.mode === "solo") throw Error("Players cannot join a solo session.");
203
+ if (this.roster.some((t) => t.id === e.id) || [
204
+ r,
205
+ s,
206
+ "server"
207
+ ].includes(e.id)) throw Error(`Player ID "${e.id}" is already used or reserved.`);
208
+ if (this.isStarted && !this.runtime.multiplayer.lateJoin) throw Error("This game does not allow joining after the start.");
209
+ if (this.roster.length >= this.runtime.multiplayer.players.max) throw Error("The game is full.");
210
+ this.roster = [...this.roster, {
211
+ id: e.id,
212
+ name: e.name,
213
+ isHost: !1,
214
+ connected: !0
215
+ }], this.broadcastPlayers();
216
+ }
217
+ removePlayer(e) {
218
+ if (e === this.authority) throw Error("The host cannot be removed.");
219
+ this.disposeInstance(e), this.roster = this.roster.filter((t) => t.id !== e), this.broadcastPlayers();
220
+ }
221
+ renamePlayer(e, t) {
222
+ this.roster = this.roster.map((n) => n.id === e ? {
223
+ ...n,
224
+ name: t
225
+ } : n), this.broadcastPlayers();
226
+ }
227
+ setConnected(e, t) {
228
+ let n = this.instances.get(e);
229
+ n && (n.connected = t), this.roster.some((t) => t.id === e) && (this.roster = this.roster.map((n) => n.id === e ? {
230
+ ...n,
231
+ connected: t
232
+ } : n), this.broadcastPlayers()), e === this.authority ? this.setAuthorityConnected(t) : t && n && this.callPlugin(e, (e) => e.authorityChanged({ connected: !0 }));
233
+ }
234
+ get services() {
235
+ return this.runtime.services.filter((e) => typeof this.options.services?.[e] == "function");
236
+ }
237
+ get paused() {
238
+ return this.pauseState.pausedAt !== null;
239
+ }
240
+ get clock() {
241
+ return {
242
+ offsetMs: 0,
243
+ ...this.pauseState
244
+ };
245
+ }
246
+ pause() {
247
+ !this.isStarted || this.isEnded || this.paused || (this.pauseState = {
248
+ ...this.pauseState,
249
+ pausedAt: Date.now()
250
+ }, this.broadcastClock());
251
+ }
252
+ resume() {
253
+ this.paused && (this.pauseState = {
254
+ pausedMs: this.pauseState.pausedMs + Math.max(0, Date.now() - this.pauseState.pausedAt),
255
+ pausedAt: null
256
+ }, this.broadcastClock());
257
+ }
258
+ broadcastClock() {
259
+ let e = this.clock;
260
+ for (let t of this.instances.keys()) this.callPlugin(t, (t) => t.clockChanged(e));
261
+ this.emit({
262
+ type: "paused",
263
+ paused: this.paused,
264
+ clock: { ...this.pauseState }
265
+ });
266
+ }
267
+ setConfig(e) {
268
+ this.hostConfig = {
269
+ ...this.hostConfig,
270
+ ...e
271
+ };
272
+ for (let e of this.instances.keys()) this.callPlugin(e, (e) => e.configChanged(this.hostConfig));
273
+ }
274
+ async end(e = "closed") {
275
+ this.isEnded || (this.isEnded = !0, e === "finished" ? await this.discardSnapshot() : await this.flushSnapshot(), await this.flushData(), await Promise.all([...this.instances.keys()].map((t) => this.callPlugin(t, (t) => t.sessionEnded(e)))));
276
+ }
277
+ createHostApi(e) {
278
+ let t = (t, n) => (async () => {
279
+ if (e.disposed) throw new o("SESSION_ENDED", "This instance has been closed.");
280
+ if (this.isEnded && t !== "reportError") throw new o("SESSION_ENDED", "The session has ended.");
281
+ return n();
282
+ })().catch((n) => {
283
+ let r = u(n);
284
+ throw this.emit({
285
+ type: "rejected",
286
+ address: e.address,
287
+ method: t,
288
+ code: r.code,
289
+ message: r.detail
290
+ }), r;
291
+ }), n = () => e.view === "solo" || e.view === "controller", r = (t, n) => {
292
+ if (!n) throw new o("NOT_ALLOWED_IN_VIEW", `${t} is not available in view "${e.view}".`);
293
+ }, f = (t) => {
294
+ if (e.address !== this.authority) throw new o("NOT_AUTHORITY", `${t} may only be called by the authority.`);
295
+ };
296
+ return {
297
+ hello: (...n) => t("hello", async () => {
298
+ let [t] = m("hello", n);
299
+ if (e.greeted) throw new o("INVALID_ARGUMENT", "hello may only be called once per instance.");
300
+ let r = c(t.protocol, a);
301
+ if (!r) throw new o("UNSUPPORTED_PROTOCOL", `The plugin speaks protocol ${t.protocol}; this host supports ${a}.`);
302
+ return e.greeted = !0, t.plugin.id !== this.plugin.manifest.id && this.emit({
303
+ type: "pluginError",
304
+ address: e.address,
305
+ code: "PLUGIN_ID_MISMATCH",
306
+ message: `The SDK reports plugin id "${t.plugin.id}", the manifest says "${this.plugin.manifest.id}".`
307
+ }), this.buildInit(e, r);
308
+ }),
309
+ ready: (...n) => t("ready", () => {
310
+ m("ready", n), !e.ready && (e.ready = !0, this.emit({
311
+ type: "ready",
312
+ address: e.address
313
+ }), e.address === this.authority && (this.mode === "solo" && !this.isStarted && !e.resumed ? (this.isStarted = !0, this.emit({ type: "started" }), this.callPlugin(e.address, (e) => e.start())) : this.isStarted && (this.resumeStartPending && (this.resumeStartPending = !1, e.resumed || this.callPlugin(e.address, (e) => e.start())), this.setAuthorityConnected(!0))));
314
+ }),
315
+ send: (...n) => t("send", () => {
316
+ let [t] = m("send", n);
317
+ r("send", e.view !== "settings");
318
+ let a = p(t.data, i.messageBytes, "MESSAGE_TOO_LARGE", "message"), c = e.address === this.authority ? 1 / Math.max(1, this.instances.size - 1) : 1;
319
+ if (!e.limiter.tryTake(c)) throw new o("RATE_LIMITED", `More than ${i.messagesPerSecond} messages per second.`);
320
+ if (!e.connected) return;
321
+ if (t.to === "authority") {
322
+ let n = this.instances.get(this.authority);
323
+ if (!n || !n.connected || !this.authorityConnected) throw new o("AUTHORITY_UNAVAILABLE", "The host is not reachable right now.");
324
+ this.deliver(e.address, this.authority, t.data, a);
325
+ return;
326
+ }
327
+ let l = t.to === "all" ? [...this.instances.keys()].filter((t) => t !== e.address && t !== s) : t.to.filter((e) => e !== s);
328
+ for (let n of l) this.deliver(e.address, n, t.data, a);
329
+ }),
330
+ saveSnapshot: (...e) => t("saveSnapshot", () => {
331
+ let [t] = m("saveSnapshot", e);
332
+ if (f("saveSnapshot"), p(t, i.snapshotBytes, "SNAPSHOT_TOO_LARGE", "snapshot"), this.finished) {
333
+ this.finishedSnapshot = structuredClone(t);
334
+ return;
335
+ }
336
+ this.pendingSnapshot = structuredClone(t), this.snapshotTimer ??= setTimeout(() => void this.flushSnapshot(), 1e3 / i.snapshotsPerSecond);
337
+ }),
338
+ recordAnswer: (...n) => t("recordAnswer", async () => {
339
+ let [t] = m("recordAnswer", n), r = this.roster.some((t) => t.id === e.address) ? e.address : null, a = t.playerId ?? r;
340
+ if (!a) throw new o("INVALID_ARGUMENT", "recordAnswer needs a playerId on the board.");
341
+ if (a !== r && f("recordAnswer for another player"), !this.roster.some((e) => e.id === a)) throw new o("INVALID_ARGUMENT", `Unknown player "${a}".`);
342
+ let s = e.address === this.authority ? 1 / Math.max(1, this.roster.length) : 1;
343
+ if (!e.answerLimiter.tryTake(s)) throw new o("RATE_LIMITED", `More than ${i.answersPerSecond} answers per second.`);
344
+ t.answer !== void 0 && p(t.answer, i.answerBytes, "DATA_TOO_LARGE", "answer");
345
+ let c = this.prepared.set.items.find((e) => e.id === t.itemId), l = (e) => Array.isArray(e) ? e.filter((e) => typeof e == "string") : [];
346
+ if (!c) {
347
+ if (!t.item) throw new o("INVALID_ARGUMENT", `Unknown item "${t.itemId}" (attach the item for a generated one).`);
348
+ p(t.item, i.generatedItemBytes, "DATA_TOO_LARGE", "item"), this.emit({
349
+ type: "answer",
350
+ playerId: a,
351
+ record: null,
352
+ answer: t,
353
+ generated: !0,
354
+ skills: l(t.item.skills),
355
+ tags: l(t.item.tags)
356
+ });
357
+ return;
358
+ }
359
+ let u = this.userKey(a), d = this.prepared.set.meta.id, h = await this.storage.loadProgress(u, d), { item: g, ..._ } = t, v = this.algorithm.apply(h[t.itemId], _, /* @__PURE__ */ new Date(), structuredClone(c));
360
+ await this.storage.saveProgress(u, d, { [t.itemId]: v }), this.emit({
361
+ type: "answer",
362
+ playerId: a,
363
+ record: v,
364
+ answer: _,
365
+ generated: !1,
366
+ skills: l(c.skills),
367
+ tags: l(c.tags)
368
+ });
369
+ }),
370
+ saveData: (...a) => t("saveData", () => {
371
+ let [t, o] = m("saveData", a);
372
+ r("saveData", n()), p(o, i.dataBytes, "DATA_TOO_LARGE", `plugin data (${t})`);
373
+ let s = `${e.address}|${t}`, c = this.pendingData.get(s);
374
+ if (c) c.value = structuredClone(o);
375
+ else {
376
+ let n = {
377
+ playerId: e.address,
378
+ scope: t,
379
+ value: structuredClone(o),
380
+ timer: setTimeout(() => void this.flushData(s), 1e3 / i.dataWritesPerSecond)
381
+ };
382
+ this.pendingData.set(s, n);
383
+ }
384
+ }),
385
+ updateSettings: (...n) => t("updateSettings", () => {
386
+ let [t] = m("updateSettings", n);
387
+ r("updateSettings", e.view === "settings");
388
+ let { values: i, errors: a } = l(this.runtime.settings, t.values);
389
+ this.settingValues = i, this.settingsOk = t.valid && a.length === 0;
390
+ let o = t.message ?? a[0];
391
+ this.emit({
392
+ type: "settings",
393
+ values: i,
394
+ valid: this.settingsOk,
395
+ ...o && { message: o }
396
+ });
397
+ }),
398
+ getAsset: (...n) => t("getAsset", async () => {
399
+ let [t, r] = m("getAsset", n);
400
+ if (!e.assetLimiter.tryTake()) throw new o("RATE_LIMITED", `More than ${i.assetsPerSecond} assets per second.`);
401
+ let a = r ? this.options.set.items.find((e) => e.id === r) : void 0, s = d(t, a, this.options.set.meta);
402
+ if (!s) throw new o("ASSET_NOT_FOUND", `Asset "${t}" does not exist.`);
403
+ if (this.options.loadAsset) return this.options.loadAsset(t, r);
404
+ if (/^https?:/i.test(s.value)) {
405
+ let e = await fetch(s.value);
406
+ if (e.ok) return e.blob();
407
+ }
408
+ throw new o("ASSET_NOT_FOUND", `Asset "${t}" cannot be loaded by this host.`);
409
+ }),
410
+ end: (...e) => t("end", async () => {
411
+ let [t] = m("end", e);
412
+ f("end"), !this.hasResult && (this.hasResult = !0, await this.discardSnapshot(), this.emit({
413
+ type: "result",
414
+ result: t
415
+ }));
416
+ }),
417
+ reportError: (...n) => t("reportError", () => {
418
+ let [t] = m("reportError", n);
419
+ e.errorLimiter.tryTake() && this.emit({
420
+ type: "pluginError",
421
+ address: e.address,
422
+ code: t.code,
423
+ message: t.message,
424
+ ...t.context && { context: t.context }
425
+ });
426
+ }),
427
+ service: (...n) => t("service", async () => {
428
+ let [t, r] = m("service", n), a = this.services.includes(t) ? this.options.services[t] : void 0;
429
+ if (!a) throw new o("SERVICE_UNAVAILABLE", `Service "${t}" is not available here (declare it in the manifest "services"; this host offers: ${this.services.join(", ") || "none"}).`);
430
+ if (p(r, i.serviceBytes, "MESSAGE_TOO_LARGE", `service ${t} request`), !e.serviceLimiter.tryTake()) throw new o("RATE_LIMITED", `More than ${i.servicesPerMinute} service calls per minute.`);
431
+ let s = this.roster.some((t) => t.id === e.address) ? e.address : null, c = await a(h(r), {
432
+ playerId: s,
433
+ address: e.address,
434
+ pluginId: this.plugin.manifest.id,
435
+ setId: this.prepared.set.meta.id
436
+ });
437
+ return p(c ?? null, i.serviceResultBytes, "MESSAGE_TOO_LARGE", `service ${t} result`), h(c ?? null);
438
+ })
439
+ };
440
+ }
441
+ async buildInit(e, t) {
442
+ let n = e.view === "solo" || e.view === "controller", r = this.prepared.set.meta.id, i = n ? this.userKey(e.address) : null, a = this.plugin.manifest.id;
443
+ e.address === this.authority && await this.flushSnapshot();
444
+ let o = e.address === this.authority ? this.finished ? structuredClone(this.finishedSnapshot) : await this.storage.loadSnapshot(this.id) : null;
445
+ return e.resumed = o !== null, {
446
+ protocol: t,
447
+ host: this.options.host ?? {
448
+ name: "@memizy/host-sdk",
449
+ version: "1.0.0-rc.6"
450
+ },
451
+ oqseVersion: "1.0",
452
+ features: [],
453
+ session: {
454
+ id: this.id,
455
+ mode: this.mode,
456
+ hostAs: this.hostAs,
457
+ view: e.view,
458
+ self: e.address,
459
+ authority: this.authority,
460
+ lateJoin: this.isStarted && e.view !== "settings"
461
+ },
462
+ players: this.roster,
463
+ set: structuredClone(this.mode === "multiplayer" && e.address !== this.authority ? this.prepared.publicSet : this.prepared.set),
464
+ settings: { ...this.settingValues },
465
+ config: this.hostConfig,
466
+ clock: this.clock,
467
+ services: this.services,
468
+ progress: i ? await this.storage.loadProgress(i, r) : {},
469
+ data: i ? {
470
+ plugin: this.pendingDataValue(e.address, "plugin") ?? await this.storage.loadData(i, a, "plugin", r) ?? null,
471
+ set: this.pendingDataValue(e.address, "set") ?? await this.storage.loadData(i, a, "set", r) ?? null
472
+ } : {
473
+ plugin: null,
474
+ set: null
475
+ },
476
+ snapshot: o
477
+ };
478
+ }
479
+ userKey(e) {
480
+ return this.options.userKeyFor?.(e) ?? e;
481
+ }
482
+ deliver(e, t, n, r) {
483
+ let i = this.instances.get(t);
484
+ if (!i || !i.connected || !i.plugin) return;
485
+ this.emit({
486
+ type: "traffic",
487
+ from: e,
488
+ to: t,
489
+ bytes: r,
490
+ data: n
491
+ });
492
+ let a = structuredClone(n);
493
+ setTimeout(() => void this.callPlugin(t, (t) => t.deliver({
494
+ from: e,
495
+ data: a,
496
+ sentAt: Date.now()
497
+ })), 0);
498
+ }
499
+ broadcastPlayers() {
500
+ this.emit({
501
+ type: "players",
502
+ players: this.roster
503
+ });
504
+ for (let e of this.instances.keys()) this.callPlugin(e, (e) => e.playersChanged(this.roster));
505
+ }
506
+ setAuthorityConnected(e) {
507
+ if (this.authorityConnected !== e) {
508
+ this.authorityConnected = e, this.emit({
509
+ type: "authority",
510
+ connected: e
511
+ });
512
+ for (let t of this.instances.keys()) t !== this.authority && t !== s && this.callPlugin(t, (t) => t.authorityChanged({ connected: e }));
513
+ }
514
+ }
515
+ async callPlugin(e, t) {
516
+ let n = this.instances.get(e);
517
+ if (n?.plugin && !n.disposed) try {
518
+ await t(n.plugin);
519
+ } catch (t) {
520
+ this.emit({
521
+ type: "pluginError",
522
+ address: e,
523
+ code: "PLUGIN_CALL_FAILED",
524
+ message: u(t).detail
525
+ });
526
+ }
527
+ }
528
+ async discardSnapshot() {
529
+ this.finished || (this.finished = !0, clearTimeout(this.snapshotTimer), this.snapshotTimer = void 0, this.finishedSnapshot = this.pendingSnapshot === void 0 ? await this.storage.loadSnapshot(this.id) : this.pendingSnapshot, this.pendingSnapshot = void 0, await this.storage.deleteSnapshot?.(this.id));
530
+ }
531
+ async flushSnapshot() {
532
+ if (clearTimeout(this.snapshotTimer), this.snapshotTimer = void 0, this.pendingSnapshot === void 0 || this.finished) return;
533
+ let e = this.pendingSnapshot;
534
+ this.pendingSnapshot = void 0, await this.storage.saveSnapshot(this.id, e), this.finished && await this.storage.deleteSnapshot?.(this.id);
535
+ }
536
+ pendingDataValue(e, t) {
537
+ return this.pendingData.get(`${e}|${t}`)?.value;
538
+ }
539
+ async flushData(e) {
540
+ let t = e ? [[e, this.pendingData.get(e)]] : [...this.pendingData.entries()];
541
+ for (let [e, n] of t) n && (clearTimeout(n.timer), this.pendingData.delete(e), await this.storage.saveData(this.userKey(n.playerId), this.plugin.manifest.id, n.scope, this.prepared.set.meta.id, n.value));
542
+ }
543
+ };
544
+ function _(e) {
545
+ return new Promise((t) => setTimeout(t, Math.max(0, e)));
546
+ }
547
+ //#endregion
548
+ export { g as LocalSession };
@@ -0,0 +1,43 @@
1
+ import { HostApi, PluginApi } from '@memizy/protocol';
2
+ import { LocalSession, SessionInstance } from './session.js';
3
+ /** Never add `allow-same-origin`: the plugin must not reach the app's origin. */
4
+ /**
5
+ * The iframe sandbox: never `allow-same-origin` (no storage, no access to the host) and
6
+ * never `allow-top-navigation` (a game cannot replace the host's page). Popups may escape
7
+ * the sandbox, so a link the SDK opens in a new tab is a normal page.
8
+ */
9
+ export declare const PLUGIN_SANDBOX = "allow-scripts allow-forms allow-modals allow-pointer-lock allow-popups allow-popups-to-escape-sandbox";
10
+ export interface MountOptions {
11
+ /** Text overrides (default: Czech or English by the session locale). */
12
+ texts?: {
13
+ waitingForHost?: string;
14
+ countdown?: (seconds: number) => string;
15
+ };
16
+ /** Show the built-in overlays (default `true`). */
17
+ overlays?: boolean;
18
+ }
19
+ export interface MountedPlugin {
20
+ readonly instance: SessionInstance;
21
+ readonly iframe: HTMLIFrameElement;
22
+ /** Removes the iframe and disconnects the instance. */
23
+ unmount(): void;
24
+ }
25
+ export interface PluginFrame {
26
+ readonly iframe: HTMLIFrameElement;
27
+ /** Resolves when the plugin has connected (Penpal handshake). */
28
+ readonly plugin: Promise<PluginApi>;
29
+ /** Closes the connection (the caller removes the iframe). */
30
+ destroy(): void;
31
+ }
32
+ /**
33
+ * Creates a sandboxed iframe with the plugin HTML and connects it to `hostApi`.
34
+ * The iframe must be inserted into the document by the caller (right away).
35
+ * The plugin reaches only what its manifest declares (SPEC 8.4): a Content Security
36
+ * Policy limits network requests, `allow` grants the declared devices.
37
+ */
38
+ export declare function createPluginFrame(html: string, title: string, hostApi: HostApi, doc?: Document): PluginFrame;
39
+ /**
40
+ * Mounts the session's plugin for `address` into `container` and connects it.
41
+ * Calling it again for the same address replaces the instance (reload).
42
+ */
43
+ export declare function mountPlugin(session: LocalSession, address: string, container: HTMLElement, options?: MountOptions): Promise<MountedPlugin>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @memizy/host-sdk – run Memizy plugins: load and validate plugins, sandboxed
3
+ * iframes, protocol validation and limits, sessions, storage and learning hooks,
4
+ * multiplayer over the relay. Protocol: @memizy/protocol (SPEC.md).
5
+ *
6
+ * The public API is listed explicitly; everything else is internal and may change
7
+ * in any release (src/index.test.ts guards the list).
8
+ */
9
+ export { HOST_SDK_VERSION } from './version.js';
10
+ export { loadPluginFromHtml, loadPluginFromUrl, prepareSetForPlugin, createEmptySet, type LoadedPlugin, type LoadPluginResult, type PreparedSet, } from './plugin.js';
11
+ export { LocalSession, type SessionConfig, type SessionPlayer, type SessionEvent, type SessionInstance, type PluginEndpoint, type ServiceHandler, } from './session.js';
12
+ export { mountPlugin, PLUGIN_SANDBOX, type MountOptions, type MountedPlugin } from './frame.js';
13
+ export { MemoryStorage, type HostStorage } from './storage.js';
14
+ export { leitner, LEITNER_INTERVAL_DAYS, type LearningAlgorithm } from './learning.js';
15
+ export { RelayHost, type RelayHostOptions, type RelayHostEvent, type AttachOptions, type HostSeat } from './relay/host.js';
16
+ export { RelayPlayer, type RelayPlayerOptions, type RelayPlayerEvent, type FrameFactory, type RemoteFrame } from './relay/player.js';
17
+ export type { RelayStatus } from './relay/socket.js';
18
+ export { RELAY_APP_VERSION, type RemoteLobbyState, type RemotePhase, type RemoteSeat } from './relay/messages.js';
@@ -0,0 +1,18 @@
1
+ import { OQSEAnyItem, ProgressRecord } from '@memizy/oqse';
2
+ import { AnswerRecord } from '@memizy/protocol';
3
+ export interface LearningAlgorithm {
4
+ /** OQSEP `meta.algorithm` identifier. */
5
+ id: string;
6
+ /**
7
+ * The new progress record of the answered item. `item` is the item of the set with its
8
+ * answers (type, difficulty, skills… for algorithms like FSRS); it may be ignored.
9
+ */
10
+ apply(previous: ProgressRecord | undefined, answer: AnswerRecord, now: Date, item: OQSEAnyItem): ProgressRecord;
11
+ }
12
+ /** Days until the next review per Leitner bucket (index = bucket). */
13
+ export declare const LEITNER_INTERVAL_DAYS: readonly [0, 1, 3, 7, 21];
14
+ /**
15
+ * Leitner boxes: a correct answer moves the item one bucket up (max 4),
16
+ * a wrong answer back to bucket 1. Skipped answers do not change the bucket.
17
+ */
18
+ export declare const leitner: LearningAlgorithm;
@@ -0,0 +1,51 @@
1
+ import { PluginManifest, PluginRuntime } from '@memizy/protocol';
2
+ import { CompatibilityReport, OQSEFile, OQSEAnyItem, OQSEMeta } from '@memizy/oqse';
3
+ export interface LoadedPlugin {
4
+ /** The plugin HTML (rendered with `srcdoc`). */
5
+ html: string;
6
+ manifest: PluginManifest;
7
+ runtime: PluginRuntime;
8
+ }
9
+ export type LoadPluginResult = {
10
+ success: true;
11
+ plugin: LoadedPlugin;
12
+ } | {
13
+ success: false;
14
+ errors: string[];
15
+ };
16
+ /** Reads and validates a plugin from its HTML (e.g. a file uploaded to the Plugin Lab). */
17
+ export declare function loadPluginFromHtml(html: string): LoadPluginResult;
18
+ /** Downloads a plugin HTML file and loads it. */
19
+ export declare function loadPluginFromUrl(url: string, fetchImpl?: typeof fetch): Promise<LoadPluginResult>;
20
+ export interface PreparedSet {
21
+ /** The items the plugin plays, in display order with answers (the authority; solo). */
22
+ set: {
23
+ meta: OQSEMeta;
24
+ items: OQSEAnyItem[];
25
+ };
26
+ /** The same items without answers (other instances in multiplayer, the relay bundle). */
27
+ publicSet: {
28
+ meta: OQSEMeta;
29
+ items: OQSEAnyItem[];
30
+ };
31
+ compatibility: CompatibilityReport;
32
+ /** Items left out because the plugin does not declare their type. */
33
+ skippedItems: number;
34
+ }
35
+ /**
36
+ * Keeps only the item types the plugin declares and reports the OQSEM
37
+ * handshake (`checkCompatibility`). The set should already be loaded with
38
+ * `loadOQSEFile`. With a `seed` (normally the session id) the options, pairs
39
+ * and orders are shuffled once for the session (SPEC 4.4).
40
+ */
41
+ export declare function prepareSetForPlugin(file: OQSEFile, manifest: PluginManifest, options?: {
42
+ seed?: string;
43
+ }): PreparedSet;
44
+ /**
45
+ * An empty study set for a plugin that plays without one (`appSpecific.memizy.set` is
46
+ * `"none"`, or `"optional"` and the host chose no set). The plugin gets `items: []`.
47
+ */
48
+ export declare function createEmptySet(meta: {
49
+ title: string;
50
+ language: string;
51
+ }): OQSEFile;