castle-web-sdk 0.4.45 → 0.4.47

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/dist/agent.js CHANGED
@@ -6,10 +6,10 @@
6
6
  // promise resolves with the whole message. A deck runs any tool calls itself
7
7
  // and calls Agent.chat again with the results.
8
8
  //
9
- // Agent.chat is limited to Castle admins for now: every other player gets
10
- // `refused`. A player whose allowance for the day is spent gets
11
- // `allowance_exhausted`. A host with no route to the service answers
12
- // `unavailable`.
9
+ // Agent.chat answers Castle admins and the players Castle has given access;
10
+ // every other player gets `refused`. A player whose allowance for the day is
11
+ // spent gets `allowance_exhausted`. A host with no route to the service
12
+ // answers `unavailable`.
13
13
  import { CastleError } from "./errors";
14
14
  import { hostRequest } from "./transport";
15
15
  async function chat(params) {
@@ -0,0 +1,16 @@
1
+ import type { AvatarResult } from "./commands";
2
+ export type { AvatarResult } from "./commands";
3
+ export interface CastleAvatarApi {
4
+ /** Present only in an SDK whose Avatar.set carries { color }; the hub-game kit offers colours only when it is here. */
5
+ readonly colors?: true;
6
+ /**
7
+ * The player's avatar: their choice, or the default every account has.
8
+ * `unavailable` for a signed-out visitor, or on a host without avatars.
9
+ */
10
+ get(): Promise<AvatarResult>;
11
+ /** Choose the avatar, and optionally its colour, worn everywhere. Requires a host with colours for options.color. */
12
+ set(avatar: number, options?: {
13
+ color?: number;
14
+ }): Promise<AvatarResult>;
15
+ }
16
+ export declare const Avatar: CastleAvatarApi;
package/dist/avatar.js ADDED
@@ -0,0 +1,31 @@
1
+ // Avatar — the player's platform avatar (cauldron-game-server docs/HUB.md,
2
+ // decision 4): one per account, worn in every hub place. v1 is an index into the
3
+ // hub-game kit's character templates, with optional colour choices.
4
+ //
5
+ // The hub-game kit is the one caller: it reads the avatar to dress the player
6
+ // and offers the choice in its picker. Other players' avatars are not read
7
+ // here; a place's server learns them from ghost.
8
+ import { CastleError } from "./errors";
9
+ import { hostRequest } from "./transport";
10
+ export const Avatar = { get, set, colors: true };
11
+ async function get() {
12
+ return hostRequest("avatar.get", {});
13
+ }
14
+ async function set(avatar, options) {
15
+ if (typeof avatar !== "number" || !Number.isInteger(avatar) || avatar < 0) {
16
+ throw new CastleError({
17
+ code: "INVALID_ARGUMENT",
18
+ message: "Avatar.set requires a template index.",
19
+ operation: "Avatar.set",
20
+ });
21
+ }
22
+ const color = options?.color;
23
+ if (color !== undefined && (!Number.isInteger(color) || color < 0)) {
24
+ throw new CastleError({
25
+ code: "INVALID_ARGUMENT",
26
+ message: "Avatar.set requires a non-negative integer colour.",
27
+ operation: "Avatar.set",
28
+ });
29
+ }
30
+ return hostRequest("avatar.set", color === undefined ? { avatar } : { avatar, color });
31
+ }
package/dist/castle.d.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  export { isEdit } from "./context";
2
2
  export { Agent } from "./agent";
3
3
  export type { AgentChatDelta, AgentChatParams, AgentChatResult, AgentMessage, AgentReasoning, AgentTool, AgentToolCall, AgentToolChoice, AgentUsage, CastleAgentApi, } from "./agent";
4
+ export { Avatar } from "./avatar";
5
+ export type { AvatarResult, CastleAvatarApi } from "./avatar";
4
6
  export { Characters } from "./characters";
5
7
  export type { CastleCharactersApi, CharacterHandle, CharacterHistoryOptions, CharacterSendInput, } from "./characters";
6
8
  export { Decks } from "./decks";
7
9
  export type { CastleDecksApi, DeckSummary, DecksListParams, DecksListResult } from "./decks";
8
10
  export { CastleError } from "./errors";
11
+ export { experiment, experiments, experimentsReady } from "./experiments";
9
12
  export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
10
13
  export { Haptics } from "./haptics";
11
14
  export type { CastleHapticsApi, HapticsResult, HapticsStatus, HapticStyle } from "./haptics";
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";
12
17
  export { Leaderboard } from "./leaderboard";
13
18
  export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardShowOptions, LeaderboardShowResult, LeaderboardShowStatus, LeaderboardSort, } from "./leaderboard";
