castle-web-sdk 0.4.46 → 0.4.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -2
- package/dist/avatar.d.ts +6 -2
- package/dist/avatar.js +12 -4
- package/dist/castle.d.ts +7 -3
- package/dist/castle.js +2 -0
- package/dist/commands.d.ts +142 -1
- package/dist/commands.js +3 -0
- package/dist/display.d.ts +3 -0
- package/dist/display.js +9 -3
- package/dist/hub.d.ts +18 -2
- package/dist/hub.js +47 -0
- package/dist/multiplayer.d.ts +7 -1
- package/dist/multiplayer.js +80 -3
- package/dist/multiplayerProtocol.d.ts +2 -0
- package/dist/party.d.ts +23 -0
- package/dist/party.js +91 -0
- package/dist/save.d.ts +26 -1
- package/dist/save.js +54 -7
- package/dist/server/hub.d.ts +110 -0
- package/dist/server/hub.js +104 -2
- package/dist/server/platformHandle.d.ts +39 -1
- package/dist/server/storage.d.ts +15 -0
- package/dist/server/storage.js +57 -3
- package/dist/server/wrapper.d.ts +43 -6
- package/dist/server/wrapper.js +93 -9
- package/dist/social.d.ts +9 -0
- package/dist/social.js +5 -0
- package/package.json +1 -1
package/dist/party.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CastleError } from './errors';
|
|
2
|
+
import { hostRequest } from './transport';
|
|
3
|
+
export const Party = {
|
|
4
|
+
current,
|
|
5
|
+
create,
|
|
6
|
+
invite,
|
|
7
|
+
respond,
|
|
8
|
+
members,
|
|
9
|
+
leave,
|
|
10
|
+
messages,
|
|
11
|
+
say,
|
|
12
|
+
};
|
|
13
|
+
const ID_MAX = 128;
|
|
14
|
+
const CURSOR_MAX = 512;
|
|
15
|
+
const PARTY_USERS_MAX = 32;
|
|
16
|
+
function id(value, field, operation) {
|
|
17
|
+
return boundedText(value, field, operation, ID_MAX);
|
|
18
|
+
}
|
|
19
|
+
function boundedText(value, field, operation, max) {
|
|
20
|
+
if (typeof value === 'string' &&
|
|
21
|
+
value.length > 0 &&
|
|
22
|
+
value.length <= max &&
|
|
23
|
+
!value.includes('\0')) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
throw new CastleError({
|
|
27
|
+
code: 'INVALID_ARGUMENT',
|
|
28
|
+
message: `${operation} requires a valid ${field}.`,
|
|
29
|
+
operation,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function ids(value, operation) {
|
|
33
|
+
if (!Array.isArray(value) || value.length > PARTY_USERS_MAX) {
|
|
34
|
+
throw new CastleError({
|
|
35
|
+
code: 'INVALID_ARGUMENT',
|
|
36
|
+
message: `${operation} requires at most ${PARTY_USERS_MAX} user ids.`,
|
|
37
|
+
operation,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return value.map((value) => id(value, 'userId', operation));
|
|
41
|
+
}
|
|
42
|
+
async function current() {
|
|
43
|
+
return hostRequest('party.current', {});
|
|
44
|
+
}
|
|
45
|
+
async function create(userIds) {
|
|
46
|
+
return hostRequest('party.create', { userIds: ids(userIds, 'Party.create') });
|
|
47
|
+
}
|
|
48
|
+
async function invite(partyId, userIds) {
|
|
49
|
+
return hostRequest('party.invite', {
|
|
50
|
+
partyId: id(partyId, 'partyId', 'Party.invite'),
|
|
51
|
+
userIds: ids(userIds, 'Party.invite'),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function respond(partyId, accept) {
|
|
55
|
+
if (typeof accept !== 'boolean') {
|
|
56
|
+
throw new CastleError({
|
|
57
|
+
code: 'INVALID_ARGUMENT',
|
|
58
|
+
message: 'Party.respond requires accept to be a boolean.',
|
|
59
|
+
operation: 'Party.respond',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return hostRequest('party.respond', {
|
|
63
|
+
partyId: id(partyId, 'partyId', 'Party.respond'),
|
|
64
|
+
accept,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function members(partyId) {
|
|
68
|
+
return hostRequest('party.members', { partyId: id(partyId, 'partyId', 'Party.members') });
|
|
69
|
+
}
|
|
70
|
+
async function leave(partyId) {
|
|
71
|
+
return hostRequest('party.leave', { partyId: id(partyId, 'partyId', 'Party.leave') });
|
|
72
|
+
}
|
|
73
|
+
async function messages(partyId, cursor) {
|
|
74
|
+
const params = {
|
|
75
|
+
partyId: id(partyId, 'partyId', 'Party.messages'),
|
|
76
|
+
};
|
|
77
|
+
if (cursor !== undefined) {
|
|
78
|
+
params.cursor = boundedText(cursor, 'cursor', 'Party.messages', CURSOR_MAX);
|
|
79
|
+
}
|
|
80
|
+
return hostRequest('party.messages', params);
|
|
81
|
+
}
|
|
82
|
+
async function say(partyId, text) {
|
|
83
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
84
|
+
throw new CastleError({
|
|
85
|
+
code: 'INVALID_ARGUMENT',
|
|
86
|
+
message: 'Party.say requires text.',
|
|
87
|
+
operation: 'Party.say',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return hostRequest('party.say', { partyId: id(partyId, 'partyId', 'Party.say'), text });
|
|
91
|
+
}
|
package/dist/save.d.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface SaveTransactionInput {
|
|
|
63
63
|
checks?: SaveCheck[];
|
|
64
64
|
changes: SaveChange[];
|
|
65
65
|
}
|
|
66
|
+
export interface SaveConditionalCheck extends Omit<SaveCheck, "expectedRevision"> {
|
|
67
|
+
/** Null checks absence without creating the target. */
|
|
68
|
+
expectedRevision: SaveRevision | null;
|
|
69
|
+
}
|
|
70
|
+
export interface SaveConditionalWriteInput extends Omit<SaveTransactionInput, "checks"> {
|
|
71
|
+
checks?: SaveConditionalCheck[];
|
|
72
|
+
}
|
|
66
73
|
export interface SaveInitial {
|
|
67
74
|
target: SaveTarget;
|
|
68
75
|
rows: SaveRows;
|
|
@@ -95,8 +102,24 @@ export type SaveOperation = {
|
|
|
95
102
|
} | {
|
|
96
103
|
state: "unknown";
|
|
97
104
|
};
|
|
98
|
-
export
|
|
105
|
+
export interface SaveRecentOperationsInput {
|
|
106
|
+
limit?: number;
|
|
107
|
+
cursor?: string;
|
|
108
|
+
}
|
|
109
|
+
export interface SaveRecentOperationsPage {
|
|
110
|
+
operations: {
|
|
111
|
+
operationId: string;
|
|
112
|
+
committedAt: string;
|
|
113
|
+
receipt: SaveReceipt;
|
|
114
|
+
}[];
|
|
115
|
+
cursor: string | null;
|
|
116
|
+
}
|
|
117
|
+
export type SaveVerb = "snapshot" | "transaction" | "claim" | "initialize" | "operation" | "conditionalWrite" | "recentOperations";
|
|
99
118
|
export interface SaveApi {
|
|
119
|
+
/** Gated atomic revision check and write, up to eight targets and two players. */
|
|
120
|
+
conditionalWrite(input: SaveConditionalWriteInput): Promise<SaveReceipt>;
|
|
121
|
+
/** Gated caller-scoped receipt discovery after losing local operation IDs. */
|
|
122
|
+
recentOperations(input?: SaveRecentOperationsInput): Promise<SaveRecentOperationsPage>;
|
|
100
123
|
snapshot(input: {
|
|
101
124
|
targets: SaveReadTarget[];
|
|
102
125
|
}): Promise<SaveSnapshot>;
|
|
@@ -156,6 +179,7 @@ export interface SaveErrorEnvelope {
|
|
|
156
179
|
receipt?: SaveReceipt;
|
|
157
180
|
retryAfterMs?: number;
|
|
158
181
|
target?: SaveTarget;
|
|
182
|
+
current?: SaveSnapshot;
|
|
159
183
|
[detail: string]: unknown;
|
|
160
184
|
}
|
|
161
185
|
export declare class SaveError extends CastleError {
|
|
@@ -165,6 +189,7 @@ export declare class SaveError extends CastleError {
|
|
|
165
189
|
readonly receipt?: SaveReceipt;
|
|
166
190
|
readonly retryAfterMs?: number;
|
|
167
191
|
readonly target?: SaveTarget;
|
|
192
|
+
readonly current?: SaveSnapshot;
|
|
168
193
|
constructor(envelope: SaveErrorEnvelope);
|
|
169
194
|
}
|
|
170
195
|
/**
|
package/dist/save.js
CHANGED
|
@@ -7,6 +7,7 @@ export class SaveError extends CastleError {
|
|
|
7
7
|
receipt;
|
|
8
8
|
retryAfterMs;
|
|
9
9
|
target;
|
|
10
|
+
current;
|
|
10
11
|
constructor(envelope) {
|
|
11
12
|
super({
|
|
12
13
|
code: envelope.code,
|
|
@@ -20,6 +21,7 @@ export class SaveError extends CastleError {
|
|
|
20
21
|
this.receipt = envelope.receipt;
|
|
21
22
|
this.retryAfterMs = envelope.retryAfterMs;
|
|
22
23
|
this.target = envelope.target;
|
|
24
|
+
this.current = envelope.current;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
@@ -60,7 +62,8 @@ const MAX_SUBJECT_BYTES = 256;
|
|
|
60
62
|
const MAX_VALUE_BYTES = 256 * 1024;
|
|
61
63
|
const encoder = new TextEncoder();
|
|
62
64
|
const byteLength = (text) => encoder.encode(text).length;
|
|
63
|
-
|
|
65
|
+
// Unicode mode consumes valid pairs together; avoid lookbehind for Safari < 16.4.
|
|
66
|
+
const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
|
|
64
67
|
function invalid(message) {
|
|
65
68
|
throw new SaveError({
|
|
66
69
|
code: "CAULDRON_STORAGE_INVALID_ARGUMENT",
|
|
@@ -240,12 +243,14 @@ function encodeChange(change, server) {
|
|
|
240
243
|
...(remove === undefined ? {} : { remove }),
|
|
241
244
|
};
|
|
242
245
|
}
|
|
243
|
-
function encodeCheck(check, server) {
|
|
246
|
+
function encodeCheck(check, server, conditional = false) {
|
|
244
247
|
const target = validateSaveTarget(check.target, server);
|
|
248
|
+
if (conditional && check.expectedRevision === undefined)
|
|
249
|
+
invalid("Every conditional check needs an expected revision.");
|
|
245
250
|
if (check.expectedRevision === undefined && check.writer === undefined) {
|
|
246
251
|
invalid("A check needs an expected revision or a writer.");
|
|
247
252
|
}
|
|
248
|
-
if (check.expectedRevision !== undefined) {
|
|
253
|
+
if (check.expectedRevision !== undefined && !(conditional && check.expectedRevision === null)) {
|
|
249
254
|
checkText(check.expectedRevision, "expected revision");
|
|
250
255
|
}
|
|
251
256
|
if (check.writer !== undefined)
|
|
@@ -258,7 +263,7 @@ function encodeCheck(check, server) {
|
|
|
258
263
|
...(check.writer === undefined ? {} : { writer: check.writer }),
|
|
259
264
|
};
|
|
260
265
|
}
|
|
261
|
-
function encodeTransaction(input, server) {
|
|
266
|
+
function encodeTransaction(input, server, conditional = false) {
|
|
262
267
|
const changes = checkList(input.changes, "changes").map((change) => encodeChange(change, server));
|
|
263
268
|
const keys = changes.map((change) => saveTargetKey(change.target));
|
|
264
269
|
if (new Set(keys).size !== keys.length) {
|
|
@@ -270,7 +275,7 @@ function encodeTransaction(input, server) {
|
|
|
270
275
|
...(input.checks === undefined
|
|
271
276
|
? {}
|
|
272
277
|
: {
|
|
273
|
-
checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server)),
|
|
278
|
+
checks: checkList(input.checks, "checks").map((check) => encodeCheck(check, server, conditional)),
|
|
274
279
|
}),
|
|
275
280
|
changes,
|
|
276
281
|
};
|
|
@@ -282,12 +287,26 @@ export function encodeSaveInput(verb, input, server = false) {
|
|
|
282
287
|
}
|
|
283
288
|
if (verb === "snapshot")
|
|
284
289
|
return { targets: encodeReads(input.targets, server) };
|
|
290
|
+
if (verb === "recentOperations") {
|
|
291
|
+
const limit = input.limit ?? 16;
|
|
292
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 32)
|
|
293
|
+
invalid("Recovery limit must be 1 to 32.");
|
|
294
|
+
if (input.cursor !== undefined)
|
|
295
|
+
checkText(input.cursor, "recovery cursor", 2048);
|
|
296
|
+
return { limit, ...(input.cursor === undefined ? {} : { cursor: input.cursor }) };
|
|
297
|
+
}
|
|
285
298
|
checkText(input.operationId, "operation id", MAX_KEY_BYTES);
|
|
286
299
|
if (verb === "operation")
|
|
287
300
|
return { operationId: input.operationId };
|
|
288
|
-
if (verb === "transaction") {
|
|
301
|
+
if (verb === "transaction" || verb === "conditionalWrite") {
|
|
302
|
+
if (verb === "conditionalWrite") {
|
|
303
|
+
const changes = input.changes;
|
|
304
|
+
const checks = input.checks ?? [];
|
|
305
|
+
if (!Array.isArray(changes) || !changes.length || changes.length + checks.length > 8)
|
|
306
|
+
invalid("Conditional writes require 1 to 8 targets.");
|
|
307
|
+
}
|
|
289
308
|
return {
|
|
290
|
-
...encodeTransaction(input, server),
|
|
309
|
+
...encodeTransaction(input, server, verb === "conditionalWrite"),
|
|
291
310
|
};
|
|
292
311
|
}
|
|
293
312
|
if (input.expectedAccess !== undefined) {
|
|
@@ -422,6 +441,34 @@ export function createSaveApi(call, server = false) {
|
|
|
422
441
|
}
|
|
423
442
|
}
|
|
424
443
|
return {
|
|
444
|
+
async conditionalWrite(input) {
|
|
445
|
+
const result = await send("conditionalWrite", { ...input });
|
|
446
|
+
return decodeReceipt(result, input.operationId, input.changes.map((change) => change.target));
|
|
447
|
+
},
|
|
448
|
+
async recentOperations(input = {}) {
|
|
449
|
+
const page = (await send("recentOperations", {
|
|
450
|
+
...input,
|
|
451
|
+
}));
|
|
452
|
+
if (!page ||
|
|
453
|
+
!Array.isArray(page.operations) ||
|
|
454
|
+
page.operations.length > (input.limit ?? 16) ||
|
|
455
|
+
(page.cursor !== null && typeof page.cursor !== "string"))
|
|
456
|
+
invalidResponse();
|
|
457
|
+
return {
|
|
458
|
+
operations: page.operations.map((operation) => {
|
|
459
|
+
if (!operation ||
|
|
460
|
+
typeof operation.operationId !== "string" ||
|
|
461
|
+
typeof operation.committedAt !== "string")
|
|
462
|
+
invalidResponse();
|
|
463
|
+
return {
|
|
464
|
+
operationId: operation.operationId,
|
|
465
|
+
committedAt: operation.committedAt,
|
|
466
|
+
receipt: decodeReceipt(operation.receipt, operation.operationId, []),
|
|
467
|
+
};
|
|
468
|
+
}),
|
|
469
|
+
cursor: page.cursor,
|
|
470
|
+
};
|
|
471
|
+
},
|
|
425
472
|
async snapshot(input) {
|
|
426
473
|
return decodeSnapshot(await send("snapshot", { ...input }), input.targets);
|
|
427
474
|
},
|
package/dist/server/hub.d.ts
CHANGED
|
@@ -64,6 +64,52 @@ export interface MultiplayerHubWearReceipt {
|
|
|
64
64
|
/** What the player now wears; null when cleared. */
|
|
65
65
|
wearable: MultiplayerHubWearable | null;
|
|
66
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
|
+
}
|
|
67
113
|
export interface MultiplayerHubPadUpdateInput {
|
|
68
114
|
/** A pad door of this place, from ghost's door table. */
|
|
69
115
|
doorId: string;
|
|
@@ -91,8 +137,12 @@ export interface MultiplayerHubPlayerResults {
|
|
|
91
137
|
records: MultiplayerHubRecord[];
|
|
92
138
|
/** The player's platform avatar, chosen or default; null when ghost has none to say. */
|
|
93
139
|
avatar: number | null;
|
|
140
|
+
/** Account-wide door hearts, oldest first; absent on older ghosts. */
|
|
141
|
+
hearts?: string[];
|
|
94
142
|
/** The player's wearable; null when none. Absent from a ghost older than wearables. */
|
|
95
143
|
wearable?: MultiplayerHubWearable | null;
|
|
144
|
+
/** The earned item the player shows (contract v1.53); absent when none. */
|
|
145
|
+
earned?: MultiplayerHubEarnedItem;
|
|
96
146
|
}
|
|
97
147
|
export interface MultiplayerHubBoard {
|
|
98
148
|
toDeckId: string;
|
|
@@ -134,6 +184,27 @@ export interface MultiplayerHubResultsRead {
|
|
|
134
184
|
* and for a deck that declares no hub block.
|
|
135
185
|
*/
|
|
136
186
|
export interface MultiplayerServerHub {
|
|
187
|
+
/** Durably appends NPC words to a private platform archive. No read operation. */
|
|
188
|
+
archiveText(playerId: string, entry: {
|
|
189
|
+
npcId: string;
|
|
190
|
+
characterId?: string;
|
|
191
|
+
conversationId?: string;
|
|
192
|
+
operationId: string;
|
|
193
|
+
input?: string;
|
|
194
|
+
reply?: string;
|
|
195
|
+
}): Promise<void>;
|
|
196
|
+
/** Numeric hub telemetry; unavailable/refused delivery returns false, never blocks play. */
|
|
197
|
+
track(playerId: string, event: {
|
|
198
|
+
kind: number;
|
|
199
|
+
subject?: number;
|
|
200
|
+
value?: number;
|
|
201
|
+
result?: number;
|
|
202
|
+
flags?: number;
|
|
203
|
+
}): Promise<boolean>;
|
|
204
|
+
/** The owner user id, only in an owner door instance. */
|
|
205
|
+
readonly owner?: string;
|
|
206
|
+
/** Host-pinned inclusive reconnect grace for this hub place, in milliseconds. */
|
|
207
|
+
readonly reconnectWindowMs?: number;
|
|
137
208
|
readonly place: string | null;
|
|
138
209
|
readonly title: string | null;
|
|
139
210
|
readonly result: MultiplayerHubResultShape | null;
|
|
@@ -156,6 +227,45 @@ export interface MultiplayerServerHub {
|
|
|
156
227
|
* without it).
|
|
157
228
|
*/
|
|
158
229
|
wear(playerId: string, wearable: MultiplayerHubWearable | null): Promise<MultiplayerHubWearReceipt>;
|
|
230
|
+
/**
|
|
231
|
+
* Gives a signed-in player one of this deck's declared earned items (castle.json
|
|
232
|
+
* `hub.unlocks`), by its id. Castle takes it only from a curated hub place, for a
|
|
233
|
+
* declared id; a first wearable unlock is also shown. Home items are never worn.
|
|
234
|
+
* Rejects with a `CAULDRON_HUB_*` code
|
|
235
|
+
* (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
|
|
236
|
+
*/
|
|
237
|
+
unlock(playerId: string, itemId: string): Promise<MultiplayerHubUnlockReceipt>;
|
|
238
|
+
/**
|
|
239
|
+
* Chooses which owned earned item a signed-in player shows, or none with null.
|
|
240
|
+
* Castle takes it only from the town; home items are refused.
|
|
241
|
+
* Rejects with a `CAULDRON_HUB_*` code
|
|
242
|
+
* (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
|
|
243
|
+
*/
|
|
244
|
+
wearEarned(playerId: string, earned: {
|
|
245
|
+
deckId: string;
|
|
246
|
+
id: string;
|
|
247
|
+
} | null): Promise<MultiplayerHubWearEarnedReceipt>;
|
|
248
|
+
/**
|
|
249
|
+
* The earned items players here own, by user id. Rejects with a `CAULDRON_HUB_*`
|
|
250
|
+
* code (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
|
|
251
|
+
*/
|
|
252
|
+
readUnlocks(userIds: readonly string[]): Promise<MultiplayerHubUnlocksRead>;
|
|
253
|
+
/** The connected player's friends among these candidates; platform refusals reject. */
|
|
254
|
+
friendsAmong(playerId: string, userIds: readonly string[]): Promise<string[]>;
|
|
255
|
+
/**
|
|
256
|
+
* The connected player's mutual friends and follows in either direction among
|
|
257
|
+
* these candidates, in one host read. Missing lists are empty; refusals reject.
|
|
258
|
+
* Optional strict mode rejects malformed raw lists and marks the result validated.
|
|
259
|
+
* These relations belong to this player only; never send them to another client.
|
|
260
|
+
*/
|
|
261
|
+
relationsAmong(playerId: string, userIds: readonly string[], options?: {
|
|
262
|
+
strict: true;
|
|
263
|
+
}): Promise<{
|
|
264
|
+
validated?: true;
|
|
265
|
+
friends: string[];
|
|
266
|
+
follows: string[];
|
|
267
|
+
followedBy: string[];
|
|
268
|
+
}>;
|
|
159
269
|
/**
|
|
160
270
|
* Whether a block hides these two players from each other: either one blocked
|
|
161
271
|
* the other, in either direction. Symmetric. The block lists themselves stay
|
package/dist/server/hub.js
CHANGED
|
@@ -39,6 +39,18 @@ function hubHiddenUserIds(raw) {
|
|
|
39
39
|
}
|
|
40
40
|
return ids.size > 0 ? ids : null;
|
|
41
41
|
}
|
|
42
|
+
function unavailable(op, what) {
|
|
43
|
+
return new CastleError({
|
|
44
|
+
code: "CAULDRON_HUB_UNAVAILABLE",
|
|
45
|
+
message: `This host's runtime cannot ${what}.`,
|
|
46
|
+
operation: `session.hub.${op}`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function reconnectMetadata(pinned) {
|
|
50
|
+
const value = pinned.reconnectWindowMs;
|
|
51
|
+
return pinned.result === null && typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 180_000
|
|
52
|
+
? { reconnectWindowMs: value } : {};
|
|
53
|
+
}
|
|
42
54
|
export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
|
|
43
55
|
const pinned = platformHandle.hub;
|
|
44
56
|
if (!pinned ||
|
|
@@ -65,16 +77,67 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
65
77
|
}
|
|
66
78
|
return parsed;
|
|
67
79
|
};
|
|
68
|
-
|
|
80
|
+
const hub = {
|
|
81
|
+
async archiveText(playerId, entry) {
|
|
82
|
+
if (platformHandle.hubTextArchiveEnabled !== true || typeof platformHandle.storage !== "function")
|
|
83
|
+
throw unavailable("archiveText", "archive NPC transcripts");
|
|
84
|
+
const raw = await platformHandle.storage("hubTextArchive", JSON.stringify({ ...entry, playerId }));
|
|
85
|
+
if (JSON.parse(raw)?.recorded !== true)
|
|
86
|
+
throw unavailable("archiveText", "archive NPC transcripts");
|
|
87
|
+
},
|
|
69
88
|
place: typeof pinned.place === "string" ? pinned.place : null,
|
|
70
89
|
title: typeof pinned.title === "string" ? pinned.title : null,
|
|
71
90
|
result: pinned.result ?? null,
|
|
91
|
+
...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
|
|
92
|
+
...reconnectMetadata(pinned),
|
|
72
93
|
arrival(playerId) {
|
|
73
94
|
return arrivals.get(playerId) ?? null;
|
|
74
95
|
},
|
|
75
96
|
hiddenPair(playerIdA, playerIdB) {
|
|
76
97
|
return hiddenPair(playerIdA, playerIdB);
|
|
77
98
|
},
|
|
99
|
+
async track(playerId, event) {
|
|
100
|
+
try {
|
|
101
|
+
const json = JSON.stringify({ ...event, playerId });
|
|
102
|
+
const raw = await platformHandle.storage?.("hubTrack", json);
|
|
103
|
+
return JSON.parse(raw ?? "null")?.recorded === true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
async friendsAmong(playerId, userIds) {
|
|
110
|
+
const json = JSON.stringify({ playerId, userIds: [...userIds] });
|
|
111
|
+
const raw = typeof platformHandle.hubFriendsAmong === "function"
|
|
112
|
+
? await platformHandle.hubFriendsAmong(json)
|
|
113
|
+
: await platformHandle.storage?.("hubFriendsAmong", json);
|
|
114
|
+
const answer = JSON.parse(raw ?? "null");
|
|
115
|
+
return Array.isArray(answer?.userIds) ? answer.userIds : [];
|
|
116
|
+
},
|
|
117
|
+
async relationsAmong(playerId, userIds, options = undefined) {
|
|
118
|
+
const asked = new Set(userIds);
|
|
119
|
+
const json = JSON.stringify({ playerId, userIds: [...userIds] });
|
|
120
|
+
let raw;
|
|
121
|
+
if (typeof platformHandle.hubFriendsAmong === "function") {
|
|
122
|
+
raw = await platformHandle.hubFriendsAmong(json);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
raw = await platformHandle.storage?.("hubFriendsAmong", json);
|
|
126
|
+
}
|
|
127
|
+
const answer = JSON.parse(raw ?? "null");
|
|
128
|
+
if (options?.strict === true && (!answer || ["userIds", "follows", "followedBy"].some((key) => !Array.isArray(answer[key]) || answer[key].length > 100 || answer[key].some((id) => typeof id !== "string" || !/^\d{1,20}$/.test(id) || !asked.has(id))))) {
|
|
129
|
+
throw new CastleError({ code: "CAULDRON_HUB_INVALID_RESPONSE", message: "The platform returned an invalid friends response.", operation: "session.hub.relationsAmong" });
|
|
130
|
+
}
|
|
131
|
+
const list = (value) => Array.isArray(value)
|
|
132
|
+
? value.filter((id) => typeof id === "string" && asked.has(id))
|
|
133
|
+
: [];
|
|
134
|
+
return {
|
|
135
|
+
...(options?.strict === true ? { validated: true } : {}),
|
|
136
|
+
friends: list(answer?.userIds),
|
|
137
|
+
follows: list(answer?.follows),
|
|
138
|
+
followedBy: list(answer?.followedBy),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
78
141
|
async reportResult(playerId, result) {
|
|
79
142
|
const { place, outcome, score, stats } = result ?? {};
|
|
80
143
|
return (await call("hubResult", {
|
|
@@ -121,5 +184,44 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
121
184
|
wearable: (receipt.wearable ?? null),
|
|
122
185
|
};
|
|
123
186
|
},
|
|
124
|
-
|
|
187
|
+
async unlock(playerId, itemId) {
|
|
188
|
+
if (typeof platformHandle.hubUnlock !== "function") {
|
|
189
|
+
throw unavailable("hubUnlock", "give earned items");
|
|
190
|
+
}
|
|
191
|
+
const receipt = await call("hubUnlock", { playerId, itemId });
|
|
192
|
+
return {
|
|
193
|
+
unlocked: true,
|
|
194
|
+
isNew: receipt.isNew === true,
|
|
195
|
+
earned: (receipt.earned ?? null),
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
async wearEarned(playerId, earned) {
|
|
199
|
+
if (typeof platformHandle.hubWearEarned !== "function") {
|
|
200
|
+
throw unavailable("hubWearEarned", "choose earned items");
|
|
201
|
+
}
|
|
202
|
+
const receipt = await call("hubWearEarned", {
|
|
203
|
+
playerId,
|
|
204
|
+
earned: earned === null || earned === undefined
|
|
205
|
+
? null
|
|
206
|
+
: { deckId: earned.deckId, id: earned.id },
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
earned: (receipt.earned ?? null),
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
async readUnlocks(userIds) {
|
|
213
|
+
if (typeof platformHandle.hubUnlocksRead !== "function") {
|
|
214
|
+
throw unavailable("hubUnlocksRead", "read earned items");
|
|
215
|
+
}
|
|
216
|
+
const read = await call("hubUnlocksRead", { userIds: [...(userIds ?? [])] });
|
|
217
|
+
return {
|
|
218
|
+
players: (read.players ?? {}),
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
// Preserve the enumerable API shape and diagnostic serialization of existing
|
|
223
|
+
// places. Opted-in kits call this method explicitly; it adds no wire field.
|
|
224
|
+
Object.defineProperty(hub, "track", { enumerable: false });
|
|
225
|
+
Object.defineProperty(hub, "archiveText", { enumerable: false });
|
|
226
|
+
return Object.freeze(hub);
|
|
125
227
|
}
|
|
@@ -6,6 +6,8 @@ export interface PlayerIdentity {
|
|
|
6
6
|
username: string;
|
|
7
7
|
isAnonymous: boolean;
|
|
8
8
|
scopedUserId?: string;
|
|
9
|
+
/** Trusted hub age boundary; omitted by older hosts. Never client supplied. */
|
|
10
|
+
under13?: boolean;
|
|
9
11
|
}
|
|
10
12
|
export interface PlatformSessionConfig {
|
|
11
13
|
readonly sessionId: string;
|
|
@@ -53,6 +55,7 @@ export interface PlatformHandle {
|
|
|
53
55
|
on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
|
|
54
56
|
on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
|
|
55
57
|
on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
|
|
58
|
+
on(event: "worldCharacterTool", callback: (request: PlatformWorldCharacterTool) => unknown): void;
|
|
56
59
|
/**
|
|
57
60
|
* Deck storage. Strings in and strings out, like the rest of this handle, so a
|
|
58
61
|
* runtime other than the Node shim can implement it without knowing what storage is.
|
|
@@ -65,15 +68,26 @@ export interface PlatformHandle {
|
|
|
65
68
|
storage?(op: string, argsJson: string): Promise<string>;
|
|
66
69
|
/** False when the host cannot broker storage, so a call can fail at once. */
|
|
67
70
|
readonly storageEnabled?: boolean;
|
|
71
|
+
readonly hubTextArchiveEnabled?: boolean;
|
|
68
72
|
/** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
|
|
69
73
|
worldCheckpointRead?(): Promise<string>;
|
|
70
74
|
worldCheckpointCommit?(argsJson: string): Promise<string>;
|
|
71
|
-
|
|
75
|
+
/** `tools` is the turn's place tools, passed only when non-empty. */
|
|
76
|
+
worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[], hubTextArchive?: {
|
|
77
|
+
npcId: string;
|
|
78
|
+
conversationId?: string;
|
|
79
|
+
input: string;
|
|
80
|
+
}): Promise<string>;
|
|
72
81
|
worldCharacterCommand?(grantId: string): Promise<string>;
|
|
73
82
|
worldCharacterRead?(grantId: string): Promise<string>;
|
|
74
83
|
worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
|
|
75
84
|
worldCharacterRuntimeList?(): Promise<string>;
|
|
76
85
|
worldCharacterReject?(grantId: string, code?: string): void;
|
|
86
|
+
/**
|
|
87
|
+
* Answers a `worldCharacterTool` event with at most 1 KiB of UTF-8. Present only
|
|
88
|
+
* when the host serves place tools, which is also what lets a send declare them.
|
|
89
|
+
*/
|
|
90
|
+
worldCharacterToolResult?(requestId: string, result: string): Promise<string>;
|
|
77
91
|
/**
|
|
78
92
|
* The hub place this session runs, as ghost pinned it from the published
|
|
79
93
|
* castle.json `hub` block. Present only when the host services the hub ops
|
|
@@ -83,8 +97,16 @@ export interface PlatformHandle {
|
|
|
83
97
|
hubResult?(argsJson: string): Promise<string>;
|
|
84
98
|
hubPadUpdate?(argsJson: string): Promise<string>;
|
|
85
99
|
hubResultsRead?(argsJson: string): Promise<string>;
|
|
100
|
+
/** Friends among candidates (hub contract v1.36); older images use storage. */
|
|
101
|
+
hubFriendsAmong?(argsJson: string): Promise<string>;
|
|
86
102
|
/** Absent on a runtime older than wearables (contract v1.4). */
|
|
87
103
|
hubWear?(argsJson: string): Promise<string>;
|
|
104
|
+
/** Absent on a runtime older than earned items (contract v1.53). */
|
|
105
|
+
hubUnlock?(argsJson: string): Promise<string>;
|
|
106
|
+
/** Absent on a runtime older than earned items (contract v1.53). */
|
|
107
|
+
hubWearEarned?(argsJson: string): Promise<string>;
|
|
108
|
+
/** Absent on a runtime older than earned items (contract v1.53). */
|
|
109
|
+
hubUnlocksRead?(argsJson: string): Promise<string>;
|
|
88
110
|
readonly config: PlatformSessionConfig;
|
|
89
111
|
}
|
|
90
112
|
export interface PlatformHubResultShape {
|
|
@@ -98,6 +120,8 @@ export interface PlatformHubResultShape {
|
|
|
98
120
|
stats: Record<string, "number" | "string">;
|
|
99
121
|
}
|
|
100
122
|
export interface PlatformHubPlace {
|
|
123
|
+
owner?: string;
|
|
124
|
+
reconnectWindowMs?: number;
|
|
101
125
|
place?: string;
|
|
102
126
|
title?: string | null;
|
|
103
127
|
result?: PlatformHubResultShape | null;
|
|
@@ -124,6 +148,20 @@ export interface PlatformWorldCharacterRead {
|
|
|
124
148
|
};
|
|
125
149
|
query: unknown;
|
|
126
150
|
}
|
|
151
|
+
/** A lookup a place declares for one character turn. */
|
|
152
|
+
export interface PlatformPlaceTool {
|
|
153
|
+
name: string;
|
|
154
|
+
description: string;
|
|
155
|
+
input_schema: Record<string, unknown>;
|
|
156
|
+
}
|
|
157
|
+
export interface PlatformWorldCharacterTool {
|
|
158
|
+
requestId: string;
|
|
159
|
+
operationId: string;
|
|
160
|
+
characterId: string;
|
|
161
|
+
playerId: string;
|
|
162
|
+
name: string;
|
|
163
|
+
input: Record<string, unknown>;
|
|
164
|
+
}
|
|
127
165
|
export interface PlatformWorldCharacterTurn {
|
|
128
166
|
grantId: string;
|
|
129
167
|
requestId: string;
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface ServerStorageScope extends SaveBucketApi {
|
|
|
23
23
|
increment(key: string, delta?: number): Promise<number>;
|
|
24
24
|
list<T extends Json = Json>(options?: ServerStorageListOptions): Promise<ServerStorageListPage<T>>;
|
|
25
25
|
}
|
|
26
|
+
export interface ServerTownStorage {
|
|
27
|
+
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
|
28
|
+
set(values: Record<string, Json>): Promise<void>;
|
|
29
|
+
remove(keys: string[]): Promise<void>;
|
|
30
|
+
increment(key: string, delta?: number): Promise<number>;
|
|
31
|
+
}
|
|
26
32
|
export interface ServerStorageBoardScore {
|
|
27
33
|
subject: string;
|
|
28
34
|
score: number;
|
|
@@ -45,6 +51,8 @@ export interface ServerStorageBoard {
|
|
|
45
51
|
*/
|
|
46
52
|
export interface ServerPlaythrough {
|
|
47
53
|
readonly generation: number;
|
|
54
|
+
/** Present only after an administrator reset this player's deck progress. */
|
|
55
|
+
readonly resetGeneration?: number;
|
|
48
56
|
/** Explicit narrative changes for this same player and generation. */
|
|
49
57
|
readonly characters: ServerPlaythroughCharacters;
|
|
50
58
|
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
|
@@ -263,6 +271,13 @@ export interface ServerStorage extends SaveApi {
|
|
|
263
271
|
readonly deck: ServerStorageScope;
|
|
264
272
|
/** One player's public data. Readable by anyone; writable by them or by this server. */
|
|
265
273
|
user(userId: string): ServerStorageScope;
|
|
274
|
+
/**
|
|
275
|
+
* The door deck's source town's deck-scope rows, for one player in this session.
|
|
276
|
+
* Only a door deck's server may call it; a town gets CAULDRON_HUB_NO_TOWN.
|
|
277
|
+
* In an owner house, only the owner's rows are accessible. Quota counts against
|
|
278
|
+
* the town. Each key must be tokens|spent|own|hat|chirp, then userId, as its first two segments.
|
|
279
|
+
*/
|
|
280
|
+
town(userId: string): ServerTownStorage;
|
|
266
281
|
board(name: string): ServerStorageBoard;
|
|
267
282
|
/** Opens one player's experimental, generation-bound game namespace. */
|
|
268
283
|
playthrough(userId: string): Promise<ServerPlaythrough>;
|