@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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { createPluginFrame as e } from "../frame.js";
|
|
2
|
+
import { RelaySocket as t, relayWebSocketUrl as n } from "./socket.js";
|
|
3
|
+
import { isSerializedBlob as r, jsonToBlob as i, serializeError as a } from "./messages.js";
|
|
4
|
+
import { HOST_API_METHODS as o, PLUGIN_API_METHODS as s, RELAY_PROTOCOL as c, extractManifestFromHtml as l } from "@memizy/protocol";
|
|
5
|
+
//#region src/relay/player.ts
|
|
6
|
+
var u = new Set(s), d = class {
|
|
7
|
+
playerId = null;
|
|
8
|
+
token;
|
|
9
|
+
name;
|
|
10
|
+
state = null;
|
|
11
|
+
hostConnected = !0;
|
|
12
|
+
options;
|
|
13
|
+
socket;
|
|
14
|
+
listeners = /* @__PURE__ */ new Set();
|
|
15
|
+
bundle = null;
|
|
16
|
+
frame = null;
|
|
17
|
+
plugin = null;
|
|
18
|
+
gen = 0;
|
|
19
|
+
callCounter = 0;
|
|
20
|
+
pending = /* @__PURE__ */ new Map();
|
|
21
|
+
skewMs = 0;
|
|
22
|
+
hostConfig = null;
|
|
23
|
+
mountQueue = Promise.resolve();
|
|
24
|
+
constructor(e) {
|
|
25
|
+
this.options = e, this.token = e.token ?? null, this.name = e.name, this.socket = new t({
|
|
26
|
+
url: n(e.serverUrl),
|
|
27
|
+
attach: () => ({
|
|
28
|
+
t: "join",
|
|
29
|
+
protocol: c,
|
|
30
|
+
pin: e.pin,
|
|
31
|
+
name: this.name,
|
|
32
|
+
token: this.token ?? void 0
|
|
33
|
+
}),
|
|
34
|
+
WebSocket: e.WebSocket
|
|
35
|
+
}), this.socket.onStatus((e) => this.emit({
|
|
36
|
+
type: "status",
|
|
37
|
+
status: e
|
|
38
|
+
})), this.socket.onMessage((e) => this.onServer(e));
|
|
39
|
+
}
|
|
40
|
+
get status() {
|
|
41
|
+
return this.socket.status;
|
|
42
|
+
}
|
|
43
|
+
on(e) {
|
|
44
|
+
return this.listeners.add(e), () => this.listeners.delete(e);
|
|
45
|
+
}
|
|
46
|
+
emit(e) {
|
|
47
|
+
for (let t of this.listeners) t(e);
|
|
48
|
+
}
|
|
49
|
+
updateConfig() {
|
|
50
|
+
this.plugin && this.hostConfig && this.plugin.configChanged(this.localConfig(this.hostConfig)).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
localConfig(e) {
|
|
53
|
+
return {
|
|
54
|
+
...e,
|
|
55
|
+
...this.options.config?.()
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
rename(e) {
|
|
59
|
+
this.name = e, this.socket.send({
|
|
60
|
+
t: "rename",
|
|
61
|
+
name: e
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
reconnectNow() {
|
|
65
|
+
this.socket.reconnectNow();
|
|
66
|
+
}
|
|
67
|
+
leave() {
|
|
68
|
+
this.destroyFrame(), this.socket.close();
|
|
69
|
+
}
|
|
70
|
+
onServer(e) {
|
|
71
|
+
switch (e.t) {
|
|
72
|
+
case "joined":
|
|
73
|
+
this.playerId = e.playerId, this.token = e.token, this.name = e.name, this.hostConnected = e.hostConnected, this.emit({
|
|
74
|
+
type: "joined",
|
|
75
|
+
playerId: e.playerId,
|
|
76
|
+
token: e.token,
|
|
77
|
+
name: e.name
|
|
78
|
+
}), this.up({
|
|
79
|
+
k: "hello",
|
|
80
|
+
gen: this.plugin ? this.gen : 0
|
|
81
|
+
});
|
|
82
|
+
break;
|
|
83
|
+
case "host-presence":
|
|
84
|
+
this.hostConnected = e.connected, this.emit({
|
|
85
|
+
type: "host",
|
|
86
|
+
connected: e.connected
|
|
87
|
+
}), e.connected && this.up({
|
|
88
|
+
k: "hello",
|
|
89
|
+
gen: this.plugin ? this.gen : 0
|
|
90
|
+
});
|
|
91
|
+
break;
|
|
92
|
+
case "data":
|
|
93
|
+
this.onHost(e.data);
|
|
94
|
+
break;
|
|
95
|
+
case "error":
|
|
96
|
+
this.emit({
|
|
97
|
+
type: "error",
|
|
98
|
+
code: e.code,
|
|
99
|
+
message: e.message,
|
|
100
|
+
fatal: e.fatal
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
case "closed": this.destroyFrame(), this.emit({
|
|
104
|
+
type: "closed",
|
|
105
|
+
reason: e.reason
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
up(e) {
|
|
110
|
+
return this.socket.send({
|
|
111
|
+
t: "up",
|
|
112
|
+
data: e
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async onHost(e) {
|
|
116
|
+
if (typeof e == "object" && e) switch (e.k) {
|
|
117
|
+
case "state":
|
|
118
|
+
this.state = e.state, this.emit({
|
|
119
|
+
type: "state",
|
|
120
|
+
state: e.state
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
case "mount":
|
|
124
|
+
this.mountQueue = this.mountQueue.then(() => this.mount(e.gen, e.bundle));
|
|
125
|
+
return;
|
|
126
|
+
case "unmount":
|
|
127
|
+
this.destroyFrame();
|
|
128
|
+
return;
|
|
129
|
+
case "result": {
|
|
130
|
+
let t = this.pending.get(e.id);
|
|
131
|
+
if (!t) return;
|
|
132
|
+
if (this.pending.delete(e.id), clearTimeout(t.timer), !e.ok) {
|
|
133
|
+
t.reject(Error(e.error?.message ?? "Host call failed"));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let n = e.value;
|
|
137
|
+
if (t.method === "hello" && this.bundle) {
|
|
138
|
+
if (e.hostNow !== void 0) {
|
|
139
|
+
let n = Date.now();
|
|
140
|
+
this.skewMs = e.hostNow + (n - t.sentAt) / 2 - n;
|
|
141
|
+
}
|
|
142
|
+
let r = n;
|
|
143
|
+
this.hostConfig = r.config, n = {
|
|
144
|
+
...r,
|
|
145
|
+
set: structuredClone(this.bundle.data.set),
|
|
146
|
+
clock: {
|
|
147
|
+
...r.clock,
|
|
148
|
+
offsetMs: r.clock.offsetMs + this.skewMs
|
|
149
|
+
},
|
|
150
|
+
config: this.localConfig(r.config)
|
|
151
|
+
};
|
|
152
|
+
} else r(n) && (n = i(n));
|
|
153
|
+
t.resolve(n);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
case "call": {
|
|
157
|
+
let t = (e) => this.up(e);
|
|
158
|
+
if (e.gen !== this.gen || !this.plugin || !u.has(e.method)) {
|
|
159
|
+
t({
|
|
160
|
+
k: "result",
|
|
161
|
+
id: e.id,
|
|
162
|
+
ok: !1,
|
|
163
|
+
error: { message: "[SESSION_ENDED] No active plugin instance." }
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let n = e.args;
|
|
168
|
+
if (e.method === "configChanged" && (this.hostConfig = n[0], n = [this.localConfig(this.hostConfig)]), e.method === "clockChanged") {
|
|
169
|
+
let e = n[0];
|
|
170
|
+
n = [{
|
|
171
|
+
...e,
|
|
172
|
+
offsetMs: e.offsetMs + this.skewMs
|
|
173
|
+
}];
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
let r = this.plugin[e.method], i = await r(...n);
|
|
177
|
+
t({
|
|
178
|
+
k: "result",
|
|
179
|
+
id: e.id,
|
|
180
|
+
ok: !0,
|
|
181
|
+
value: i ?? null
|
|
182
|
+
});
|
|
183
|
+
} catch (n) {
|
|
184
|
+
t({
|
|
185
|
+
k: "result",
|
|
186
|
+
id: e.id,
|
|
187
|
+
ok: !1,
|
|
188
|
+
error: a(n)
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async ensureBundle(e) {
|
|
196
|
+
if (this.bundle?.version === e) return this.bundle.data;
|
|
197
|
+
let t = await (this.options.fetch ?? fetch)(new URL(`/api/rooms/${this.options.pin}/bundle`, this.options.serverUrl));
|
|
198
|
+
if (!t.ok) throw Error(`Could not download the game (${t.status}).`);
|
|
199
|
+
let n = await t.json();
|
|
200
|
+
return this.bundle = {
|
|
201
|
+
version: Number(t.headers.get("X-Bundle-Version") ?? e),
|
|
202
|
+
data: n
|
|
203
|
+
}, n;
|
|
204
|
+
}
|
|
205
|
+
async mount(t, n) {
|
|
206
|
+
this.destroyFrame(), this.gen = t;
|
|
207
|
+
try {
|
|
208
|
+
let r = await this.ensureBundle(n);
|
|
209
|
+
if (this.gen !== t) return;
|
|
210
|
+
let i = this.options.container();
|
|
211
|
+
if (!i) throw Error("No place for the game on this page.");
|
|
212
|
+
let a = l(r.pluginHtml), o = a.success ? String(a.data.appName ?? "Game") : "Game", s = this.options.transformHtml ? this.options.transformHtml(r.pluginHtml) : r.pluginHtml, c = (this.options.createFrame ?? e)(s, o, this.hostApiFor(t), i.ownerDocument);
|
|
213
|
+
this.frame = c, i.replaceChildren(c.iframe), this.emit({
|
|
214
|
+
type: "frame",
|
|
215
|
+
active: !0
|
|
216
|
+
});
|
|
217
|
+
let u = await c.plugin;
|
|
218
|
+
if (this.gen !== t) return;
|
|
219
|
+
this.plugin = u, this.up({
|
|
220
|
+
k: "mounted",
|
|
221
|
+
gen: t
|
|
222
|
+
});
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (this.gen !== t) return;
|
|
225
|
+
this.emit({
|
|
226
|
+
type: "error",
|
|
227
|
+
code: "MOUNT_FAILED",
|
|
228
|
+
message: e.message,
|
|
229
|
+
fatal: !1
|
|
230
|
+
}), this.up({
|
|
231
|
+
k: "mount-failed",
|
|
232
|
+
gen: t,
|
|
233
|
+
error: a(e)
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
hostApiFor(e) {
|
|
238
|
+
let t = {};
|
|
239
|
+
for (let n of o) t[n] = (...t) => {
|
|
240
|
+
if (this.gen !== e) return Promise.reject(/* @__PURE__ */ Error("[SESSION_ENDED] This plugin instance was replaced."));
|
|
241
|
+
let r = ++this.callCounter;
|
|
242
|
+
return new Promise((i, a) => {
|
|
243
|
+
let o = setTimeout(() => {
|
|
244
|
+
this.pending.delete(r), a(/* @__PURE__ */ Error(`[INTERNAL_ERROR] The host did not answer ${n} in time.`));
|
|
245
|
+
}, this.options.callTimeoutMs ?? 15e3);
|
|
246
|
+
this.pending.set(r, {
|
|
247
|
+
method: n,
|
|
248
|
+
sentAt: Date.now(),
|
|
249
|
+
resolve: i,
|
|
250
|
+
reject: a,
|
|
251
|
+
timer: o
|
|
252
|
+
}), this.up({
|
|
253
|
+
k: "call",
|
|
254
|
+
id: r,
|
|
255
|
+
gen: e,
|
|
256
|
+
method: n,
|
|
257
|
+
args: t
|
|
258
|
+
}) || (clearTimeout(o), this.pending.delete(r), n === "hello" ? a(/* @__PURE__ */ Error("[INTERNAL_ERROR] Offline.")) : i(void 0));
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
return t;
|
|
262
|
+
}
|
|
263
|
+
destroyFrame() {
|
|
264
|
+
let e = this.frame !== null;
|
|
265
|
+
this.frame?.destroy(), this.frame?.iframe.remove(), this.frame = null, this.plugin = null, this.gen = 0;
|
|
266
|
+
for (let e of this.pending.values()) clearTimeout(e.timer), e.reject(/* @__PURE__ */ Error("[SESSION_ENDED] The plugin instance was closed."));
|
|
267
|
+
this.pending.clear(), e && this.emit({
|
|
268
|
+
type: "frame",
|
|
269
|
+
active: !1
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
//#endregion
|
|
274
|
+
export { d as RelayPlayer };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=class{status=`connecting`;options;ws=null;retry=0;retryTimer;pingTimer;stopped=!1;messageListeners=new Set;statusListeners=new Set;constructor(e){this.options=e,this.open()}onMessage(e){return this.messageListeners.add(e),()=>this.messageListeners.delete(e)}onStatus(e){return this.statusListeners.add(e),()=>this.statusListeners.delete(e)}send(e){return this.status!==`online`||this.ws?.readyState!==1?!1:(this.ws.send(JSON.stringify(e)),!0)}close(){this.stopped=!0,clearTimeout(this.retryTimer),clearInterval(this.pingTimer),this.ws?.close(1e3,`bye`),this.setStatus(`closed`)}reconnectNow(){this.stopped||this.status===`online`||this.status===`connecting`||(clearTimeout(this.retryTimer),this.open())}setStatus(e){if(this.status!==e){this.status=e;for(let t of this.statusListeners)t(e)}}open(){if(this.stopped)return;this.setStatus(`connecting`);let e=new(this.options.WebSocket??WebSocket)(this.options.url);this.ws=e,e.onopen=()=>{this.ws===e&&e.send(JSON.stringify(this.options.attach()))},e.onmessage=t=>{if(this.ws!==e)return;let n;try{n=JSON.parse(String(t.data))}catch{return}(n.t===`hosting`||n.t===`joined`)&&(this.retry=0,this.setStatus(`online`),clearInterval(this.pingTimer),this.pingTimer=setInterval(()=>this.send({t:`ping`}),2e4)),(n.t===`error`&&n.fatal||n.t===`closed`)&&(this.stopped=!0);for(let e of this.messageListeners)e(n);this.stopped&&this.close()},e.onclose=t=>{if(this.ws!==e)return;if(clearInterval(this.pingTimer),this.ws=null,t.reason?.startsWith(`replaced`)){this.stopped=!0,this.setStatus(`replaced`);return}if(this.stopped){this.setStatus(`closed`);return}this.setStatus(`offline`);let n=Math.min(5e3,300*2**this.retry++)+Math.random()*300;this.retryTimer=setTimeout(()=>this.open(),n)},e.onerror=()=>{}}};function t(e){let t=new URL(e);return t.protocol=t.protocol===`https:`?`wss:`:`ws:`,t.pathname=`${t.pathname.replace(/\/$/,``)}/ws`,t.href}exports.RelaySocket=e,exports.relayWebSocketUrl=t;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
//#region src/relay/socket.ts
|
|
2
|
+
var e = class {
|
|
3
|
+
status = "connecting";
|
|
4
|
+
options;
|
|
5
|
+
ws = null;
|
|
6
|
+
retry = 0;
|
|
7
|
+
retryTimer;
|
|
8
|
+
pingTimer;
|
|
9
|
+
stopped = !1;
|
|
10
|
+
messageListeners = /* @__PURE__ */ new Set();
|
|
11
|
+
statusListeners = /* @__PURE__ */ new Set();
|
|
12
|
+
constructor(e) {
|
|
13
|
+
this.options = e, this.open();
|
|
14
|
+
}
|
|
15
|
+
onMessage(e) {
|
|
16
|
+
return this.messageListeners.add(e), () => this.messageListeners.delete(e);
|
|
17
|
+
}
|
|
18
|
+
onStatus(e) {
|
|
19
|
+
return this.statusListeners.add(e), () => this.statusListeners.delete(e);
|
|
20
|
+
}
|
|
21
|
+
send(e) {
|
|
22
|
+
return this.status !== "online" || this.ws?.readyState !== 1 ? !1 : (this.ws.send(JSON.stringify(e)), !0);
|
|
23
|
+
}
|
|
24
|
+
close() {
|
|
25
|
+
this.stopped = !0, clearTimeout(this.retryTimer), clearInterval(this.pingTimer), this.ws?.close(1e3, "bye"), this.setStatus("closed");
|
|
26
|
+
}
|
|
27
|
+
reconnectNow() {
|
|
28
|
+
this.stopped || this.status === "online" || this.status === "connecting" || (clearTimeout(this.retryTimer), this.open());
|
|
29
|
+
}
|
|
30
|
+
setStatus(e) {
|
|
31
|
+
if (this.status !== e) {
|
|
32
|
+
this.status = e;
|
|
33
|
+
for (let t of this.statusListeners) t(e);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
open() {
|
|
37
|
+
if (this.stopped) return;
|
|
38
|
+
this.setStatus("connecting");
|
|
39
|
+
let e = new (this.options.WebSocket ?? WebSocket)(this.options.url);
|
|
40
|
+
this.ws = e, e.onopen = () => {
|
|
41
|
+
this.ws === e && e.send(JSON.stringify(this.options.attach()));
|
|
42
|
+
}, e.onmessage = (t) => {
|
|
43
|
+
if (this.ws !== e) return;
|
|
44
|
+
let n;
|
|
45
|
+
try {
|
|
46
|
+
n = JSON.parse(String(t.data));
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
(n.t === "hosting" || n.t === "joined") && (this.retry = 0, this.setStatus("online"), clearInterval(this.pingTimer), this.pingTimer = setInterval(() => this.send({ t: "ping" }), 2e4)), (n.t === "error" && n.fatal || n.t === "closed") && (this.stopped = !0);
|
|
51
|
+
for (let e of this.messageListeners) e(n);
|
|
52
|
+
this.stopped && this.close();
|
|
53
|
+
}, e.onclose = (t) => {
|
|
54
|
+
if (this.ws !== e) return;
|
|
55
|
+
if (clearInterval(this.pingTimer), this.ws = null, t.reason?.startsWith("replaced")) {
|
|
56
|
+
this.stopped = !0, this.setStatus("replaced");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (this.stopped) {
|
|
60
|
+
this.setStatus("closed");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.setStatus("offline");
|
|
64
|
+
let n = Math.min(5e3, 300 * 2 ** this.retry++) + Math.random() * 300;
|
|
65
|
+
this.retryTimer = setTimeout(() => this.open(), n);
|
|
66
|
+
}, e.onerror = () => {};
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
function t(e) {
|
|
70
|
+
let t = new URL(e);
|
|
71
|
+
return t.protocol = t.protocol === "https:" ? "wss:" : "ws:", t.pathname = `${t.pathname.replace(/\/$/, "")}/ws`, t.href;
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { e as RelaySocket, t as relayWebSocketUrl };
|
package/dist/session.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=require("./plugin.cjs"),t=require("./learning.cjs"),n=require("./storage.cjs");let r=require("@memizy/protocol"),i=require("@memizy/oqse");var a=`settings`;function o(e){try{return structuredClone(e)}catch{return JSON.parse(JSON.stringify(e))}}var s=class{id;plugin;runtime;mode;hostAs;authority;prepared;players;settings;settingsValid=!0;config;started=!1;ended=!1;pauseState={pausedMs:0,pausedAt:null};options;storage;algorithm;instances=new Map;listeners=new Set;authorityConnected=!0;startPromise=null;resumeStartPending=!1;pendingSnapshot=void 0;snapshotTimer;pendingData=new Map;constructor(i){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.MemoryStorage,this.algorithm=i.algorithm??t.leitner,this.config={locale:i.config?.locale??`cs`,theme:i.config?.theme??`light`},i.mode===`solo`){if(!this.runtime.solo)throw Error(`The plugin does not support solo mode.`);this.hostAs=null}else{if(!this.runtime.multiplayer)throw Error(`The plugin does not support multiplayer.`);let e=i.hostAs??this.runtime.multiplayer.hostAs[0];if(!this.runtime.multiplayer.hostAs.includes(e))throw Error(`The plugin does not support hostAs "${e}".`);this.hostAs=e}let a=i.players??(i.mode===`solo`?[{id:`player`,name:`Player`,isHost:!0}]:[]);if(i.mode===`solo`&&a.length!==1)throw Error(`A solo session has exactly one player.`);if(new Set(a.map(e=>e.id)).size!==a.length)throw Error(`Player IDs must be unique.`);if(a.some(e=>e.id===r.BOARD_ADDRESS||e.id===`settings`||e.id===`server`))throw Error(`Player IDs "board", "settings" and "server" are reserved.`);if(this.players=a.map(e=>({id:e.id,name:e.name,isHost:i.mode===`solo`?!0:e.isHost??!1,connected:!0})),i.mode===`solo`)this.authority=this.players[0].id;else if(this.hostAs===`presenter`)this.authority=r.BOARD_ADDRESS;else{let e=this.players.find(e=>e.isHost);if(!e)throw Error(`With hostAs "player", one player must have isHost: true.`);this.authority=e.id}let s=(0,r.resolveSettings)(this.runtime.settings,i.settings??{});if(s.errors.length>0)throw Error(`Invalid settings: ${s.errors.join(`; `)}`);this.settings=s.values;let c=i.shuffleSeed===null?void 0:i.shuffleSeed??this.id;this.prepared=e.prepareSetForPlugin(o(i.set),i.plugin.manifest,{seed:c}),i.resume&&(this.started=!0,this.resumeStartPending=!0),i.clock&&(this.pauseState={pausedMs:i.clock.pausedMs??0,pausedAt:i.clock.pausedAt??null})}on(e){return this.listeners.add(e),()=>this.listeners.delete(e)}emit(e){for(let t of this.listeners)try{t(e)}catch(e){console.error(`[memizy host] session listener failed:`,e)}}viewOf(e){return e===`settings`?`settings`:this.mode===`solo`?`solo`:e===r.BOARD_ADDRESS?`board`:`controller`}addresses(){return[...this.hostAs===`presenter`?[r.BOARD_ADDRESS]:[],...this.players.map(e=>e.id)]}async connect(e,t,n={}){let i=this.viewOf(e);if(i===`settings`){if(!this.runtime.multiplayer||!this.runtime.settingsScreen)throw Error(`The plugin has no settings screen (settingsScreen).`);if(this.started)throw Error(`The settings screen is only available before the game starts.`)}else if(!this.addresses().includes(e))throw Error(`Unknown address "${e}" in this session.`);if(this.ended)throw Error(`The session has ended.`);this.disposeInstance(e);let a={address:e,view:i,hostApi:void 0,endpoint:t,onDispose:n.onDispose,plugin:null,ready:!1,connected:!0,disposed:!1,resumed:!1,limiter:new r.RateLimiter(r.LIMITS.messagesPerSecond,r.LIMITS.messageBurst),serviceLimiter:new r.RateLimiter(r.LIMITS.servicesPerMinute/60,10)};a.hostApi=this.createHostApi(a),this.instances.set(e,a),e===this.authority&&this.started&&this.setAuthorityConnected(!1);try{a.plugin=await t(a.hostApi)}catch(e){if(a.disposed)return a;throw e}return a}async reload(e){let t=this.instances.get(e);if(!t)throw Error(`No instance at "${e}".`);return e===this.authority&&await this.flushSnapshot(),this.connect(e,t.endpoint,{onDispose:t.onDispose})}disconnect(e){this.disposeInstance(e),e===this.authority&&this.started&&this.setAuthorityConnected(!1)}disposeInstance(e){let t=this.instances.get(e);if(t){t.disposed=!0,this.instances.delete(e);try{t.onDispose?.()}catch(e){console.warn(`[memizy host] dispose failed:`,e)}}}getInstance(e){return this.instances.get(e)}start(){if(this.mode===`solo`||this.started&&!this.startPromise)return Promise.resolve();if(!this.settingsValid)return Promise.reject(Error(`The settings are not valid.`));let e=this.players.length,t=this.runtime.multiplayer.players;return e<t.min||e>t.max?Promise.reject(Error(`This plugin needs ${t.min}–${t.max} players (now ${e}).`)):(this.startPromise??=this.runStart(),this.startPromise)}async runStart(){this.disposeInstance(a);let e=Date.now()+(this.options.readyTimeoutMs??15e3),t=()=>[...this.instances.values()].every(e=>e.ready),n=()=>this.instances.get(this.authority)?.ready===!0;for(;!(t()&&n())&&(Date.now()<e||!n());){if(this.ended)return;await c(25)}let r=this.options.countdownMs??3e3;for(let e=Math.ceil(r/1e3);e>0;e--)this.emit({type:`countdown`,secondsLeft:e}),await c(Math.min(1e3,r-(Math.ceil(r/1e3)-e)*1e3));this.ended||(this.started=!0,this.emit({type:`started`}),await this.callPlugin(this.authority,e=>e.start()))}addPlayer(e){if(this.mode===`solo`)throw Error(`Players cannot join a solo session.`);if(this.players.some(t=>t.id===e.id)||[r.BOARD_ADDRESS,`settings`,`server`].includes(e.id))throw Error(`Player ID "${e.id}" is already used or reserved.`);if(this.started&&!this.runtime.multiplayer.lateJoin)throw Error(`This game does not allow joining after the start.`);if(this.players.length>=this.runtime.multiplayer.players.max)throw Error(`The game is full.`);this.players=[...this.players,{id:e.id,name:e.name,isHost:!1,connected:!0}],this.broadcastPlayers()}removePlayer(e){if(e===this.authority)throw Error(`The host cannot be removed.`);this.disposeInstance(e),this.players=this.players.filter(t=>t.id!==e),this.broadcastPlayers()}renamePlayer(e,t){this.players=this.players.map(n=>n.id===e?{...n,name:t}:n),this.broadcastPlayers()}setConnected(e,t){let n=this.instances.get(e);n&&(n.connected=t),this.players.some(t=>t.id===e)&&(this.players=this.players.map(n=>n.id===e?{...n,connected:t}:n),this.broadcastPlayers()),e===this.authority?this.setAuthorityConnected(t):t&&n&&this.callPlugin(e,e=>e.authorityChanged({connected:!0}))}get services(){return this.runtime.services.filter(e=>typeof this.options.services?.[e]==`function`)}get paused(){return this.pauseState.pausedAt!==null}get clock(){return{offsetMs:0,...this.pauseState}}pause(){!this.started||this.ended||this.paused||(this.pauseState={...this.pauseState,pausedAt:Date.now()},this.broadcastClock())}resume(){this.paused&&(this.pauseState={pausedMs:this.pauseState.pausedMs+Math.max(0,Date.now()-this.pauseState.pausedAt),pausedAt:null},this.broadcastClock())}broadcastClock(){let e=this.clock;for(let t of this.instances.keys())this.callPlugin(t,t=>t.clockChanged(e));this.emit({type:`paused`,paused:this.paused,clock:{...this.pauseState}})}setConfig(e){this.config={...this.config,...e};for(let e of this.instances.keys())this.callPlugin(e,e=>e.configChanged(this.config))}async end(e=`closed`){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)))))}createHostApi(e){let t=(t,n)=>(async()=>{if(e.disposed)throw new r.ProtocolError(`SESSION_ENDED`,`This instance has been closed.`);if(this.ended&&t!==`reportError`)throw new r.ProtocolError(`SESSION_ENDED`,`The session has ended.`);return n()})().catch(n=>{let i=(0,r.toProtocolError)(n);throw this.emit({type:`rejected`,address:e.address,method:t,code:i.code,message:i.detail}),i}),n=()=>e.view===`solo`||e.view===`controller`,s=(t,n)=>{if(!n)throw new r.ProtocolError(`NOT_ALLOWED_IN_VIEW`,`${t} is not available in view "${e.view}".`)},c=t=>{if(e.address!==this.authority)throw new r.ProtocolError(`NOT_AUTHORITY`,`${t} may only be called by the authority.`)};return{hello:(...n)=>t(`hello`,async()=>{let[t]=(0,r.parseHostCall)(`hello`,n),i=(0,r.negotiateProtocolVersion)(t.protocol,r.PROTOCOL_VERSION);if(!i)throw new r.ProtocolError(`UNSUPPORTED_PROTOCOL`,`The plugin speaks protocol ${t.protocol}; this host supports ${r.PROTOCOL_VERSION}.`);return t.plugin.id!==this.plugin.manifest.id&&this.emit({type:`pluginError`,address:e.address,code:`PLUGIN_ID_MISMATCH`,message:`The SDK reports plugin id "${t.plugin.id}", the manifest says "${this.plugin.manifest.id}".`}),this.buildInit(e,i)}),ready:(...n)=>t(`ready`,()=>{(0,r.parseHostCall)(`ready`,n),!e.ready&&(e.ready=!0,this.emit({type:`ready`,address:e.address}),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))))}),send:(...n)=>t(`send`,()=>{let[t]=(0,r.parseHostCall)(`send`,n);s(`send`,e.view!==`settings`);let i=(0,r.assertJsonWithin)(t.data,r.LIMITS.messageBytes,`MESSAGE_TOO_LARGE`,`message`),o=e.address===this.authority?1/Math.max(1,this.instances.size-1):1;if(!e.limiter.tryTake(o))throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.messagesPerSecond} messages per second.`);if(!e.connected)return;if(t.to===`authority`){let n=this.instances.get(this.authority);if(!n||!n.connected||!this.authorityConnected)throw new r.ProtocolError(`AUTHORITY_UNAVAILABLE`,`The host is not reachable right now.`);this.deliver(e.address,this.authority,t.data,i);return}let c=t.to===`all`?[...this.instances.keys()].filter(t=>t!==e.address&&t!==`settings`):t.to.filter(e=>e!==a);for(let n of c)this.deliver(e.address,n,t.data,i)}),saveSnapshot:(...e)=>t(`saveSnapshot`,()=>{let[t]=(0,r.parseHostCall)(`saveSnapshot`,e);c(`saveSnapshot`),(0,r.assertJsonWithin)(t,r.LIMITS.snapshotBytes,`SNAPSHOT_TOO_LARGE`,`snapshot`),this.pendingSnapshot=structuredClone(t),this.snapshotTimer??=setTimeout(()=>void this.flushSnapshot(),1e3/r.LIMITS.snapshotsPerSecond)}),recordAnswer:(...n)=>t(`recordAnswer`,async()=>{let[t]=(0,r.parseHostCall)(`recordAnswer`,n),i=this.players.some(t=>t.id===e.address)?e.address:null,a=t.playerId??i;if(!a)throw new r.ProtocolError(`INVALID_ARGUMENT`,`recordAnswer needs a playerId on the board.`);if(a!==i&&c(`recordAnswer for another player`),!this.players.some(e=>e.id===a))throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown player "${a}".`);t.answer!==void 0&&(0,r.assertJsonWithin)(t.answer,r.LIMITS.answerBytes,`DATA_TOO_LARGE`,`answer`);let o=this.prepared.set.items.find(e=>e.id===t.itemId),s=e=>Array.isArray(e)?e.filter(e=>typeof e==`string`):[];if(!o){if(!t.item)throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown item "${t.itemId}" (attach the item for a generated one).`);(0,r.assertJsonWithin)(t.item,r.LIMITS.generatedItemBytes,`DATA_TOO_LARGE`,`item`),this.emit({type:`answer`,playerId:a,record:null,answer:t,generated:!0,skills:s(t.item.skills),tags:s(t.item.tags)});return}let l=this.userKey(a),u=this.prepared.set.meta.id,d=await this.storage.loadProgress(l,u),{item:f,...p}=t,m=this.algorithm.apply(d[t.itemId],p,new Date);await this.storage.saveProgress(l,u,{[t.itemId]:m}),this.emit({type:`answer`,playerId:a,record:m,answer:p,generated:!1,skills:s(o.skills),tags:s(o.tags)})}),saveProgress:(...i)=>t(`saveProgress`,async()=>{let[t]=(0,r.parseHostCall)(`saveProgress`,i);s(`saveProgress`,n());let a=new Set(this.prepared.set.items.map(e=>e.id)),o=Object.keys(t).filter(e=>!a.has(e));if(o.length>0)throw new r.ProtocolError(`INVALID_ARGUMENT`,`Unknown items: ${o.join(`, `)}.`);await this.storage.saveProgress(this.userKey(e.address),this.prepared.set.meta.id,t)}),saveData:(...i)=>t(`saveData`,()=>{let[t,a]=(0,r.parseHostCall)(`saveData`,i);s(`saveData`,n()),(0,r.assertJsonWithin)(a,r.LIMITS.dataBytes,`DATA_TOO_LARGE`,`plugin data (${t})`);let o=`${e.address}|${t}`,c=this.pendingData.get(o);if(c)c.value=structuredClone(a);else{let n={playerId:e.address,scope:t,value:structuredClone(a),timer:setTimeout(()=>void this.flushData(o),1e3/r.LIMITS.dataWritesPerSecond)};this.pendingData.set(o,n)}}),updateSettings:(...n)=>t(`updateSettings`,()=>{let[t]=(0,r.parseHostCall)(`updateSettings`,n);s(`updateSettings`,e.view===`settings`);let{values:i,errors:a}=(0,r.resolveSettings)(this.runtime.settings,t.values);this.settings=i,this.settingsValid=t.valid&&a.length===0;let o=t.message??a[0];this.emit({type:`settings`,values:i,valid:this.settingsValid,...o&&{message:o}})}),getAsset:(...e)=>t(`getAsset`,async()=>{let[t,n]=(0,r.parseHostCall)(`getAsset`,e),a=n?this.options.set.items.find(e=>e.id===n):void 0,o=(0,i.resolveAsset)(t,a,this.options.set.meta);if(!o)throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" does not exist.`);if(this.options.loadAsset)return this.options.loadAsset(t,n);if(/^https?:/i.test(o.value)){let e=await fetch(o.value);if(e.ok)return e.blob()}throw new r.ProtocolError(`ASSET_NOT_FOUND`,`Asset "${t}" cannot be loaded by this host.`)}),end:(...e)=>t(`end`,async()=>{let[t]=(0,r.parseHostCall)(`end`,e);c(`end`),this.emit({type:`ended`,result:t})}),resize:(...n)=>t(`resize`,()=>{let[t]=(0,r.parseHostCall)(`resize`,n);this.emit({type:`resize`,address:e.address,height:t.height})}),reportError:(...n)=>t(`reportError`,()=>{let[t]=(0,r.parseHostCall)(`reportError`,n);this.emit({type:`pluginError`,address:e.address,code:t.code,message:t.message,...t.context&&{context:t.context}})}),exit:(...n)=>t(`exit`,()=>{(0,r.parseHostCall)(`exit`,n),this.emit({type:`exit`,address:e.address})}),service:(...n)=>t(`service`,async()=>{let[t,i]=(0,r.parseHostCall)(`service`,n),a=this.services.includes(t)?this.options.services[t]:void 0;if(!a)throw new r.ProtocolError(`SERVICE_UNAVAILABLE`,`Service "${t}" is not available here (declare it in the manifest "services"; this host offers: ${this.services.join(`, `)||`none`}).`);if((0,r.assertJsonWithin)(i,r.LIMITS.serviceBytes,`MESSAGE_TOO_LARGE`,`service ${t} request`),!e.serviceLimiter.tryTake())throw new r.ProtocolError(`RATE_LIMITED`,`More than ${r.LIMITS.servicesPerMinute} service calls per minute.`);let s=this.players.some(t=>t.id===e.address)?e.address:null,c=await a(o(i),{playerId:s,address:e.address,pluginId:this.plugin.manifest.id,setId:this.prepared.set.meta.id});return(0,r.assertJsonWithin)(c??null,r.LIMITS.messageBytes*4,`MESSAGE_TOO_LARGE`,`service ${t} result`),o(c??null)})}}async buildInit(e,t){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;e.address===this.authority&&await this.flushSnapshot();let o=e.address===this.authority?await this.storage.loadSnapshot(this.id):null;return e.resumed=o!==null,{protocol:t,host:this.options.host??{name:`@memizy/host-sdk`,version:`1.0.0-rc.4`},oqseVersion:`1.0`,features:[],session:{id:this.id,mode:this.mode,hostAs:this.hostAs,view:e.view,self:e.address,authority:this.authority,lateJoin:this.started&&e.view!==`settings`},players:this.players,set:structuredClone(this.mode===`multiplayer`&&e.address!==this.authority?this.prepared.publicSet:this.prepared.set),settings:{...this.settings},config:this.config,clock:this.clock,services:this.services,progress:i?await this.storage.loadProgress(i,r):{},data:i?{plugin:this.pendingDataValue(e.address,`plugin`)??await this.storage.loadData(i,a,`plugin`,r)??null,set:this.pendingDataValue(e.address,`set`)??await this.storage.loadData(i,a,`set`,r)??null}:{plugin:null,set:null},snapshot:o}}userKey(e){return this.options.userKeyFor?.(e)??e}deliver(e,t,n,r){let i=this.instances.get(t);if(!i||!i.connected||!i.plugin)return;this.emit({type:`traffic`,from:e,to:t,bytes:r,data:n});let a=structuredClone(n);setTimeout(()=>void this.callPlugin(t,t=>t.deliver({from:e,data:a,sentAt:Date.now()})),0)}broadcastPlayers(){this.emit({type:`players`,players:this.players});for(let e of this.instances.keys())this.callPlugin(e,e=>e.playersChanged(this.players))}setAuthorityConnected(e){if(this.authorityConnected!==e){this.authorityConnected=e,this.emit({type:`authority`,connected:e});for(let t of this.instances.keys())t!==this.authority&&t!==`settings`&&this.callPlugin(t,t=>t.authorityChanged({connected:e}))}}async callPlugin(e,t){let n=this.instances.get(e);if(n?.plugin&&!n.disposed)try{await t(n.plugin)}catch(t){this.emit({type:`pluginError`,address:e,code:`PLUGIN_CALL_FAILED`,message:(0,r.toProtocolError)(t).detail})}}async flushSnapshot(){if(clearTimeout(this.snapshotTimer),this.snapshotTimer=void 0,this.pendingSnapshot===void 0)return;let e=this.pendingSnapshot;this.pendingSnapshot=void 0,await this.storage.saveSnapshot(this.id,e)}pendingDataValue(e,t){return this.pendingData.get(`${e}|${t}`)?.value}async flushData(e){let t=e?[[e,this.pendingData.get(e)]]:[...this.pendingData.entries()];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))}};function c(e){return new Promise(t=>setTimeout(t,Math.max(0,e)))}exports.LocalSession=s,exports.SETTINGS_ADDRESS=a;
|