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