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.
@@ -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
- if (fields.layout?.toLowerCase() !== "fill")
46
- return CARD_DISPLAY;
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({
@@ -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.
@@ -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
- if (error instanceof SocketClosedError && error.code === 1008)
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;
@@ -14,6 +14,8 @@ export interface SessionPlayer extends PlayerIdentity {
14
14
  playerId: string;
15
15
  }
16
16
  export interface ClientHelloFrame {
17
+ serverBundleSha256?: string;
18
+ serverBundleId?: string;
17
19
  t: "hello";
18
20
  playerId: string;
19
21
  reconnectToken: string;
@@ -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;