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 CHANGED
@@ -26,12 +26,15 @@ import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
26
26
  - [Pass](#pass)
27
27
  - [Snapshot](#snapshot)
28
28
  - [Portal](#portal)
29
+ - [Social](#social)
30
+ - [Party](#party)
29
31
  - [Hub](#hub)
30
32
  - [Decks](#decks)
31
33
  - [Clipboard](#clipboard)
32
34
  - [Haptics](#haptics)
33
35
  - [Agent](#agent)
34
36
  - [Lifecycle](#lifecycle)
37
+ - [Display](#display)
35
38
  - [Multiplayer](#multiplayer)
36
39
  - [Setup](#setup)
37
40
  - [Saving files](#writefilepath-contents-promisevoid)
@@ -65,6 +68,18 @@ reconnects temporary socket drops automatically. `onStateChange` reports
65
68
  `acquiring`, `connecting`, `connected`, `reconnecting`, and `closed`; its optional
66
69
  metadata distinguishes a server `bye`, a replaced server version, an explicit
67
70
  `leave`, and connection failures.
71
+
72
+ `connection.serverBundleId` and `connection.serverBundleSha256` identify the server publication running the connected session. These read-only diagnostics are `null` before a live connection, while disconnected, in solo mode, or when the host does not provide them. The ID identifies a publication; the SHA-256 identifies its code bytes, so two publications can share a hash. Read the values again after a reconnect or a destination join. They describe the running server, not the client bundle or the deck’s newest save.
73
+
74
+ Experimental: `Multiplayer.join({ mode: "public", pongTimeoutMs: 10_000 })`
75
+ reconnects if a keepalive ping receives no pong within the given milliseconds.
76
+ Omitting the option (or passing `0`) preserves the existing keepalive behavior.
77
+ The deadline starts at a ping, normally sent every 25 seconds; it is not a
78
+ deadline for game messages. A deadline callback delayed by more than one second (for example during page
79
+ suspension) sends one fresh ping and waits a full deadline before reconnecting. Only one
80
+ such extension is allowed without a pong, so repeated timer throttling cannot
81
+ postpone recovery indefinitely.
82
+
68
83
  During local development, reloading a Play panel restores that panel's session
69
84
  when its dev server is still running. A separate Play panel joins separately.
70
85
 
@@ -147,6 +162,13 @@ export default {
147
162
 
148
163
  `session.storage.deck` is shared by everyone playing.
149
164
  `session.storage.user(userId)` is one player's public data.
165
+ `await session.storage.playthrough(userId)` opens that player's generation-bound
166
+ game state. Its `resetGeneration` is present only after an administrator has
167
+ reset that player's progress for this deck. It is a monotonic reset marker,
168
+ separate from the handle's `generation`, which fences playthrough operations.
169
+ Deck-scope storage writes are not fenced by either generation; a server using
170
+ `session.storage.deck` must discard its own stale queued writes and cached
171
+ per-player values when it observes a newer reset marker.
150
172
  `session.storage.board(name)` is a leaderboard. The root and its buckets also
151
173
  support the [atomic save API](#atomic-saves). Each scope has the same
152
174
  `get`, `set`, `remove`, `increment` and `list` as `Store`.
@@ -196,6 +218,10 @@ dev serve.
196
218
 
197
219
  - `session.hub.place`, `.title` and `.result` are the place and the result shape
198
220
  as published (`result` is `null` for a place that reports none, such as a town).
221
+ - `session.hub.reconnectWindowMs`, when present, is Castle's pinned hub-place
222
+ reconnect grace in milliseconds, inclusive at the deadline. It is absent on
223
+ sessions without a pinned grace and older hosts. Kits use it to match visit retention to the
224
+ transport window; deck code does not choose it.
199
225
  - `session.hub.arrival(playerId)` is how a player came in when Castle placed them
200
226
  through a hub door: `{ fromDeckId, doorId }` (`doorId` is `null` when the door
201
227
  names none), else `null`. It is Castle's word, never a client's, and it is also
@@ -257,6 +283,9 @@ dev serve.
257
283
  ids. Missing lists are empty on older hosts. Mutual friends keep the chat age
258
284
  and block rules; follows in either direction require no block, with no age rule.
259
285
  These lists belong to the viewer only and must never go to another client.
286
+ Optional third argument `{ strict: true }` rejects malformed raw lists before
287
+ normalization and adds `validated: true` to a successful result. Without it,
288
+ the existing response shape and older-host behavior remain unchanged.
260
289
  - `session.hub.hiddenPair(playerIdA, playerIdB)` is true when a block hides these
261
290
  two players from each other (either one blocked the other). Never send either
262
291
  one the other's pose, avatar or name. The block lists themselves never reach
@@ -625,6 +654,61 @@ claim can fail with writer loss while `committed === true` and its original
625
654
  receipt; it never takes ownership again. Transport ambiguity keeps the outcome
626
655
  unknown until receipt lookup or identical replay settles it.
627
656
 
657
+ ### Conditional writes and recovery (gated)
658
+
659
+ `Store.conditionalWrite(input)` and `session.storage.conditionalWrite(input)`
660
+ use the same request and receipt as `transaction`. Read with `snapshot`, then
661
+ pass its `access` and each target's `revision` as `expectedAccess` and
662
+ `expectedRevision`. Use `null` to create an absent target. A revision belongs
663
+ to a **prefix**, so changing any key in it changes the revision. For an
664
+ independent value, give it a separate, non-overlapping prefix.
665
+
666
+ This additive API is off by default. The platform must enable conditional
667
+ storage for the deck after its database index and host support are ready.
668
+ Older hosts and the dev memory store do not support it. Keep a dependent
669
+ feature off when the API is unavailable; never fall back to ordinary writes.
670
+ Existing `get`, `set` and save methods keep their behavior.
671
+
672
+ One call changes up to eight targets, with up to eight explicit key changes
673
+ per target, across the same deck and at most two players. Every target needs
674
+ its expected revision. A read-only `check` can use `expectedRevision: null`
675
+ to require absence without creating that target. All changes commit together. Clients may address their
676
+ own user/private targets and writable deck targets; only the deck server can
677
+ condition on another player's user target. Servers cannot access private data.
678
+ Existing quota, byte and rate allowances still apply. A prefix replacement
679
+ also removes its previous rows, subject to those allowances.
680
+
681
+ On a revision conflict, `SaveError.code` is
682
+ `CAULDRON_STORAGE_REVISION_CONFLICT`, `committed` is `false`, and `current`
683
+ contains the current values and revisions read at the failed comparison.
684
+ If that full snapshot exceeds the byte allowance, the call fails with
685
+ `CAULDRON_STORAGE_TOO_LARGE`; no changes commit. Keep the player's proposed
686
+ change and reload before offering another choice.
687
+
688
+ Use a unique `operationId` for each action. Retrying the **identical request**
689
+ after a lost reply returns its original committed receipt, even if values
690
+ have since changed. Reusing the ID with different input is refused. Failed
691
+ comparisons do not create receipts. Receipts have no automatic expiry and
692
+ are retained beyond 24 hours; an explicit data/account reset may remove them.
693
+ Never reuse old operation IDs or access tokens after a reset.
694
+
695
+ `recentOperations({ limit?, cursor? })`, on both root APIs, discovers the
696
+ caller's committed operation IDs after a restart. It returns
697
+ `{ operations: [{ operationId, committedAt, receipt }], cursor }`, newest
698
+ first. `limit` defaults to 16 and is at most 32; pass the returned cursor for
699
+ the next page. This lists existing save receipts too. Client and server
700
+ histories are separate; all layers of the same deck share the server history.
701
+ Use a smaller limit if the reply exceeds the byte allowance. A history page
702
+ is discovery, not a snapshot of current values: reload with `snapshot` before
703
+ building the next change. `operation(id)` still looks up a known ID.
704
+
705
+ Pairing can atomically create a project and reserve both players' recovery
706
+ entries. The game server must still validate consent, membership and editing
707
+ leases. A market sale can atomically update stock, the daily cap and the
708
+ purse, once **every** writer of those values uses the managed targets.
709
+ Durable game recovery entries belong in those targets; recent operation
710
+ receipts alone do not define permanent project ownership or sale policy.
711
+
628
712
  ### Save coordinator
629
713
 
630
714
  `createSaveCoordinator`, from `castle-web-sdk` or `castle-web-sdk/server`,
@@ -1075,14 +1159,111 @@ Snapshot.onTaken(() => {
1075
1159
  });
1076
1160
  ```
1077
1161
 
1162
+ ## Social
1163
+
1164
+ `Social` gives a Cauldron deck the signed-in player's mutual Castle follows.
1165
+ The host supplies the player and current deck from trusted play context; a deck
1166
+ cannot ask about another account or deck. A host from before Social rejects with
1167
+ `CastleError` code `UNKNOWN_COMMAND`.
1168
+
1169
+ - `Social.friends()` returns at most 20 mutual follows as
1170
+ `{ userId, username, photoUrl? }`. A one-way follow is not a friend here.
1171
+ - `Social.presence()` returns the mutual friends playing this deck now as
1172
+ `{ userId, username, photoUrl?, sessionId, routeId?, slot?, updatedAt }`.
1173
+ Castle's now-playing privacy and multiplayer gates apply. It fails closed to
1174
+ `[]` when presence is gated or unavailable.
1175
+
1176
+ Presence has no event in v1. Poll `Social.presence()` when the game needs to
1177
+ refresh its list; do not treat `updatedAt` as a durable offline record.
1178
+
1179
+ ```js
1180
+ import { Social } from "castle-web-sdk";
1181
+
1182
+ const friends = await Social.friends();
1183
+ const playingHere = await Social.presence();
1184
+ ```
1185
+
1186
+ ## Party
1187
+
1188
+ `Party` creates and drives Castle parties with the signed-in player's account.
1189
+ Castle still enforces membership, roles, age and party permissions; refusals are
1190
+ `CastleError`s carrying the server's code. Party and user-id arrays are bounded
1191
+ to 32 users.
1192
+
1193
+ - `Party.current()` returns the party this play was launched with as
1194
+ `{ partyId, name, status, hasUnreadMessages }`, or `null`. The party id comes
1195
+ only from trusted host context, and no request is made when the play has none.
1196
+ - `Party.create(userIds)` creates a party containing those users and the
1197
+ signed-in player. It never sets an initial deck. Creating one does not change
1198
+ `Party.current()` or the launch party used by
1199
+ `Multiplayer.join({ mode: "party" })`.
1200
+ - `Party.invite(partyId, userIds)` invites more users.
1201
+ - `Party.respond(partyId, accept)` accepts or rejects an invitation.
1202
+ - `Party.members(partyId)` returns
1203
+ `{ userId, username, photoUrl?, status, role }` rows. `role` is `member`,
1204
+ `host` or `owner`.
1205
+ - `Party.leave(partyId)` leaves the party.
1206
+ - `Party.messages(partyId, cursor?)` returns
1207
+ `{ messages, cursor }`. Messages are newest first; pass the cursor to load
1208
+ **older** history (the opposite direction from `Hub.chat(after)`). Each message
1209
+ has `{ id, fromUserId, fromUsername, body, text, createdAt }`. `body` is the
1210
+ Castle chat JSON; `text` is non-null only when that body is plain text with no
1211
+ tags, links or embeds.
1212
+ - `Party.say(partyId, text)` sends plain text to the party. The host trims it
1213
+ and caps it at 1,000 characters, then returns the stored `PartyMessage`.
1214
+
1215
+ ```js
1216
+ import { Party } from "castle-web-sdk";
1217
+
1218
+ const party = await Party.current();
1219
+ if (party) {
1220
+ const page = await Party.messages(party.partyId);
1221
+ await Party.say(party.partyId, "Meet by the gate!");
1222
+ }
1223
+ ```
1224
+
1078
1225
  ## Hub
1079
1226
 
1227
+ Hub contract v1.95 adds optional invites. A place declaring `hub.invite: true`
1228
+ can call `hub.invite(playerId)` on its server to offer **Invite a friend**.
1229
+ The hub kit asks `Hub.invite()` for a link; `Hub.invite({ crew: true })` makes
1230
+ a crew link for up to five invitees. Links expire within 24 hours and default
1231
+ to one invitee. Hosts without invite support return `unavailable`.
1232
+
1233
+ The trusted host resolves the link after sign-in, retains its token, and requests
1234
+ the inviter's layer when it has room. An invite can also route through the
1235
+ existing `Hub.inviteArrival()` result `{ status: 'route', doorId }` to the
1236
+ inviter's house or active run behind a curated door that allows late joins.
1237
+ Contract v1.113 active-run routing requires root `hub.bringFriend: true`
1238
+ in addition to `invite: true` on both the town and destination. The destination
1239
+ also declares `hub.bringFriend: true` so its saved bundle and fresh pin retain
1240
+ the invite capability;
1241
+ it admits only to the matching live run while the inviter is present. A full,
1242
+ ended or unavailable run is refused rather than recreated. The kit calls
1243
+ `Hub.inviteArrival()` after joining that destination. Ghost verifies arrival
1244
+ before creating mutual follows among the eligible
1245
+ over-13 members of that link. Under-13 arrivals create no follows. The arrival
1246
+ surface shows an authored now-friends line and `Hub.undoInvite(userId)` removes
1247
+ both follow directions for that pair. No player-entered message travels with a link.
1248
+ These operations retain the existing hub access gates. Link creation and arrival
1249
+ completion wait up to 16 seconds when the server presence roster is still catching
1250
+ up with the game connection.
1251
+
1080
1252
  `Hub` moves a player between hub places: every deck built on the hub-game kit is
1081
1253
  a place, and a door is a row in Castle's curated door table. A deck names only a
1082
1254
  door; the host supplies the current place, the session it joined there and the
1083
1255
  party, and calls Castle with the player's own account. Hosts without hub support
1084
1256
  answer `unavailable`; older hosts reject with `CastleError` code `UNKNOWN_COMMAND`.
1085
1257
 
1258
+ `Hub.signIn({ reason })` asks the host to open its native sign-in flow for an
1259
+ anonymous player. `reason` is the short explanation shown to the player (at most
1260
+ 200 characters and 512 UTF-8 bytes). It resolves to
1261
+ `{ status: 'signedIn', userId? }`, `{ status: 'dismissed' }`, or
1262
+ `{ status: 'unavailable' }`. `userId` is present only when the host can return
1263
+ the signed-in account id. A new host without native sign-in support returns
1264
+ `unavailable`; an older host that does not know this command rejects with
1265
+ `UNKNOWN_COMMAND`.
1266
+
1086
1267
  - `Hub.doors()` reads the current place's doors and who is inside them:
1087
1268
  `{ status: "ok", doors }` or `{ status: "unavailable", doors: null }`. A row has
1088
1269
  `doorId`, `launcherKind` (`door`, `table`, `cabinet`, `gate`), `placementKind`
@@ -1511,6 +1692,13 @@ startGame(saved ?? newGame());
1511
1692
  Lifecycle.ready();
1512
1693
  ```
1513
1694
 
1695
+ ## Display
1696
+
1697
+ `getDisplay()` returns a `DeckDisplay`. Its optional `orientation` field is the
1698
+ deck's preferred display orientation: `landscape`, `portrait`, or `any`.
1699
+ Missing or unrecognized preferences mean `any`; read the value as
1700
+ `display.orientation ?? "any"`.
1701
+
1514
1702
  ## Setup
1515
1703
 
1516
1704
  Startup, editor-mode check, and a file-write call for editor UI.
@@ -1702,3 +1890,12 @@ places may save hearts. Guests and unavailable hosts return
1702
1890
  `{ status: 'unavailable', hearts: [] }`; older hosts throw `UNKNOWN_COMMAND`.
1703
1891
  Invalid lists throw `INVALID_ARGUMENT`, and other host failures use `CastleError`.
1704
1892
  The dev serve keeps hearts per account in memory until it stops.
1893
+
1894
+ Server hub analytics (hub-game contract v1.93): `session.hub.track(playerId,
1895
+ { kind, subject?, value?, result?, flags? })` sends a numeric event through the
1896
+ server's authenticated platform channel. It returns `Promise<boolean>`: `true`
1897
+ acknowledges queueing, while unsupported hosts and refused delivery return `false`.
1898
+ It never throws. Only opted-in hub bundles are recorded; Ghost stamps the player
1899
+ and town from the session and door pin and excludes the town creator's own plays.
1900
+ The hub-game kit exposes `hub.track(playerId, eventName, properties)` only on
1901
+ places declaring `hub.analytics: true`; see its contract for the numeric vocabulary.
package/dist/castle.d.ts CHANGED
@@ -13,7 +13,11 @@ export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckF
13
13
  export { Haptics } from "./haptics";
14
14
  export type { CastleHapticsApi, HapticsResult, HapticsStatus, HapticStyle } from "./haptics";
15
15
  export { Hub } from "./hub";
16
- export type { CastleHubApi, HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubHeartsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./hub";
16
+ export type { CastleHubApi, HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubInviteResult, HubInviteArrivalResult, HubHeartsResult, HubGoParams, HubSignInParams, HubSignInResult, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./hub";
17
+ export { Social } from "./social";
18
+ export type { CastleSocialApi, SocialFriend, SocialPresence } from "./social";
19
+ export { Party } from "./party";
20
+ export type { CastlePartyApi, PartyMember, PartyMessage, PartyMessagesResult, PartyStatus, PartySummary, } from "./party";
17
21
  export { Leaderboard } from "./leaderboard";
18
22
  export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardShowOptions, LeaderboardShowResult, LeaderboardShowStatus, LeaderboardSort, } from "./leaderboard";
19
23
  export { Lifecycle } from "./lifecycle";
@@ -35,7 +39,7 @@ export type { CastlePortalApi, PortalOpenResult, PortalOpenStatus, PortalPrefetc
35
39
  export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
36
40
  export type { FileChange, FilesChangedEvent } from "./runtime";
37
41
  export { cardUnit, fitStage, fovForAspect, getDisplay } from "./display";
38
- export type { DeckDisplay, DeckLayout } from "./display";
42
+ export type { DeckDisplay, DeckLayout, DeckOrientation } from "./display";
39
43
  export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
40
44
  export type { SaveState } from "./saveQueue";
41
45
  export { SharedStorage, Storage } from "./storage";
@@ -49,4 +53,4 @@ export type { CastleUser, CastleUserApi } from "./user";
49
53
  export { createSaveCoordinator } from "./saveCoordinator";
50
54
  export type { CoordinatedChange, CoordinatedSave, SaveCoordinator, SaveStatus, } from "./saveCoordinator";
51
55
  export { SaveError } from "./save";
52
- export type { SaveApi, SaveBucketApi, SaveBucketRead, SaveChange, SaveCheck, SaveClaimed, SaveClaimInput, SaveErrorEnvelope, SaveInitial, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveReceipt, SaveRevision, SaveRows, SaveScope, SaveSelection, SaveSnapshot, SaveTarget, SaveTransactionInput, SaveWriter, } from "./save";
56
+ export type { SaveApi, SaveBucketApi, SaveBucketRead, SaveChange, SaveCheck, SaveConditionalCheck, SaveConditionalWriteInput, SaveClaimed, SaveClaimInput, SaveErrorEnvelope, SaveInitial, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveReceipt, SaveRecentOperationsInput, SaveRecentOperationsPage, SaveRevision, SaveRows, SaveScope, SaveSelection, SaveSnapshot, SaveTarget, SaveTransactionInput, SaveWriter, } from "./save";
package/dist/castle.js CHANGED
@@ -9,6 +9,8 @@ export { experiment, experiments, experimentsReady } from "./experiments";
9
9
  export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
10
10
  export { Haptics } from "./haptics";
11
11
  export { Hub } from "./hub";
12
+ export { Social } from "./social";
13
+ export { Party } from "./party";
12
14
  export { Leaderboard } from "./leaderboard";
13
15
  export { Lifecycle } from "./lifecycle";
14
16
  export { Multiplayer } from "./multiplayer";
@@ -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,29 @@ 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
+ }
161
228
  /** Account-wide hub door hearts, in oldest-first order (at most eight). */
162
229
  export interface HubHeartsResult {
163
230
  status: "ok" | "unavailable";
@@ -532,14 +599,50 @@ export interface CommandParams {
532
599
  "portal.info": {
533
600
  targetDeckId: string;
534
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
+ };
535
630
  "hub.go": HubGoParams;
536
631
  "hub.back": Record<string, never>;
537
632
  "hub.doors": Record<string, never>;
538
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
+ };
539
641
  "hub.hearts": Record<string, never>;
540
642
  "hub.setHearts": {
541
643
  doorIds: string[];
542
644
  };
645
+ "hub.signIn": HubSignInParams;
543
646
  "hub.jump": HubJumpParams;
544
647
  "hub.report": HubReportParams;
545
648
  "hub.chat": HubChatParams;
@@ -601,6 +704,8 @@ export interface CommandParams {
601
704
  "cauldronStorage.transaction": SaveTransactionInput;
602
705
  "cauldronStorage.claim": SaveClaimInput;
603
706
  "cauldronStorage.initialize": SaveInitializeInput;
707
+ "cauldronStorage.conditionalWrite": SaveConditionalWriteInput;
708
+ "cauldronStorage.recentOperations": SaveRecentOperationsInput;
604
709
  "cauldronStorage.operation": {
605
710
  operationId: string;
606
711
  };
@@ -706,12 +811,32 @@ export interface CommandResult {
706
811
  "portal.open": PortalOpenResult;
707
812
  "portal.prefetch": PortalPrefetchResult;
708
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;
709
830
  "hub.go": HubGoResult;
710
831
  "hub.back": HubBackResult;
711
832
  "hub.doors": HubDoorsResult;
712
833
  "hub.friends": HubFriendsResult;
834
+ "hub.invite": HubInviteResult;
835
+ "hub.inviteArrival": HubInviteArrivalResult;
836
+ "hub.undoInvite": HubReportResult;
713
837
  "hub.hearts": HubHeartsResult;
714
838
  "hub.setHearts": HubHeartsResult;
839
+ "hub.signIn": HubSignInResult;
715
840
  "hub.jump": HubJumpResult;
716
841
  "hub.report": HubReportResult;
717
842
  "hub.chat": HubChatResult;
@@ -747,6 +872,8 @@ export interface CommandResult {
747
872
  "cauldronStorage.transaction": SaveReceipt;
748
873
  "cauldronStorage.claim": SaveClaimed;
749
874
  "cauldronStorage.initialize": SaveOpened;
875
+ "cauldronStorage.conditionalWrite": SaveReceipt;
876
+ "cauldronStorage.recentOperations": SaveRecentOperationsPage;
750
877
  "cauldronStorage.operation": SaveOperation;
751
878
  "cauldronStorage.get": {
752
879
  entries: StoreEntry[];
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, HubHeartsResult, HubGoResult, HubJumpResult, HubReportResult, HubSayResult } from "./commands";
2
- export type { HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubHeartsResult, 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,10 +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>;
38
46
  /** Read this signed-in account's hub door hearts. */
39
47
  hearts(): Promise<HubHeartsResult>;
40
48
  /** Replace the hearts, oldest first, at most eight distinct door ids. */
41
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>;
42
54
  /**
43
55
  * Acquire the layer of this place a friend is in. The destination is this
44
56
  * same place in another session; join it exactly like a door's
package/dist/hub.js CHANGED
@@ -5,8 +5,12 @@ export const Hub = {
5
5
  back,
6
6
  doors,
7
7
  friends,
8
+ invite,
9
+ inviteArrival,
10
+ undoInvite,
8
11
  hearts,
9
12
  setHearts,
13
+ signIn,
10
14
  jump,
11
15
  report,
12
16
  chat,
@@ -34,6 +38,19 @@ async function doors() {
34
38
  async function friends() {
35
39
  return hostRequest("hub.friends", {});
36
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
+ }
37
54
  async function hearts() {
38
55
  return hostRequest("hub.hearts", {});
39
56
  }
@@ -45,6 +62,23 @@ async function setHearts(doorIds) {
45
62
  }
46
63
  return hostRequest("hub.setHearts", { doorIds });
47
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
+ }
48
82
  async function jump(userId) {
49
83
  if (typeof userId !== "string" || userId.length === 0) {
50
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.