castle-web-sdk 0.4.17 → 0.4.19
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 +46 -0
- package/dist/castle.d.ts +5 -1
- package/dist/castle.js +3 -1
- package/dist/commands.d.ts +13 -0
- package/dist/multiplayer.d.ts +43 -0
- package/dist/multiplayer.js +456 -0
- package/dist/multiplayerProtocol.d.ts +51 -0
- package/dist/multiplayerProtocol.js +5 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.js +35 -0
- package/dist/server/platformHandle.d.ts +25 -0
- package/dist/server/platformHandle.js +1 -0
- package/dist/server/wrapper.d.ts +28 -0
- package/dist/server/wrapper.js +109 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -22,10 +22,56 @@ import { setup, initCard, Storage, Leaderboard } from "castle-web-sdk";
|
|
|
22
22
|
- [Portal](#portal)
|
|
23
23
|
- [Haptics](#haptics)
|
|
24
24
|
- [Lifecycle](#lifecycle)
|
|
25
|
+
- [Multiplayer](#multiplayer)
|
|
25
26
|
- [Setup](#setup)
|
|
26
27
|
- [Saving files](#writefilepath-contents-promisevoid)
|
|
27
28
|
- [CastleError](#castleerror)
|
|
28
29
|
|
|
30
|
+
## Multiplayer
|
|
31
|
+
|
|
32
|
+
`Multiplayer.join` connects the deck to its Cauldron server. Pick a public
|
|
33
|
+
session, the current Castle party, or a stable named room:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { Multiplayer } from "castle-web-sdk";
|
|
37
|
+
|
|
38
|
+
const connection = await Multiplayer.join({ mode: "public" });
|
|
39
|
+
connection.onRoster(({ players, you }) => {
|
|
40
|
+
console.log(`${you?.username} is one of ${players.length} players`);
|
|
41
|
+
});
|
|
42
|
+
connection.onMessage((message) => receiveServerMessage(message));
|
|
43
|
+
connection.send({ type: "ready" });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Named rooms use `{ mode: "named", key: "room-name" }`; party sessions use
|
|
47
|
+
`{ mode: "party" }`. Only one connection can be live per deck instance. The SDK
|
|
48
|
+
reconnects temporary socket drops automatically. `onStateChange` reports
|
|
49
|
+
`acquiring`, `connecting`, `connected`, `reconnecting`, and `closed`; its optional
|
|
50
|
+
metadata distinguishes a server `bye`, a replaced server version, an explicit
|
|
51
|
+
`leave`, and connection failures.
|
|
52
|
+
|
|
53
|
+
When Castle selects solo fallback, the returned connection has `isSolo === true`,
|
|
54
|
+
a one-player roster, and no server process. Calls to `send` are accepted but do
|
|
55
|
+
nothing. Client frames are limited to 16KB after JSON serialization.
|
|
56
|
+
|
|
57
|
+
Server callbacks are the default export from the deck's server entry. They are
|
|
58
|
+
session-first and receive only platform-trusted player identities:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
export default {
|
|
62
|
+
onPlayerJoin(session, player) {
|
|
63
|
+
session.broadcast({ type: "joined", playerId: player.playerId });
|
|
64
|
+
},
|
|
65
|
+
onMessage(session, player, message) {
|
|
66
|
+
session.broadcast({ from: player.playerId, message });
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
TypeScript server entries can import callback, session, player, and platform
|
|
72
|
+
handle types from `castle-web-sdk/server`. Server transport has no runtime
|
|
73
|
+
dependency on a WebSocket package.
|
|
74
|
+
|
|
29
75
|
## Storage
|
|
30
76
|
|
|
31
77
|
`Storage` saves data for the current player. Nobody else can read it.
|
package/dist/castle.d.ts
CHANGED
|
@@ -7,11 +7,15 @@ export { Leaderboard } from "./leaderboard";
|
|
|
7
7
|
export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardShowOptions, LeaderboardShowResult, LeaderboardShowStatus, LeaderboardSort, } from "./leaderboard";
|
|
8
8
|
export { Lifecycle } from "./lifecycle";
|
|
9
9
|
export type { CastleLifecycleApi } from "./lifecycle";
|
|
10
|
+
export { Multiplayer } from "./multiplayer";
|
|
11
|
+
export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
|
|
12
|
+
export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
|
|
13
|
+
export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
|
|
10
14
|
export { Pass } from "./passes";
|
|
11
15
|
export type { CastlePassApi, PassOfferResult, PassOfferStatus, } from "./passes";
|
|
12
16
|
export { Portal } from "./portal";
|
|
13
17
|
export type { CastlePortalApi, PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./portal";
|
|
14
|
-
export { CARD_RATIO, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
|
|
18
|
+
export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
|
|
15
19
|
export type { FileChange, FilesChangedEvent } from "./runtime";
|
|
16
20
|
export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
|
|
17
21
|
export type { SaveState } from "./saveQueue";
|
package/dist/castle.js
CHANGED
|
@@ -5,9 +5,11 @@ export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckF
|
|
|
5
5
|
export { Haptics } from "./haptics";
|
|
6
6
|
export { Leaderboard } from "./leaderboard";
|
|
7
7
|
export { Lifecycle } from "./lifecycle";
|
|
8
|
+
export { Multiplayer } from "./multiplayer";
|
|
9
|
+
export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
|
|
8
10
|
export { Pass } from "./passes";
|
|
9
11
|
export { Portal } from "./portal";
|
|
10
|
-
export { CARD_RATIO, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
|
|
12
|
+
export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
|
|
11
13
|
export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
|
|
12
14
|
export { SharedStorage, Storage } from "./storage";
|
|
13
15
|
export { Time } from "./time";
|
package/dist/commands.d.ts
CHANGED
|
@@ -47,6 +47,17 @@ export type HapticsStatus = "triggered" | "unavailable";
|
|
|
47
47
|
export interface HapticsResult {
|
|
48
48
|
status: HapticsStatus;
|
|
49
49
|
}
|
|
50
|
+
export type MultiplayerMode = "named" | "party" | "public";
|
|
51
|
+
export interface MultiplayerGetSessionParams {
|
|
52
|
+
mode: MultiplayerMode;
|
|
53
|
+
key?: string | null;
|
|
54
|
+
}
|
|
55
|
+
export interface MultiplayerGetSessionResult {
|
|
56
|
+
status: "ok" | "solo" | "unavailable";
|
|
57
|
+
url: string | null;
|
|
58
|
+
nonce: string | null;
|
|
59
|
+
sessionId: string | null;
|
|
60
|
+
}
|
|
50
61
|
export interface CommandParams {
|
|
51
62
|
"deckStorage.load": Record<string, never>;
|
|
52
63
|
"deckStorage.update": {
|
|
@@ -96,6 +107,7 @@ export interface CommandParams {
|
|
|
96
107
|
"haptics.play": {
|
|
97
108
|
style: HapticStyle;
|
|
98
109
|
};
|
|
110
|
+
"multiplayer.getSession": MultiplayerGetSessionParams;
|
|
99
111
|
}
|
|
100
112
|
export interface CommandResult {
|
|
101
113
|
"deckStorage.load": {
|
|
@@ -136,6 +148,7 @@ export interface CommandResult {
|
|
|
136
148
|
"portal.open": PortalOpenResult;
|
|
137
149
|
"portal.prefetch": PortalPrefetchResult;
|
|
138
150
|
"haptics.play": HapticsResult;
|
|
151
|
+
"multiplayer.getSession": MultiplayerGetSessionResult;
|
|
139
152
|
}
|
|
140
153
|
export type CommandName = keyof CommandParams;
|
|
141
154
|
export interface SerializedCommandError {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { CastleError } from "./errors";
|
|
2
|
+
import { type MultiplayerPlayer } from "./multiplayerProtocol";
|
|
3
|
+
export type MultiplayerJoinOptions = {
|
|
4
|
+
mode: "named";
|
|
5
|
+
key: string;
|
|
6
|
+
} | {
|
|
7
|
+
mode: "party";
|
|
8
|
+
} | {
|
|
9
|
+
mode: "public";
|
|
10
|
+
};
|
|
11
|
+
export type MultiplayerConnectionState = "acquiring" | "connecting" | "connected" | "reconnecting" | "closed";
|
|
12
|
+
export type MultiplayerStateMetadata = {
|
|
13
|
+
type: "connectionLost";
|
|
14
|
+
} | {
|
|
15
|
+
type: "replaced";
|
|
16
|
+
} | {
|
|
17
|
+
type: "bye";
|
|
18
|
+
reason?: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: "left";
|
|
21
|
+
} | {
|
|
22
|
+
type: "error";
|
|
23
|
+
error: CastleError;
|
|
24
|
+
};
|
|
25
|
+
export interface MultiplayerRoster {
|
|
26
|
+
players: MultiplayerPlayer[];
|
|
27
|
+
you: MultiplayerPlayer | null;
|
|
28
|
+
}
|
|
29
|
+
export interface MultiplayerConnection {
|
|
30
|
+
readonly state: MultiplayerConnectionState;
|
|
31
|
+
readonly playerId: string;
|
|
32
|
+
readonly sessionId: string;
|
|
33
|
+
readonly isSolo: boolean;
|
|
34
|
+
send(data: unknown): void;
|
|
35
|
+
onMessage(callback: (data: unknown) => void): () => void;
|
|
36
|
+
onRoster(callback: (roster: MultiplayerRoster) => void): () => void;
|
|
37
|
+
onStateChange(callback: (state: MultiplayerConnectionState, metadata?: MultiplayerStateMetadata) => void): () => void;
|
|
38
|
+
leave(): void;
|
|
39
|
+
}
|
|
40
|
+
export interface CastleMultiplayerApi {
|
|
41
|
+
join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
|
|
42
|
+
}
|
|
43
|
+
export declare const Multiplayer: CastleMultiplayerApi;
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { CastleError } from "./errors";
|
|
2
|
+
import { MAX_CLIENT_FRAME_BYTES, } from "./multiplayerProtocol";
|
|
3
|
+
import { hostRequest } from "./transport";
|
|
4
|
+
const KEEPALIVE_INTERVAL_MS = 25_000;
|
|
5
|
+
const HELLO_TIMEOUT_MS = 10_000;
|
|
6
|
+
const RECONNECT_BUDGET_MS = 30_000;
|
|
7
|
+
const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
|
|
8
|
+
let activeConnection = null;
|
|
9
|
+
export const Multiplayer = { join };
|
|
10
|
+
async function join(options) {
|
|
11
|
+
const params = joinParams(options);
|
|
12
|
+
activeConnection?.leave();
|
|
13
|
+
const connection = new MultiplayerConnectionImpl(params, () => {
|
|
14
|
+
if (activeConnection === connection)
|
|
15
|
+
activeConnection = null;
|
|
16
|
+
});
|
|
17
|
+
activeConnection = connection;
|
|
18
|
+
await connection.start();
|
|
19
|
+
return connection;
|
|
20
|
+
}
|
|
21
|
+
class MultiplayerConnectionImpl {
|
|
22
|
+
params;
|
|
23
|
+
onClosed;
|
|
24
|
+
currentState = "acquiring";
|
|
25
|
+
currentPlayerId = "";
|
|
26
|
+
currentSessionId = "";
|
|
27
|
+
solo = false;
|
|
28
|
+
baseUrl = "";
|
|
29
|
+
reconnectToken = "";
|
|
30
|
+
socket = null;
|
|
31
|
+
socketAttempt = 0;
|
|
32
|
+
keepalive = null;
|
|
33
|
+
reAcquisitionUsed = false;
|
|
34
|
+
recovering = false;
|
|
35
|
+
roster = null;
|
|
36
|
+
messages = new Set();
|
|
37
|
+
rosters = new Set();
|
|
38
|
+
states = new Set();
|
|
39
|
+
constructor(params, onClosed) {
|
|
40
|
+
this.params = params;
|
|
41
|
+
this.onClosed = onClosed;
|
|
42
|
+
}
|
|
43
|
+
get state() {
|
|
44
|
+
return this.currentState;
|
|
45
|
+
}
|
|
46
|
+
get playerId() {
|
|
47
|
+
return this.currentPlayerId;
|
|
48
|
+
}
|
|
49
|
+
get sessionId() {
|
|
50
|
+
return this.currentSessionId;
|
|
51
|
+
}
|
|
52
|
+
get isSolo() {
|
|
53
|
+
return this.solo;
|
|
54
|
+
}
|
|
55
|
+
async start() {
|
|
56
|
+
try {
|
|
57
|
+
await this.acquire();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const castleError = asCastleError(error);
|
|
61
|
+
if (this.currentState !== "closed") {
|
|
62
|
+
this.finish({ type: "error", error: castleError });
|
|
63
|
+
}
|
|
64
|
+
throw castleError;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
send(data) {
|
|
68
|
+
const text = serializeClientMessage(data);
|
|
69
|
+
if (this.currentState !== "connected") {
|
|
70
|
+
throw multiplayerError("MULTIPLAYER_NOT_CONNECTED", "Multiplayer.send requires a connected session.", "Multiplayer.send");
|
|
71
|
+
}
|
|
72
|
+
if (this.solo)
|
|
73
|
+
return;
|
|
74
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
75
|
+
throw multiplayerError("MULTIPLAYER_NOT_CONNECTED", "The multiplayer connection is not available.", "Multiplayer.send");
|
|
76
|
+
}
|
|
77
|
+
this.socket.send(text);
|
|
78
|
+
}
|
|
79
|
+
onMessage(callback) {
|
|
80
|
+
this.messages.add(callback);
|
|
81
|
+
return () => this.messages.delete(callback);
|
|
82
|
+
}
|
|
83
|
+
onRoster(callback) {
|
|
84
|
+
this.rosters.add(callback);
|
|
85
|
+
if (this.roster)
|
|
86
|
+
callListener(callback, this.roster);
|
|
87
|
+
return () => this.rosters.delete(callback);
|
|
88
|
+
}
|
|
89
|
+
onStateChange(callback) {
|
|
90
|
+
this.states.add(callback);
|
|
91
|
+
callStateListener(callback, this.currentState);
|
|
92
|
+
return () => this.states.delete(callback);
|
|
93
|
+
}
|
|
94
|
+
leave() {
|
|
95
|
+
if (this.currentState === "closed")
|
|
96
|
+
return;
|
|
97
|
+
this.finish({ type: "left" });
|
|
98
|
+
}
|
|
99
|
+
async acquire() {
|
|
100
|
+
this.transition("acquiring");
|
|
101
|
+
const result = await hostRequest("multiplayer.getSession", this.params);
|
|
102
|
+
this.assertOpen();
|
|
103
|
+
if (result.status === "solo") {
|
|
104
|
+
this.connectSolo(result);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (result.status !== "ok" || !result.url || !result.nonce) {
|
|
108
|
+
throw multiplayerError("MULTIPLAYER_UNAVAILABLE", "Multiplayer is unavailable for this deck.");
|
|
109
|
+
}
|
|
110
|
+
this.solo = false;
|
|
111
|
+
this.baseUrl = result.url;
|
|
112
|
+
this.transition("connecting");
|
|
113
|
+
await this.openSocket("nonce", result.nonce);
|
|
114
|
+
}
|
|
115
|
+
connectSolo(result) {
|
|
116
|
+
this.solo = true;
|
|
117
|
+
this.currentPlayerId = "solo";
|
|
118
|
+
this.currentSessionId = result.sessionId ?? "solo";
|
|
119
|
+
const you = {
|
|
120
|
+
playerId: this.currentPlayerId,
|
|
121
|
+
userId: "solo",
|
|
122
|
+
username: "You",
|
|
123
|
+
isAnonymous: true,
|
|
124
|
+
};
|
|
125
|
+
this.setRoster({ players: [you], you });
|
|
126
|
+
this.transition("connected");
|
|
127
|
+
}
|
|
128
|
+
openSocket(credential, value) {
|
|
129
|
+
const attempt = ++this.socketAttempt;
|
|
130
|
+
const socket = new WebSocket(authenticatedUrl(this.baseUrl, credential, value));
|
|
131
|
+
this.socket = socket;
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
let greeted = false;
|
|
134
|
+
const timeout = setTimeout(() => {
|
|
135
|
+
reject(new SocketClosedError(1006));
|
|
136
|
+
closeSocket(socket, 1000, "hello timeout");
|
|
137
|
+
}, HELLO_TIMEOUT_MS);
|
|
138
|
+
socket.onmessage = (event) => {
|
|
139
|
+
if (this.socketAttempt !== attempt || this.currentState === "closed")
|
|
140
|
+
return;
|
|
141
|
+
const frame = parsePlatformFrame(event.data);
|
|
142
|
+
if (!frame)
|
|
143
|
+
return;
|
|
144
|
+
if (frame.t === "hello") {
|
|
145
|
+
greeted = true;
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
this.acceptHello(frame);
|
|
148
|
+
resolve();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.handleFrame(frame);
|
|
152
|
+
};
|
|
153
|
+
socket.onclose = (event) => {
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
if (this.socketAttempt !== attempt) {
|
|
156
|
+
if (this.currentState === "closed") {
|
|
157
|
+
reject(new SocketClosedError(event.code));
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (this.socket === socket)
|
|
162
|
+
this.socket = null;
|
|
163
|
+
this.stopKeepalive();
|
|
164
|
+
if (!greeted) {
|
|
165
|
+
reject(new SocketClosedError(event.code));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (this.currentState === "connected")
|
|
169
|
+
this.beginRecovery();
|
|
170
|
+
};
|
|
171
|
+
socket.onerror = () => {
|
|
172
|
+
// Browsers provide actionable close information through `onclose`.
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
acceptHello(frame) {
|
|
177
|
+
this.currentPlayerId = frame.playerId;
|
|
178
|
+
this.currentSessionId = frame.sessionId;
|
|
179
|
+
this.reconnectToken = frame.reconnectToken;
|
|
180
|
+
this.transition("connected");
|
|
181
|
+
this.startKeepalive();
|
|
182
|
+
}
|
|
183
|
+
handleFrame(frame) {
|
|
184
|
+
switch (frame.t) {
|
|
185
|
+
case "roster":
|
|
186
|
+
this.acceptRoster(frame);
|
|
187
|
+
break;
|
|
188
|
+
case "m":
|
|
189
|
+
for (const callback of this.messages)
|
|
190
|
+
callListener(callback, frame.d);
|
|
191
|
+
break;
|
|
192
|
+
case "replaced":
|
|
193
|
+
this.finish({ type: "replaced" });
|
|
194
|
+
break;
|
|
195
|
+
case "bye":
|
|
196
|
+
this.finish({ type: "bye", reason: frame.reason });
|
|
197
|
+
break;
|
|
198
|
+
case "hello":
|
|
199
|
+
this.acceptHello(frame);
|
|
200
|
+
break;
|
|
201
|
+
case "pong":
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
acceptRoster(frame) {
|
|
206
|
+
const players = frame.players.slice();
|
|
207
|
+
const you = players.find((player) => player.playerId === this.currentPlayerId) ?? null;
|
|
208
|
+
this.setRoster({ players, you });
|
|
209
|
+
}
|
|
210
|
+
setRoster(roster) {
|
|
211
|
+
this.roster = roster;
|
|
212
|
+
for (const callback of this.rosters)
|
|
213
|
+
callListener(callback, roster);
|
|
214
|
+
}
|
|
215
|
+
beginRecovery() {
|
|
216
|
+
if (this.recovering || this.currentState === "closed")
|
|
217
|
+
return;
|
|
218
|
+
this.recovering = true;
|
|
219
|
+
this.transition("reconnecting", { type: "connectionLost" });
|
|
220
|
+
void this.recover()
|
|
221
|
+
.catch((error) => {
|
|
222
|
+
if (this.currentState === "closed")
|
|
223
|
+
return;
|
|
224
|
+
const castleError = asCastleError(error);
|
|
225
|
+
this.finish({ type: "error", error: castleError });
|
|
226
|
+
})
|
|
227
|
+
.finally(() => {
|
|
228
|
+
this.recovering = false;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async recover() {
|
|
232
|
+
const startedAt = Date.now();
|
|
233
|
+
let attempt = 0;
|
|
234
|
+
while (Date.now() - startedAt < RECONNECT_BUDGET_MS) {
|
|
235
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
236
|
+
attempt += 1;
|
|
237
|
+
await pause(delay);
|
|
238
|
+
this.assertOpen();
|
|
239
|
+
try {
|
|
240
|
+
await this.openSocket("reconnect", this.reconnectToken);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof SocketClosedError && error.code === 1008)
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
await this.reacquireOnce();
|
|
249
|
+
}
|
|
250
|
+
async reacquireOnce() {
|
|
251
|
+
if (this.reAcquisitionUsed) {
|
|
252
|
+
throw this.closeAfterRecovery();
|
|
253
|
+
}
|
|
254
|
+
this.reAcquisitionUsed = true;
|
|
255
|
+
try {
|
|
256
|
+
await this.acquire();
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
const castleError = asCastleError(error);
|
|
260
|
+
this.finish({ type: "error", error: castleError });
|
|
261
|
+
throw castleError;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
closeAfterRecovery() {
|
|
265
|
+
const error = multiplayerError("MULTIPLAYER_CONNECTION_LOST", "The multiplayer connection could not be restored.");
|
|
266
|
+
this.finish({ type: "error", error });
|
|
267
|
+
return error;
|
|
268
|
+
}
|
|
269
|
+
startKeepalive() {
|
|
270
|
+
this.stopKeepalive();
|
|
271
|
+
this.keepalive = setInterval(() => {
|
|
272
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
273
|
+
try {
|
|
274
|
+
this.socket.send('{"t":"ping"}');
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
closeSocket(this.socket, 1000, "keepalive failed");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
281
|
+
}
|
|
282
|
+
stopKeepalive() {
|
|
283
|
+
if (this.keepalive === null)
|
|
284
|
+
return;
|
|
285
|
+
clearInterval(this.keepalive);
|
|
286
|
+
this.keepalive = null;
|
|
287
|
+
}
|
|
288
|
+
transition(state, metadata) {
|
|
289
|
+
if (this.currentState === "closed" || this.currentState === state)
|
|
290
|
+
return;
|
|
291
|
+
this.currentState = state;
|
|
292
|
+
for (const callback of this.states)
|
|
293
|
+
callStateListener(callback, state, metadata);
|
|
294
|
+
}
|
|
295
|
+
finish(metadata) {
|
|
296
|
+
if (this.currentState === "closed")
|
|
297
|
+
return;
|
|
298
|
+
this.stopKeepalive();
|
|
299
|
+
const socket = this.socket;
|
|
300
|
+
this.socket = null;
|
|
301
|
+
this.socketAttempt += 1;
|
|
302
|
+
closeSocket(socket, 1000, "session closed");
|
|
303
|
+
this.currentState = "closed";
|
|
304
|
+
for (const callback of this.states)
|
|
305
|
+
callStateListener(callback, "closed", metadata);
|
|
306
|
+
this.onClosed();
|
|
307
|
+
}
|
|
308
|
+
assertOpen() {
|
|
309
|
+
if (this.currentState === "closed") {
|
|
310
|
+
throw multiplayerError("MULTIPLAYER_CONNECTION_CLOSED", "The multiplayer connection is closed.");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
class SocketClosedError extends Error {
|
|
315
|
+
code;
|
|
316
|
+
constructor(code) {
|
|
317
|
+
super("Multiplayer WebSocket closed before hello.");
|
|
318
|
+
this.code = code;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function joinParams(options) {
|
|
322
|
+
if (!options || typeof options !== "object") {
|
|
323
|
+
throw invalidJoinOptions();
|
|
324
|
+
}
|
|
325
|
+
if (options.mode === "named") {
|
|
326
|
+
if (typeof options.key !== "string" || options.key.length === 0) {
|
|
327
|
+
throw invalidJoinOptions();
|
|
328
|
+
}
|
|
329
|
+
return { mode: "named", key: options.key };
|
|
330
|
+
}
|
|
331
|
+
if (options.mode === "party" || options.mode === "public") {
|
|
332
|
+
return { mode: options.mode, key: null };
|
|
333
|
+
}
|
|
334
|
+
throw invalidJoinOptions();
|
|
335
|
+
}
|
|
336
|
+
function invalidJoinOptions() {
|
|
337
|
+
return multiplayerError("INVALID_ARGUMENT", "Multiplayer.join requires named, party, or public options.");
|
|
338
|
+
}
|
|
339
|
+
function serializeClientMessage(data) {
|
|
340
|
+
let text;
|
|
341
|
+
try {
|
|
342
|
+
const serializedData = JSON.stringify(data);
|
|
343
|
+
if (serializedData === undefined)
|
|
344
|
+
throw new Error("not serializable");
|
|
345
|
+
const serializedFrame = JSON.stringify({ t: "m", d: data });
|
|
346
|
+
if (serializedFrame === undefined)
|
|
347
|
+
throw new Error("not serializable");
|
|
348
|
+
text = serializedFrame;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
throw multiplayerError("INVALID_ARGUMENT", "Multiplayer.send data must be JSON-serializable.", "Multiplayer.send");
|
|
352
|
+
}
|
|
353
|
+
if (new TextEncoder().encode(text).byteLength > MAX_CLIENT_FRAME_BYTES) {
|
|
354
|
+
throw multiplayerError("MULTIPLAYER_MESSAGE_TOO_LARGE", `Multiplayer.send messages are limited to ${MAX_CLIENT_FRAME_BYTES} bytes.`, "Multiplayer.send");
|
|
355
|
+
}
|
|
356
|
+
return text;
|
|
357
|
+
}
|
|
358
|
+
function authenticatedUrl(baseUrl, credential, value) {
|
|
359
|
+
const fallback = typeof window === "undefined" ? undefined : window.location.href;
|
|
360
|
+
const url = new URL(baseUrl, fallback);
|
|
361
|
+
url.searchParams.delete(credential === "nonce" ? "reconnect" : "nonce");
|
|
362
|
+
url.searchParams.set(credential, value);
|
|
363
|
+
return url.toString();
|
|
364
|
+
}
|
|
365
|
+
function parsePlatformFrame(value) {
|
|
366
|
+
if (typeof value !== "string")
|
|
367
|
+
return null;
|
|
368
|
+
let frame;
|
|
369
|
+
try {
|
|
370
|
+
frame = JSON.parse(value);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
if (!isRecord(frame) || typeof frame.t !== "string")
|
|
376
|
+
return null;
|
|
377
|
+
switch (frame.t) {
|
|
378
|
+
case "hello":
|
|
379
|
+
return isHelloFrame(frame) ? frame : null;
|
|
380
|
+
case "roster":
|
|
381
|
+
return isRosterFrame(frame) ? frame : null;
|
|
382
|
+
case "m":
|
|
383
|
+
return Object.prototype.hasOwnProperty.call(frame, "d")
|
|
384
|
+
? frame
|
|
385
|
+
: null;
|
|
386
|
+
case "replaced":
|
|
387
|
+
case "pong":
|
|
388
|
+
return frame;
|
|
389
|
+
case "bye":
|
|
390
|
+
return frame.reason === undefined || typeof frame.reason === "string"
|
|
391
|
+
? frame
|
|
392
|
+
: null;
|
|
393
|
+
default:
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function isHelloFrame(frame) {
|
|
398
|
+
return (typeof frame.playerId === "string" &&
|
|
399
|
+
typeof frame.reconnectToken === "string" &&
|
|
400
|
+
typeof frame.sessionId === "string" &&
|
|
401
|
+
isMode(frame.mode));
|
|
402
|
+
}
|
|
403
|
+
function isRosterFrame(frame) {
|
|
404
|
+
return Array.isArray(frame.players) && frame.players.every(isPlayer);
|
|
405
|
+
}
|
|
406
|
+
function isPlayer(value) {
|
|
407
|
+
return (isRecord(value) &&
|
|
408
|
+
typeof value.playerId === "string" &&
|
|
409
|
+
typeof value.userId === "string" &&
|
|
410
|
+
typeof value.username === "string" &&
|
|
411
|
+
typeof value.isAnonymous === "boolean" &&
|
|
412
|
+
(value.scopedUserId === undefined || typeof value.scopedUserId === "string"));
|
|
413
|
+
}
|
|
414
|
+
function isMode(value) {
|
|
415
|
+
return value === "named" || value === "party" || value === "public";
|
|
416
|
+
}
|
|
417
|
+
function isRecord(value) {
|
|
418
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
419
|
+
}
|
|
420
|
+
function pause(milliseconds) {
|
|
421
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
422
|
+
}
|
|
423
|
+
function closeSocket(socket, code, reason) {
|
|
424
|
+
if (!socket || socket.readyState >= WebSocket.CLOSING)
|
|
425
|
+
return;
|
|
426
|
+
try {
|
|
427
|
+
socket.close(code, reason);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// The socket is already unusable.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function callListener(callback, value) {
|
|
434
|
+
try {
|
|
435
|
+
callback(value);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// One deck listener must not break the connection or other listeners.
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function callStateListener(callback, state, metadata) {
|
|
442
|
+
try {
|
|
443
|
+
callback(state, metadata);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// One deck listener must not break the connection or other listeners.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function multiplayerError(code, message, operation = "Multiplayer.join") {
|
|
450
|
+
return new CastleError({ code, message, operation });
|
|
451
|
+
}
|
|
452
|
+
function asCastleError(error) {
|
|
453
|
+
if (error instanceof CastleError)
|
|
454
|
+
return error;
|
|
455
|
+
return multiplayerError("MULTIPLAYER_CONNECTION_FAILED", error instanceof Error ? error.message : "The multiplayer connection failed.");
|
|
456
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const MAX_CLIENT_FRAME_BYTES: number;
|
|
2
|
+
export declare const MAX_PLAYERS_CAP = 24;
|
|
3
|
+
export type SessionType = "named" | "party" | "public";
|
|
4
|
+
export interface PlayerIdentity {
|
|
5
|
+
userId: string;
|
|
6
|
+
username: string;
|
|
7
|
+
isAnonymous: boolean;
|
|
8
|
+
scopedUserId?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SessionPlayer extends PlayerIdentity {
|
|
11
|
+
playerId: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ClientHelloFrame {
|
|
14
|
+
t: "hello";
|
|
15
|
+
playerId: string;
|
|
16
|
+
reconnectToken: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
mode: SessionType;
|
|
19
|
+
}
|
|
20
|
+
export interface ClientRosterFrame {
|
|
21
|
+
t: "roster";
|
|
22
|
+
players: SessionPlayer[];
|
|
23
|
+
}
|
|
24
|
+
export interface ClientMessageFrame {
|
|
25
|
+
t: "m";
|
|
26
|
+
d: unknown;
|
|
27
|
+
}
|
|
28
|
+
export interface ClientReplacedFrame {
|
|
29
|
+
t: "replaced";
|
|
30
|
+
}
|
|
31
|
+
export interface ClientByeFrame {
|
|
32
|
+
t: "bye";
|
|
33
|
+
reason?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface ClientPongFrame {
|
|
36
|
+
t: "pong";
|
|
37
|
+
}
|
|
38
|
+
export type PlatformToClientFrame = ClientHelloFrame | ClientRosterFrame | ClientMessageFrame | ClientReplacedFrame | ClientByeFrame | ClientPongFrame;
|
|
39
|
+
export interface ClientToPlatformMessageFrame {
|
|
40
|
+
t: "m";
|
|
41
|
+
d: unknown;
|
|
42
|
+
}
|
|
43
|
+
export interface ClientPingFrame {
|
|
44
|
+
t: "ping";
|
|
45
|
+
}
|
|
46
|
+
export type ClientToPlatformFrame = ClientToPlatformMessageFrame | ClientPingFrame;
|
|
47
|
+
export type MultiplayerMode = SessionType;
|
|
48
|
+
export type MultiplayerPlayer = SessionPlayer;
|
|
49
|
+
export type MultiplayerHelloFrame = ClientHelloFrame;
|
|
50
|
+
export type MultiplayerRosterFrame = ClientRosterFrame;
|
|
51
|
+
export type PlatformToMultiplayerClientFrame = PlatformToClientFrame;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Client-facing Cauldron multiplayer wire contract. This intentionally mirrors
|
|
2
|
+
// cauldron-game-server/shared without importing it: the SDK is independently
|
|
3
|
+
// published and must not pull the server package into deck bundles.
|
|
4
|
+
export const MAX_CLIENT_FRAME_BYTES = 16 * 1024;
|
|
5
|
+
export const MAX_PLAYERS_CAP = 24;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -32,6 +32,9 @@ export declare function setup(): void;
|
|
|
32
32
|
*/
|
|
33
33
|
export declare function writeFileOnce(path: string, contents: string): Promise<LocalResponse>;
|
|
34
34
|
export declare function fileUrl(path: string): string;
|
|
35
|
+
export declare function renameFile(from: string, to: string): Promise<void>;
|
|
36
|
+
export declare function deleteFile(path: string): Promise<void>;
|
|
37
|
+
export declare function openFile(path: string): void;
|
|
35
38
|
export declare function initCard(): HTMLDivElement;
|
|
36
39
|
export declare function sendLocalCommand<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResponseEnvelope>;
|
|
37
40
|
/**
|
package/dist/runtime.js
CHANGED
|
@@ -66,6 +66,41 @@ export function writeFileOnce(path, contents) {
|
|
|
66
66
|
export function fileUrl(path) {
|
|
67
67
|
return `/__castle/files/raw?path=${encodeURIComponent(path)}`;
|
|
68
68
|
}
|
|
69
|
+
// Move a deck file. `writeFile`'s counterpart for the case where the file's
|
|
70
|
+
// NAME is the thing changing -- an editor renaming a blueprint has to move the
|
|
71
|
+
// file, not write a copy and leave the old one behind. Rejects when the
|
|
72
|
+
// destination exists or the source is gone (the serve's own guards), so a
|
|
73
|
+
// caller can surface the collision instead of silently clobbering.
|
|
74
|
+
export async function renameFile(from, to) {
|
|
75
|
+
await fileOp("rename", { from, to });
|
|
76
|
+
}
|
|
77
|
+
// Delete a deck file. Editor UI only, same as `writeFile` -- a published deck
|
|
78
|
+
// has no serve to ask.
|
|
79
|
+
export async function deleteFile(path) {
|
|
80
|
+
await fileOp("delete", { path });
|
|
81
|
+
}
|
|
82
|
+
async function fileOp(action, body) {
|
|
83
|
+
const res = await fetch(`/__castle/files/${action}`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
if (res.ok)
|
|
89
|
+
return;
|
|
90
|
+
const detail = await res.text().catch(() => "");
|
|
91
|
+
throw new Error(`castle: ${action} failed (${res.status}) ${detail}`.trim());
|
|
92
|
+
}
|
|
93
|
+
// Ask the editor shell to open (or focus) an editor for `path`. This is how a
|
|
94
|
+
// kit editor hands the creator off to another file -- the creation modal
|
|
95
|
+
// dropping them into the pixel editor for the sprite it just made. A no-op
|
|
96
|
+
// anywhere there's no shell listening (a published deck, a bare serve), which
|
|
97
|
+
// is why it neither returns nor throws: the handoff is a courtesy, and the
|
|
98
|
+
// file was already written before we asked.
|
|
99
|
+
export function openFile(path) {
|
|
100
|
+
if (typeof window === "undefined" || window.parent === window)
|
|
101
|
+
return;
|
|
102
|
+
window.parent.postMessage({ type: "castle-open-file", path }, "*");
|
|
103
|
+
}
|
|
69
104
|
export function initCard() {
|
|
70
105
|
const style = document.createElement("style");
|
|
71
106
|
style.textContent = `
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type MultiplayerSessionMode = "named" | "party" | "public";
|
|
2
|
+
export interface PlayerIdentity {
|
|
3
|
+
userId: string;
|
|
4
|
+
username: string;
|
|
5
|
+
isAnonymous: boolean;
|
|
6
|
+
scopedUserId?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PlatformSessionConfig {
|
|
9
|
+
readonly sessionId: string;
|
|
10
|
+
readonly deckId: string;
|
|
11
|
+
readonly mode: MultiplayerSessionMode;
|
|
12
|
+
readonly tickRate: number;
|
|
13
|
+
readonly maxPlayers: number;
|
|
14
|
+
}
|
|
15
|
+
export interface PlatformHandle {
|
|
16
|
+
send(connId: string, text: string): void;
|
|
17
|
+
broadcast(text: string): void;
|
|
18
|
+
disconnect(connId: string, code: string | number): void;
|
|
19
|
+
on(event: "join", callback: (connId: string, player: PlayerIdentity) => unknown): void;
|
|
20
|
+
on(event: "leave", callback: (connId: string) => unknown): void;
|
|
21
|
+
on(event: "message", callback: (connId: string, text: string) => unknown): void;
|
|
22
|
+
on(event: "replaced", callback: () => unknown): void;
|
|
23
|
+
on(event: "shutdown", callback: (reason?: string) => unknown): void;
|
|
24
|
+
readonly config: PlatformSessionConfig;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
|
|
2
|
+
export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlayerIdentity, } from "./platformHandle";
|
|
3
|
+
export interface MultiplayerServerPlayer extends PlayerIdentity {
|
|
4
|
+
playerId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface MultiplayerBroadcastOptions {
|
|
7
|
+
except?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MultiplayerServerSession {
|
|
10
|
+
readonly players: MultiplayerServerPlayer[];
|
|
11
|
+
readonly sessionId: string;
|
|
12
|
+
readonly deckId: string;
|
|
13
|
+
readonly mode: MultiplayerSessionMode;
|
|
14
|
+
send(playerId: string, data: unknown): void;
|
|
15
|
+
broadcast(data: unknown, options?: MultiplayerBroadcastOptions): void;
|
|
16
|
+
disconnect(playerId: string, reason?: string): void;
|
|
17
|
+
}
|
|
18
|
+
type CallbackResult = unknown | Promise<unknown>;
|
|
19
|
+
export interface MultiplayerServerCallbacks {
|
|
20
|
+
onStart?: (session: MultiplayerServerSession) => CallbackResult;
|
|
21
|
+
onPlayerJoin?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
|
|
22
|
+
onPlayerLeave?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
|
|
23
|
+
onMessage?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer, data: unknown) => CallbackResult;
|
|
24
|
+
onTick?: (session: MultiplayerServerSession) => CallbackResult;
|
|
25
|
+
onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
|
|
26
|
+
}
|
|
27
|
+
export type CastleSessionBoot = (platformHandle: PlatformHandle) => Promise<void>;
|
|
28
|
+
export declare function createCastleSessionBoot(callbacks: MultiplayerServerCallbacks): CastleSessionBoot;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The publish bundler binds the author's default callbacks export once and
|
|
2
|
+
// exports the returned one-argument function as `__castleBootSession`.
|
|
3
|
+
export function createCastleSessionBoot(callbacks) {
|
|
4
|
+
return async function __castleBootSession(platformHandle) {
|
|
5
|
+
await bootSession(platformHandle, callbacks);
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
async function bootSession(platformHandle, callbacks) {
|
|
9
|
+
const players = new Map();
|
|
10
|
+
const session = createAuthorSession(platformHandle, players);
|
|
11
|
+
let tickTimer = null;
|
|
12
|
+
platformHandle.on("join", async (connId, identity) => {
|
|
13
|
+
// The compatibility branch accepts the current Node shim's legacy
|
|
14
|
+
// single-argument SessionPlayer event while the public handle contract uses
|
|
15
|
+
// the runtime-agnostic (connId, trusted identity) pair.
|
|
16
|
+
const player = joinedPlayer(connId, identity);
|
|
17
|
+
players.set(player.playerId, player);
|
|
18
|
+
await callbacks.onPlayerJoin?.(session, player);
|
|
19
|
+
});
|
|
20
|
+
platformHandle.on("leave", async (connId) => {
|
|
21
|
+
const player = players.get(connId);
|
|
22
|
+
players.delete(connId);
|
|
23
|
+
if (player)
|
|
24
|
+
await callbacks.onPlayerLeave?.(session, player);
|
|
25
|
+
});
|
|
26
|
+
platformHandle.on("message", async (connId, text) => {
|
|
27
|
+
const player = players.get(connId);
|
|
28
|
+
if (!player)
|
|
29
|
+
return;
|
|
30
|
+
await callbacks.onMessage?.(session, player, parseHandleMessage(text));
|
|
31
|
+
});
|
|
32
|
+
platformHandle.on("replaced", async () => {
|
|
33
|
+
await callbacks.onReplaced?.(session);
|
|
34
|
+
});
|
|
35
|
+
platformHandle.on("shutdown", () => {
|
|
36
|
+
if (tickTimer !== null)
|
|
37
|
+
clearInterval(tickTimer);
|
|
38
|
+
tickTimer = null;
|
|
39
|
+
});
|
|
40
|
+
await callbacks.onStart?.(session);
|
|
41
|
+
const onTick = callbacks.onTick;
|
|
42
|
+
if (onTick && platformHandle.config.tickRate > 0) {
|
|
43
|
+
tickTimer = setInterval(() => {
|
|
44
|
+
invokeTick(onTick, session);
|
|
45
|
+
}, 1_000 / platformHandle.config.tickRate);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function createAuthorSession(platformHandle, players) {
|
|
49
|
+
return {
|
|
50
|
+
get players() {
|
|
51
|
+
return Array.from(players.values());
|
|
52
|
+
},
|
|
53
|
+
sessionId: platformHandle.config.sessionId,
|
|
54
|
+
deckId: platformHandle.config.deckId,
|
|
55
|
+
mode: platformHandle.config.mode,
|
|
56
|
+
send(playerId, data) {
|
|
57
|
+
platformHandle.send(playerId, stringifyHandleMessage(data));
|
|
58
|
+
},
|
|
59
|
+
broadcast(data, options) {
|
|
60
|
+
const text = stringifyHandleMessage(data);
|
|
61
|
+
if (!options?.except) {
|
|
62
|
+
platformHandle.broadcast(text);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const playerId of players.keys()) {
|
|
66
|
+
if (playerId !== options.except)
|
|
67
|
+
platformHandle.send(playerId, text);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
disconnect(playerId, reason) {
|
|
71
|
+
platformHandle.disconnect(playerId, reason ?? 1000);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function joinedPlayer(connId, identity) {
|
|
76
|
+
if (typeof connId === "string")
|
|
77
|
+
return { ...identity, playerId: connId };
|
|
78
|
+
const legacy = connId;
|
|
79
|
+
return {
|
|
80
|
+
playerId: legacy.playerId,
|
|
81
|
+
userId: legacy.userId,
|
|
82
|
+
username: legacy.username,
|
|
83
|
+
isAnonymous: legacy.isAnonymous,
|
|
84
|
+
...(legacy.scopedUserId ? { scopedUserId: legacy.scopedUserId } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function parseHandleMessage(text) {
|
|
88
|
+
if (typeof text !== "string")
|
|
89
|
+
return text;
|
|
90
|
+
return JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
function stringifyHandleMessage(data) {
|
|
93
|
+
const text = JSON.stringify(data);
|
|
94
|
+
if (text === undefined) {
|
|
95
|
+
throw new TypeError("Multiplayer server messages must be JSON-serializable.");
|
|
96
|
+
}
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
function invokeTick(callback, session) {
|
|
100
|
+
try {
|
|
101
|
+
void Promise.resolve(callback(session)).catch(reportTickError);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
reportTickError(error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function reportTickError(error) {
|
|
108
|
+
console.error(`onTick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castle-web-sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/castle.js",
|
|
6
6
|
"types": "dist/castle.d.ts",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"types": "./dist/castle.d.ts",
|
|
10
10
|
"default": "./dist/castle.js"
|
|
11
|
+
},
|
|
12
|
+
"./server": {
|
|
13
|
+
"types": "./dist/server/wrapper.d.ts",
|
|
14
|
+
"default": "./dist/server/wrapper.js"
|
|
11
15
|
}
|
|
12
16
|
},
|
|
13
17
|
"//host": "host.{js,d.ts} is the host-side executor and leaderboardPanel.{js,d.ts} is the host-side leaderboard UI \u2014 both deliberately NOT exported and NOT packaged. They are vendored into host repos via scripts/copy-host-module.mjs; decks must never receive them.",
|