castle-web-sdk 0.4.47 → 0.4.48
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 +197 -0
- package/dist/castle.d.ts +7 -3
- package/dist/castle.js +2 -0
- package/dist/commands.d.ts +128 -1
- package/dist/commands.js +3 -0
- package/dist/display.d.ts +3 -0
- package/dist/display.js +9 -3
- package/dist/hub.d.ts +14 -2
- package/dist/hub.js +34 -0
- package/dist/multiplayer.d.ts +7 -1
- package/dist/multiplayer.js +63 -2
- package/dist/multiplayerProtocol.d.ts +2 -0
- package/dist/party.d.ts +23 -0
- package/dist/party.js +91 -0
- package/dist/save.d.ts +26 -1
- package/dist/save.js +52 -6
- package/dist/server/hub.d.ts +24 -1
- package/dist/server/hub.js +35 -3
- package/dist/server/platformHandle.d.ts +9 -1
- package/dist/server/storage.d.ts +2 -0
- package/dist/server/storage.js +11 -3
- package/dist/server/wrapper.d.ts +7 -1
- package/dist/server/wrapper.js +10 -1
- package/dist/social.d.ts +9 -0
- package/dist/social.js +5 -0
- package/package.json +1 -1
package/dist/multiplayer.js
CHANGED
|
@@ -46,13 +46,17 @@ async function join(options) {
|
|
|
46
46
|
const request = hub
|
|
47
47
|
? { command: "hub", nonce: hub.nonce }
|
|
48
48
|
: { command: "multiplayer.getSession", params: joinParams(options) };
|
|
49
|
+
const pongTimeoutMs = options.pongTimeoutMs ?? 0;
|
|
50
|
+
if (!Number.isFinite(pongTimeoutMs) || pongTimeoutMs < 0 || pongTimeoutMs > 2_147_483_647) {
|
|
51
|
+
throw new RangeError("pongTimeoutMs must be between 0 and 2147483647 milliseconds");
|
|
52
|
+
}
|
|
49
53
|
latestJoinAttempt += 1;
|
|
50
54
|
cancelPendingDestinationJoin();
|
|
51
55
|
activeConnection?.leave();
|
|
52
56
|
const connection = new MultiplayerConnectionImpl(request, () => {
|
|
53
57
|
if (activeConnection === connection)
|
|
54
58
|
activeConnection = null;
|
|
55
|
-
});
|
|
59
|
+
}, null, pongTimeoutMs);
|
|
56
60
|
activeConnection = connection;
|
|
57
61
|
await connection.start(hub ?? undefined);
|
|
58
62
|
// The host moves its place (and every place-scoped command with it) when the
|
|
@@ -183,9 +187,12 @@ class MultiplayerConnectionImpl {
|
|
|
183
187
|
request;
|
|
184
188
|
onClosed;
|
|
185
189
|
destinationAudience;
|
|
190
|
+
pongTimeoutMs;
|
|
186
191
|
currentState = "acquiring";
|
|
187
192
|
currentPlayerId = "";
|
|
188
193
|
currentSessionId = "";
|
|
194
|
+
currentServerBundleSha256 = null;
|
|
195
|
+
currentServerBundleId = null;
|
|
189
196
|
solo = false;
|
|
190
197
|
currentSoloReason = null;
|
|
191
198
|
baseUrl = "";
|
|
@@ -194,6 +201,8 @@ class MultiplayerConnectionImpl {
|
|
|
194
201
|
socket = null;
|
|
195
202
|
socketAttempt = 0;
|
|
196
203
|
keepalive = null;
|
|
204
|
+
pongExtensionUsed = false;
|
|
205
|
+
pongDeadline = null;
|
|
197
206
|
reAcquisitionUsed = false;
|
|
198
207
|
recovering = false;
|
|
199
208
|
hubSettled = false;
|
|
@@ -219,10 +228,11 @@ class MultiplayerConnectionImpl {
|
|
|
219
228
|
players: () => this.roster?.players ?? [],
|
|
220
229
|
isSolo: () => this.solo,
|
|
221
230
|
});
|
|
222
|
-
constructor(request, onClosed, destinationAudience = null) {
|
|
231
|
+
constructor(request, onClosed, destinationAudience = null, pongTimeoutMs = 0) {
|
|
223
232
|
this.request = request;
|
|
224
233
|
this.onClosed = onClosed;
|
|
225
234
|
this.destinationAudience = destinationAudience;
|
|
235
|
+
this.pongTimeoutMs = pongTimeoutMs;
|
|
226
236
|
}
|
|
227
237
|
get state() {
|
|
228
238
|
return this.currentState;
|
|
@@ -233,6 +243,12 @@ class MultiplayerConnectionImpl {
|
|
|
233
243
|
get sessionId() {
|
|
234
244
|
return this.currentSessionId;
|
|
235
245
|
}
|
|
246
|
+
get serverBundleSha256() {
|
|
247
|
+
return this.currentState === "connected" && !this.solo ? this.currentServerBundleSha256 : null;
|
|
248
|
+
}
|
|
249
|
+
get serverBundleId() {
|
|
250
|
+
return this.currentState === "connected" && !this.solo ? this.currentServerBundleId : null;
|
|
251
|
+
}
|
|
236
252
|
get isSolo() {
|
|
237
253
|
return this.solo;
|
|
238
254
|
}
|
|
@@ -553,6 +569,10 @@ class MultiplayerConnectionImpl {
|
|
|
553
569
|
acceptHello(frame) {
|
|
554
570
|
this.currentPlayerId = frame.playerId;
|
|
555
571
|
this.currentSessionId = frame.sessionId;
|
|
572
|
+
// Diagnostics never decide whether a hello is accepted. Missing or malformed
|
|
573
|
+
// metadata clears the previous host's identity on every reconnect.
|
|
574
|
+
this.currentServerBundleSha256 = typeof frame.serverBundleSha256 === "string" && /^[0-9a-f]{64}$/.test(frame.serverBundleSha256) ? frame.serverBundleSha256 : null;
|
|
575
|
+
this.currentServerBundleId = typeof frame.serverBundleId === "string" && /^[A-Za-z0-9_-]{10}$/.test(frame.serverBundleId) ? frame.serverBundleId : null;
|
|
556
576
|
this.reconnectToken = frame.reconnectToken;
|
|
557
577
|
this.sharedCharactersSupported = frame.capabilities?.sharedCharactersV1 === true;
|
|
558
578
|
if (this.devReconnectStorageKey)
|
|
@@ -587,6 +607,8 @@ class MultiplayerConnectionImpl {
|
|
|
587
607
|
this.acceptHello(frame);
|
|
588
608
|
break;
|
|
589
609
|
case "pong":
|
|
610
|
+
this.pongExtensionUsed = false;
|
|
611
|
+
this.clearPongDeadline();
|
|
590
612
|
break;
|
|
591
613
|
}
|
|
592
614
|
}
|
|
@@ -676,10 +698,14 @@ class MultiplayerConnectionImpl {
|
|
|
676
698
|
}
|
|
677
699
|
startKeepalive() {
|
|
678
700
|
this.stopKeepalive();
|
|
701
|
+
this.pongExtensionUsed = false;
|
|
679
702
|
this.keepalive = setInterval(() => {
|
|
680
703
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
704
|
+
if (this.pongDeadline !== null)
|
|
705
|
+
return;
|
|
681
706
|
try {
|
|
682
707
|
this.socket.send('{"t":"ping"}');
|
|
708
|
+
this.expectPong(this.socket);
|
|
683
709
|
}
|
|
684
710
|
catch {
|
|
685
711
|
closeSocket(this.socket, 1000, "keepalive failed");
|
|
@@ -688,11 +714,46 @@ class MultiplayerConnectionImpl {
|
|
|
688
714
|
}, KEEPALIVE_INTERVAL_MS);
|
|
689
715
|
}
|
|
690
716
|
stopKeepalive() {
|
|
717
|
+
this.clearPongDeadline();
|
|
691
718
|
if (this.keepalive === null)
|
|
692
719
|
return;
|
|
693
720
|
clearInterval(this.keepalive);
|
|
694
721
|
this.keepalive = null;
|
|
695
722
|
}
|
|
723
|
+
clearPongDeadline() {
|
|
724
|
+
if (this.pongDeadline === null)
|
|
725
|
+
return;
|
|
726
|
+
clearTimeout(this.pongDeadline);
|
|
727
|
+
this.pongDeadline = null;
|
|
728
|
+
}
|
|
729
|
+
expectPong(socket) {
|
|
730
|
+
if (!this.pongTimeoutMs)
|
|
731
|
+
return;
|
|
732
|
+
const due = Date.now() + this.pongTimeoutMs;
|
|
733
|
+
this.pongDeadline = setTimeout(() => {
|
|
734
|
+
this.pongDeadline = null;
|
|
735
|
+
if (this.socket !== socket || this.currentState !== "connected")
|
|
736
|
+
return;
|
|
737
|
+
// A suspended page can run an overdue timer before queued socket events.
|
|
738
|
+
// Verify liveness with a fresh ping instead of condemning that socket.
|
|
739
|
+
if (!this.pongExtensionUsed && Date.now() - due > 1_000 && socket.readyState === WebSocket.OPEN) {
|
|
740
|
+
this.pongExtensionUsed = true;
|
|
741
|
+
try {
|
|
742
|
+
socket.send('{"t":"ping"}');
|
|
743
|
+
this.expectPong(socket);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
catch { /* Fall through to recovery if the fresh probe cannot send. */ }
|
|
747
|
+
}
|
|
748
|
+
// A half-open transport may never emit close. Fence its queued callbacks
|
|
749
|
+
// and enter recovery directly rather than waiting for that close event.
|
|
750
|
+
this.socketAttempt += 1;
|
|
751
|
+
this.socket = null;
|
|
752
|
+
this.stopKeepalive();
|
|
753
|
+
closeSocket(socket, 1000, "pong timeout");
|
|
754
|
+
this.beginRecovery();
|
|
755
|
+
}, this.pongTimeoutMs);
|
|
756
|
+
}
|
|
696
757
|
transition(state, metadata) {
|
|
697
758
|
if (this.currentState === "closed" || this.currentState === state)
|
|
698
759
|
return;
|
package/dist/party.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PartyMember, PartyMessage, PartyMessagesResult, PartySummary } from './commands';
|
|
2
|
+
export type { PartyMember, PartyMessage, PartyMessagesResult, PartyStatus, PartySummary, } from './commands';
|
|
3
|
+
export interface CastlePartyApi {
|
|
4
|
+
/** The party this play was launched with, or null when it has none. */
|
|
5
|
+
current(): Promise<PartySummary | null>;
|
|
6
|
+
/** Create a party containing these users and the signed-in player. */
|
|
7
|
+
create(userIds: string[]): Promise<PartySummary>;
|
|
8
|
+
invite(partyId: string, userIds: string[]): Promise<{
|
|
9
|
+
ok: true;
|
|
10
|
+
}>;
|
|
11
|
+
respond(partyId: string, accept: boolean): Promise<{
|
|
12
|
+
ok: true;
|
|
13
|
+
}>;
|
|
14
|
+
members(partyId: string): Promise<PartyMember[]>;
|
|
15
|
+
leave(partyId: string): Promise<{
|
|
16
|
+
ok: true;
|
|
17
|
+
}>;
|
|
18
|
+
/** Newest first. Pass `cursor` to load older history. */
|
|
19
|
+
messages(partyId: string, cursor?: string): Promise<PartyMessagesResult>;
|
|
20
|
+
/** Send plain text; the host trims it to Castle's 1,000-character limit. */
|
|
21
|
+
say(partyId: string, text: string): Promise<PartyMessage>;
|
|
22
|
+
}
|
|
23
|
+
export declare const Party: CastlePartyApi;
|
package/dist/party.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CastleError } from './errors';
|
|
2
|
+
import { hostRequest } from './transport';
|
|
3
|
+
export const Party = {
|
|
4
|
+
current,
|
|
5
|
+
create,
|
|
6
|
+
invite,
|
|
7
|
+
respond,
|
|
8
|
+
members,
|
|
9
|
+
leave,
|
|
10
|
+
messages,
|
|
11
|
+
say,
|
|
12
|
+
};
|
|
13
|
+
const ID_MAX = 128;
|
|
14
|
+
const CURSOR_MAX = 512;
|
|
15
|
+
const PARTY_USERS_MAX = 32;
|
|
16
|
+
function id(value, field, operation) {
|
|
17
|
+
return boundedText(value, field, operation, ID_MAX);
|
|
18
|
+
}
|
|
19
|
+
function boundedText(value, field, operation, max) {
|
|
20
|
+
if (typeof value === 'string' &&
|
|
21
|
+
value.length > 0 &&
|
|
22
|
+
value.length <= max &&
|
|
23
|
+
!value.includes('\0')) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
throw new CastleError({
|
|
27
|
+
code: 'INVALID_ARGUMENT',
|
|
28
|
+
message: `${operation} requires a valid ${field}.`,
|
|
29
|
+
operation,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function ids(value, operation) {
|
|
33
|
+
if (!Array.isArray(value) || value.length > PARTY_USERS_MAX) {
|
|
34
|
+
throw new CastleError({
|
|
35
|
+
code: 'INVALID_ARGUMENT',
|
|
36
|
+
message: `${operation} requires at most ${PARTY_USERS_MAX} user ids.`,
|
|
37
|
+
operation,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return value.map((value) => id(value, 'userId', operation));
|
|
41
|
+
}
|
|
42
|
+
async function current() {
|
|
43
|
+
return hostRequest('party.current', {});
|
|
44
|
+
}
|
|
45
|
+
async function create(userIds) {
|
|
46
|
+
return hostRequest('party.create', { userIds: ids(userIds, 'Party.create') });
|
|
47
|
+
}
|
|
48
|
+
async function invite(partyId, userIds) {
|
|
49
|
+
return hostRequest('party.invite', {
|
|
50
|
+
partyId: id(partyId, 'partyId', 'Party.invite'),
|
|
51
|
+
userIds: ids(userIds, 'Party.invite'),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function respond(partyId, accept) {
|
|
55
|
+
if (typeof accept !== 'boolean') {
|
|
56
|
+
throw new CastleError({
|
|
57
|
+
code: 'INVALID_ARGUMENT',
|
|
58
|
+
message: 'Party.respond requires accept to be a boolean.',
|
|
59
|
+
operation: 'Party.respond',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return hostRequest('party.respond', {
|
|
63
|
+
partyId: id(partyId, 'partyId', 'Party.respond'),
|
|
64
|
+
accept,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function members(partyId) {
|
|
68
|
+
return hostRequest('party.members', { partyId: id(partyId, 'partyId', 'Party.members') });
|
|
69
|
+
}
|
|
70
|
+
async function leave(partyId) {
|
|
71
|
+
return hostRequest('party.leave', { partyId: id(partyId, 'partyId', 'Party.leave') });
|
|
72
|
+
}
|
|
73
|
+
async function messages(partyId, cursor) {
|
|
74
|
+
const params = {
|
|
75
|
+
partyId: id(partyId, 'partyId', 'Party.messages'),
|
|
76
|
+
};
|
|
77
|
+
if (cursor !== undefined) {
|
|
78
|
+
params.cursor = boundedText(cursor, 'cursor', 'Party.messages', CURSOR_MAX);
|
|
79
|
+
}
|
|
80
|
+
return hostRequest('party.messages', params);
|
|
81
|
+
}
|
|
82
|
+
async function say(partyId, text) {
|
|
83
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
84
|
+
throw new CastleError({
|
|
85
|
+
code: 'INVALID_ARGUMENT',
|
|
86
|
+
message: 'Party.say requires text.',
|
|
87
|
+
operation: 'Party.say',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return hostRequest('party.say', { partyId: id(partyId, 'partyId', 'Party.say'), text });
|
|
91
|
+
}
|
package/dist/save.d.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface SaveTransactionInput {
|
|
|
63
63
|
checks?: SaveCheck[];
|
|
64
64
|
changes: SaveChange[];
|
|
65
65
|
}
|
|
66
|
+
export interface SaveConditionalCheck extends Omit<SaveCheck, "expectedRevision"> {
|
|
67
|
+
/** Null checks absence without creating the target. */
|
|
68
|
+
expectedRevision: SaveRevision | null;
|
|
69
|
+
}
|
|
70
|
+
export interface SaveConditionalWriteInput extends Omit<SaveTransactionInput, "checks"> {
|
|
71
|
+
checks?: SaveConditionalCheck[];
|
|
72
|
+
}
|
|
66
73
|
export interface SaveInitial {
|
|
67
74
|
target: SaveTarget;
|
|
68
75
|
rows: SaveRows;
|
|
@@ -95,8 +102,24 @@ export type SaveOperation = {
|
|
|
95
102
|
} | {
|
|
96
103
|
state: "unknown";
|
|
97
104
|
};
|
|
98
|
-
export
|
|
105
|
+
export interface SaveRecentOperationsInput {
|
|
106
|
+
limit?: number;
|
|
107
|
+
cursor?: string;
|
|
108
|
+
}
|
|
109
|
+
export interface SaveRecentOperationsPage {
|
|
110
|
+
operations: {
|
|
111
|
+
operationId: string;
|
|
112
|
+
committedAt: string;
|
|
113
|
+
receipt: SaveReceipt;
|
|
114
|
+
}[];
|
|
115
|
+
cursor: string | null;
|
|
116
|
+
}
|
|
117
|
+
export type SaveVerb = "snapshot" | "transaction" | "claim" | "initialize" | "operation" | "conditionalWrite" | "recentOperations";
|
|
99
118
|
export interface SaveApi {
|
|
119
|
+
/** Gated atomic revision check and write, up to eight targets and two players. */
|
|
120
|
+
conditionalWrite(input: SaveConditionalWriteInput): Promise<SaveReceipt>;
|
|
121
|
+
/** Gated caller-scoped receipt discovery after losing local operation IDs. */
|
|
122
|
+
recentOperations(input?: SaveRecentOperationsInput): Promise<SaveRecentOperationsPage>;
|
|
100
123
|
snapshot(input: {
|
|
101
124
|
targets: SaveReadTarget[];
|
|
102
125
|
}): Promise<SaveSnapshot>;
|
|
@@ -156,6 +179,7 @@ export interface SaveErrorEnvelope {
|
|
|
156
179
|
receipt?: SaveReceipt;
|
|
157
180
|
retryAfterMs?: number;
|
|
158
181
|
target?: SaveTarget;
|
|
182
|
+
current?: SaveSnapshot;
|
|
159
183
|
[detail: string]: unknown;
|
|
160
184
|
}
|
|
161
185
|
export declare class SaveError extends CastleError {
|
|
@@ -165,6 +189,7 @@ export declare class SaveError extends CastleError {
|
|
|
165
189
|
readonly receipt?: SaveReceipt;
|
|
166
190
|
readonly retryAfterMs?: number;
|
|
167
191
|
readonly target?: SaveTarget;
|
|
192
|
+
readonly current?: SaveSnapshot;
|
|
168
193
|
constructor(envelope: SaveErrorEnvelope);
|
|
169
194
|
}
|
|
170
195
|
/**
|
package/dist/save.js
CHANGED
|
@@ -7,6 +7,7 @@ export class SaveError extends CastleError {
|
|
|
7
7
|
receipt;
|
|
8
8
|
retryAfterMs;
|
|
9
9
|
target;
|
|
10
|
+
current;
|
|
10
11
|
constructor(envelope) {
|
|
11
12
|
super({
|
|
12
13
|
code: envelope.code,
|
|
@@ -20,6 +21,7 @@ export class SaveError extends CastleError {
|
|
|
20
21
|
this.receipt = envelope.receipt;
|
|
21
22
|
this.retryAfterMs = envelope.retryAfterMs;
|
|
22
23
|
this.target = envelope.target;
|
|
24
|
+
this.current = envelope.current;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
@@ -241,12 +243,14 @@ function encodeChange(change, server) {
|
|
|
241
243
|
...(remove === undefined ? {} : { remove }),
|
|
242
244
|
};
|
|
243
245
|
}
|
|
244
|
-
function encodeCheck(check, server) {
|
|
246
|
+
function encodeCheck(check, server, conditional = false) {
|
|
245
247
|
const target = validateSaveTarget(check.target, server);
|
|
248
|
+
if (conditional && check.expectedRevision === undefined)
|
|
249
|
+
invalid("Every conditional check needs an expected revision.");
|
|
246
250
|
if (check.expectedRevision === undefined && check.writer === undefined) {
|
|
247
251
|
invalid("A check needs an expected revision or a writer.");
|
|
248
252
|
}
|
|
249
|
-
if (check.expectedRevision !== undefined) {
|
|
253
|
+
if (check.expectedRevision !== undefined && !(conditional && check.expectedRevision === null)) {
|
|
250
254
|
checkText(check.expectedRevision, "expected revision");
|
|
251
255
|
}
|
|
252
256
|
if (check.writer !== undefined)
|
|
@@ -259,7 +263,7 @@ function encodeCheck(check, server) {
|
|
|
259
263
|
...(check.writer === undefined ? {} : { writer: check.writer }),
|
|
260
264
|
};
|
|
261
265
|
}
|
|
262
|
-
function encodeTransaction(input, server) {
|
|
266
|
+
function encodeTransaction(input, server, conditional = false) {
|
|
263
267
|
const changes = checkList(input.changes, "changes").map((change) => encodeChange(change, server));
|
|
264
268
|
const keys = changes.map((change) => saveTargetKey(change.target));
|
|
265
269
|
if (new Set(keys).size !== keys.length) {
|
|
@@ -271,7 +275,7 @@ function encodeTransaction(input, server) {
|
|
|
271
275
|
...(input.checks === undefined
|
|
272
276
|
? {}
|
|
273
277
|
: {
|
|
274
|
-
checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server)),
|
|
278
|
+
checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server, conditional)),
|
|
275
279
|
}),
|
|
276
280
|
changes,
|
|
277
281
|
};
|
|
@@ -283,12 +287,26 @@ export function encodeSaveInput(verb, input, server = false) {
|
|
|
283
287
|
}
|
|
284
288
|
if (verb === "snapshot")
|
|
285
289
|
return { targets: encodeReads(input.targets, server) };
|
|
290
|
+
if (verb === "recentOperations") {
|
|
291
|
+
const limit = input.limit ?? 16;
|
|
292
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 32)
|
|
293
|
+
invalid("Recovery limit must be 1 to 32.");
|
|
294
|
+
if (input.cursor !== undefined)
|
|
295
|
+
checkText(input.cursor, "recovery cursor", 2048);
|
|
296
|
+
return { limit, ...(input.cursor === undefined ? {} : { cursor: input.cursor }) };
|
|
297
|
+
}
|
|
286
298
|
checkText(input.operationId, "operation id", MAX_KEY_BYTES);
|
|
287
299
|
if (verb === "operation")
|
|
288
300
|
return { operationId: input.operationId };
|
|
289
|
-
if (verb === "transaction") {
|
|
301
|
+
if (verb === "transaction" || verb === "conditionalWrite") {
|
|
302
|
+
if (verb === "conditionalWrite") {
|
|
303
|
+
const changes = input.changes;
|
|
304
|
+
const checks = input.checks ?? [];
|
|
305
|
+
if (!Array.isArray(changes) || !changes.length || changes.length + checks.length > 8)
|
|
306
|
+
invalid("Conditional writes require 1 to 8 targets.");
|
|
307
|
+
}
|
|
290
308
|
return {
|
|
291
|
-
...encodeTransaction(input, server),
|
|
309
|
+
...encodeTransaction(input, server, verb === "conditionalWrite"),
|
|
292
310
|
};
|
|
293
311
|
}
|
|
294
312
|
if (input.expectedAccess !== undefined) {
|
|
@@ -423,6 +441,34 @@ export function createSaveApi(call, server = false) {
|
|
|
423
441
|
}
|
|
424
442
|
}
|
|
425
443
|
return {
|
|
444
|
+
async conditionalWrite(input) {
|
|
445
|
+
const result = await send("conditionalWrite", { ...input });
|
|
446
|
+
return decodeReceipt(result, input.operationId, input.changes.map((change) => change.target));
|
|
447
|
+
},
|
|
448
|
+
async recentOperations(input = {}) {
|
|
449
|
+
const page = (await send("recentOperations", {
|
|
450
|
+
...input,
|
|
451
|
+
}));
|
|
452
|
+
if (!page ||
|
|
453
|
+
!Array.isArray(page.operations) ||
|
|
454
|
+
page.operations.length > (input.limit ?? 16) ||
|
|
455
|
+
(page.cursor !== null && typeof page.cursor !== "string"))
|
|
456
|
+
invalidResponse();
|
|
457
|
+
return {
|
|
458
|
+
operations: page.operations.map((operation) => {
|
|
459
|
+
if (!operation ||
|
|
460
|
+
typeof operation.operationId !== "string" ||
|
|
461
|
+
typeof operation.committedAt !== "string")
|
|
462
|
+
invalidResponse();
|
|
463
|
+
return {
|
|
464
|
+
operationId: operation.operationId,
|
|
465
|
+
committedAt: operation.committedAt,
|
|
466
|
+
receipt: decodeReceipt(operation.receipt, operation.operationId, []),
|
|
467
|
+
};
|
|
468
|
+
}),
|
|
469
|
+
cursor: page.cursor,
|
|
470
|
+
};
|
|
471
|
+
},
|
|
426
472
|
async snapshot(input) {
|
|
427
473
|
return decodeSnapshot(await send("snapshot", { ...input }), input.targets);
|
|
428
474
|
},
|
package/dist/server/hub.d.ts
CHANGED
|
@@ -184,8 +184,27 @@ export interface MultiplayerHubResultsRead {
|
|
|
184
184
|
* and for a deck that declares no hub block.
|
|
185
185
|
*/
|
|
186
186
|
export interface MultiplayerServerHub {
|
|
187
|
+
/** Durably appends NPC words to a private platform archive. No read operation. */
|
|
188
|
+
archiveText(playerId: string, entry: {
|
|
189
|
+
npcId: string;
|
|
190
|
+
characterId?: string;
|
|
191
|
+
conversationId?: string;
|
|
192
|
+
operationId: string;
|
|
193
|
+
input?: string;
|
|
194
|
+
reply?: string;
|
|
195
|
+
}): Promise<void>;
|
|
196
|
+
/** Numeric hub telemetry; unavailable/refused delivery returns false, never blocks play. */
|
|
197
|
+
track(playerId: string, event: {
|
|
198
|
+
kind: number;
|
|
199
|
+
subject?: number;
|
|
200
|
+
value?: number;
|
|
201
|
+
result?: number;
|
|
202
|
+
flags?: number;
|
|
203
|
+
}): Promise<boolean>;
|
|
187
204
|
/** The owner user id, only in an owner door instance. */
|
|
188
205
|
readonly owner?: string;
|
|
206
|
+
/** Host-pinned inclusive reconnect grace for this hub place, in milliseconds. */
|
|
207
|
+
readonly reconnectWindowMs?: number;
|
|
189
208
|
readonly place: string | null;
|
|
190
209
|
readonly title: string | null;
|
|
191
210
|
readonly result: MultiplayerHubResultShape | null;
|
|
@@ -236,9 +255,13 @@ export interface MultiplayerServerHub {
|
|
|
236
255
|
/**
|
|
237
256
|
* The connected player's mutual friends and follows in either direction among
|
|
238
257
|
* these candidates, in one host read. Missing lists are empty; refusals reject.
|
|
258
|
+
* Optional strict mode rejects malformed raw lists and marks the result validated.
|
|
239
259
|
* These relations belong to this player only; never send them to another client.
|
|
240
260
|
*/
|
|
241
|
-
relationsAmong(playerId: string, userIds: readonly string[]
|
|
261
|
+
relationsAmong(playerId: string, userIds: readonly string[], options?: {
|
|
262
|
+
strict: true;
|
|
263
|
+
}): Promise<{
|
|
264
|
+
validated?: true;
|
|
242
265
|
friends: string[];
|
|
243
266
|
follows: string[];
|
|
244
267
|
followedBy: string[];
|
package/dist/server/hub.js
CHANGED
|
@@ -46,6 +46,11 @@ function unavailable(op, what) {
|
|
|
46
46
|
operation: `session.hub.${op}`,
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
+
function reconnectMetadata(pinned) {
|
|
50
|
+
const value = pinned.reconnectWindowMs;
|
|
51
|
+
return pinned.result === null && typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 180_000
|
|
52
|
+
? { reconnectWindowMs: value } : {};
|
|
53
|
+
}
|
|
49
54
|
export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
|
|
50
55
|
const pinned = platformHandle.hub;
|
|
51
56
|
if (!pinned ||
|
|
@@ -72,17 +77,35 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
72
77
|
}
|
|
73
78
|
return parsed;
|
|
74
79
|
};
|
|
75
|
-
|
|
80
|
+
const hub = {
|
|
81
|
+
async archiveText(playerId, entry) {
|
|
82
|
+
if (platformHandle.hubTextArchiveEnabled !== true || typeof platformHandle.storage !== "function")
|
|
83
|
+
throw unavailable("archiveText", "archive NPC transcripts");
|
|
84
|
+
const raw = await platformHandle.storage("hubTextArchive", JSON.stringify({ ...entry, playerId }));
|
|
85
|
+
if (JSON.parse(raw)?.recorded !== true)
|
|
86
|
+
throw unavailable("archiveText", "archive NPC transcripts");
|
|
87
|
+
},
|
|
76
88
|
place: typeof pinned.place === "string" ? pinned.place : null,
|
|
77
89
|
title: typeof pinned.title === "string" ? pinned.title : null,
|
|
78
90
|
result: pinned.result ?? null,
|
|
79
91
|
...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
|
|
92
|
+
...reconnectMetadata(pinned),
|
|
80
93
|
arrival(playerId) {
|
|
81
94
|
return arrivals.get(playerId) ?? null;
|
|
82
95
|
},
|
|
83
96
|
hiddenPair(playerIdA, playerIdB) {
|
|
84
97
|
return hiddenPair(playerIdA, playerIdB);
|
|
85
98
|
},
|
|
99
|
+
async track(playerId, event) {
|
|
100
|
+
try {
|
|
101
|
+
const json = JSON.stringify({ ...event, playerId });
|
|
102
|
+
const raw = await platformHandle.storage?.("hubTrack", json);
|
|
103
|
+
return JSON.parse(raw ?? "null")?.recorded === true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
86
109
|
async friendsAmong(playerId, userIds) {
|
|
87
110
|
const json = JSON.stringify({ playerId, userIds: [...userIds] });
|
|
88
111
|
const raw = typeof platformHandle.hubFriendsAmong === "function"
|
|
@@ -91,7 +114,7 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
91
114
|
const answer = JSON.parse(raw ?? "null");
|
|
92
115
|
return Array.isArray(answer?.userIds) ? answer.userIds : [];
|
|
93
116
|
},
|
|
94
|
-
async relationsAmong(playerId, userIds) {
|
|
117
|
+
async relationsAmong(playerId, userIds, options = undefined) {
|
|
95
118
|
const asked = new Set(userIds);
|
|
96
119
|
const json = JSON.stringify({ playerId, userIds: [...userIds] });
|
|
97
120
|
let raw;
|
|
@@ -102,10 +125,14 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
102
125
|
raw = await platformHandle.storage?.("hubFriendsAmong", json);
|
|
103
126
|
}
|
|
104
127
|
const answer = JSON.parse(raw ?? "null");
|
|
128
|
+
if (options?.strict === true && (!answer || ["userIds", "follows", "followedBy"].some((key) => !Array.isArray(answer[key]) || answer[key].length > 100 || answer[key].some((id) => typeof id !== "string" || !/^\d{1,20}$/.test(id) || !asked.has(id))))) {
|
|
129
|
+
throw new CastleError({ code: "CAULDRON_HUB_INVALID_RESPONSE", message: "The platform returned an invalid friends response.", operation: "session.hub.relationsAmong" });
|
|
130
|
+
}
|
|
105
131
|
const list = (value) => Array.isArray(value)
|
|
106
132
|
? value.filter((id) => typeof id === "string" && asked.has(id))
|
|
107
133
|
: [];
|
|
108
134
|
return {
|
|
135
|
+
...(options?.strict === true ? { validated: true } : {}),
|
|
109
136
|
friends: list(answer?.userIds),
|
|
110
137
|
follows: list(answer?.follows),
|
|
111
138
|
followedBy: list(answer?.followedBy),
|
|
@@ -191,5 +218,10 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
191
218
|
players: (read.players ?? {}),
|
|
192
219
|
};
|
|
193
220
|
},
|
|
194
|
-
}
|
|
221
|
+
};
|
|
222
|
+
// Preserve the enumerable API shape and diagnostic serialization of existing
|
|
223
|
+
// places. Opted-in kits call this method explicitly; it adds no wire field.
|
|
224
|
+
Object.defineProperty(hub, "track", { enumerable: false });
|
|
225
|
+
Object.defineProperty(hub, "archiveText", { enumerable: false });
|
|
226
|
+
return Object.freeze(hub);
|
|
195
227
|
}
|
|
@@ -6,6 +6,8 @@ export interface PlayerIdentity {
|
|
|
6
6
|
username: string;
|
|
7
7
|
isAnonymous: boolean;
|
|
8
8
|
scopedUserId?: string;
|
|
9
|
+
/** Trusted hub age boundary; omitted by older hosts. Never client supplied. */
|
|
10
|
+
under13?: boolean;
|
|
9
11
|
}
|
|
10
12
|
export interface PlatformSessionConfig {
|
|
11
13
|
readonly sessionId: string;
|
|
@@ -66,11 +68,16 @@ export interface PlatformHandle {
|
|
|
66
68
|
storage?(op: string, argsJson: string): Promise<string>;
|
|
67
69
|
/** False when the host cannot broker storage, so a call can fail at once. */
|
|
68
70
|
readonly storageEnabled?: boolean;
|
|
71
|
+
readonly hubTextArchiveEnabled?: boolean;
|
|
69
72
|
/** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
|
|
70
73
|
worldCheckpointRead?(): Promise<string>;
|
|
71
74
|
worldCheckpointCommit?(argsJson: string): Promise<string>;
|
|
72
75
|
/** `tools` is the turn's place tools, passed only when non-empty. */
|
|
73
|
-
worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[]
|
|
76
|
+
worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[], hubTextArchive?: {
|
|
77
|
+
npcId: string;
|
|
78
|
+
conversationId?: string;
|
|
79
|
+
input: string;
|
|
80
|
+
}): Promise<string>;
|
|
74
81
|
worldCharacterCommand?(grantId: string): Promise<string>;
|
|
75
82
|
worldCharacterRead?(grantId: string): Promise<string>;
|
|
76
83
|
worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
|
|
@@ -114,6 +121,7 @@ export interface PlatformHubResultShape {
|
|
|
114
121
|
}
|
|
115
122
|
export interface PlatformHubPlace {
|
|
116
123
|
owner?: string;
|
|
124
|
+
reconnectWindowMs?: number;
|
|
117
125
|
place?: string;
|
|
118
126
|
title?: string | null;
|
|
119
127
|
result?: PlatformHubResultShape | null;
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface ServerStorageBoard {
|
|
|
51
51
|
*/
|
|
52
52
|
export interface ServerPlaythrough {
|
|
53
53
|
readonly generation: number;
|
|
54
|
+
/** Present only after an administrator reset this player's deck progress. */
|
|
55
|
+
readonly resetGeneration?: number;
|
|
54
56
|
/** Explicit narrative changes for this same player and generation. */
|
|
55
57
|
readonly characters: ServerPlaythroughCharacters;
|
|
56
58
|
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
package/dist/server/storage.js
CHANGED
|
@@ -100,11 +100,11 @@ export function createServerStorage(platformHandle) {
|
|
|
100
100
|
async playthrough(userId) {
|
|
101
101
|
const subject = assertUserId(userId, "session.storage.playthrough");
|
|
102
102
|
const opened = await call("playthroughOpen", { subject });
|
|
103
|
-
return createPlaythrough(call, subject, assertGeneration(opened?.generation));
|
|
103
|
+
return createPlaythrough(call, subject, assertGeneration(opened?.generation), optionalResetGeneration(opened?.resetGeneration));
|
|
104
104
|
},
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
function createPlaythrough(call, subject, generation) {
|
|
107
|
+
function createPlaythrough(call, subject, generation, resetGeneration) {
|
|
108
108
|
const characters = Object.freeze({
|
|
109
109
|
async send(input) {
|
|
110
110
|
const characterId = assertNarrativeId(input?.characterId, "characterId");
|
|
@@ -220,6 +220,7 @@ function createPlaythrough(call, subject, generation) {
|
|
|
220
220
|
});
|
|
221
221
|
return Object.freeze({
|
|
222
222
|
generation,
|
|
223
|
+
...(resetGeneration === undefined ? {} : { resetGeneration }),
|
|
223
224
|
characters,
|
|
224
225
|
async get(keys) {
|
|
225
226
|
const entries = await call("playthroughGet", { subject, generation, keys: assertKeys(keys, "session.storage.playthrough.get") });
|
|
@@ -242,7 +243,7 @@ function createPlaythrough(call, subject, generation) {
|
|
|
242
243
|
generation,
|
|
243
244
|
operationId: assertOperationId(operationId),
|
|
244
245
|
});
|
|
245
|
-
return createPlaythrough(call, subject, assertGeneration(reset?.generation));
|
|
246
|
+
return createPlaythrough(call, subject, assertGeneration(reset?.generation), resetGeneration);
|
|
246
247
|
},
|
|
247
248
|
});
|
|
248
249
|
}
|
|
@@ -417,6 +418,13 @@ function assertGeneration(value) {
|
|
|
417
418
|
}
|
|
418
419
|
return value;
|
|
419
420
|
}
|
|
421
|
+
function optionalResetGeneration(value) {
|
|
422
|
+
if (value === undefined || value === null)
|
|
423
|
+
return undefined;
|
|
424
|
+
if (Number.isSafeInteger(value) && value >= 1 && value <= MAX_GENERATION)
|
|
425
|
+
return value;
|
|
426
|
+
throw storageError("CAULDRON_STORAGE_INVALID_RESPONSE", "The platform returned an invalid progress reset generation.", "session.storage.playthrough");
|
|
427
|
+
}
|
|
420
428
|
function assertOperationId(value) {
|
|
421
429
|
if (typeof value !== "string" ||
|
|
422
430
|
value.length === 0 ||
|