14
19
  export { Lifecycle } from "./lifecycle";
15
20
  export type { CastleLifecycleApi } from "./lifecycle";
16
21
  export { Multiplayer } from "./multiplayer";
17
- export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerDestinationJoinResult, MultiplayerDestinationOptions, MultiplayerJoinHint, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
22
+ export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerCompleteScheduledBeatCommand, MultiplayerCompleteScheduledBeatResult, MultiplayerDestinationJoinResult, MultiplayerDestinationOptions, MultiplayerEvaluateBeatsCommand, MultiplayerEvaluateBeatsResult, MultiplayerJoinHint, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerScheduledBeatReason, MultiplayerScheduledBeatSelection, MultiplayerStateMetadata, MultiplayerStoryOpportunity, MultiplayerWorldCharacterCommand, MultiplayerWorldCharacterCommandResult, MultiplayerWorldCharacterCommandResultValue, } from "./multiplayer";
18
23
  export type { MultiplayerVoice, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, } from "./multiplayerVoice";
19
24
  export type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
20
25
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
@@ -41,3 +46,7 @@ export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
41
46
  export type { Json } from "./types";
42
47
  export { User } from "./user";
43
48
  export type { CastleUser, CastleUserApi } from "./user";
49
+ export { createSaveCoordinator } from "./saveCoordinator";
50
+ export type { CoordinatedChange, CoordinatedSave, SaveCoordinator, SaveStatus, } from "./saveCoordinator";
51
+ 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";
package/dist/castle.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // Castle Web SDK
2
2
  export { isEdit } from "./context";
3
3
  export { Agent } from "./agent";
4
+ export { Avatar } from "./avatar";
4
5
  export { Characters } from "./characters";
5
6
  export { Decks } from "./decks";
6
7
  export { CastleError } from "./errors";
8
+ export { experiment, experiments, experimentsReady } from "./experiments";
7
9
  export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckFile, } from "./files";
8
10
  export { Haptics } from "./haptics";
11
+ export { Hub } from "./hub";
9
12
  export { Leaderboard } from "./leaderboard";
10
13
  export { Lifecycle } from "./lifecycle";
11
14
  export { Multiplayer } from "./multiplayer";
@@ -21,3 +24,5 @@ export { SharedStorage, Storage } from "./storage";
21
24
  export { Store } from "./store";
22
25
  export { Time } from "./time";
23
26
  export { User } from "./user";
27
+ export { createSaveCoordinator } from "./saveCoordinator";
28
+ export { SaveError } from "./save";
@@ -1,3 +1,4 @@
1
+ import type { SaveClaimed, SaveClaimInput, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveReceipt, SaveSnapshot, SaveTransactionInput } from "./save";
1
2
  import type { Json } from "./types";
2
3
  export declare const CASTLE_SDK_PROTOCOL = 1;
3
4
  export type StorageBlob = Record<string, string>;
