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.
@@ -0,0 +1,252 @@
1
+ import { asSaveError, encodeSaveInput, matchSaveTarget, SaveError, saveTargetKey, } from "./save";
2
+ // The request is built when a save is first sent, from the revisions known then.
3
+ function buildRequest(intent, access, revisions, writers) {
4
+ const changes = intent.changes.map((change) => {
5
+ const key = saveTargetKey(change.target);
6
+ const expectedRevision = change.expectedRevision === undefined
7
+ ? revisions.get(key)
8
+ : change.expectedRevision;
9
+ if (expectedRevision === undefined) {
10
+ throw new SaveError({
11
+ code: "CAULDRON_STORAGE_INVALID_ARGUMENT",
12
+ message: "Load a target's revision before saving it.",
13
+ operationId: intent.operationId,
14
+ retryable: false,
15
+ committed: false,
16
+ target: change.target,
17
+ });
18
+ }
19
+ const writer = change.writer ?? writers.get(key);
20
+ return {
21
+ ...change,
22
+ expectedRevision,
23
+ ...(writer === undefined ? {} : { writer }),
24
+ };
25
+ });
26
+ return {
27
+ operationId: intent.operationId,
28
+ expectedAccess: access,
29
+ ...(intent.checks ? { checks: intent.checks } : {}),
30
+ changes,
31
+ };
32
+ }
33
+ // A committed error with its receipt is a repeat of a save that committed
34
+ // earlier, whose fresh reply failed for some other reason.
35
+ async function send(api, request) {
36
+ try {
37
+ return await api.transaction(request);
38
+ }
39
+ catch (failure) {
40
+ const error = asSaveError(failure, request.operationId);
41
+ if (error.committed === true && error.receipt)
42
+ return error.receipt;
43
+ throw error;
44
+ }
45
+ }
46
+ // An unknown outcome is not a cancellation: the first request may still
47
+ // arrive. So it is looked up, then sent again exactly as before.
48
+ async function settle(api, request) {
49
+ try {
50
+ return await send(api, request);
51
+ }
52
+ catch (error) {
53
+ if (error.committed !== "unknown")
54
+ throw error;
55
+ }
56
+ let found;
57
+ try {
58
+ found = await api.operation(request.operationId);
59
+ }
60
+ catch (failure) {
61
+ const error = asSaveError(failure, request.operationId);
62
+ throw new SaveError({
63
+ ...error.extensions,
64
+ code: error.code,
65
+ message: error.message,
66
+ operationId: request.operationId,
67
+ retryable: error.retryable,
68
+ committed: "unknown",
69
+ });
70
+ }
71
+ if (found.state === "committed")
72
+ return found.receipt;
73
+ return send(api, request);
74
+ }
75
+ /**
76
+ * Starts from a snapshot of the targets to be saved and the writers of any
77
+ * claimed ones. Keep the coordinator where it outlives a replaceable worker.
78
+ */
79
+ export function createSaveCoordinator(api, snapshot, writers = []) {
80
+ const access = snapshot.access;
81
+ const revisions = new Map(snapshot.targets.map((entry) => [
82
+ saveTargetKey(entry.target),
83
+ entry.revision,
84
+ ]));
85
+ const writerTokens = new Map(writers.map((entry) => [saveTargetKey(entry.target), entry.writer]));
86
+ const submittedIds = new Set();
87
+ const queue = [];
88
+ let flushes = [];
89
+ let blocked;
90
+ let running;
91
+ function apply(receipt, request) {
92
+ const targets = request.changes.map((change) => change.target);
93
+ for (const entry of receipt.revisions) {
94
+ const target = matchSaveTarget(entry.target, targets);
95
+ revisions.set(saveTargetKey(target), entry.revision);
96
+ }
97
+ }
98
+ function block(item, error) {
99
+ blocked = error;
100
+ if (error.committed === false && !error.retryable) {
101
+ item.failed = true;
102
+ item.reject(error);
103
+ }
104
+ for (const flush of flushes)
105
+ flush.reject(error);
106
+ flushes = [];
107
+ }
108
+ function commit(item, receipt) {
109
+ queue.shift();
110
+ item.resolve(receipt);
111
+ flushes = flushes.filter((flush) => {
112
+ if (flush.last !== item)
113
+ return true;
114
+ flush.resolve();
115
+ return false;
116
+ });
117
+ }
118
+ async function drain() {
119
+ while (queue.length > 0 && !blocked) {
120
+ const item = queue[0];
121
+ try {
122
+ item.request ??= buildRequest(item.intent, access, revisions, writerTokens);
123
+ const receipt = await settle(api, item.request);
124
+ apply(receipt, item.request);
125
+ commit(item, receipt);
126
+ }
127
+ catch (failure) {
128
+ block(item, asSaveError(failure, item.intent.operationId));
129
+ }
130
+ }
131
+ }
132
+ // A save submitted while the last drain was finishing finds it still running,
133
+ // so the drain looks again once it is done.
134
+ function start() {
135
+ running ??= drain().finally(() => {
136
+ running = undefined;
137
+ if (queue.length > 0 && !blocked)
138
+ void start();
139
+ });
140
+ return running;
141
+ }
142
+ return {
143
+ get status() {
144
+ if (blocked)
145
+ return "blocked";
146
+ return queue.length > 0 ? "pending" : "saved";
147
+ },
148
+ get error() {
149
+ return blocked;
150
+ },
151
+ get pendingOperationIds() {
152
+ return queue
153
+ .filter((item) => !item.failed)
154
+ .map((item) => item.intent.operationId);
155
+ },
156
+ revision: (target) => revisions.get(saveTargetKey(target)),
157
+ submit(input) {
158
+ if (submittedIds.has(input.operationId)) {
159
+ return Promise.reject(new SaveError({
160
+ code: "CAULDRON_STORAGE_OPERATION_MISMATCH",
161
+ message: "This coordinator already has that operation id.",
162
+ operationId: input.operationId,
163
+ retryable: false,
164
+ committed: false,
165
+ }));
166
+ }
167
+ // Encoding validates and copies the save, so later changes to the
168
+ // caller's objects do not reach it.
169
+ let intent;
170
+ try {
171
+ const encoded = encodeSaveInput("transaction", {
172
+ operationId: input.operationId,
173
+ expectedAccess: access,
174
+ ...(input.checks ? { checks: input.checks } : {}),
175
+ changes: input.changes.map((change) => ({
176
+ ...change,
177
+ expectedRevision: change.expectedRevision ?? null,
178
+ })),
179
+ });
180
+ intent = {
181
+ operationId: encoded.operationId,
182
+ ...(encoded.checks ? { checks: encoded.checks } : {}),
183
+ changes: encoded.changes.map((change, index) => ({
184
+ ...change,
185
+ expectedRevision: input.changes[index].expectedRevision,
186
+ })),
187
+ };
188
+ }
189
+ catch (error) {
190
+ return Promise.reject(asSaveError(error, input.operationId));
191
+ }
192
+ submittedIds.add(input.operationId);
193
+ const promise = new Promise((resolve, reject) => {
194
+ queue.push({ intent, failed: false, resolve, reject });
195
+ });
196
+ // A deck may watch status or flush() instead of every save's promise.
197
+ promise.catch(() => { });
198
+ void start();
199
+ return promise;
200
+ },
201
+ flush() {
202
+ const last = queue.at(-1);
203
+ if (!last)
204
+ return Promise.resolve();
205
+ if (blocked)
206
+ return Promise.reject(blocked);
207
+ return new Promise((resolve, reject) => {
208
+ flushes.push({ last, resolve, reject });
209
+ });
210
+ },
211
+ async retry() {
212
+ await running;
213
+ if (!blocked)
214
+ return;
215
+ if (queue[0]?.failed)
216
+ throw blocked;
217
+ if (blocked.retryAfterMs !== undefined && blocked.retryAfterMs > 0) {
218
+ const delay = blocked.retryAfterMs;
219
+ await new Promise((resolve) => setTimeout(resolve, delay));
220
+ }
221
+ blocked = undefined;
222
+ return start().then(() => {
223
+ if (blocked)
224
+ throw blocked;
225
+ });
226
+ },
227
+ discardUnsent() {
228
+ const error = new SaveError({
229
+ code: "CAULDRON_STORAGE_CANCELLED",
230
+ message: "The save was discarded before it was sent.",
231
+ retryable: false,
232
+ committed: false,
233
+ });
234
+ for (let index = queue.length - 1; index >= 0; index--) {
235
+ const item = queue[index];
236
+ if (item.request || item.failed)
237
+ continue;
238
+ queue.splice(index, 1);
239
+ item.reject(error);
240
+ }
241
+ // A flush still waits for the sent save ahead of what was dropped.
242
+ flushes = flushes.filter((flush) => {
243
+ if (!flush.last || !queue.includes(flush.last))
244
+ flush.last = queue[0];
245
+ if (flush.last)
246
+ return true;
247
+ flush.resolve();
248
+ return false;
249
+ });
250
+ },
251
+ };
252
+ }
@@ -0,0 +1,256 @@
1
+ import type { PlatformHandle, PlatformHubResultShape } from "./platformHandle";
2
+ export type MultiplayerHubOutcome = "win" | "lose" | "draw";
3
+ export type MultiplayerHubResultShape = PlatformHubResultShape;
4
+ /** Where a player came in through a hub door: ghost's word, never a client's. */
5
+ export interface MultiplayerHubArrival {
6
+ /** The deck of the place the player came from. */
7
+ readonly fromDeckId: string;
8
+ /** The door of this place the player arrived by, when the door row names one. */
9
+ readonly doorId: string | null;
10
+ }
11
+ export interface MultiplayerHubResultInput {
12
+ /** This session's place; refused when it names another. */
13
+ place?: string;
14
+ outcome: MultiplayerHubOutcome;
15
+ score?: number;
16
+ stats?: Record<string, number | string>;
17
+ }
18
+ export interface MultiplayerHubRecord {
19
+ deckId: string;
20
+ place: string;
21
+ title: string | null;
22
+ plays: number;
23
+ outcomes: {
24
+ win: number;
25
+ lose: number;
26
+ draw: number;
27
+ };
28
+ bestScore: number | null;
29
+ unit: string | null;
30
+ better: "higher" | "lower" | null;
31
+ winStreak: number;
32
+ lastOutcome: MultiplayerHubOutcome | null;
33
+ lastScore: number | null;
34
+ lastAt: string;
35
+ }
36
+ export interface MultiplayerHubResultRow {
37
+ deckId: string;
38
+ place: string;
39
+ title: string | null;
40
+ outcome: MultiplayerHubOutcome;
41
+ score: number | null;
42
+ unit: string | null;
43
+ better: "higher" | "lower" | null;
44
+ stats: Record<string, number | string>;
45
+ at: string;
46
+ }
47
+ export interface MultiplayerHubReportReceipt {
48
+ recorded: boolean;
49
+ /** Why nothing was recorded: ghost already holds this visit's result. */
50
+ reason?: "duplicate";
51
+ record?: MultiplayerHubRecord;
52
+ }
53
+ /**
54
+ * A wearable on the player's platform avatar, drawn by the hub kit in every hub
55
+ * place: `kind` matches /^[a-z][a-z0-9-]{0,23}$/ (the kit draws the kinds it
56
+ * knows), `color` is '#rrggbb', `rarity` an integer 0-9.
57
+ */
58
+ export interface MultiplayerHubWearable {
59
+ kind: string;
60
+ color: string;
61
+ rarity: number;
62
+ }
63
+ export interface MultiplayerHubWearReceipt {
64
+ /** What the player now wears; null when cleared. */
65
+ wearable: MultiplayerHubWearable | null;
66
+ }
67
+ /** The classes of earned item a hub game may give (contract v1.53). */
68
+ export type MultiplayerHubEarnedClass = "badge" | "pin" | "belt" | "sash" | "held";
69
+ /** The home classes a hub game may give (contract v1.70/v1.73), never worn. */
70
+ export type MultiplayerHubHomeClass = "furniture" | "deed" | "pet";
71
+ /**
72
+ * An earned item: something a hub game gave a player for a stated deed, declared
73
+ * in that game's castle.json `hub.unlocks` and kept by Castle per account. It has
74
+ * no rarity. `deckId` is the game that declared it; `id` and `shape` match
75
+ * /^[a-z][a-z0-9-]{0,23}$/, `color` is '#rrggbb'.
76
+ */
77
+ export interface MultiplayerHubEarnedItem {
78
+ deckId: string;
79
+ id: string;
80
+ class: MultiplayerHubEarnedClass;
81
+ shape: string;
82
+ name: string;
83
+ deed: string;
84
+ color: string;
85
+ }
86
+ /** A furniture piece, area deed or adopted pet owned for the house, never shown on an avatar. */
87
+ export interface MultiplayerHubHomeItem {
88
+ deckId: string;
89
+ id: string;
90
+ class: MultiplayerHubHomeClass;
91
+ piece: string;
92
+ name: string;
93
+ deed: string;
94
+ color: string;
95
+ }
96
+ export interface MultiplayerHubUnlockReceipt {
97
+ unlocked: true;
98
+ /** True when this call gave the player the item for the first time. */
99
+ isNew: boolean;
100
+ /** What the player now shows in the earned slot (never a home item); null when none. */
101
+ earned: MultiplayerHubEarnedItem | null;
102
+ }
103
+ export interface MultiplayerHubWearEarnedReceipt {
104
+ /** What the player now shows in the earned slot; null when cleared. */
105
+ earned: MultiplayerHubEarnedItem | null;
106
+ }
107
+ export interface MultiplayerHubUnlocksRead {
108
+ /** Each player's owned wearable and home items, by user id. */
109
+ players: Record<string, {
110
+ owned: (MultiplayerHubEarnedItem | MultiplayerHubHomeItem)[];
111
+ }>;
112
+ }
113
+ export interface MultiplayerHubPadUpdateInput {
114
+ /** A pad door of this place, from ghost's door table. */
115
+ doorId: string;
116
+ /** This gathering's id; a fired pad's group is fixed, so the next one needs a new id. */
117
+ padId: string;
118
+ /** Who stands on the pad. The host keeps only players connected to this session. */
119
+ memberUserIds: readonly string[];
120
+ /** Send the pad's players through the door now. */
121
+ fire?: boolean;
122
+ }
123
+ export interface MultiplayerHubPadUpdateResult {
124
+ status: "gathering" | "fired";
125
+ members: string[];
126
+ }
127
+ export interface MultiplayerHubResultsReadInput {
128
+ /** Players of this session to read; anyone not connected is dropped by the host. */
129
+ userIds?: readonly string[];
130
+ /** Doors of this place whose best scores to read, at most 16. */
131
+ doorIds?: readonly string[];
132
+ }
133
+ export interface MultiplayerHubPlayerResults {
134
+ /** Newest five results from the last hour. */
135
+ recent: MultiplayerHubResultRow[];
136
+ /** Newest twenty per-place records. */
137
+ records: MultiplayerHubRecord[];
138
+ /** The player's platform avatar, chosen or default; null when ghost has none to say. */
139
+ avatar: number | null;
140
+ /** Account-wide door hearts, oldest first; absent on older ghosts. */
141
+ hearts?: string[];
142
+ /** The player's wearable; null when none. Absent from a ghost older than wearables. */
143
+ wearable?: MultiplayerHubWearable | null;
144
+ /** The earned item the player shows (contract v1.53); absent when none. */
145
+ earned?: MultiplayerHubEarnedItem;
146
+ }
147
+ export interface MultiplayerHubBoard {
148
+ toDeckId: string;
149
+ title: string | null;
150
+ unit: string | null;
151
+ better: "higher" | "lower" | null;
152
+ top: {
153
+ userId: string;
154
+ username: string;
155
+ score: number;
156
+ at: string;
157
+ }[];
158
+ /** Featured today (the daily rotation, or set by hand). */
159
+ featured?: boolean;
160
+ /**
161
+ * The door row's group: the pad's minimum, the most that go through, and whether
162
+ * later arrivals join a running instance. Null where the row does not say; absent
163
+ * from a ghost older than these fields.
164
+ */
165
+ minPlayers?: number | null;
166
+ maxPlayers?: number | null;
167
+ lateJoin?: boolean;
168
+ /**
169
+ * Live presence behind the door, counts only and never who: players in the
170
+ * destination's public sessions from every town layer and entry, and how many of
171
+ * those sessions are open. Zero when nobody is inside; for an event gate, the
172
+ * current event's game. Absent from a ghost older than these fields.
173
+ */
174
+ playersInside?: number;
175
+ instancesOpen?: number;
176
+ }
177
+ export interface MultiplayerHubResultsRead {
178
+ players: Record<string, MultiplayerHubPlayerResults>;
179
+ boards: Record<string, MultiplayerHubBoard>;
180
+ }
181
+ /**
182
+ * The hub place this session runs, as ghost pinned it from the deck's published
183
+ * castle.json `hub` block. `session.hub` is null on a host without hub support
184
+ * and for a deck that declares no hub block.
185
+ */
186
+ export interface MultiplayerServerHub {
187
+ /** The owner user id, only in an owner door instance. */
188
+ readonly owner?: string;
189
+ readonly place: string | null;
190
+ readonly title: string | null;
191
+ readonly result: MultiplayerHubResultShape | null;
192
+ /** How this player came in, when ghost placed them through a door; null otherwise. */
193
+ arrival(playerId: string): MultiplayerHubArrival | null;
194
+ /**
195
+ * Records a signed-in player's result for this visit, once. The host checks it
196
+ * against the published shape; ghost keeps it only for a place behind a curated
197
+ * door. Rejects with the platform's `CAULDRON_HUB_*` code.
198
+ */
199
+ reportResult(playerId: string, result: MultiplayerHubResultInput): Promise<MultiplayerHubReportReceipt>;
200
+ /** Names who stands on a pad, and fires it. */
201
+ updatePad(input: MultiplayerHubPadUpdateInput): Promise<MultiplayerHubPadUpdateResult>;
202
+ /** Results, avatars and wearables of players here, and the boards of this place's doors. */
203
+ readResults(input?: MultiplayerHubResultsReadInput): Promise<MultiplayerHubResultsRead>;
204
+ /**
205
+ * Puts a wearable on a signed-in player's platform avatar in every hub place, or
206
+ * clears it with null; latest wins. Castle takes it only from a curated hub place.
207
+ * Rejects with a `CAULDRON_HUB_*` code (`CAULDRON_HUB_UNAVAILABLE` on a runtime
208
+ * without it).
209
+ */
210
+ wear(playerId: string, wearable: MultiplayerHubWearable | null): Promise<MultiplayerHubWearReceipt>;
211
+ /**
212
+ * Gives a signed-in player one of this deck's declared earned items (castle.json
213
+ * `hub.unlocks`), by its id. Castle takes it only from a curated hub place, for a
214
+ * declared id; a first wearable unlock is also shown. Home items are never worn.
215
+ * Rejects with a `CAULDRON_HUB_*` code
216
+ * (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
217
+ */
218
+ unlock(playerId: string, itemId: string): Promise<MultiplayerHubUnlockReceipt>;
219
+ /**
220
+ * Chooses which owned earned item a signed-in player shows, or none with null.
221
+ * Castle takes it only from the town; home items are refused.
222
+ * Rejects with a `CAULDRON_HUB_*` code
223
+ * (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
224
+ */
225
+ wearEarned(playerId: string, earned: {
226
+ deckId: string;
227
+ id: string;
228
+ } | null): Promise<MultiplayerHubWearEarnedReceipt>;
229
+ /**
230
+ * The earned items players here own, by user id. Rejects with a `CAULDRON_HUB_*`
231
+ * code (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
232
+ */
233
+ readUnlocks(userIds: readonly string[]): Promise<MultiplayerHubUnlocksRead>;
234
+ /** The connected player's friends among these candidates; platform refusals reject. */
235
+ friendsAmong(playerId: string, userIds: readonly string[]): Promise<string[]>;
236
+ /**
237
+ * The connected player's mutual friends and follows in either direction among
238
+ * these candidates, in one host read. Missing lists are empty; refusals reject.
239
+ * These relations belong to this player only; never send them to another client.
240
+ */
241
+ relationsAmong(playerId: string, userIds: readonly string[]): Promise<{
242
+ friends: string[];
243
+ follows: string[];
244
+ followedBy: string[];
245
+ }>;
246
+ /**
247
+ * Whether a block hides these two players from each other: either one blocked
248
+ * the other, in either direction. Symmetric. The block lists themselves stay
249
+ * with the host and this wrapper; a place server learns only the pair, and
250
+ * only for players who are both here. False on hosts that send none.
251
+ */
252
+ hiddenPair(playerIdA: string, playerIdB: string): boolean;
253
+ }
254
+ export declare function hubArrival(raw: unknown): MultiplayerHubArrival | null;
255
+ export declare function recordHiddenUserIds(hidden: Map<string, ReadonlySet<string>>, playerId: string, raw: unknown): void;
256
+ export declare function createServerHub(platformHandle: PlatformHandle, arrivals: ReadonlyMap<string, MultiplayerHubArrival>, hiddenPair?: (playerIdA: string, playerIdB: string) => boolean): MultiplayerServerHub | null;