castle-web-sdk 0.4.44 → 0.4.46
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 +487 -53
- package/dist/agent.js +4 -4
- package/dist/avatar.d.ts +12 -0
- package/dist/avatar.js +23 -0
- package/dist/castle.d.ts +10 -1
- package/dist/castle.js +5 -0
- package/dist/commands.d.ts +229 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +6 -0
- package/dist/experiments.d.ts +15 -0
- package/dist/experiments.js +83 -0
- package/dist/hub.d.ts +85 -0
- package/dist/hub.js +107 -0
- package/dist/multiplayer.d.ts +88 -6
- package/dist/multiplayer.js +219 -8
- package/dist/save.d.ts +197 -0
- package/dist/save.js +525 -0
- package/dist/saveCoordinator.d.ts +39 -0
- package/dist/saveCoordinator.js +252 -0
- package/dist/server/hub.d.ts +169 -0
- package/dist/server/hub.js +125 -0
- package/dist/server/platformHandle.d.ts +51 -2
- package/dist/server/storage.d.ts +58 -4
- package/dist/server/storage.js +25 -5
- package/dist/server/wrapper.d.ts +147 -6
- package/dist/server/wrapper.js +741 -34
- package/dist/store.d.ts +3 -2
- package/dist/store.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +13 -1
- package/package.json +1 -1
|
@@ -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,169 @@
|
|
|
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
|
+
export interface MultiplayerHubPadUpdateInput {
|
|
68
|
+
/** A pad door of this place, from ghost's door table. */
|
|
69
|
+
doorId: string;
|
|
70
|
+
/** This gathering's id; a fired pad's group is fixed, so the next one needs a new id. */
|
|
71
|
+
padId: string;
|
|
72
|
+
/** Who stands on the pad. The host keeps only players connected to this session. */
|
|
73
|
+
memberUserIds: readonly string[];
|
|
74
|
+
/** Send the pad's players through the door now. */
|
|
75
|
+
fire?: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface MultiplayerHubPadUpdateResult {
|
|
78
|
+
status: "gathering" | "fired";
|
|
79
|
+
members: string[];
|
|
80
|
+
}
|
|
81
|
+
export interface MultiplayerHubResultsReadInput {
|
|
82
|
+
/** Players of this session to read; anyone not connected is dropped by the host. */
|
|
83
|
+
userIds?: readonly string[];
|
|
84
|
+
/** Doors of this place whose best scores to read, at most 16. */
|
|
85
|
+
doorIds?: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
export interface MultiplayerHubPlayerResults {
|
|
88
|
+
/** Newest five results from the last hour. */
|
|
89
|
+
recent: MultiplayerHubResultRow[];
|
|
90
|
+
/** Newest twenty per-place records. */
|
|
91
|
+
records: MultiplayerHubRecord[];
|
|
92
|
+
/** The player's platform avatar, chosen or default; null when ghost has none to say. */
|
|
93
|
+
avatar: number | null;
|
|
94
|
+
/** The player's wearable; null when none. Absent from a ghost older than wearables. */
|
|
95
|
+
wearable?: MultiplayerHubWearable | null;
|
|
96
|
+
}
|
|
97
|
+
export interface MultiplayerHubBoard {
|
|
98
|
+
toDeckId: string;
|
|
99
|
+
title: string | null;
|
|
100
|
+
unit: string | null;
|
|
101
|
+
better: "higher" | "lower" | null;
|
|
102
|
+
top: {
|
|
103
|
+
userId: string;
|
|
104
|
+
username: string;
|
|
105
|
+
score: number;
|
|
106
|
+
at: string;
|
|
107
|
+
}[];
|
|
108
|
+
/** Featured today (the daily rotation, or set by hand). */
|
|
109
|
+
featured?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The door row's group: the pad's minimum, the most that go through, and whether
|
|
112
|
+
* later arrivals join a running instance. Null where the row does not say; absent
|
|
113
|
+
* from a ghost older than these fields.
|
|
114
|
+
*/
|
|
115
|
+
minPlayers?: number | null;
|
|
116
|
+
maxPlayers?: number | null;
|
|
117
|
+
lateJoin?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Live presence behind the door, counts only and never who: players in the
|
|
120
|
+
* destination's public sessions from every town layer and entry, and how many of
|
|
121
|
+
* those sessions are open. Zero when nobody is inside; for an event gate, the
|
|
122
|
+
* current event's game. Absent from a ghost older than these fields.
|
|
123
|
+
*/
|
|
124
|
+
playersInside?: number;
|
|
125
|
+
instancesOpen?: number;
|
|
126
|
+
}
|
|
127
|
+
export interface MultiplayerHubResultsRead {
|
|
128
|
+
players: Record<string, MultiplayerHubPlayerResults>;
|
|
129
|
+
boards: Record<string, MultiplayerHubBoard>;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The hub place this session runs, as ghost pinned it from the deck's published
|
|
133
|
+
* castle.json `hub` block. `session.hub` is null on a host without hub support
|
|
134
|
+
* and for a deck that declares no hub block.
|
|
135
|
+
*/
|
|
136
|
+
export interface MultiplayerServerHub {
|
|
137
|
+
readonly place: string | null;
|
|
138
|
+
readonly title: string | null;
|
|
139
|
+
readonly result: MultiplayerHubResultShape | null;
|
|
140
|
+
/** How this player came in, when ghost placed them through a door; null otherwise. */
|
|
141
|
+
arrival(playerId: string): MultiplayerHubArrival | null;
|
|
142
|
+
/**
|
|
143
|
+
* Records a signed-in player's result for this visit, once. The host checks it
|
|
144
|
+
* against the published shape; ghost keeps it only for a place behind a curated
|
|
145
|
+
* door. Rejects with the platform's `CAULDRON_HUB_*` code.
|
|
146
|
+
*/
|
|
147
|
+
reportResult(playerId: string, result: MultiplayerHubResultInput): Promise<MultiplayerHubReportReceipt>;
|
|
148
|
+
/** Names who stands on a pad, and fires it. */
|
|
149
|
+
updatePad(input: MultiplayerHubPadUpdateInput): Promise<MultiplayerHubPadUpdateResult>;
|
|
150
|
+
/** Results, avatars and wearables of players here, and the boards of this place's doors. */
|
|
151
|
+
readResults(input?: MultiplayerHubResultsReadInput): Promise<MultiplayerHubResultsRead>;
|
|
152
|
+
/**
|
|
153
|
+
* Puts a wearable on a signed-in player's platform avatar in every hub place, or
|
|
154
|
+
* clears it with null; latest wins. Castle takes it only from a curated hub place.
|
|
155
|
+
* Rejects with a `CAULDRON_HUB_*` code (`CAULDRON_HUB_UNAVAILABLE` on a runtime
|
|
156
|
+
* without it).
|
|
157
|
+
*/
|
|
158
|
+
wear(playerId: string, wearable: MultiplayerHubWearable | null): Promise<MultiplayerHubWearReceipt>;
|
|
159
|
+
/**
|
|
160
|
+
* Whether a block hides these two players from each other: either one blocked
|
|
161
|
+
* the other, in either direction. Symmetric. The block lists themselves stay
|
|
162
|
+
* with the host and this wrapper; a place server learns only the pair, and
|
|
163
|
+
* only for players who are both here. False on hosts that send none.
|
|
164
|
+
*/
|
|
165
|
+
hiddenPair(playerIdA: string, playerIdB: string): boolean;
|
|
166
|
+
}
|
|
167
|
+
export declare function hubArrival(raw: unknown): MultiplayerHubArrival | null;
|
|
168
|
+
export declare function recordHiddenUserIds(hidden: Map<string, ReadonlySet<string>>, playerId: string, raw: unknown): void;
|
|
169
|
+
export declare function createServerHub(platformHandle: PlatformHandle, arrivals: ReadonlyMap<string, MultiplayerHubArrival>, hiddenPair?: (playerIdA: string, playerIdB: string) => boolean): MultiplayerServerHub | null;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { CastleError } from "../errors";
|
|
2
|
+
const DOOR_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
3
|
+
export function hubArrival(raw) {
|
|
4
|
+
if (!raw || typeof raw !== "object")
|
|
5
|
+
return null;
|
|
6
|
+
const { fromDeckId, doorId } = raw;
|
|
7
|
+
if (typeof fromDeckId !== "string" || fromDeckId.length === 0 || fromDeckId.length > 64) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
fromDeckId,
|
|
12
|
+
doorId: typeof doorId === "string" && DOOR_ID.test(doorId) ? doorId : null,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
// The co-present users a block hides from a joining hub player, as the host
|
|
16
|
+
// sends them on the join (`player.hiddenUserIds`): decimal user ids, or null
|
|
17
|
+
// when there are none or the list is malformed.
|
|
18
|
+
const HIDDEN_USER_ID = /^\d{1,20}$/;
|
|
19
|
+
const HIDDEN_USER_IDS_MAX = 1000;
|
|
20
|
+
export function recordHiddenUserIds(hidden, playerId, raw) {
|
|
21
|
+
const ids = hubHiddenUserIds(raw);
|
|
22
|
+
if (ids) {
|
|
23
|
+
hidden.set(playerId, ids);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
hidden.delete(playerId);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function hubHiddenUserIds(raw) {
|
|
30
|
+
if (!raw || typeof raw !== "object")
|
|
31
|
+
return null;
|
|
32
|
+
const list = raw.hiddenUserIds;
|
|
33
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
34
|
+
return null;
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
for (const id of list.slice(0, HIDDEN_USER_IDS_MAX)) {
|
|
37
|
+
if (typeof id === "string" && HIDDEN_USER_ID.test(id))
|
|
38
|
+
ids.add(id);
|
|
39
|
+
}
|
|
40
|
+
return ids.size > 0 ? ids : null;
|
|
41
|
+
}
|
|
42
|
+
export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
|
|
43
|
+
const pinned = platformHandle.hub;
|
|
44
|
+
if (!pinned ||
|
|
45
|
+
typeof platformHandle.hubResult !== "function" ||
|
|
46
|
+
typeof platformHandle.hubPadUpdate !== "function" ||
|
|
47
|
+
typeof platformHandle.hubResultsRead !== "function") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const call = async (op, args) => {
|
|
51
|
+
const raw = await platformHandle[op](JSON.stringify(args));
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
parsed = null;
|
|
58
|
+
}
|
|
59
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
60
|
+
throw new CastleError({
|
|
61
|
+
code: "CAULDRON_HUB_INVALID_RESPONSE",
|
|
62
|
+
message: `The platform returned an invalid ${op} response.`,
|
|
63
|
+
operation: `session.hub.${op}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
};
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
place: typeof pinned.place === "string" ? pinned.place : null,
|
|
70
|
+
title: typeof pinned.title === "string" ? pinned.title : null,
|
|
71
|
+
result: pinned.result ?? null,
|
|
72
|
+
arrival(playerId) {
|
|
73
|
+
return arrivals.get(playerId) ?? null;
|
|
74
|
+
},
|
|
75
|
+
hiddenPair(playerIdA, playerIdB) {
|
|
76
|
+
return hiddenPair(playerIdA, playerIdB);
|
|
77
|
+
},
|
|
78
|
+
async reportResult(playerId, result) {
|
|
79
|
+
const { place, outcome, score, stats } = result ?? {};
|
|
80
|
+
return (await call("hubResult", {
|
|
81
|
+
playerId,
|
|
82
|
+
...(place === undefined ? {} : { place }),
|
|
83
|
+
outcome,
|
|
84
|
+
...(score === undefined || score === null ? {} : { score }),
|
|
85
|
+
...(stats === undefined ? {} : { stats }),
|
|
86
|
+
}));
|
|
87
|
+
},
|
|
88
|
+
async updatePad(input) {
|
|
89
|
+
return (await call("hubPadUpdate", {
|
|
90
|
+
doorId: input?.doorId,
|
|
91
|
+
padId: input?.padId,
|
|
92
|
+
memberUserIds: [...(input?.memberUserIds ?? [])],
|
|
93
|
+
...(input?.fire === true ? { fire: true } : {}),
|
|
94
|
+
}));
|
|
95
|
+
},
|
|
96
|
+
async readResults(input = {}) {
|
|
97
|
+
const read = await call("hubResultsRead", {
|
|
98
|
+
...(input.userIds ? { userIds: [...input.userIds] } : {}),
|
|
99
|
+
...(input.doorIds ? { doorIds: [...input.doorIds] } : {}),
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
players: (read.players ?? {}),
|
|
103
|
+
boards: (read.boards ?? {}),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
async wear(playerId, wearable) {
|
|
107
|
+
if (typeof platformHandle.hubWear !== "function") {
|
|
108
|
+
throw new CastleError({
|
|
109
|
+
code: "CAULDRON_HUB_UNAVAILABLE",
|
|
110
|
+
message: "This host's runtime cannot change wearables.",
|
|
111
|
+
operation: "session.hub.hubWear",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const receipt = await call("hubWear", {
|
|
115
|
+
playerId,
|
|
116
|
+
wearable: wearable === null || wearable === undefined
|
|
117
|
+
? null
|
|
118
|
+
: { kind: wearable.kind, color: wearable.color, rarity: wearable.rarity },
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
wearable: (receipt.wearable ?? null),
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Json } from "../types";
|
|
2
|
+
import type { MultiplayerWorldCharacterCommand } from "../multiplayer";
|
|
2
3
|
export type MultiplayerSessionMode = "named" | "party" | "public";
|
|
3
4
|
export interface PlayerIdentity {
|
|
4
5
|
userId: string;
|
|
@@ -13,6 +14,16 @@ export interface PlatformSessionConfig {
|
|
|
13
14
|
readonly tickRate: number;
|
|
14
15
|
readonly maxPlayers: number;
|
|
15
16
|
readonly world?: PlatformWorldSnapshot;
|
|
17
|
+
/**
|
|
18
|
+
* True when the host brokers a hub place's shared characters into this
|
|
19
|
+
* session without a world (a layer of the place). Absent on hosts that do not.
|
|
20
|
+
*/
|
|
21
|
+
readonly placeCharacters?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The place this session was started for, stamped by a host that placed it
|
|
24
|
+
* through a door (the hub's door table). Absent on hosts that do not.
|
|
25
|
+
*/
|
|
26
|
+
readonly place?: string;
|
|
16
27
|
}
|
|
17
28
|
export interface PlatformWorldCheckpoint {
|
|
18
29
|
version: number;
|
|
@@ -21,6 +32,11 @@ export interface PlatformWorldCheckpoint {
|
|
|
21
32
|
export interface PlatformWorldSnapshot {
|
|
22
33
|
revision: string;
|
|
23
34
|
checkpoint: PlatformWorldCheckpoint | null;
|
|
35
|
+
/** Selected destination timing policy, stamped by hosts that support catch-up. */
|
|
36
|
+
worldTime?: PlatformWorldTimeConfig;
|
|
37
|
+
}
|
|
38
|
+
export interface PlatformWorldTimeConfig {
|
|
39
|
+
advance: "wallClock";
|
|
24
40
|
}
|
|
25
41
|
export interface PlatformHandle {
|
|
26
42
|
send(connId: string, text: string): void;
|
|
@@ -28,7 +44,7 @@ export interface PlatformHandle {
|
|
|
28
44
|
sendBinary?(connId: string, bytes: Uint8Array | ArrayBuffer): void;
|
|
29
45
|
broadcastBinary?(bytes: Uint8Array | ArrayBuffer): void;
|
|
30
46
|
disconnect(connId: string, code: string | number): void;
|
|
31
|
-
on(event: "join", callback: (connId: string, player: PlayerIdentity) => unknown): void;
|
|
47
|
+
on(event: "join", callback: (connId: string, player: PlayerIdentity, arrival?: PlatformHubArrival, experimentVariants?: Record<string, string>) => unknown): void;
|
|
32
48
|
on(event: "leave", callback: (connId: string) => unknown): void;
|
|
33
49
|
on(event: "message", callback: (connId: string, text: string) => unknown): void;
|
|
34
50
|
on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
|
|
@@ -43,6 +59,8 @@ export interface PlatformHandle {
|
|
|
43
59
|
*
|
|
44
60
|
* Optional because a published bundle may run on a host that predates storage.
|
|
45
61
|
* Rejects with an Error whose `code` is the platform's error code.
|
|
62
|
+
* Persistent-world journal and participant operations share this broker; the
|
|
63
|
+
* host stamps their world binding and participant identity.
|
|
46
64
|
*/
|
|
47
65
|
storage?(op: string, argsJson: string): Promise<string>;
|
|
48
66
|
/** False when the host cannot broker storage, so a call can fail at once. */
|
|
@@ -56,8 +74,39 @@ export interface PlatformHandle {
|
|
|
56
74
|
worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
|
|
57
75
|
worldCharacterRuntimeList?(): Promise<string>;
|
|
58
76
|
worldCharacterReject?(grantId: string, code?: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* The hub place this session runs, as ghost pinned it from the published
|
|
79
|
+
* castle.json `hub` block. Present only when the host services the hub ops
|
|
80
|
+
* below, so a deck without a hub block never sees any of them.
|
|
81
|
+
*/
|
|
82
|
+
readonly hub?: Readonly<PlatformHubPlace>;
|
|
83
|
+
hubResult?(argsJson: string): Promise<string>;
|
|
84
|
+
hubPadUpdate?(argsJson: string): Promise<string>;
|
|
85
|
+
hubResultsRead?(argsJson: string): Promise<string>;
|
|
86
|
+
/** Absent on a runtime older than wearables (contract v1.4). */
|
|
87
|
+
hubWear?(argsJson: string): Promise<string>;
|
|
59
88
|
readonly config: PlatformSessionConfig;
|
|
60
89
|
}
|
|
90
|
+
export interface PlatformHubResultShape {
|
|
91
|
+
outcomes: ("win" | "lose" | "draw")[];
|
|
92
|
+
score: {
|
|
93
|
+
unit: "ms" | "s" | "points";
|
|
94
|
+
better: "higher" | "lower";
|
|
95
|
+
min: number;
|
|
96
|
+
max: number;
|
|
97
|
+
} | null;
|
|
98
|
+
stats: Record<string, "number" | "string">;
|
|
99
|
+
}
|
|
100
|
+
export interface PlatformHubPlace {
|
|
101
|
+
place?: string;
|
|
102
|
+
title?: string | null;
|
|
103
|
+
result?: PlatformHubResultShape | null;
|
|
104
|
+
}
|
|
105
|
+
/** Where a player came in, when ghost placed them through a hub door. Ghost's word, not a client's. */
|
|
106
|
+
export interface PlatformHubArrival {
|
|
107
|
+
fromDeckId: string;
|
|
108
|
+
doorId?: string;
|
|
109
|
+
}
|
|
61
110
|
export interface PlatformWorldCharacterCommand {
|
|
62
111
|
grantId: string;
|
|
63
112
|
requestId: string;
|
|
@@ -65,7 +114,7 @@ export interface PlatformWorldCharacterCommand {
|
|
|
65
114
|
speaker: PlayerIdentity & {
|
|
66
115
|
playerId: string;
|
|
67
116
|
};
|
|
68
|
-
command:
|
|
117
|
+
command: MultiplayerWorldCharacterCommand;
|
|
69
118
|
}
|
|
70
119
|
export interface PlatformWorldCharacterRead {
|
|
71
120
|
grantId: string;
|