@@ -83,6 +84,179 @@ export type PortalInfoResult = {
83
84
  export interface PortalPrefetchResult {
84
85
  status: PortalPrefetchStatus;
85
86
  }
87
+ export interface HubGoParams {
88
+ doorId: string;
89
+ }
90
+ export type HubPlacementKind = "layer" | "pad" | "exact";
91
+ export type HubHandoffStatus = "ok" | "cancelled" | "full" | "unavailable" | "not_found" | "forbidden" | "party_required";
92
+ /** Session acquisition, not a completed scene swap or socket join. */
93
+ export type HubHandoffResult = {
94
+ status: "ok";
95
+ /** Destination session WebSocket URL, as in multiplayer.getSession. */
96
+ url: string;
97
+ /** Single-use join nonce, valid for 60 s. */
98
+ nonce: string;
99
+ sessionId: string;
100
+ /** The destination session's pinned server bundle (sha256). */
101
+ bundleHash: string;
102
+ /** Immutable URL of the content pack `bundleHash` names; null when the
103
+ * destination has none. */
104
+ contentUrl: string | null;
105
+ kind?: undefined;
106
+ } | {
107
+ /**
108
+ * A cabinet: the door leads to a classic Castle deck, which the host plays
109
+ * itself. There is no session to join; the trip is already committed, and
110
+ * `Hub.back()` is the way out.
111
+ */
112
+ status: "ok";
113
+ kind: "classic";
114
+ url: null;
115
+ nonce: null;
116
+ sessionId: null;
117
+ bundleHash: string;
118
+ contentUrl: null;
119
+ } | {
120
+ status: Exclude<HubHandoffStatus, "ok">;
121
+ url: null;
122
+ nonce: null;
123
+ sessionId: null;
124
+ bundleHash: null;
125
+ contentUrl: null;
126
+ };
127
+ export type HubGoResult = HubHandoffResult;
128
+ export type HubBackResult = HubHandoffResult;
129
+ export type HubJumpResult = HubHandoffResult;
130
+ export interface HubJumpParams {
131
+ userId: string;
132
+ }
133
+ /** A mutual friend ghost shows in a layer of the current place right now. */
134
+ export interface HubFriend {
135
+ userId: string;
136
+ username: string | null;
137
+ /** In the layer the player is in already: nothing to jump to. */
138
+ here: boolean;
139
+ /**
140
+ * Set when the friend is not in this place but inside the place behind one
141
+ * of its doors: that door's id. Null for a friend in a layer of this place.
142
+ */
143
+ door: string | null;
144
+ }
145
+ /** Hub.report: another player, by user id, and an optional short reason. */
146
+ export interface HubReportParams {
147
+ userId: string;
148
+ reason?: string;
149
+ }
150
+ /** `unavailable` on hosts without hub support or outside a hub session. */
151
+ export interface HubReportResult {
152
+ status: "ok" | "unavailable";
153
+ }
154
+ export type HubFriendsResult = {
155
+ status: "ok";
156
+ friends: HubFriend[];
157
+ } | {
158
+ status: "unavailable";
159
+ friends: null;
160
+ };
161
+ /** Account-wide hub door hearts, in oldest-first order (at most eight). */
162
+ export interface HubHeartsResult {
163
+ status: "ok" | "unavailable";
164
+ hearts: string[];
165
+ }
166
+ /** Hub.chat: only the messages after a cursor an earlier answer returned. */
167
+ export interface HubChatParams {
168
+ after?: string;
169
+ }
170
+ /** One chat message ghost lets this player read. */
171
+ export interface HubChatMessage {
172
+ id: string;
173
+ fromUserId: string;
174
+ fromUsername: string | null;
175
+ /** At most 200 characters. */
176
+ text: string;
177
+ /** When ghost took it, as an ISO 8601 time. */
178
+ at: string;
179
+ }
180
+ /**
181
+ * `canChat` is whether ghost lets this player chat at all; `peers` are the
182
+ * user ids in this hub session who would be sent what they say. `unavailable`
183
+ * on hosts without hub support or outside a hub session.
184
+ */
185
+ export type HubChatResult = {
186
+ status: "ok";
187
+ canChat: boolean;
188
+ peers: string[];
189
+ messages: HubChatMessage[];
190
+ /** Pass to the next `Hub.chat` to read only what is newer. */
191
+ cursor: string | null;
192
+ } | {
193
+ status: "unavailable";
194
+ canChat: false;
195
+ peers: null;
196
+ messages: null;
197
+ cursor: null;
198
+ };
199
+ /** Hub.say: free text, at most 200 characters once trimmed. */
200
+ export interface HubSayParams {
201
+ text: string;
202
+ }
203
+ /**
204
+ * `sent` carries the message's id; `none` when nobody in the session may read
205
+ * it, `refused` when ghost will not take it, `limited` when the player is
206
+ * saying too much too fast. `unavailable` on hosts without hub support or
207
+ * outside a hub session.
208
+ */
209
+ export interface HubSayResult {
210
+ status: "sent" | "none" | "refused" | "limited" | "unavailable";
211
+ id: string | null;
212
+ }
213
+ /** Hub.reportChat: a message this player was returned, by its id. */
214
+ export interface HubReportChatParams {
215
+ messageId: string;
216
+ }
217
+ /** Public door row; destination identity and return tickets stay host-owned. */
218
+ export interface HubDoor {
219
+ doorId: string;
220
+ launcherKind: string;
221
+ placementKind: HubPlacementKind;
222
+ /** The row's placement data: `{}`, or `{ key }` for an exact door. */
223
+ placement: Json;
224
+ padSize: number | null;
225
+ enabled: boolean;
226
+ featured: boolean;
227
+ /** Ghost-owned presence, not a count maintained by the place's engine. */
228
+ status: {
229
+ playersInside: number;
230
+ friendsInside: number;
231
+ };
232
+ }
233
+ export type HubDoorsResult = {
234
+ status: "ok";
235
+ doors: HubDoor[];
236
+ } | {
237
+ status: "unavailable";
238
+ doors: null;
239
+ };
240
+ export interface HubCommitParams {
241
+ /** The acquisition's nonce: unique to it, unlike its session. */
242
+ nonce: string;
243
+ }
244
+ export type HubCommitStatus = "committed" | "expired" | "superseded" | "cancelled" | "none" | "unavailable";
245
+ export interface HubCommitResult {
246
+ status: HubCommitStatus;
247
+ }
248
+ export type HubCancelStatus = "cancelled" | "expired" | "none" | "unavailable";
249
+ export interface HubCancelResult {
250
+ status: HubCancelStatus;
251
+ }
252
+ export type HubEvent = {
253
+ type: "back";
254
+ };
255
+ export interface HubEventEnvelope {
256
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
257
+ hub: HubEvent;
258
+ }
259
+ export declare function hubEventFrom(value: unknown): HubEvent | null;
86
260
  export type HapticStyle = "light" | "medium" | "heavy" | "selection" | "success" | "warning" | "error";
87
261
  export type HapticsStatus = "triggered" | "unavailable";
88
262
  export interface HapticsResult {
@@ -331,10 +505,16 @@ export interface CommandParams {
331
505
  "user.get": {
332
506
  userId: string;
333
507
  };
508
+ "experiments.get": Record<string, never>;
334
509
  "time.getServerTime": Record<string, never>;
335
510
  "pass.has": {
336
511
  passId: string;
337
512
  };
513
+ "avatar.get": Record<string, never>;
514
+ "avatar.set": {
515
+ avatar: number;
516
+ color?: number;
517
+ };
338
518
  "pass.offer": {
339
519
  passId: string;
340
520
  };
@@ -352,6 +532,23 @@ export interface CommandParams {
352
532
  "portal.info": {
353
533
  targetDeckId: string;
354
534
  };
535
+ "hub.go": HubGoParams;
536
+ "hub.back": Record<string, never>;
537
+ "hub.doors": Record<string, never>;
538
+ "hub.friends": Record<string, never>;
539
+ "hub.hearts": Record<string, never>;
540
+ "hub.setHearts": {
541
+ doorIds: string[];
542
+ };
543
+ "hub.jump": HubJumpParams;
544
+ "hub.report": HubReportParams;
545
+ "hub.chat": HubChatParams;
546
+ "hub.say": HubSayParams;
547
+ "hub.reportChat": HubReportChatParams;
548
+ "hub.commit": HubCommitParams;
549
+ "hub.cancel": {
550
+ nonce?: string;
551
+ };
355
552
  "haptics.play": {
356
553
  style: HapticStyle;
357
554
  };
@@ -398,6 +595,15 @@ export interface CommandParams {
398
595
  characterId: string;
399
596
  operationId: string;
400
597
  };
598
+ "cauldronStorage.snapshot": {
599
+ targets: SaveReadTarget[];
600
+ };
601
+ "cauldronStorage.transaction": SaveTransactionInput;
602
+ "cauldronStorage.claim": SaveClaimInput;
603
+ "cauldronStorage.initialize": SaveInitializeInput;
604
+ "cauldronStorage.operation": {
605
+ operationId: string;
606
+ };
401
607
  "cauldronStorage.get": {
402
608
  scope: StoreScope;
403
609
  subject?: string | null;
@@ -482,6 +688,9 @@ export interface CommandResult {
482
688
  "user.get": {
483
689
  user: UserPayload | null;
484
690
  };
691
+ "experiments.get": {
692
+ variants: Record<string, string>;
693
+ };
485
694
  "time.getServerTime": {
486
695
  timestamp: number;
487
696
  timezoneOffset: number;
@@ -490,11 +699,26 @@ export interface CommandResult {
490
699
  "pass.has": {
491
700
  hasPass: boolean;
492
701
  };
702
+ "avatar.get": AvatarResult;
703
+ "avatar.set": AvatarResult;
493
704
  "pass.offer": PassOfferResult;
494
705
  "snapshot.offer": SnapshotOfferResult;
495
706
  "portal.open": PortalOpenResult;
496
707
  "portal.prefetch": PortalPrefetchResult;
497
708
  "portal.info": PortalInfoResult;
709
+ "hub.go": HubGoResult;
710
+ "hub.back": HubBackResult;
711
+ "hub.doors": HubDoorsResult;
712
+ "hub.friends": HubFriendsResult;
713
+ "hub.hearts": HubHeartsResult;
714
+ "hub.setHearts": HubHeartsResult;
715
+ "hub.jump": HubJumpResult;
716
+ "hub.report": HubReportResult;
717
+ "hub.chat": HubChatResult;
718
+ "hub.say": HubSayResult;
719
+ "hub.reportChat": HubReportResult;
720
+ "hub.commit": HubCommitResult;
721
+ "hub.cancel": HubCancelResult;
498
722
  "haptics.play": HapticsResult;
499
723
  "multiplayer.getSession": MultiplayerGetSessionResult;
500
724
  "multiplayer.getJoinHint": MultiplayerJoinHint | null;
@@ -519,6 +743,11 @@ export interface CommandResult {
519
743
  "characters.status": CharacterOperation;
520
744
  "characters.history": CharacterHistory;
521
745
  "characters.reset": CharacterAdmission;
746
+ "cauldronStorage.snapshot": SaveSnapshot;
747
+ "cauldronStorage.transaction": SaveReceipt;
748
+ "cauldronStorage.claim": SaveClaimed;
749
+ "cauldronStorage.initialize": SaveOpened;
750
+ "cauldronStorage.operation": SaveOperation;
522
751
  "cauldronStorage.get": {
523
752
  entries: StoreEntry[];
524
753
  };
@@ -557,6 +786,14 @@ export interface UserPayload {
557
786
  frameUrl: string | null;
558
787
  color: string | null;
559
788
  }
789
+ export interface AvatarResult {
790
+ /** Absent from a host before avatar colours. */
791
+ color?: number;
792
+ status: "ok" | "unavailable";
793
+ avatar: number | null;
794
+ /** False while the player wears the default ghost gives every account. */
795
+ chosen: boolean;
796
+ }
560
797
  export type CommandName = keyof CommandParams;
561
798
  export declare const COMMAND_TIMEOUTS_MS: Partial<Record<CommandName, number>>;
562
799
  export declare const STREAM_IDLE_TIMEOUT_MS = 60000;
package/dist/commands.js CHANGED
@@ -6,6 +6,17 @@
6
6
  // Marker + protocol version. Doubles as a discriminator so host messages can't
7
7
  // be confused with embed.js `castlexyz:` strings or RN console messages.
8
8
  export const CASTLE_SDK_PROTOCOL = 1;
9
+ export function hubEventFrom(value) {
10
+ if (typeof value !== "object" || value === null)
11
+ return null;
12
+ const record = value;
13
+ if (record.castleSdk !== CASTLE_SDK_PROTOCOL)
14
+ return null;
15
+ const event = record.hub;
16
+ if (typeof event !== "object" || event === null)
17
+ return null;
18
+ return event.type === "back" ? { type: "back" } : null;
19
+ }
9
20
  // How long the deck waits for a reply to these commands, in place of the
10
21
  // data-command timeout. A deck list fetches every cover on the list. A host
11
22
  // from before streaming reads a whole model reply before answering agent.chat.
@@ -13,6 +24,11 @@ export const COMMAND_TIMEOUTS_MS = {
13
24
  "agent.chat": 180_000,
14
25
  "decks.mine": 30_000,
15
26
  "decks.saved": 30_000,
27
+ // A door used right after arriving can wait out ghost's roster lag (up to
28
+ // 15 s) before the host gives up on it.
29
+ "hub.go": 30_000,
30
+ "hub.back": 30_000,
31
+ "hub.jump": 30_000,
16
32
  };
17
33
  // Once a host streams a command, each stream envelope restarts the wait at this
18
34
  // length instead. The deck-agent service sends a heartbeat every 15 seconds, and the host
package/dist/context.d.ts CHANGED
@@ -7,6 +7,12 @@ export interface CastleEmbed {
7
7
  */
8
8
  feed?: boolean;
9
9
  host?: CastleHost;
10
+ /**
11
+ * The player's A/B arms on this deck, `{ experimentName: variant }`, when the
12
+ * host hands them over with the page. A host that does not answers the
13
+ * `experiments.get` command instead (experiments.ts).
14
+ */
15
+ experiments?: Record<string, string>;
10
16
  }
11
17
  declare global {
12
18
  interface Window {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The variant this player has in `name`, or `null` when they are in no such
3
+ * experiment. Synchronous; see `experimentsReady` for the one case where a read
4
+ * can come before the answer.
5
+ */
6
+ export declare function experiment(name: string): string | null;
7
+ /** Every experiment this player is in on this deck, `{ name: variant }`. */
8
+ export declare function experiments(): Record<string, string>;
9
+ /**
10
+ * Resolves, with the same map `experiments()` returns, once the host has
11
+ * answered. Await it once before the first read. On a host that hands the
12
+ * answer over with the page it is already resolved. Never rejects: a host that
13
+ * cannot answer means no experiments.
14
+ */
15
+ export declare function experimentsReady(): Promise<Record<string, string>>;
@@ -0,0 +1,83 @@
1
+ // A/B experiments. Castle's server gives each player one variant of every
2
+ // experiment this deck is in (castle.json `experiments`, plus any Castle runs
3
+ // across decks and lists this deck in), the same one on every load, and resolves
4
+ // them once per deck load. The deck only ever reads the answer.
5
+ //
6
+ // The reads are synchronous. A host that builds the deck's document puts the
7
+ // answer in `CastleEmbed.experiments`, and it is there from the first line the
8
+ // deck runs. Any other host answers one `experiments.get` command instead, so a
9
+ // deck awaits `experimentsReady()` once at boot and reads synchronously after.
10
+ //
11
+ // `null` is a normal answer: no such experiment, one that has ended, a player
12
+ // who is not in it, or a host that predates experiments. Always handle it.
13
+ import { getCastleEmbed } from "./context";
14
+ import { hostRequest } from "./transport";
15
+ const NONE = Object.freeze({});
16
+ let variants = null;
17
+ let loading = null;
18
+ let warnedEarlyRead = false;
19
+ /**
20
+ * The variant this player has in `name`, or `null` when they are in no such
21
+ * experiment. Synchronous; see `experimentsReady` for the one case where a read
22
+ * can come before the answer.
23
+ */
24
+ export function experiment(name) {
25
+ const known = current();
26
+ return Object.hasOwn(known, name) ? known[name] : null;
27
+ }
28
+ /** Every experiment this player is in on this deck, `{ name: variant }`. */
29
+ export function experiments() {
30
+ return { ...current() };
31
+ }
32
+ /**
33
+ * Resolves, with the same map `experiments()` returns, once the host has
34
+ * answered. Await it once before the first read. On a host that hands the
35
+ * answer over with the page it is already resolved. Never rejects: a host that
36
+ * cannot answer means no experiments.
37
+ */
38
+ export function experimentsReady() {
39
+ return load().then((known) => ({ ...known }));
40
+ }
41
+ function current() {
42
+ if (variants)
43
+ return variants;
44
+ const embedded = fromEmbed();
45
+ if (embedded) {
46
+ variants = embedded;
47
+ return variants;
48
+ }
49
+ if (!loading && !warnedEarlyRead) {
50
+ warnedEarlyRead = true;
51
+ console.warn("Castle experiment() was read before the host answered, so it returned null. " +
52
+ "Call `await experimentsReady()` once at boot, before the first read.");
53
+ }
54
+ // Asked for now, so a later read has the answer even without the await.
55
+ void load();
56
+ return NONE;
57
+ }
58
+ function load() {
59
+ if (variants)
60
+ return Promise.resolve(variants);
61
+ const embedded = fromEmbed();
62
+ if (embedded) {
63
+ variants = embedded;
64
+ return Promise.resolve(variants);
65
+ }
66
+ loading ??= hostRequest("experiments.get", {})
67
+ .then((result) => clean(result?.variants) ?? NONE)
68
+ // A host from before experiments does not know the command.
69
+ .catch(() => NONE)
70
+ .then((known) => {
71
+ variants = known;
72
+ return known;
73
+ });
74
+ return loading;
75
+ }
76
+ function fromEmbed() {
77
+ return clean(getCastleEmbed()?.experiments);
78
+ }
79
+ function clean(value) {
80
+ if (!value || typeof value !== "object" || Array.isArray(value))
81
+ return null;
82
+ return Object.freeze(Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string")));
83
+ }
package/dist/hub.d.ts ADDED
@@ -0,0 +1,89 @@
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";
3
+ /**
4
+ * Doors between hub places. Every hub-kit deck is a place; ghost's door table
5
+ * is the allowlist, and the host supplies real authentication and the current
6
+ * place, session and party. A deck names only a door, never a destination
7
+ * deck, session or party.
8
+ *
9
+ * Going through a door is acquire, then join: `go` (or `back`) acquires the
10
+ * destination and changes nothing else; the deck loads the destination's
11
+ * content behind its door animation and joins with
12
+ * `Multiplayer.join({ mode: "hub", handoff })`. When that socket says hello the
13
+ * SDK tells the host, which commits the trip: presence moves and the return
14
+ * ticket is written. A failure before then leaves the player where they were.
15
+ */
16
+ export interface CastleHubApi {
17
+ /**
18
+ * Acquire the destination behind a door of the current place. A door used
19
+ * within seconds of arriving may take up to 15 s while ghost catches up with
20
+ * the arrival. `unavailable` on hosts without hub support.
21
+ */
22
+ go(doorId: string): Promise<HubGoResult>;
23
+ /**
24
+ * Acquire the way back through the door this player came in by: the layer
25
+ * left behind if it has room, else the party's, else the usual pick. The deck
26
+ * cannot choose the return place. `not_found` when there is no way back.
27
+ */
28
+ back(): Promise<HubBackResult>;
29
+ /** The current place's door rows and who is inside, read at boot. */
30
+ doors(): Promise<HubDoorsResult>;
31
+ /**
32
+ * Mutual friends in a layer of the current place right now (at most 20);
33
+ * `here` is true for those in the player's own layer. Ghost shows only the
34
+ * friends whose now-playing privacy lets this player see them. `unavailable`
35
+ * on hosts without hub support.
36
+ */
37
+ friends(): Promise<HubFriendsResult>;
38
+ /** Read this signed-in account's hub door hearts. */
39
+ hearts(): Promise<HubHeartsResult>;
40
+ /** Replace the hearts, oldest first, at most eight distinct door ids. */
41
+ setHearts(doorIds: string[]): Promise<HubHeartsResult>;
42
+ /**
43
+ * Acquire the layer of this place a friend is in. The destination is this
44
+ * same place in another session; join it exactly like a door's
45
+ * (`Multiplayer.join({ mode: "hub", handoff })`), which commits it. The way
46
+ * back is unchanged. `not_found` when the friend is not in another layer.
47
+ */
48
+ jump(userId: string): Promise<HubJumpResult>;
49
+ /**
50
+ * Report another player in this hub session to Castle's moderators, with an
51
+ * optional short reason. Hub chrome only: the host fills in the place and
52
+ * session. `unavailable` on hosts without hub support.
53
+ */
54
+ report(userId: string, reason?: string): Promise<HubReportResult>;
55
+ /**
56
+ * Read this hub session's chat: the messages this player may read, whether
57
+ * they may chat at all, and the user ids who would be sent what they say.
58
+ * Who may chat is ghost's rule (players who follow each other, both over
59
+ * 13), never the deck's. Poll it, passing the last answer's `cursor` as
60
+ * `after` for only what is newer. `unavailable` on hosts without hub support
61
+ * or outside a hub session.
62
+ */
63
+ chat(after?: string): Promise<HubChatResult>;
64
+ /**
65
+ * Say something in this hub session's chat (at most 200 characters). The
66
+ * deck cannot choose who is sent it: ghost sends it to the players here who
67
+ * may chat with this one. `none` when there is nobody, `refused` when ghost
68
+ * will not take it, `limited` when said too fast.
69
+ */
70
+ say(text: string): Promise<HubSayResult>;
71
+ /**
72
+ * Report a chat message this player was returned to Castle's moderators, by
73
+ * its id. Hub chrome only: the host fills in the place and session.
74
+ * `unavailable` on hosts without hub support or outside a hub session.
75
+ */
76
+ reportChat(messageId: string): Promise<HubReportResult>;
77
+ /**
78
+ * Abandon an acquisition the deck will not join (its content failed to load,
79
+ * the player walked away). Optional: an unjoined one expires on its own.
80
+ */
81
+ cancel(): Promise<HubCancelResult>;
82
+ /**
83
+ * The player pressed the host's Back control, which a game cannot hide.
84
+ * Answer it with `back()` and the door animation; a host that hears nothing
85
+ * takes the player home itself. Returns an unsubscribe function.
86
+ */
87
+ onBack(listener: () => void): () => void;
88
+ }
89
+ export declare const Hub: CastleHubApi;