castle-web-sdk 0.4.46 → 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 +276 -2
- package/dist/avatar.d.ts +6 -2
- package/dist/avatar.js +12 -4
- package/dist/castle.d.ts +7 -3
- package/dist/castle.js +2 -0
- package/dist/commands.d.ts +142 -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 +18 -2
- package/dist/hub.js +47 -0
- package/dist/multiplayer.d.ts +7 -1
- package/dist/multiplayer.js +80 -3
- 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 +54 -7
- package/dist/server/hub.d.ts +110 -0
- package/dist/server/hub.js +104 -2
- package/dist/server/platformHandle.d.ts +39 -1
- package/dist/server/storage.d.ts +15 -0
- package/dist/server/storage.js +57 -3
- package/dist/server/wrapper.d.ts +43 -6
- package/dist/server/wrapper.js +93 -9
- package/dist/social.d.ts +9 -0
- package/dist/social.js +5 -0
- package/package.json +1 -1
package/dist/commands.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SaveClaimed, SaveClaimInput, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveReceipt, SaveSnapshot, SaveTransactionInput } from "./save";
|
|
1
|
+
import type { SaveClaimed, SaveConditionalWriteInput, SaveClaimInput, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveRecentOperationsInput, SaveRecentOperationsPage, SaveReceipt, SaveSnapshot, SaveTransactionInput } from "./save";
|
|
2
2
|
import type { Json } from "./types";
|
|
3
3
|
export declare const CASTLE_SDK_PROTOCOL = 1;
|
|
4
4
|
export type StorageBlob = Record<string, string>;
|
|
@@ -84,6 +84,41 @@ export type PortalInfoResult = {
|
|
|
84
84
|
export interface PortalPrefetchResult {
|
|
85
85
|
status: PortalPrefetchStatus;
|
|
86
86
|
}
|
|
87
|
+
/** One mutual Castle friend visible to a Cauldron deck. */
|
|
88
|
+
export interface SocialFriend {
|
|
89
|
+
userId: string;
|
|
90
|
+
username: string;
|
|
91
|
+
photoUrl?: string;
|
|
92
|
+
}
|
|
93
|
+
export interface SocialPresence extends SocialFriend {
|
|
94
|
+
sessionId: string;
|
|
95
|
+
routeId?: string;
|
|
96
|
+
slot?: string;
|
|
97
|
+
updatedAt: string;
|
|
98
|
+
}
|
|
99
|
+
export type PartyStatus = "invited" | "joined" | "rejected" | "left" | "party_closed" | "banned";
|
|
100
|
+
export interface PartySummary {
|
|
101
|
+
partyId: string;
|
|
102
|
+
name: string;
|
|
103
|
+
status: PartyStatus;
|
|
104
|
+
hasUnreadMessages: boolean;
|
|
105
|
+
}
|
|
106
|
+
export interface PartyMember extends SocialFriend {
|
|
107
|
+
status: PartyStatus;
|
|
108
|
+
role: "member" | "host" | "owner";
|
|
109
|
+
}
|
|
110
|
+
export interface PartyMessage {
|
|
111
|
+
id: string;
|
|
112
|
+
fromUserId: string;
|
|
113
|
+
fromUsername: string | null;
|
|
114
|
+
body: Json;
|
|
115
|
+
text: string | null;
|
|
116
|
+
createdAt: string;
|
|
117
|
+
}
|
|
118
|
+
export interface PartyMessagesResult {
|
|
119
|
+
messages: PartyMessage[];
|
|
120
|
+
cursor: string | null;
|
|
121
|
+
}
|
|
87
122
|
export interface HubGoParams {
|
|
88
123
|
doorId: string;
|
|
89
124
|
}
|
|
@@ -151,6 +186,15 @@ export interface HubReportParams {
|
|
|
151
186
|
export interface HubReportResult {
|
|
152
187
|
status: "ok" | "unavailable";
|
|
153
188
|
}
|
|
189
|
+
/** Why the hub is asking the player to replace an anonymous account. */
|
|
190
|
+
export interface HubSignInParams {
|
|
191
|
+
reason: string;
|
|
192
|
+
}
|
|
193
|
+
/** The outcome of the host-owned sign-in flow. */
|
|
194
|
+
export interface HubSignInResult {
|
|
195
|
+
status: "signedIn" | "dismissed" | "unavailable";
|
|
196
|
+
userId?: string;
|
|
197
|
+
}
|
|
154
198
|
export type HubFriendsResult = {
|
|
155
199
|
status: "ok";
|
|
156
200
|
friends: HubFriend[];
|
|
@@ -158,6 +202,34 @@ export type HubFriendsResult = {
|
|
|
158
202
|
status: "unavailable";
|
|
159
203
|
friends: null;
|
|
160
204
|
};
|
|
205
|
+
export interface HubInviteResult {
|
|
206
|
+
status: "ok" | "unavailable";
|
|
207
|
+
url?: string;
|
|
208
|
+
expiresAt?: string;
|
|
209
|
+
}
|
|
210
|
+
export interface HubInviteArrivalResult {
|
|
211
|
+
status: "ok" | "unavailable" | "route";
|
|
212
|
+
doorId?: string;
|
|
213
|
+
destination?: {
|
|
214
|
+
hub: {
|
|
215
|
+
version: 1;
|
|
216
|
+
townDeckId: string;
|
|
217
|
+
target: {
|
|
218
|
+
kind: "player";
|
|
219
|
+
id: string;
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
friends?: Array<{
|
|
224
|
+
userId: string;
|
|
225
|
+
username: string;
|
|
226
|
+
}>;
|
|
227
|
+
}
|
|
228
|
+
/** Account-wide hub door hearts, in oldest-first order (at most eight). */
|
|
229
|
+
export interface HubHeartsResult {
|
|
230
|
+
status: "ok" | "unavailable";
|
|
231
|
+
hearts: string[];
|
|
232
|
+
}
|
|
161
233
|
/** Hub.chat: only the messages after a cursor an earlier answer returned. */
|
|
162
234
|
export interface HubChatParams {
|
|
163
235
|
after?: string;
|
|
@@ -508,6 +580,7 @@ export interface CommandParams {
|
|
|
508
580
|
"avatar.get": Record<string, never>;
|
|
509
581
|
"avatar.set": {
|
|
510
582
|
avatar: number;
|
|
583
|
+
color?: number;
|
|
511
584
|
};
|
|
512
585
|
"pass.offer": {
|
|
513
586
|
passId: string;
|
|
@@ -526,10 +599,50 @@ export interface CommandParams {
|
|
|
526
599
|
"portal.info": {
|
|
527
600
|
targetDeckId: string;
|
|
528
601
|
};
|
|
602
|
+
"social.friends": Record<string, never>;
|
|
603
|
+
"social.presence": Record<string, never>;
|
|
604
|
+
"party.current": Record<string, never>;
|
|
605
|
+
"party.create": {
|
|
606
|
+
userIds: string[];
|
|
607
|
+
};
|
|
608
|
+
"party.invite": {
|
|
609
|
+
partyId: string;
|
|
610
|
+
userIds: string[];
|
|
611
|
+
};
|
|
612
|
+
"party.respond": {
|
|
613
|
+
partyId: string;
|
|
614
|
+
accept: boolean;
|
|
615
|
+
};
|
|
616
|
+
"party.members": {
|
|
617
|
+
partyId: string;
|
|
618
|
+
};
|
|
619
|
+
"party.leave": {
|
|
620
|
+
partyId: string;
|
|
621
|
+
};
|
|
622
|
+
"party.messages": {
|
|
623
|
+
partyId: string;
|
|
624
|
+
cursor?: string;
|
|
625
|
+
};
|
|
626
|
+
"party.say": {
|
|
627
|
+
partyId: string;
|
|
628
|
+
text: string;
|
|
629
|
+
};
|
|
529
630
|
"hub.go": HubGoParams;
|
|
530
631
|
"hub.back": Record<string, never>;
|
|
531
632
|
"hub.doors": Record<string, never>;
|
|
532
633
|
"hub.friends": Record<string, never>;
|
|
634
|
+
"hub.invite": {
|
|
635
|
+
crew?: boolean;
|
|
636
|
+
};
|
|
637
|
+
"hub.inviteArrival": Record<string, never>;
|
|
638
|
+
"hub.undoInvite": {
|
|
639
|
+
userId: string;
|
|
640
|
+
};
|
|
641
|
+
"hub.hearts": Record<string, never>;
|
|
642
|
+
"hub.setHearts": {
|
|
643
|
+
doorIds: string[];
|
|
644
|
+
};
|
|
645
|
+
"hub.signIn": HubSignInParams;
|
|
533
646
|
"hub.jump": HubJumpParams;
|
|
534
647
|
"hub.report": HubReportParams;
|
|
535
648
|
"hub.chat": HubChatParams;
|
|
@@ -591,6 +704,8 @@ export interface CommandParams {
|
|
|
591
704
|
"cauldronStorage.transaction": SaveTransactionInput;
|
|
592
705
|
"cauldronStorage.claim": SaveClaimInput;
|
|
593
706
|
"cauldronStorage.initialize": SaveInitializeInput;
|
|
707
|
+
"cauldronStorage.conditionalWrite": SaveConditionalWriteInput;
|
|
708
|
+
"cauldronStorage.recentOperations": SaveRecentOperationsInput;
|
|
594
709
|
"cauldronStorage.operation": {
|
|
595
710
|
operationId: string;
|
|
596
711
|
};
|
|
@@ -696,10 +811,32 @@ export interface CommandResult {
|
|
|
696
811
|
"portal.open": PortalOpenResult;
|
|
697
812
|
"portal.prefetch": PortalPrefetchResult;
|
|
698
813
|
"portal.info": PortalInfoResult;
|
|
814
|
+
"social.friends": SocialFriend[];
|
|
815
|
+
"social.presence": SocialPresence[];
|
|
816
|
+
"party.current": PartySummary | null;
|
|
817
|
+
"party.create": PartySummary;
|
|
818
|
+
"party.invite": {
|
|
819
|
+
ok: true;
|
|
820
|
+
};
|
|
821
|
+
"party.respond": {
|
|
822
|
+
ok: true;
|
|
823
|
+
};
|
|
824
|
+
"party.members": PartyMember[];
|
|
825
|
+
"party.leave": {
|
|
826
|
+
ok: true;
|
|
827
|
+
};
|
|
828
|
+
"party.messages": PartyMessagesResult;
|
|
829
|
+
"party.say": PartyMessage;
|
|
699
830
|
"hub.go": HubGoResult;
|
|
700
831
|
"hub.back": HubBackResult;
|
|
701
832
|
"hub.doors": HubDoorsResult;
|
|
702
833
|
"hub.friends": HubFriendsResult;
|
|
834
|
+
"hub.invite": HubInviteResult;
|
|
835
|
+
"hub.inviteArrival": HubInviteArrivalResult;
|
|
836
|
+
"hub.undoInvite": HubReportResult;
|
|
837
|
+
"hub.hearts": HubHeartsResult;
|
|
838
|
+
"hub.setHearts": HubHeartsResult;
|
|
839
|
+
"hub.signIn": HubSignInResult;
|
|
703
840
|
"hub.jump": HubJumpResult;
|
|
704
841
|
"hub.report": HubReportResult;
|
|
705
842
|
"hub.chat": HubChatResult;
|
|
@@ -735,6 +872,8 @@ export interface CommandResult {
|
|
|
735
872
|
"cauldronStorage.transaction": SaveReceipt;
|
|
736
873
|
"cauldronStorage.claim": SaveClaimed;
|
|
737
874
|
"cauldronStorage.initialize": SaveOpened;
|
|
875
|
+
"cauldronStorage.conditionalWrite": SaveReceipt;
|
|
876
|
+
"cauldronStorage.recentOperations": SaveRecentOperationsPage;
|
|
738
877
|
"cauldronStorage.operation": SaveOperation;
|
|
739
878
|
"cauldronStorage.get": {
|
|
740
879
|
entries: StoreEntry[];
|
|
@@ -775,6 +914,8 @@ export interface UserPayload {
|
|
|
775
914
|
color: string | null;
|
|
776
915
|
}
|
|
777
916
|
export interface AvatarResult {
|
|
917
|
+
/** Absent from a host before avatar colours. */
|
|
918
|
+
color?: number;
|
|
778
919
|
status: "ok" | "unavailable";
|
|
779
920
|
avatar: number | null;
|
|
780
921
|
/** False while the player wears the default ghost gives every account. */
|
package/dist/commands.js
CHANGED
|
@@ -29,6 +29,9 @@ export const COMMAND_TIMEOUTS_MS = {
|
|
|
29
29
|
"hub.go": 30_000,
|
|
30
30
|
"hub.back": 30_000,
|
|
31
31
|
"hub.jump": 30_000,
|
|
32
|
+
// Native auth may include a provider handoff, but it must not leave a deck
|
|
33
|
+
// waiting forever if the host loses the completion callback.
|
|
34
|
+
"hub.signIn": 120_000,
|
|
32
35
|
};
|
|
33
36
|
// Once a host streams a command, each stream envelope restarts the wait at this
|
|
34
37
|
// length instead. The deck-agent service sends a heartbeat every 15 seconds, and the host
|
package/dist/display.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export type DeckLayout = "card" | "fill";
|
|
2
|
+
export type DeckOrientation = "landscape" | "portrait" | "any";
|
|
2
3
|
export interface DeckDisplay {
|
|
3
4
|
layout: DeckLayout;
|
|
5
|
+
/** The orientation the deck prefers the host to use. */
|
|
6
|
+
orientation?: DeckOrientation;
|
|
4
7
|
/** Narrowest width / height the deck accepts. */
|
|
5
8
|
minAspect: number;
|
|
6
9
|
/** Widest width / height the deck accepts. */
|
package/dist/display.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// <meta name="castle-display" content="layout=fill">
|
|
7
7
|
// <meta name="castle-display" content="layout=fill, min-aspect=9/16, max-aspect=16/9">
|
|
8
|
+
// <meta name="castle-display" content="layout=fill, orientation=landscape">
|
|
8
9
|
//
|
|
9
10
|
// With no tag the deck is a `card`: the classic 5:7 portrait card, exactly as
|
|
10
11
|
// before. A `fill` deck takes whatever box the host gives it, as long as the
|
|
@@ -42,13 +43,18 @@ export function parseDisplayContent(content) {
|
|
|
42
43
|
continue;
|
|
43
44
|
fields[part.slice(0, eq).trim().toLowerCase()] = part.slice(eq + 1).trim();
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const declaredOrientation = fields.orientation?.toLowerCase();
|
|
47
|
+
const orientation = declaredOrientation === "landscape" || declaredOrientation === "portrait"
|
|
48
|
+
? declaredOrientation
|
|
49
|
+
: "any";
|
|
50
|
+
if (fields.layout?.toLowerCase() !== "fill") {
|
|
51
|
+
return orientation === "any" ? CARD_DISPLAY : { ...CARD_DISPLAY, orientation };
|
|
52
|
+
}
|
|
47
53
|
let minAspect = parseAspect(fields["min-aspect"] ?? "") ?? FILL_MIN_ASPECT;
|
|
48
54
|
let maxAspect = parseAspect(fields["max-aspect"] ?? "") ?? FILL_MAX_ASPECT;
|
|
49
55
|
if (minAspect > maxAspect)
|
|
50
56
|
[minAspect, maxAspect] = [maxAspect, minAspect];
|
|
51
|
-
return { layout: "fill", minAspect, maxAspect };
|
|
57
|
+
return { layout: "fill", orientation, minAspect, maxAspect };
|
|
52
58
|
}
|
|
53
59
|
let cached = null;
|
|
54
60
|
/** The display this deck declared in its index.html (a card when it declared none). */
|
package/dist/hub.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { HubBackResult, HubCancelResult, HubChatResult, HubDoorsResult, HubFriendsResult, HubGoResult, HubJumpResult, HubReportResult, HubSayResult } from "./commands";
|
|
2
|
-
export type { HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./commands";
|
|
1
|
+
import type { HubBackResult, HubCancelResult, HubChatResult, HubDoorsResult, HubFriendsResult, HubInviteResult, HubInviteArrivalResult, HubHeartsResult, HubGoResult, HubJumpResult, HubReportResult, HubSayResult, HubSignInResult } from "./commands";
|
|
2
|
+
export type { HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubInviteResult, HubInviteArrivalResult, HubHeartsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, HubSignInParams, HubSignInResult, } from "./commands";
|
|
3
3
|
/**
|
|
4
4
|
* Doors between hub places. Every hub-kit deck is a place; ghost's door table
|
|
5
5
|
* is the allowlist, and the host supplies real authentication and the current
|
|
@@ -35,6 +35,22 @@ export interface CastleHubApi {
|
|
|
35
35
|
* on hosts without hub support.
|
|
36
36
|
*/
|
|
37
37
|
friends(): Promise<HubFriendsResult>;
|
|
38
|
+
/** Create an expiring link from this hub session. Crew links admit up to five invitees. */
|
|
39
|
+
invite(options?: {
|
|
40
|
+
crew?: boolean;
|
|
41
|
+
}): Promise<HubInviteResult>;
|
|
42
|
+
/** Hub-kit arrival hook: the trusted host retains the invite token. */
|
|
43
|
+
inviteArrival(): Promise<HubInviteArrivalResult>;
|
|
44
|
+
/** Undo both follow directions for one pair from this host's invite arrival. */
|
|
45
|
+
undoInvite(userId: string): Promise<HubReportResult>;
|
|
46
|
+
/** Read this signed-in account's hub door hearts. */
|
|
47
|
+
hearts(): Promise<HubHeartsResult>;
|
|
48
|
+
/** Replace the hearts, oldest first, at most eight distinct door ids. */
|
|
49
|
+
setHearts(doorIds: string[]): Promise<HubHeartsResult>;
|
|
50
|
+
/** Ask the host to let an anonymous player sign in to a permanent account. */
|
|
51
|
+
signIn(options: {
|
|
52
|
+
reason: string;
|
|
53
|
+
}): Promise<HubSignInResult>;
|
|
38
54
|
/**
|
|
39
55
|
* Acquire the layer of this place a friend is in. The destination is this
|
|
40
56
|
* same place in another session; join it exactly like a door's
|
package/dist/hub.js
CHANGED
|
@@ -5,6 +5,12 @@ export const Hub = {
|
|
|
5
5
|
back,
|
|
6
6
|
doors,
|
|
7
7
|
friends,
|
|
8
|
+
invite,
|
|
9
|
+
inviteArrival,
|
|
10
|
+
undoInvite,
|
|
11
|
+
hearts,
|
|
12
|
+
setHearts,
|
|
13
|
+
signIn,
|
|
8
14
|
jump,
|
|
9
15
|
report,
|
|
10
16
|
chat,
|
|
@@ -32,6 +38,47 @@ async function doors() {
|
|
|
32
38
|
async function friends() {
|
|
33
39
|
return hostRequest("hub.friends", {});
|
|
34
40
|
}
|
|
41
|
+
async function invite(options = {}) {
|
|
42
|
+
if (options.crew !== undefined && typeof options.crew !== "boolean")
|
|
43
|
+
throw new CastleError({ code: "INVALID_ARGUMENT", message: "Hub.invite crew must be a boolean.", operation: "Hub.invite" });
|
|
44
|
+
return hostRequest("hub.invite", options.crew === true ? { crew: true } : {});
|
|
45
|
+
}
|
|
46
|
+
async function inviteArrival() {
|
|
47
|
+
return hostRequest("hub.inviteArrival", {});
|
|
48
|
+
}
|
|
49
|
+
async function undoInvite(userId) {
|
|
50
|
+
if (!/^[1-9]\d{0,9}$/.test(userId))
|
|
51
|
+
throw new CastleError({ code: "INVALID_ARGUMENT", message: "Hub.undoInvite requires a userId.", operation: "Hub.undoInvite" });
|
|
52
|
+
return hostRequest("hub.undoInvite", { userId });
|
|
53
|
+
}
|
|
54
|
+
async function hearts() {
|
|
55
|
+
return hostRequest("hub.hearts", {});
|
|
56
|
+
}
|
|
57
|
+
async function setHearts(doorIds) {
|
|
58
|
+
if (!Array.isArray(doorIds) || doorIds.length > 8 ||
|
|
59
|
+
doorIds.some((id) => typeof id !== "string" || !id.length || id.length > 64) ||
|
|
60
|
+
new Set(doorIds).size !== doorIds.length) {
|
|
61
|
+
throw new CastleError({ code: "INVALID_ARGUMENT", message: "Hub.setHearts requires at most eight distinct door ids.", operation: "Hub.setHearts" });
|
|
62
|
+
}
|
|
63
|
+
return hostRequest("hub.setHearts", { doorIds });
|
|
64
|
+
}
|
|
65
|
+
const SIGN_IN_REASON_MAX_CHARACTERS = 200;
|
|
66
|
+
const SIGN_IN_REASON_MAX_BYTES = 512;
|
|
67
|
+
async function signIn(options) {
|
|
68
|
+
const reason = options?.reason;
|
|
69
|
+
if (typeof reason !== "string" ||
|
|
70
|
+
reason.length === 0 ||
|
|
71
|
+
reason.length > SIGN_IN_REASON_MAX_CHARACTERS ||
|
|
72
|
+
reason.includes("\0") ||
|
|
73
|
+
new TextEncoder().encode(reason).byteLength > SIGN_IN_REASON_MAX_BYTES) {
|
|
74
|
+
throw new CastleError({
|
|
75
|
+
code: "INVALID_ARGUMENT",
|
|
76
|
+
message: `Hub.signIn requires reason of at most ${SIGN_IN_REASON_MAX_CHARACTERS} characters and ${SIGN_IN_REASON_MAX_BYTES} bytes.`,
|
|
77
|
+
operation: "Hub.signIn",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return hostRequest("hub.signIn", { reason });
|
|
81
|
+
}
|
|
35
82
|
async function jump(userId) {
|
|
36
83
|
if (typeof userId !== "string" || userId.length === 0) {
|
|
37
84
|
throw new CastleError({
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Json } from "./types";
|
|
|
4
4
|
import { type MultiplayerPlayer, type MultiplayerCharacterDisclosure, type MultiplayerCharacterTurnStatus } from "./multiplayerProtocol";
|
|
5
5
|
import { type MultiplayerVoice } from "./multiplayerVoice";
|
|
6
6
|
export type { MultiplayerJoinHint } from "./commands";
|
|
7
|
-
export type MultiplayerJoinOptions = {
|
|
7
|
+
export type MultiplayerJoinOptions = ({
|
|
8
8
|
mode: "named";
|
|
9
9
|
key: string;
|
|
10
10
|
} | {
|
|
@@ -14,6 +14,9 @@ export type MultiplayerJoinOptions = {
|
|
|
14
14
|
} | {
|
|
15
15
|
mode: "hub";
|
|
16
16
|
handoff: HubHandoffResult;
|
|
17
|
+
}) & {
|
|
18
|
+
/** Experimental opt-in: reconnect if a keepalive ping receives no pong. */
|
|
19
|
+
pongTimeoutMs?: number;
|
|
17
20
|
};
|
|
18
21
|
export interface MultiplayerDestinationOptions {
|
|
19
22
|
routeId: string;
|
|
@@ -184,6 +187,9 @@ export interface MultiplayerConnection {
|
|
|
184
187
|
readonly state: MultiplayerConnectionState;
|
|
185
188
|
readonly playerId: string;
|
|
186
189
|
readonly sessionId: string;
|
|
190
|
+
/** Current host publication diagnostics; null when unavailable or not connected. */
|
|
191
|
+
readonly serverBundleSha256: string | null;
|
|
192
|
+
readonly serverBundleId: string | null;
|
|
187
193
|
readonly isSolo: boolean;
|
|
188
194
|
/**
|
|
189
195
|
* Why the join fell back to solo; null while multiplayer is live.
|
package/dist/multiplayer.js
CHANGED
|
@@ -7,6 +7,13 @@ const HELLO_TIMEOUT_MS = 10_000;
|
|
|
7
7
|
const RECONNECT_BUDGET_MS = 30_000;
|
|
8
8
|
const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
|
|
9
9
|
const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
|
|
10
|
+
// The play proxy closes a handshake it refuses with a reason (cauldron-game-server
|
|
11
|
+
// shared/src/constants.ts). The host that owns the session rejecting the credential is
|
|
12
|
+
// final. "Session ended" is ghost's routing verdict, which a host drain or a ghost
|
|
13
|
+
// error can also produce, so it takes two in a row before the session is given up on.
|
|
14
|
+
const PROXY_CREDENTIAL_REJECTED_CLOSE_CODE = 4401;
|
|
15
|
+
const PROXY_SESSION_ENDED_CLOSE_CODE = 4404;
|
|
16
|
+
const SESSION_ENDED_CONFIRMATIONS = 2;
|
|
10
17
|
// The host may spend the full 15-second grant window waiting for author admission,
|
|
11
18
|
// then up to 10 seconds on the fenced control-plane request. Keep the client above
|
|
12
19
|
// that complete lifecycle so it cannot discard a valid durable receipt.
|
|
@@ -39,13 +46,17 @@ async function join(options) {
|
|
|
39
46
|
const request = hub
|
|
40
47
|
? { command: "hub", nonce: hub.nonce }
|
|
41
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
|
+
}
|
|
42
53
|
latestJoinAttempt += 1;
|
|
43
54
|
cancelPendingDestinationJoin();
|
|
44
55
|
activeConnection?.leave();
|
|
45
56
|
const connection = new MultiplayerConnectionImpl(request, () => {
|
|
46
57
|
if (activeConnection === connection)
|
|
47
58
|
activeConnection = null;
|
|
48
|
-
});
|
|
59
|
+
}, null, pongTimeoutMs);
|
|
49
60
|
activeConnection = connection;
|
|
50
61
|
await connection.start(hub ?? undefined);
|
|
51
62
|
// The host moves its place (and every place-scoped command with it) when the
|
|
@@ -176,9 +187,12 @@ class MultiplayerConnectionImpl {
|
|
|
176
187
|
request;
|
|
177
188
|
onClosed;
|
|
178
189
|
destinationAudience;
|
|
190
|
+
pongTimeoutMs;
|
|
179
191
|
currentState = "acquiring";
|
|
180
192
|
currentPlayerId = "";
|
|
181
193
|
currentSessionId = "";
|
|
194
|
+
currentServerBundleSha256 = null;
|
|
195
|
+
currentServerBundleId = null;
|
|
182
196
|
solo = false;
|
|
183
197
|
currentSoloReason = null;
|
|
184
198
|
baseUrl = "";
|
|
@@ -187,6 +201,8 @@ class MultiplayerConnectionImpl {
|
|
|
187
201
|
socket = null;
|
|
188
202
|
socketAttempt = 0;
|
|
189
203
|
keepalive = null;
|
|
204
|
+
pongExtensionUsed = false;
|
|
205
|
+
pongDeadline = null;
|
|
190
206
|
reAcquisitionUsed = false;
|
|
191
207
|
recovering = false;
|
|
192
208
|
hubSettled = false;
|
|
@@ -212,10 +228,11 @@ class MultiplayerConnectionImpl {
|
|
|
212
228
|
players: () => this.roster?.players ?? [],
|
|
213
229
|
isSolo: () => this.solo,
|
|
214
230
|
});
|
|
215
|
-
constructor(request, onClosed, destinationAudience = null) {
|
|
231
|
+
constructor(request, onClosed, destinationAudience = null, pongTimeoutMs = 0) {
|
|
216
232
|
this.request = request;
|
|
217
233
|
this.onClosed = onClosed;
|
|
218
234
|
this.destinationAudience = destinationAudience;
|
|
235
|
+
this.pongTimeoutMs = pongTimeoutMs;
|
|
219
236
|
}
|
|
220
237
|
get state() {
|
|
221
238
|
return this.currentState;
|
|
@@ -226,6 +243,12 @@ class MultiplayerConnectionImpl {
|
|
|
226
243
|
get sessionId() {
|
|
227
244
|
return this.currentSessionId;
|
|
228
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
|
+
}
|
|
229
252
|
get isSolo() {
|
|
230
253
|
return this.solo;
|
|
231
254
|
}
|
|
@@ -546,6 +569,10 @@ class MultiplayerConnectionImpl {
|
|
|
546
569
|
acceptHello(frame) {
|
|
547
570
|
this.currentPlayerId = frame.playerId;
|
|
548
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;
|
|
549
576
|
this.reconnectToken = frame.reconnectToken;
|
|
550
577
|
this.sharedCharactersSupported = frame.capabilities?.sharedCharactersV1 === true;
|
|
551
578
|
if (this.devReconnectStorageKey)
|
|
@@ -580,6 +607,8 @@ class MultiplayerConnectionImpl {
|
|
|
580
607
|
this.acceptHello(frame);
|
|
581
608
|
break;
|
|
582
609
|
case "pong":
|
|
610
|
+
this.pongExtensionUsed = false;
|
|
611
|
+
this.clearPongDeadline();
|
|
583
612
|
break;
|
|
584
613
|
}
|
|
585
614
|
}
|
|
@@ -623,6 +652,7 @@ class MultiplayerConnectionImpl {
|
|
|
623
652
|
async recover() {
|
|
624
653
|
const startedAt = Date.now();
|
|
625
654
|
let attempt = 0;
|
|
655
|
+
let sessionEndedCloses = 0;
|
|
626
656
|
while (Date.now() - startedAt < RECONNECT_BUDGET_MS) {
|
|
627
657
|
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
628
658
|
attempt += 1;
|
|
@@ -633,7 +663,15 @@ class MultiplayerConnectionImpl {
|
|
|
633
663
|
return;
|
|
634
664
|
}
|
|
635
665
|
catch (error) {
|
|
636
|
-
|
|
666
|
+
const code = error instanceof SocketClosedError ? error.code : null;
|
|
667
|
+
if (code === 1008 || code === PROXY_CREDENTIAL_REJECTED_CLOSE_CODE)
|
|
668
|
+
break;
|
|
669
|
+
// Retrying a session that is gone only spends the budget; ask for a fresh join.
|
|
670
|
+
// Any other failure in between means the two refusals were not in a row.
|
|
671
|
+
sessionEndedCloses = code === PROXY_SESSION_ENDED_CLOSE_CODE
|
|
672
|
+
? sessionEndedCloses + 1
|
|
673
|
+
: 0;
|
|
674
|
+
if (sessionEndedCloses >= SESSION_ENDED_CONFIRMATIONS)
|
|
637
675
|
break;
|
|
638
676
|
}
|
|
639
677
|
}
|
|
@@ -660,10 +698,14 @@ class MultiplayerConnectionImpl {
|
|
|
660
698
|
}
|
|
661
699
|
startKeepalive() {
|
|
662
700
|
this.stopKeepalive();
|
|
701
|
+
this.pongExtensionUsed = false;
|
|
663
702
|
this.keepalive = setInterval(() => {
|
|
664
703
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
704
|
+
if (this.pongDeadline !== null)
|
|
705
|
+
return;
|
|
665
706
|
try {
|
|
666
707
|
this.socket.send('{"t":"ping"}');
|
|
708
|
+
this.expectPong(this.socket);
|
|
667
709
|
}
|
|
668
710
|
catch {
|
|
669
711
|
closeSocket(this.socket, 1000, "keepalive failed");
|
|
@@ -672,11 +714,46 @@ class MultiplayerConnectionImpl {
|
|
|
672
714
|
}, KEEPALIVE_INTERVAL_MS);
|
|
673
715
|
}
|
|
674
716
|
stopKeepalive() {
|
|
717
|
+
this.clearPongDeadline();
|
|
675
718
|
if (this.keepalive === null)
|
|
676
719
|
return;
|
|
677
720
|
clearInterval(this.keepalive);
|
|
678
721
|
this.keepalive = null;
|
|
679
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
|
+
}
|
|
680
757
|
transition(state, metadata) {
|
|
681
758
|
if (this.currentState === "closed" || this.currentState === state)
|
|
682
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;
|