@jgengine/ws 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +346 -0
- package/dist/createWsBackend.js +2 -1
- package/dist/host.js +1 -11
- package/dist/hostRouter.d.ts +2 -0
- package/dist/hostRouter.js +155 -33
- package/dist/peer.js +45 -12
- package/dist/worldHost.d.ts +13 -2
- package/dist/worldHost.js +132 -47
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +22 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-multiplayer/SKILL.md +1 -1
- package/skills/jgengine-multiplayer/api.md +6 -6
- package/skills/jgengine-multiplayer/reference.md +27 -3
- package/skills/jgengine-multiplayer/references/peer-worlds.md +62 -0
package/dist/hostRouter.js
CHANGED
|
@@ -53,6 +53,13 @@ export function createHostRouter(options) {
|
|
|
53
53
|
const authenticate = options.authenticate ??
|
|
54
54
|
(allowAnonymous ? ({ userId }) => userId : null);
|
|
55
55
|
const connections = new Set();
|
|
56
|
+
let closed = false;
|
|
57
|
+
const work = new Set();
|
|
58
|
+
const track = (promise) => {
|
|
59
|
+
work.add(promise);
|
|
60
|
+
void promise.finally(() => work.delete(promise)).catch(() => { });
|
|
61
|
+
return promise;
|
|
62
|
+
};
|
|
56
63
|
const sessionsByUserId = new Map();
|
|
57
64
|
const resumeTokens = new Map();
|
|
58
65
|
const pendingLeaves = new Map();
|
|
@@ -77,6 +84,45 @@ export function createHostRouter(options) {
|
|
|
77
84
|
}
|
|
78
85
|
return false;
|
|
79
86
|
};
|
|
87
|
+
const scheduleLeave = (connection, userId, serverId) => {
|
|
88
|
+
clearPendingLeave(userId, serverId);
|
|
89
|
+
const key = ticketKey(userId, serverId);
|
|
90
|
+
pendingLeaves.set(key, setTimeout(() => {
|
|
91
|
+
pendingLeaves.delete(key);
|
|
92
|
+
void track((async () => {
|
|
93
|
+
const admissions = [...connections]
|
|
94
|
+
.filter(other => other !== connection && other.userId === userId)
|
|
95
|
+
.flatMap(other => other.admission === null || (other.admission.serverId !== undefined && other.admission.serverId !== serverId)
|
|
96
|
+
? [] : [other.admission.result]);
|
|
97
|
+
await Promise.allSettled(admissions);
|
|
98
|
+
if (closed || heldByOtherConnection(connection, userId, serverId))
|
|
99
|
+
return;
|
|
100
|
+
await host.leaveServer({ userId, serverId });
|
|
101
|
+
})()).catch(() => { });
|
|
102
|
+
}, graceMs));
|
|
103
|
+
};
|
|
104
|
+
const admit = async (connection, userId, join, role, sessionId, serverId) => {
|
|
105
|
+
const admission = join().then(result => {
|
|
106
|
+
if (result === null || closed)
|
|
107
|
+
return result;
|
|
108
|
+
if (connection.closed || connection.userId !== userId) {
|
|
109
|
+
scheduleLeave(connection, userId, result.serverId);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
connection.joinedServers.add(result.serverId);
|
|
113
|
+
connection.roles.set(result.serverId, role ?? "player");
|
|
114
|
+
if (sessionId !== undefined)
|
|
115
|
+
getOrCreate(connection.sessions, result.serverId, () => new Set()).add(sessionId);
|
|
116
|
+
return result;
|
|
117
|
+
});
|
|
118
|
+
connection.admission = { serverId, result: admission };
|
|
119
|
+
try {
|
|
120
|
+
return await admission;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
connection.admission = null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
80
126
|
const subscribers = new Map();
|
|
81
127
|
const subscribersOf = (key) => {
|
|
82
128
|
const set = subscribers.get(key);
|
|
@@ -84,6 +130,13 @@ export function createHostRouter(options) {
|
|
|
84
130
|
};
|
|
85
131
|
const addSubscription = (connection, key) => {
|
|
86
132
|
connection.subscriptions.add(key);
|
|
133
|
+
const push = connection.subscriptionPushes.get(key);
|
|
134
|
+
if (push === undefined) {
|
|
135
|
+
connection.subscriptionPushes.set(key, { generation: 0, pending: false });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
push.generation += 1;
|
|
139
|
+
}
|
|
87
140
|
let set = subscribers.get(key);
|
|
88
141
|
if (set === undefined) {
|
|
89
142
|
set = new Set();
|
|
@@ -93,6 +146,13 @@ export function createHostRouter(options) {
|
|
|
93
146
|
};
|
|
94
147
|
const removeSubscription = (connection, key) => {
|
|
95
148
|
connection.subscriptions.delete(key);
|
|
149
|
+
const push = connection.subscriptionPushes.get(key);
|
|
150
|
+
if (push !== undefined) {
|
|
151
|
+
push.generation += 1;
|
|
152
|
+
push.pending = false;
|
|
153
|
+
if (push.run === undefined)
|
|
154
|
+
connection.subscriptionPushes.delete(key);
|
|
155
|
+
}
|
|
96
156
|
const set = subscribers.get(key);
|
|
97
157
|
if (set === undefined)
|
|
98
158
|
return;
|
|
@@ -105,6 +165,8 @@ export function createHostRouter(options) {
|
|
|
105
165
|
const histories = new Map();
|
|
106
166
|
const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
|
|
107
167
|
const send = (connection, message) => {
|
|
168
|
+
if (closed || connection.closed)
|
|
169
|
+
return;
|
|
108
170
|
connection.transport.send(encodeWsMessage(message));
|
|
109
171
|
};
|
|
110
172
|
const reply = (connection, id, result) => {
|
|
@@ -156,6 +218,8 @@ export function createHostRouter(options) {
|
|
|
156
218
|
atMs: now(),
|
|
157
219
|
...args,
|
|
158
220
|
});
|
|
221
|
+
if (closed || connection.closed)
|
|
222
|
+
return false;
|
|
159
223
|
if (!decision.allow) {
|
|
160
224
|
replyError(connection, id, decision.reason);
|
|
161
225
|
return false;
|
|
@@ -244,32 +308,42 @@ export function createHostRouter(options) {
|
|
|
244
308
|
reply(connection, message.id, null);
|
|
245
309
|
broadcastChat(message.serverId, message.channelId);
|
|
246
310
|
};
|
|
247
|
-
const pushSubscription = async (connection, channel, serverId, action) => {
|
|
248
|
-
if (connection.userId === null)
|
|
311
|
+
const pushSubscription = async (connection, channel, serverId, action, isCurrent) => {
|
|
312
|
+
if (connection.userId === null || !isCurrent())
|
|
249
313
|
return;
|
|
314
|
+
if (host.pullWorld !== undefined) {
|
|
315
|
+
if (!(await host.isMember({ userId: connection.userId, serverId })) || !isCurrent())
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
250
318
|
if (channel === "server") {
|
|
251
319
|
if (host.pullWorld !== undefined) {
|
|
252
320
|
const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
|
|
253
321
|
const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
|
|
322
|
+
if (!isCurrent())
|
|
323
|
+
return;
|
|
254
324
|
if (world !== null) {
|
|
255
|
-
connection.worldRevisions.set(serverId, world.revision);
|
|
256
325
|
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
257
|
-
if (data !== null) {
|
|
326
|
+
if (data !== null && isCurrent()) {
|
|
258
327
|
send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
|
|
328
|
+
connection.worldRevisions.set(serverId, world.revision);
|
|
259
329
|
}
|
|
260
330
|
return;
|
|
261
331
|
}
|
|
332
|
+
return;
|
|
262
333
|
}
|
|
263
334
|
const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
|
|
264
|
-
|
|
335
|
+
if (isCurrent())
|
|
336
|
+
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
265
337
|
}
|
|
266
338
|
else if (channel === "player") {
|
|
267
339
|
const data = await host.getPlayerView({ userId: connection.userId, serverId });
|
|
268
|
-
|
|
340
|
+
if (isCurrent())
|
|
341
|
+
send(connection, { v: 1, t: "update", channel, serverId, data });
|
|
269
342
|
}
|
|
270
343
|
else if (channel === "feed") {
|
|
271
344
|
const data = await host.getFeed({ userId: connection.userId, serverId, action: action ?? "" });
|
|
272
|
-
|
|
345
|
+
if (isCurrent())
|
|
346
|
+
send(connection, { v: 1, t: "update", channel, serverId, action: action ?? "", data });
|
|
273
347
|
}
|
|
274
348
|
else if (channel === "chat") {
|
|
275
349
|
const channelId = action ?? "";
|
|
@@ -297,18 +371,50 @@ export function createHostRouter(options) {
|
|
|
297
371
|
send(connection, { v: 1, t: "update", channel, serverId, data: presenceRows(serverId) });
|
|
298
372
|
}
|
|
299
373
|
};
|
|
374
|
+
const requestSubscriptionPush = (connection, channel, serverId, action) => {
|
|
375
|
+
const key = subscriptionKey(channel, serverId, action);
|
|
376
|
+
const push = connection.subscriptionPushes.get(key);
|
|
377
|
+
if (push === undefined)
|
|
378
|
+
return Promise.resolve();
|
|
379
|
+
push.pending = true;
|
|
380
|
+
if (push.run !== undefined)
|
|
381
|
+
return push.run;
|
|
382
|
+
const isActive = () => connections.has(connection) && connection.userId !== null && connection.subscriptions.has(key) && connection.subscriptionPushes.get(key) === push;
|
|
383
|
+
push.run = track(Promise.resolve().then(async () => {
|
|
384
|
+
while (push.pending && isActive()) {
|
|
385
|
+
push.pending = false;
|
|
386
|
+
const generation = push.generation;
|
|
387
|
+
try {
|
|
388
|
+
await pushSubscription(connection, channel, serverId, action, () => isActive() && push.generation === generation);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// A failed read leaves the sent revision intact; a later notification retries it.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}).finally(() => {
|
|
395
|
+
push.run = undefined;
|
|
396
|
+
if (!isActive()) {
|
|
397
|
+
if (connection.subscriptionPushes.get(key) === push)
|
|
398
|
+
connection.subscriptionPushes.delete(key);
|
|
399
|
+
}
|
|
400
|
+
else if (push.pending) {
|
|
401
|
+
void requestSubscriptionPush(connection, channel, serverId, action);
|
|
402
|
+
}
|
|
403
|
+
}));
|
|
404
|
+
return push.run;
|
|
405
|
+
};
|
|
300
406
|
const onHostEvent = (event) => {
|
|
301
407
|
if (event.type === "server") {
|
|
302
408
|
for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
|
|
303
409
|
if (connection.userId === null)
|
|
304
410
|
continue;
|
|
305
|
-
void
|
|
411
|
+
void requestSubscriptionPush(connection, "server", event.serverId);
|
|
306
412
|
}
|
|
307
413
|
}
|
|
308
414
|
else if (event.type === "player") {
|
|
309
415
|
for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
|
|
310
416
|
if (connection.userId === event.userId) {
|
|
311
|
-
void
|
|
417
|
+
void requestSubscriptionPush(connection, "player", event.serverId);
|
|
312
418
|
}
|
|
313
419
|
}
|
|
314
420
|
}
|
|
@@ -316,7 +422,7 @@ export function createHostRouter(options) {
|
|
|
316
422
|
for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
|
|
317
423
|
if (connection.userId === null)
|
|
318
424
|
continue;
|
|
319
|
-
void
|
|
425
|
+
void requestSubscriptionPush(connection, "feed", event.serverId, event.action);
|
|
320
426
|
}
|
|
321
427
|
}
|
|
322
428
|
};
|
|
@@ -365,6 +471,8 @@ export function createHostRouter(options) {
|
|
|
365
471
|
dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
|
|
366
472
|
};
|
|
367
473
|
const handleMessage = async (connection, message) => {
|
|
474
|
+
if (closed || connection.closed)
|
|
475
|
+
return;
|
|
368
476
|
if (message.t === "hello") {
|
|
369
477
|
if (connection.userId !== null) {
|
|
370
478
|
replyError(connection, message.id, "Already authenticated");
|
|
@@ -379,6 +487,8 @@ export function createHostRouter(options) {
|
|
|
379
487
|
const userId = resumeToken !== undefined && message.token === resumeToken
|
|
380
488
|
? message.userId
|
|
381
489
|
: await authenticate({ userId: message.userId, token: message.token });
|
|
490
|
+
if (closed || connection.closed)
|
|
491
|
+
return;
|
|
382
492
|
if (userId === null) {
|
|
383
493
|
replyError(connection, message.id, "Not authenticated");
|
|
384
494
|
connection.transport.close();
|
|
@@ -424,18 +534,18 @@ export function createHostRouter(options) {
|
|
|
424
534
|
case "join": {
|
|
425
535
|
if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
|
|
426
536
|
return;
|
|
427
|
-
const result = await host.joinServer({
|
|
537
|
+
const result = await admit(connection, userId, () => host.joinServer({
|
|
428
538
|
userId,
|
|
429
539
|
gameId: message.gameId,
|
|
430
540
|
serverId: message.serverId,
|
|
431
541
|
attributes: message.attributes,
|
|
432
542
|
code: message.code,
|
|
433
543
|
role: message.role,
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
544
|
+
}), message.role, message.sessionId, message.serverId);
|
|
545
|
+
if (result === null)
|
|
546
|
+
return;
|
|
547
|
+
if (closed || connection.closed || connection.userId !== userId)
|
|
548
|
+
return;
|
|
439
549
|
reply(connection, message.id, {
|
|
440
550
|
...result,
|
|
441
551
|
resumeTicket: ticketFor(userId, result.serverId),
|
|
@@ -445,14 +555,14 @@ export function createHostRouter(options) {
|
|
|
445
555
|
case "joinByCode": {
|
|
446
556
|
if (!(await gate(connection, message.id, "join", {})))
|
|
447
557
|
return;
|
|
448
|
-
const result = await host.joinByCode({
|
|
558
|
+
const result = await admit(connection, userId, () => host.joinByCode({
|
|
449
559
|
userId,
|
|
450
560
|
gameId: message.gameId,
|
|
451
561
|
code: message.code,
|
|
452
|
-
});
|
|
562
|
+
}), message.role);
|
|
453
563
|
if (result !== null) {
|
|
454
|
-
connection.
|
|
455
|
-
|
|
564
|
+
if (closed || connection.closed || connection.userId !== userId)
|
|
565
|
+
return;
|
|
456
566
|
}
|
|
457
567
|
reply(connection, message.id, result === null
|
|
458
568
|
? null
|
|
@@ -566,18 +676,20 @@ export function createHostRouter(options) {
|
|
|
566
676
|
return;
|
|
567
677
|
}
|
|
568
678
|
case "subscribe": {
|
|
569
|
-
if ((message.channel === "chat" ||
|
|
679
|
+
if ((host.pullWorld !== undefined || message.channel === "chat" ||
|
|
570
680
|
message.channel === "voice" ||
|
|
571
681
|
message.channel === "presence") &&
|
|
572
682
|
!(await host.isMember({ userId, serverId: message.serverId }))) {
|
|
573
683
|
replyError(connection, message.id, "Not a member of this server");
|
|
574
684
|
return;
|
|
575
685
|
}
|
|
686
|
+
if (closed || connection.closed)
|
|
687
|
+
return;
|
|
576
688
|
if (message.channel === "server")
|
|
577
689
|
connection.worldRevisions.set(message.serverId, null);
|
|
578
690
|
addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
|
|
579
691
|
reply(connection, message.id, null);
|
|
580
|
-
|
|
692
|
+
void requestSubscriptionPush(connection, message.channel, message.serverId, message.action);
|
|
581
693
|
return;
|
|
582
694
|
}
|
|
583
695
|
case "unsubscribe": {
|
|
@@ -592,26 +704,22 @@ export function createHostRouter(options) {
|
|
|
592
704
|
}
|
|
593
705
|
};
|
|
594
706
|
const leaveJoinedServers = (connection) => {
|
|
595
|
-
if (connection.userId === null)
|
|
707
|
+
if (closed || connection.userId === null)
|
|
596
708
|
return;
|
|
597
709
|
const userId = connection.userId;
|
|
598
710
|
const servers = [...connection.joinedServers];
|
|
599
711
|
connection.joinedServers.clear();
|
|
600
712
|
connection.sessions.clear();
|
|
601
713
|
for (const serverId of servers) {
|
|
602
|
-
|
|
603
|
-
clearPendingLeave(userId, serverId);
|
|
604
|
-
pendingLeaves.set(key, setTimeout(() => {
|
|
605
|
-
pendingLeaves.delete(key);
|
|
606
|
-
if (heldByOtherConnection(connection, userId, serverId))
|
|
607
|
-
return;
|
|
608
|
-
void host.leaveServer({ userId, serverId }).catch(() => undefined);
|
|
609
|
-
}, graceMs));
|
|
714
|
+
scheduleLeave(connection, userId, serverId);
|
|
610
715
|
}
|
|
611
716
|
};
|
|
612
717
|
return {
|
|
613
718
|
connect: (transport) => {
|
|
719
|
+
if (closed)
|
|
720
|
+
throw new Error("Host router is closed");
|
|
614
721
|
const connection = {
|
|
722
|
+
closed: false,
|
|
615
723
|
transport,
|
|
616
724
|
userId: null,
|
|
617
725
|
subscriptions: new Set(),
|
|
@@ -621,10 +729,14 @@ export function createHostRouter(options) {
|
|
|
621
729
|
queuedMessages: 0,
|
|
622
730
|
worldRevisions: new Map(),
|
|
623
731
|
roles: new Map(),
|
|
732
|
+
subscriptionPushes: new Map(),
|
|
733
|
+
admission: null,
|
|
624
734
|
};
|
|
625
735
|
connections.add(connection);
|
|
626
736
|
return {
|
|
627
737
|
handleRaw: (raw) => {
|
|
738
|
+
if (closed || connection.closed)
|
|
739
|
+
return;
|
|
628
740
|
const text = typeof raw === "string" ? raw : String(raw);
|
|
629
741
|
const message = decodeWsClientMessage(text);
|
|
630
742
|
if (message === null) {
|
|
@@ -640,14 +752,17 @@ export function createHostRouter(options) {
|
|
|
640
752
|
return;
|
|
641
753
|
}
|
|
642
754
|
connection.queuedMessages += 1;
|
|
643
|
-
connection.queue = connection.queue
|
|
755
|
+
connection.queue = track(connection.queue
|
|
644
756
|
.then(() => handleMessage(connection, message))
|
|
645
757
|
.catch(() => undefined)
|
|
646
758
|
.finally(() => {
|
|
647
759
|
connection.queuedMessages -= 1;
|
|
648
|
-
});
|
|
760
|
+
}));
|
|
649
761
|
},
|
|
650
762
|
close: () => {
|
|
763
|
+
if (connection.closed)
|
|
764
|
+
return;
|
|
765
|
+
connection.closed = true;
|
|
651
766
|
if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
|
|
652
767
|
sessionsByUserId.delete(connection.userId);
|
|
653
768
|
}
|
|
@@ -674,6 +789,9 @@ export function createHostRouter(options) {
|
|
|
674
789
|
return positions;
|
|
675
790
|
},
|
|
676
791
|
close: () => {
|
|
792
|
+
if (closed)
|
|
793
|
+
return;
|
|
794
|
+
closed = true;
|
|
677
795
|
unsubscribeHost();
|
|
678
796
|
for (const timer of pendingLeaves.values())
|
|
679
797
|
clearTimeout(timer);
|
|
@@ -681,6 +799,10 @@ export function createHostRouter(options) {
|
|
|
681
799
|
connections.clear();
|
|
682
800
|
subscribers.clear();
|
|
683
801
|
},
|
|
802
|
+
async drain() {
|
|
803
|
+
while (work.size > 0)
|
|
804
|
+
await Promise.allSettled([...work]);
|
|
805
|
+
},
|
|
684
806
|
};
|
|
685
807
|
}
|
|
686
808
|
export function loopbackPipe(router) {
|
package/dist/peer.js
CHANGED
|
@@ -126,12 +126,19 @@ export function createPeerHost(options) {
|
|
|
126
126
|
};
|
|
127
127
|
channel.onclose = () => connection.close();
|
|
128
128
|
};
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
try {
|
|
130
|
+
await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
|
|
131
|
+
const answer = await pc.createAnswer();
|
|
132
|
+
await pc.setLocalDescription(answer);
|
|
133
|
+
await waitForIceGathering(pc, iceGatherTimeoutMs);
|
|
134
|
+
const description = localDescriptionOrThrow(pc);
|
|
135
|
+
return encodePeerSignal({ type: "answer", sdp: description.sdp });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
peerConnections.delete(pc);
|
|
139
|
+
pc.close();
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
135
142
|
};
|
|
136
143
|
return {
|
|
137
144
|
backend,
|
|
@@ -190,7 +197,10 @@ export function broadcastChannelSignaling(room) {
|
|
|
190
197
|
const channel = new BroadcastChannel(room);
|
|
191
198
|
const pendingAnswers = new Map();
|
|
192
199
|
let currentAnswer = null;
|
|
200
|
+
let closed = false;
|
|
193
201
|
channel.onmessage = (event) => {
|
|
202
|
+
if (closed)
|
|
203
|
+
return;
|
|
194
204
|
const envelope = event.data;
|
|
195
205
|
if (!isSignalEnvelope(envelope))
|
|
196
206
|
return;
|
|
@@ -199,25 +209,40 @@ export function broadcastChannelSignaling(room) {
|
|
|
199
209
|
if (answer === null)
|
|
200
210
|
return;
|
|
201
211
|
void answer(envelope.code).then((answerCode) => {
|
|
212
|
+
if (closed)
|
|
213
|
+
return;
|
|
202
214
|
channel.postMessage({ t: "answer", id: envelope.id, code: answerCode });
|
|
215
|
+
}, (error) => {
|
|
216
|
+
if (!closed)
|
|
217
|
+
throw error;
|
|
203
218
|
});
|
|
204
219
|
return;
|
|
205
220
|
}
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
221
|
+
const pending = pendingAnswers.get(envelope.id);
|
|
222
|
+
if (pending === undefined)
|
|
208
223
|
return;
|
|
209
224
|
pendingAnswers.delete(envelope.id);
|
|
210
|
-
resolve(envelope.code);
|
|
225
|
+
pending.resolve(envelope.code);
|
|
211
226
|
};
|
|
212
227
|
return {
|
|
213
228
|
publishOffer: (offerCode) => {
|
|
229
|
+
if (closed)
|
|
230
|
+
return Promise.reject(new Error("Peer signaling is closed"));
|
|
214
231
|
const id = crypto.randomUUID();
|
|
215
|
-
return new Promise((resolve) => {
|
|
216
|
-
pendingAnswers.set(id, resolve);
|
|
217
|
-
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
pendingAnswers.set(id, { resolve, reject });
|
|
234
|
+
try {
|
|
235
|
+
channel.postMessage({ t: "offer", id, code: offerCode });
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
pendingAnswers.delete(id);
|
|
239
|
+
reject(error);
|
|
240
|
+
}
|
|
218
241
|
});
|
|
219
242
|
},
|
|
220
243
|
onOffer: (answer) => {
|
|
244
|
+
if (closed)
|
|
245
|
+
throw new Error("Peer signaling is closed");
|
|
221
246
|
currentAnswer = answer;
|
|
222
247
|
return () => {
|
|
223
248
|
if (currentAnswer === answer)
|
|
@@ -225,6 +250,14 @@ export function broadcastChannelSignaling(room) {
|
|
|
225
250
|
};
|
|
226
251
|
},
|
|
227
252
|
close: () => {
|
|
253
|
+
if (closed)
|
|
254
|
+
return;
|
|
255
|
+
closed = true;
|
|
256
|
+
currentAnswer = null;
|
|
257
|
+
channel.onmessage = null;
|
|
258
|
+
for (const pending of pendingAnswers.values()) {
|
|
259
|
+
pending.reject(new Error("Peer signaling is closed"));
|
|
260
|
+
}
|
|
228
261
|
pendingAnswers.clear();
|
|
229
262
|
channel.close();
|
|
230
263
|
},
|
package/dist/worldHost.d.ts
CHANGED
|
@@ -4,18 +4,29 @@ import type { GameHost } from "./host.js";
|
|
|
4
4
|
export interface WorldGameHostOptions {
|
|
5
5
|
/**
|
|
6
6
|
* Resolve (or lazily build) the authoritative {@link HostedWorldSession} for a server — called once per new
|
|
7
|
-
* `serverId
|
|
7
|
+
* loaded `serverId`, and again after successful unload. Create a fresh session on reload. Return `null` for an
|
|
8
|
+
* unknown game. Bind the game's definition + content here.
|
|
8
9
|
*/
|
|
9
10
|
session(args: {
|
|
10
11
|
gameId: string;
|
|
11
12
|
serverId: string;
|
|
12
13
|
}): HostedWorldSession | Promise<HostedWorldSession | null> | null;
|
|
13
14
|
now?: () => number;
|
|
15
|
+
/** Maximum active players per world; reconnecting members and spectators do not consume a new slot. */
|
|
16
|
+
slotsPerServer?: number;
|
|
14
17
|
}
|
|
15
|
-
/** A {@link GameHost}
|
|
18
|
+
/** A {@link GameHost} backed by `HostedWorldSession`s; `tick` advances worlds and `unload` explicitly saves and releases idle world references. */
|
|
16
19
|
export interface WorldGameHost extends GameHost {
|
|
17
20
|
/** Advance every live world by `dtSeconds` and emit a `server` change for each whose revision moved. */
|
|
18
21
|
tick(dtSeconds: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Save and release an unoccupied world's host references. Returns `occupied` for admitted players/spectators or
|
|
24
|
+
* resident members, and `missing` for an unloaded world. Save failure rejects and retains the world for retry.
|
|
25
|
+
* Later joins resolve a fresh session; external resources remain caller-owned. Closed hosts reject new unloads.
|
|
26
|
+
*/
|
|
27
|
+
unload(serverId: string): Promise<"unloaded" | "missing" | "occupied">;
|
|
28
|
+
/** Permanently close admission/ticks, drain accepted world operations, and save. Repeated calls share completion. */
|
|
29
|
+
stop(): Promise<void>;
|
|
19
30
|
}
|
|
20
31
|
/**
|
|
21
32
|
* The GameContext-loop counterpart of the reducer `createGameHost`: a structural {@link GameHost} that serves each
|