@bountyboard/arcade-sdk 1.1.0 → 1.3.0
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/AGENTS.md +22 -10
- package/CHANGELOG.md +73 -0
- package/README.md +378 -85
- package/dist/{chunk-OWOESH4X.js → chunk-PYW5F45R.js} +384 -10
- package/dist/chunk-PYW5F45R.js.map +1 -0
- package/dist/global-sdk.d.ts +100 -3
- package/dist/global.js +482 -48
- package/dist/index.cjs +383 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/multiplayer.cjs +481 -48
- package/dist/multiplayer.cjs.map +1 -1
- package/dist/multiplayer.d.cts +2 -2
- package/dist/multiplayer.d.ts +2 -2
- package/dist/multiplayer.js +88 -29
- package/dist/multiplayer.js.map +1 -1
- package/dist/{types-Cq8p7ZkC.d.cts → types-B5bqWnHH.d.cts} +99 -4
- package/dist/{types-Cq8p7ZkC.d.ts → types-B5bqWnHH.d.ts} +99 -4
- package/docs/external-authoritative-servers.md +265 -0
- package/docs/llms.txt +800 -0
- package/docs/relay-rooms.md +134 -0
- package/package.json +2 -1
- package/dist/chunk-OWOESH4X.js.map +0 -1
package/dist/multiplayer.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BBArcade
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-PYW5F45R.js";
|
|
4
4
|
|
|
5
5
|
// src/multiplayer.ts
|
|
6
6
|
var WELCOME_TIMEOUT_MS = 1e4;
|
|
7
|
+
var MIN_WELCOME_TIMEOUT_MS = 1e3;
|
|
8
|
+
var MAX_WELCOME_TIMEOUT_MS = 6e4;
|
|
7
9
|
var MAX_RECONNECT_ATTEMPTS = 3;
|
|
8
10
|
var MAX_JOIN_DATA_BYTES = 1024;
|
|
11
|
+
var SEAT_FULL_CLOSE_CODE = 4005;
|
|
9
12
|
var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
10
13
|
var CODE_LENGTH = 4;
|
|
11
|
-
function sdkError(code) {
|
|
14
|
+
function sdkError(code, detail) {
|
|
12
15
|
const err = new Error(code);
|
|
13
16
|
err.code = code;
|
|
17
|
+
if (detail) err.detail = detail;
|
|
14
18
|
return err;
|
|
15
19
|
}
|
|
16
20
|
function randomChars(length) {
|
|
@@ -27,23 +31,13 @@ function randomChars(length) {
|
|
|
27
31
|
function generateCode() {
|
|
28
32
|
return randomChars(CODE_LENGTH);
|
|
29
33
|
}
|
|
34
|
+
var cachedTextEncoder = null;
|
|
30
35
|
function utf8ByteLength(value) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (code < 128) bytes += 1;
|
|
35
|
-
else if (code < 2048) bytes += 2;
|
|
36
|
-
else if (code >= 55296 && code <= 56319 && i + 1 < value.length) {
|
|
37
|
-
const next = value.charCodeAt(i + 1);
|
|
38
|
-
if (next >= 56320 && next <= 57343) {
|
|
39
|
-
bytes += 4;
|
|
40
|
-
i += 1;
|
|
41
|
-
} else {
|
|
42
|
-
bytes += 3;
|
|
43
|
-
}
|
|
44
|
-
} else bytes += 3;
|
|
36
|
+
if (typeof TextEncoder !== "undefined") {
|
|
37
|
+
cachedTextEncoder = cachedTextEncoder || new TextEncoder();
|
|
38
|
+
return cachedTextEncoder.encode(value).length;
|
|
45
39
|
}
|
|
46
|
-
return
|
|
40
|
+
return new Blob([value]).size;
|
|
47
41
|
}
|
|
48
42
|
function serializeJoinData(data) {
|
|
49
43
|
if (data === void 0) return null;
|
|
@@ -59,23 +53,29 @@ function serializeJoinData(data) {
|
|
|
59
53
|
}
|
|
60
54
|
return serialized;
|
|
61
55
|
}
|
|
62
|
-
function requestTicket(
|
|
56
|
+
function requestTicket(payload) {
|
|
63
57
|
const request = BBArcade._request;
|
|
64
58
|
if (typeof request !== "function") return Promise.reject(sdkError("unsupported"));
|
|
65
|
-
return request("mp_ticket",
|
|
59
|
+
return request("mp_ticket", payload).then((grant) => {
|
|
66
60
|
const g = grant;
|
|
67
61
|
if (!g || typeof g.ticket !== "string" || typeof g.roomUrl !== "string") {
|
|
68
62
|
throw sdkError("error");
|
|
69
63
|
}
|
|
70
|
-
return {
|
|
64
|
+
return {
|
|
65
|
+
ticket: g.ticket,
|
|
66
|
+
roomUrl: g.roomUrl,
|
|
67
|
+
expiresAt: Number(g.expiresAt) || 0,
|
|
68
|
+
roomId: typeof g.roomId === "string" && g.roomId ? g.roomId : null
|
|
69
|
+
};
|
|
71
70
|
});
|
|
72
71
|
}
|
|
73
72
|
var RoomConnection = class {
|
|
74
|
-
constructor(code, devGrant, joinData) {
|
|
73
|
+
constructor(code, devGrant, joinData, welcomeTimeoutMs = WELCOME_TIMEOUT_MS) {
|
|
75
74
|
this.playerId = "";
|
|
76
75
|
this.players = [];
|
|
77
76
|
this.state = null;
|
|
78
77
|
this.connected = false;
|
|
78
|
+
this.latencyMs = null;
|
|
79
79
|
this.ws = null;
|
|
80
80
|
// Keyed by event name; values are erased to (unknown) => void internally and
|
|
81
81
|
// retyped at the on()/emit() boundary, which keeps the public API strictly
|
|
@@ -88,15 +88,20 @@ var RoomConnection = class {
|
|
|
88
88
|
// or the welcome timed out — permanent outcomes that must not reconnect.
|
|
89
89
|
this.terminal = false;
|
|
90
90
|
this.reconnectAttempts = 0;
|
|
91
|
+
// The in-flight connect()'s settlement, so leave() can reject a join that
|
|
92
|
+
// is still waiting on its welcome instead of stranding it on the timeout.
|
|
93
|
+
this.pendingConnect = null;
|
|
91
94
|
this.code = code;
|
|
92
95
|
this.devGrant = devGrant;
|
|
93
96
|
this.joinData = joinData;
|
|
97
|
+
this.welcomeTimeoutMs = welcomeTimeoutMs;
|
|
94
98
|
}
|
|
95
99
|
/** Connect and resolve on the server's welcome (post-ticket-verification). */
|
|
96
100
|
connect(grant) {
|
|
97
101
|
return new Promise((resolve, reject) => {
|
|
98
102
|
let settled = false;
|
|
99
103
|
const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket) + (this.joinData === null ? "" : "&join=" + encodeURIComponent(this.joinData));
|
|
104
|
+
const connectStartedAt = Date.now();
|
|
100
105
|
let ws;
|
|
101
106
|
try {
|
|
102
107
|
ws = new WebSocket(url);
|
|
@@ -107,13 +112,15 @@ var RoomConnection = class {
|
|
|
107
112
|
const welcomeTimer = setTimeout(() => {
|
|
108
113
|
if (settled) return;
|
|
109
114
|
settled = true;
|
|
115
|
+
this.pendingConnect = null;
|
|
110
116
|
this.terminal = true;
|
|
111
117
|
try {
|
|
112
118
|
ws.close();
|
|
113
119
|
} catch (err) {
|
|
114
120
|
}
|
|
115
121
|
reject(sdkError("error"));
|
|
116
|
-
},
|
|
122
|
+
}, this.welcomeTimeoutMs);
|
|
123
|
+
this.pendingConnect = { reject, timer: welcomeTimer };
|
|
117
124
|
ws.onmessage = (event) => {
|
|
118
125
|
var _a, _b, _c, _d, _e;
|
|
119
126
|
let msg;
|
|
@@ -124,14 +131,23 @@ var RoomConnection = class {
|
|
|
124
131
|
}
|
|
125
132
|
if (!msg || typeof msg.t !== "string") return;
|
|
126
133
|
if (msg.t === "welcome") {
|
|
134
|
+
if (this.intentionalClose) {
|
|
135
|
+
try {
|
|
136
|
+
ws.close();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
127
141
|
const reconnected = this.reconnectAttempts > 0;
|
|
128
142
|
this.playerId = String((_a = msg.playerId) != null ? _a : "");
|
|
129
143
|
this.players = Array.isArray(msg.players) ? msg.players : [];
|
|
130
144
|
this.state = (_b = msg.state) != null ? _b : null;
|
|
131
145
|
this.connected = true;
|
|
132
146
|
this.reconnectAttempts = 0;
|
|
147
|
+
this.latencyMs = Math.max(0, Date.now() - connectStartedAt);
|
|
133
148
|
if (!settled) {
|
|
134
149
|
settled = true;
|
|
150
|
+
this.pendingConnect = null;
|
|
135
151
|
clearTimeout(welcomeTimer);
|
|
136
152
|
resolve(this);
|
|
137
153
|
}
|
|
@@ -173,21 +189,24 @@ var RoomConnection = class {
|
|
|
173
189
|
if (!settled) {
|
|
174
190
|
settled = true;
|
|
175
191
|
this.terminal = true;
|
|
192
|
+
this.pendingConnect = null;
|
|
176
193
|
clearTimeout(welcomeTimer);
|
|
177
194
|
try {
|
|
178
195
|
ws.close();
|
|
179
196
|
} catch (err) {
|
|
180
197
|
}
|
|
181
|
-
reject(sdkError(code === "unauthenticated" ? "unauthenticated" : "rejected"));
|
|
198
|
+
reject(sdkError(code === "unauthenticated" ? "unauthenticated" : "rejected", code));
|
|
182
199
|
return;
|
|
183
200
|
}
|
|
184
201
|
this.emit("error", { code });
|
|
185
202
|
}
|
|
186
203
|
};
|
|
187
|
-
ws.onclose = () => {
|
|
204
|
+
ws.onclose = (event) => {
|
|
188
205
|
this.connected = false;
|
|
206
|
+
if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;
|
|
189
207
|
if (!settled) {
|
|
190
208
|
settled = true;
|
|
209
|
+
this.pendingConnect = null;
|
|
191
210
|
clearTimeout(welcomeTimer);
|
|
192
211
|
reject(sdkError("error"));
|
|
193
212
|
return;
|
|
@@ -216,7 +235,7 @@ var RoomConnection = class {
|
|
|
216
235
|
await new Promise((r) => setTimeout(r, 400 * this.reconnectAttempts));
|
|
217
236
|
if (this.intentionalClose || this.ended || this.terminal) return;
|
|
218
237
|
try {
|
|
219
|
-
const grant = (_a = this.devGrant) != null ? _a : await requestTicket(this.code);
|
|
238
|
+
const grant = (_a = this.devGrant) != null ? _a : await requestTicket({ roomId: this.code });
|
|
220
239
|
if (this.intentionalClose || this.ended || this.terminal) return;
|
|
221
240
|
await this.connect(grant);
|
|
222
241
|
if (this.intentionalClose) this.leave();
|
|
@@ -251,6 +270,12 @@ var RoomConnection = class {
|
|
|
251
270
|
var _a;
|
|
252
271
|
this.intentionalClose = true;
|
|
253
272
|
this.connected = false;
|
|
273
|
+
if (this.pendingConnect) {
|
|
274
|
+
const pending = this.pendingConnect;
|
|
275
|
+
this.pendingConnect = null;
|
|
276
|
+
clearTimeout(pending.timer);
|
|
277
|
+
pending.reject(sdkError("error"));
|
|
278
|
+
}
|
|
254
279
|
try {
|
|
255
280
|
(_a = this.ws) == null ? void 0 : _a.close(1e3);
|
|
256
281
|
} catch (err) {
|
|
@@ -270,23 +295,57 @@ var RoomConnection = class {
|
|
|
270
295
|
}
|
|
271
296
|
}
|
|
272
297
|
};
|
|
298
|
+
var MATCH_ATTEMPTS = 3;
|
|
299
|
+
function normalizeWelcomeTimeoutMs(value) {
|
|
300
|
+
if (typeof value !== "number" || !isFinite(value)) return WELCOME_TIMEOUT_MS;
|
|
301
|
+
return Math.min(MAX_WELCOME_TIMEOUT_MS, Math.max(MIN_WELCOME_TIMEOUT_MS, Math.floor(value)));
|
|
302
|
+
}
|
|
303
|
+
async function matchJoin(joinData, timeoutMs) {
|
|
304
|
+
let lastError = sdkError("error");
|
|
305
|
+
for (let attempt = 0; attempt < MATCH_ATTEMPTS; attempt++) {
|
|
306
|
+
const grant = await requestTicket({ matchmake: true });
|
|
307
|
+
if (!grant.roomId) throw sdkError("error");
|
|
308
|
+
try {
|
|
309
|
+
return await new RoomConnection(grant.roomId, null, joinData, timeoutMs).connect(grant);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
lastError = error;
|
|
312
|
+
const detail = error == null ? void 0 : error.detail;
|
|
313
|
+
if (detail !== "room_full" && detail !== "room_over") throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
throw lastError;
|
|
317
|
+
}
|
|
273
318
|
function joinRoom(options) {
|
|
274
319
|
const opts = options != null ? options : {};
|
|
275
320
|
if (typeof window === "undefined" || typeof WebSocket === "undefined") {
|
|
276
321
|
return Promise.reject(sdkError("unsupported"));
|
|
277
322
|
}
|
|
278
|
-
const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
|
|
279
323
|
let joinData;
|
|
280
324
|
try {
|
|
281
325
|
joinData = serializeJoinData(opts.joinData);
|
|
282
326
|
} catch (err) {
|
|
283
327
|
return Promise.reject(err);
|
|
284
328
|
}
|
|
329
|
+
const timeoutMs = normalizeWelcomeTimeoutMs(opts.timeoutMs);
|
|
330
|
+
if (opts.match) {
|
|
331
|
+
if (opts.code || opts.create || opts.roomUrl || opts.ticket) {
|
|
332
|
+
return Promise.reject(sdkError("rejected"));
|
|
333
|
+
}
|
|
334
|
+
return matchJoin(joinData, timeoutMs);
|
|
335
|
+
}
|
|
336
|
+
const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
|
|
285
337
|
if (opts.roomUrl && opts.ticket) {
|
|
286
|
-
const devGrant = {
|
|
287
|
-
|
|
338
|
+
const devGrant = {
|
|
339
|
+
ticket: opts.ticket,
|
|
340
|
+
roomUrl: opts.roomUrl,
|
|
341
|
+
expiresAt: 0,
|
|
342
|
+
roomId: code
|
|
343
|
+
};
|
|
344
|
+
return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);
|
|
288
345
|
}
|
|
289
|
-
return requestTicket(
|
|
346
|
+
return requestTicket({ roomId: code }).then(
|
|
347
|
+
(grant) => new RoomConnection(code, null, joinData, timeoutMs).connect(grant)
|
|
348
|
+
);
|
|
290
349
|
}
|
|
291
350
|
var multiplayer = { joinRoom };
|
|
292
351
|
export {
|
package/dist/multiplayer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/multiplayer.ts"],"sourcesContent":["/**\n * Multiplayer client for the Bounty Board arcade room server\n * (packages/arcade-mp-server — authoritative Durable-Object rooms).\n *\n * Trust model: the game NEVER holds credentials. joinRoom() asks the embedding\n * Bounty Board host for a short-lived signed ticket (postMessage `mp_ticket`,\n * answered by the host frame which holds the player's session), then opens a\n * WebSocket to the room URL the ticket API returned. The room server verifies\n * the ticket before accepting, runs the simulation itself, and reports match\n * results to Bounty Board over a server-to-server channel — so nothing a\n * modified client sends can forge an identity or a recorded outcome. Clients\n * send INPUTS; the server sends per-viewer-filtered snapshots.\n */\nimport { BBArcade } from './index';\nimport type {\n BBArcadeErrorCode,\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nexport type {\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nconst WELCOME_TIMEOUT_MS = 10_000;\nconst MAX_RECONNECT_ATTEMPTS = 3;\nconst MAX_JOIN_DATA_BYTES = 1_024;\n// Unambiguous alphabet (no 0/O/1/I) for share codes friends read aloud.\nconst CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';\nconst CODE_LENGTH = 4;\n\ninterface TicketGrant {\n ticket: string;\n roomUrl: string;\n expiresAt: number;\n}\n\ninterface SdkError extends Error {\n code: BBArcadeErrorCode;\n}\n\nfunction sdkError(code: BBArcadeErrorCode): SdkError {\n const err = new Error(code) as SdkError;\n err.code = code;\n return err;\n}\n\nfunction randomChars(length: number): string {\n const indices = new Uint8Array(length);\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(indices);\n } else {\n for (let i = 0; i < length; i++) indices[i] = Math.floor(Math.random() * 256);\n }\n let out = '';\n // 256 is an exact multiple of the 32-char alphabet, so no modulo bias.\n for (let i = 0; i < length; i++) out += CODE_ALPHABET[indices[i] % CODE_ALPHABET.length];\n return out;\n}\n\nfunction generateCode(): string {\n return randomChars(CODE_LENGTH);\n}\n\nfunction utf8ByteLength(value: string): number {\n let bytes = 0;\n for (let i = 0; i < value.length; i++) {\n const code = value.charCodeAt(i);\n if (code < 0x80) bytes += 1;\n else if (code < 0x800) bytes += 2;\n else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) {\n const next = value.charCodeAt(i + 1);\n if (next >= 0xdc00 && next <= 0xdfff) {\n bytes += 4;\n i += 1;\n } else {\n bytes += 3;\n }\n } else bytes += 3;\n }\n return bytes;\n}\n\nfunction serializeJoinData(data: BBArcadeMpJoinOptions['joinData']): string | null {\n if (data === undefined) return null;\n if (!data || typeof data !== 'object' || Array.isArray(data)) throw sdkError('rejected');\n\n let serialized: string;\n try {\n serialized = JSON.stringify(data);\n } catch {\n throw sdkError('rejected');\n }\n if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {\n throw sdkError('rejected');\n }\n return serialized;\n}\n\n/** The host-frame handshake, riding the core SDK's request/response transport. */\nfunction requestTicket(roomId: string): Promise<TicketGrant> {\n const request = (\n BBArcade as unknown as { _request?: (type: string, payload?: unknown) => Promise<unknown> }\n )._request;\n if (typeof request !== 'function') return Promise.reject(sdkError('unsupported'));\n return request('mp_ticket', { roomId }).then(grant => {\n const g = grant as Partial<TicketGrant> | null;\n if (!g || typeof g.ticket !== 'string' || typeof g.roomUrl !== 'string') {\n throw sdkError('error');\n }\n return { ticket: g.ticket, roomUrl: g.roomUrl, expiresAt: Number(g.expiresAt) || 0 };\n });\n}\n\nclass RoomConnection implements BBArcadeMpRoom {\n code: string;\n playerId = '';\n players: BBArcadeMpPlayer[] = [];\n state: unknown = null;\n connected = false;\n\n private ws: WebSocket | null = null;\n // Keyed by event name; values are erased to (unknown) => void internally and\n // retyped at the on()/emit() boundary, which keeps the public API strictly\n // typed without fighting generic index-assignment rules.\n private handlers: Record<string, Array<(data: unknown) => void> | undefined> = {};\n private seq = 0;\n private intentionalClose = false;\n private ended = false;\n // Set when the server definitively rejected us (bad ticket, room full/over)\n // or the welcome timed out — permanent outcomes that must not reconnect.\n private terminal = false;\n private reconnectAttempts = 0;\n private readonly devGrant: TicketGrant | null;\n private readonly joinData: string | null;\n\n constructor(code: string, devGrant: TicketGrant | null, joinData: string | null) {\n this.code = code;\n this.devGrant = devGrant;\n this.joinData = joinData;\n }\n\n /** Connect and resolve on the server's welcome (post-ticket-verification). */\n connect(grant: TicketGrant): Promise<this> {\n return new Promise((resolve, reject) => {\n let settled = false;\n const url =\n grant.roomUrl +\n (grant.roomUrl.indexOf('?') === -1 ? '?' : '&') +\n 'ticket=' +\n encodeURIComponent(grant.ticket) +\n (this.joinData === null ? '' : '&join=' + encodeURIComponent(this.joinData));\n let ws: WebSocket;\n try {\n ws = new WebSocket(url);\n } catch (err) {\n return reject(sdkError('error'));\n }\n this.ws = ws;\n\n const welcomeTimer = setTimeout(() => {\n if (settled) return;\n settled = true;\n this.terminal = true; // the join already failed — closing must not retry\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n reject(sdkError('error'));\n }, WELCOME_TIMEOUT_MS);\n\n ws.onmessage = event => {\n let msg: { t?: string } & Record<string, unknown>;\n try {\n msg = JSON.parse(String(event.data));\n } catch (err) {\n return; // non-JSON frames are not part of the protocol\n }\n if (!msg || typeof msg.t !== 'string') return;\n\n if (msg.t === 'welcome') {\n const reconnected = this.reconnectAttempts > 0;\n this.playerId = String(msg.playerId ?? '');\n this.players = Array.isArray(msg.players) ? (msg.players as BBArcadeMpPlayer[]) : [];\n this.state = msg.state ?? null;\n this.connected = true;\n this.reconnectAttempts = 0;\n if (!settled) {\n settled = true;\n clearTimeout(welcomeTimer);\n resolve(this);\n }\n if (reconnected) this.emit('connection', { connected: true, reconnecting: false });\n return;\n }\n if (msg.t === 'snapshot') {\n this.state = msg.state ?? this.state;\n this.emit('snapshot', { tick: Number(msg.tick) || 0, state: this.state });\n return;\n }\n if (msg.t === 'player_join') {\n const player = msg.player as BBArcadeMpPlayer;\n if (player && player.id && !this.players.some(p => p.id === player.id)) {\n this.players.push(player);\n }\n this.emit('playerJoin', { player });\n return;\n }\n if (msg.t === 'player_leave') {\n const playerId = String(msg.playerId ?? '');\n this.players = this.players.filter(p => p.id !== playerId);\n this.emit('playerLeave', { playerId });\n return;\n }\n if (msg.t === 'event') {\n this.emit('event', msg.data);\n return;\n }\n if (msg.t === 'match_end') {\n this.ended = true;\n this.emit('end', {\n results: Array.isArray(msg.results) ? (msg.results as BBArcadeMpResult[]) : [],\n });\n return;\n }\n if (msg.t === 'error') {\n const code = String(msg.code ?? 'error');\n if (!settled) {\n // A pre-welcome error is a DEFINITIVE rejection (bad ticket, room\n // full/over, unknown game) — reconnecting would just repeat it.\n settled = true;\n this.terminal = true;\n clearTimeout(welcomeTimer);\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n reject(sdkError(code === 'unauthenticated' ? 'unauthenticated' : 'rejected'));\n return;\n }\n this.emit('error', { code });\n }\n };\n\n ws.onclose = () => {\n this.connected = false;\n if (!settled) {\n settled = true;\n clearTimeout(welcomeTimer);\n reject(sdkError('error'));\n return;\n }\n if (this.intentionalClose || this.ended || this.terminal) {\n this.emit('connection', { connected: false, reconnecting: false });\n this.emit('close', { reconnecting: false });\n return;\n }\n void this.reconnect();\n };\n\n ws.onerror = () => {\n /* onclose always follows and carries the handling */\n };\n });\n }\n\n private async reconnect(): Promise<void> {\n if (this.intentionalClose || this.ended || this.terminal) return;\n if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n this.emit('close', { reconnecting: false });\n return;\n }\n this.reconnectAttempts += 1;\n this.emit('connection', { connected: false, reconnecting: true });\n this.emit('close', { reconnecting: true });\n await new Promise(r => setTimeout(r, 400 * this.reconnectAttempts));\n // leave() may have been called during the backoff — opening a new socket\n // then would strand a connection the caller can no longer close.\n if (this.intentionalClose || this.ended || this.terminal) return;\n try {\n // Tickets are 60s single-purpose grants — always fetch a fresh one\n // (except in dev-override mode, where the dev ticket is reused).\n const grant = this.devGrant ?? (await requestTicket(this.code));\n if (this.intentionalClose || this.ended || this.terminal) return;\n await this.connect(grant);\n // leave() raced the reconnect while the socket was opening: close it.\n if (this.intentionalClose) this.leave();\n } catch (err) {\n void this.reconnect();\n }\n }\n\n send(input: unknown): void {\n this.trySend(input);\n }\n\n trySend(input: unknown): boolean {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;\n this.seq += 1;\n try {\n this.ws.send(JSON.stringify({ t: 'input', seq: this.seq, data: input }));\n return true;\n } catch (err) {\n /* socket raced shut — the close handler owns recovery */\n return false;\n }\n }\n\n on<K extends keyof BBArcadeMpRoomEvents>(\n event: K,\n handler: (data: BBArcadeMpRoomEvents[K]) => void\n ): () => void {\n const list = (this.handlers[event] ??= []);\n const entry = handler as (data: unknown) => void;\n list.push(entry);\n return () => {\n const idx = list.indexOf(entry);\n if (idx !== -1) list.splice(idx, 1);\n };\n }\n\n leave(): void {\n this.intentionalClose = true;\n this.connected = false;\n try {\n this.ws?.close(1000);\n } catch (err) {\n /* already closed */\n }\n }\n\n private emit<K extends keyof BBArcadeMpRoomEvents>(event: K, data: BBArcadeMpRoomEvents[K]) {\n const list = this.handlers[event];\n if (!list) return;\n for (const handler of list.slice()) {\n try {\n handler(data);\n } catch (err) {\n setTimeout(() => {\n throw err;\n }, 0);\n }\n }\n }\n}\n\nexport function joinRoom(options?: BBArcadeMpJoinOptions): Promise<BBArcadeMpRoom> {\n const opts = options ?? {};\n if (typeof window === 'undefined' || typeof WebSocket === 'undefined') {\n return Promise.reject(sdkError('unsupported'));\n }\n\n const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();\n let joinData: string | null;\n try {\n joinData = serializeJoinData(opts.joinData);\n } catch (err) {\n return Promise.reject(err);\n }\n\n // Dev override: straight to a local room server, no host handshake.\n if (opts.roomUrl && opts.ticket) {\n const devGrant: TicketGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };\n return new RoomConnection(code, devGrant, joinData).connect(devGrant);\n }\n\n return requestTicket(code).then(grant => new RoomConnection(code, null, joinData).connect(grant));\n}\n\nexport const multiplayer: BBArcadeMultiplayer = { joinRoom };\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/multiplayer.ts"],"sourcesContent":["/**\n * Multiplayer client for the Bounty Board arcade room server\n * (packages/arcade-mp-server — authoritative Durable-Object rooms).\n *\n * Trust model: the game NEVER holds credentials. joinRoom() asks the embedding\n * Bounty Board host for a short-lived signed ticket (postMessage `mp_ticket`,\n * answered by the host frame which holds the player's session), then opens a\n * WebSocket to the room URL the ticket API returned. The room server verifies\n * the ticket before accepting, runs the simulation itself, and reports match\n * results to Bounty Board over a server-to-server channel — so nothing a\n * modified client sends can forge an identity or a recorded outcome. Clients\n * send INPUTS; the server sends per-viewer-filtered snapshots.\n */\nimport { BBArcade } from './index';\nimport type {\n BBArcadeErrorCode,\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nexport type {\n BBArcadeMpJoinOptions,\n BBArcadeMpPlayer,\n BBArcadeMpResult,\n BBArcadeMpRoom,\n BBArcadeMpRoomEvents,\n BBArcadeMultiplayer,\n} from './types';\n\nconst WELCOME_TIMEOUT_MS = 10_000;\nconst MIN_WELCOME_TIMEOUT_MS = 1_000;\nconst MAX_WELCOME_TIMEOUT_MS = 60_000;\nconst MAX_RECONNECT_ATTEMPTS = 3;\nconst MAX_JOIN_DATA_BYTES = 1_024;\n/** Server closes with this code when the seat already has its max sockets. */\nconst SEAT_FULL_CLOSE_CODE = 4005;\n// Unambiguous alphabet (no 0/O/1/I) for share codes friends read aloud.\nconst CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';\nconst CODE_LENGTH = 4;\n\ninterface TicketGrant {\n ticket: string;\n roomUrl: string;\n expiresAt: number;\n /** The room the ticket is bound to — how quick match learns its code. */\n roomId: string | null;\n}\n\ninterface SdkError extends Error {\n code: BBArcadeErrorCode;\n detail?: string;\n}\n\nfunction sdkError(code: BBArcadeErrorCode, detail?: string): SdkError {\n const err = new Error(code) as SdkError;\n err.code = code;\n if (detail) err.detail = detail;\n return err;\n}\n\nfunction randomChars(length: number): string {\n const indices = new Uint8Array(length);\n if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {\n crypto.getRandomValues(indices);\n } else {\n for (let i = 0; i < length; i++) indices[i] = Math.floor(Math.random() * 256);\n }\n let out = '';\n // 256 is an exact multiple of the 32-char alphabet, so no modulo bias.\n for (let i = 0; i < length; i++) out += CODE_ALPHABET[indices[i] % CODE_ALPHABET.length];\n return out;\n}\n\nfunction generateCode(): string {\n return randomChars(CODE_LENGTH);\n}\n\n// Lazy: import must stay clean even where TextEncoder is missing (some\n// test/SSR shims); Blob gives the same exact UTF-8 byte count as a fallback.\nlet cachedTextEncoder: TextEncoder | null = null;\n\nfunction utf8ByteLength(value: string): number {\n if (typeof TextEncoder !== 'undefined') {\n cachedTextEncoder = cachedTextEncoder || new TextEncoder();\n return cachedTextEncoder.encode(value).length;\n }\n return new Blob([value]).size;\n}\n\nfunction serializeJoinData(data: BBArcadeMpJoinOptions['joinData']): string | null {\n if (data === undefined) return null;\n if (!data || typeof data !== 'object' || Array.isArray(data)) throw sdkError('rejected');\n\n let serialized: string;\n try {\n serialized = JSON.stringify(data);\n } catch {\n throw sdkError('rejected');\n }\n if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {\n throw sdkError('rejected');\n }\n return serialized;\n}\n\n/** The host-frame handshake, riding the core SDK's request/response transport. */\nfunction requestTicket(payload: { roomId: string } | { matchmake: true }): Promise<TicketGrant> {\n const request = (\n BBArcade as unknown as { _request?: (type: string, payload?: unknown) => Promise<unknown> }\n )._request;\n if (typeof request !== 'function') return Promise.reject(sdkError('unsupported'));\n return request('mp_ticket', payload).then(grant => {\n const g = grant as Partial<TicketGrant> | null;\n if (!g || typeof g.ticket !== 'string' || typeof g.roomUrl !== 'string') {\n throw sdkError('error');\n }\n return {\n ticket: g.ticket,\n roomUrl: g.roomUrl,\n expiresAt: Number(g.expiresAt) || 0,\n roomId: typeof g.roomId === 'string' && g.roomId ? g.roomId : null,\n };\n });\n}\n\nclass RoomConnection implements BBArcadeMpRoom {\n code: string;\n playerId = '';\n players: BBArcadeMpPlayer[] = [];\n state: unknown = null;\n connected = false;\n latencyMs: number | null = null;\n\n private ws: WebSocket | null = null;\n // Keyed by event name; values are erased to (unknown) => void internally and\n // retyped at the on()/emit() boundary, which keeps the public API strictly\n // typed without fighting generic index-assignment rules.\n private handlers: Record<string, Array<(data: unknown) => void> | undefined> = {};\n private seq = 0;\n private intentionalClose = false;\n private ended = false;\n // Set when the server definitively rejected us (bad ticket, room full/over)\n // or the welcome timed out — permanent outcomes that must not reconnect.\n private terminal = false;\n private reconnectAttempts = 0;\n private readonly devGrant: TicketGrant | null;\n private readonly joinData: string | null;\n private readonly welcomeTimeoutMs: number;\n // The in-flight connect()'s settlement, so leave() can reject a join that\n // is still waiting on its welcome instead of stranding it on the timeout.\n private pendingConnect: {\n reject: (err: SdkError) => void;\n timer: ReturnType<typeof setTimeout>;\n } | null = null;\n\n constructor(\n code: string,\n devGrant: TicketGrant | null,\n joinData: string | null,\n welcomeTimeoutMs: number = WELCOME_TIMEOUT_MS\n ) {\n this.code = code;\n this.devGrant = devGrant;\n this.joinData = joinData;\n this.welcomeTimeoutMs = welcomeTimeoutMs;\n }\n\n /** Connect and resolve on the server's welcome (post-ticket-verification). */\n connect(grant: TicketGrant): Promise<this> {\n return new Promise((resolve, reject) => {\n let settled = false;\n const url =\n grant.roomUrl +\n (grant.roomUrl.indexOf('?') === -1 ? '?' : '&') +\n 'ticket=' +\n encodeURIComponent(grant.ticket) +\n (this.joinData === null ? '' : '&join=' + encodeURIComponent(this.joinData));\n const connectStartedAt = Date.now();\n let ws: WebSocket;\n try {\n ws = new WebSocket(url);\n } catch (err) {\n return reject(sdkError('error'));\n }\n this.ws = ws;\n\n const welcomeTimer = setTimeout(() => {\n if (settled) return;\n settled = true;\n this.pendingConnect = null;\n this.terminal = true; // the join already failed — closing must not retry\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n reject(sdkError('error'));\n }, this.welcomeTimeoutMs);\n this.pendingConnect = { reject, timer: welcomeTimer };\n\n ws.onmessage = event => {\n let msg: { t?: string } & Record<string, unknown>;\n try {\n msg = JSON.parse(String(event.data));\n } catch (err) {\n return; // non-JSON frames are not part of the protocol\n }\n if (!msg || typeof msg.t !== 'string') return;\n\n if (msg.t === 'welcome') {\n // leave() beat the welcome: the join already rejected, so this late\n // welcome must not resurrect a room the caller walked away from.\n if (this.intentionalClose) {\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n return;\n }\n const reconnected = this.reconnectAttempts > 0;\n this.playerId = String(msg.playerId ?? '');\n this.players = Array.isArray(msg.players) ? (msg.players as BBArcadeMpPlayer[]) : [];\n this.state = msg.state ?? null;\n this.connected = true;\n this.reconnectAttempts = 0;\n // Join-handshake latency: socket open → server welcome (ticket verify\n // + one round trip). A tuning estimate, not a measured RTT.\n this.latencyMs = Math.max(0, Date.now() - connectStartedAt);\n if (!settled) {\n settled = true;\n this.pendingConnect = null;\n clearTimeout(welcomeTimer);\n resolve(this);\n }\n if (reconnected) this.emit('connection', { connected: true, reconnecting: false });\n return;\n }\n if (msg.t === 'snapshot') {\n this.state = msg.state ?? this.state;\n this.emit('snapshot', { tick: Number(msg.tick) || 0, state: this.state });\n return;\n }\n if (msg.t === 'player_join') {\n const player = msg.player as BBArcadeMpPlayer;\n if (player && player.id && !this.players.some(p => p.id === player.id)) {\n this.players.push(player);\n }\n this.emit('playerJoin', { player });\n return;\n }\n if (msg.t === 'player_leave') {\n const playerId = String(msg.playerId ?? '');\n this.players = this.players.filter(p => p.id !== playerId);\n this.emit('playerLeave', { playerId });\n return;\n }\n if (msg.t === 'event') {\n this.emit('event', msg.data);\n return;\n }\n if (msg.t === 'match_end') {\n this.ended = true;\n this.emit('end', {\n results: Array.isArray(msg.results) ? (msg.results as BBArcadeMpResult[]) : [],\n });\n return;\n }\n if (msg.t === 'error') {\n const code = String(msg.code ?? 'error');\n if (!settled) {\n // A pre-welcome error is a DEFINITIVE rejection (bad ticket, room\n // full/over, unknown game) — reconnecting would just repeat it.\n settled = true;\n this.terminal = true;\n this.pendingConnect = null;\n clearTimeout(welcomeTimer);\n try {\n ws.close();\n } catch (err) {\n /* already closing */\n }\n // The raw server code rides along as `detail` so quick match can\n // distinguish a lost seat race (room_full/room_over) from a hard no.\n reject(sdkError(code === 'unauthenticated' ? 'unauthenticated' : 'rejected', code));\n return;\n }\n this.emit('error', { code });\n }\n };\n\n ws.onclose = (event: CloseEvent) => {\n this.connected = false;\n // 4005: the server rejected/superseded this socket because the seat is\n // at capacity. Reconnecting would immediately re-trigger it (or evict a\n // sibling), so it is permanent for this Room instance.\n if (event && event.code === SEAT_FULL_CLOSE_CODE) this.terminal = true;\n if (!settled) {\n settled = true;\n this.pendingConnect = null;\n clearTimeout(welcomeTimer);\n reject(sdkError('error'));\n return;\n }\n if (this.intentionalClose || this.ended || this.terminal) {\n this.emit('connection', { connected: false, reconnecting: false });\n this.emit('close', { reconnecting: false });\n return;\n }\n void this.reconnect();\n };\n\n ws.onerror = () => {\n /* onclose always follows and carries the handling */\n };\n });\n }\n\n private async reconnect(): Promise<void> {\n if (this.intentionalClose || this.ended || this.terminal) return;\n if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n this.emit('close', { reconnecting: false });\n return;\n }\n this.reconnectAttempts += 1;\n this.emit('connection', { connected: false, reconnecting: true });\n this.emit('close', { reconnecting: true });\n await new Promise(r => setTimeout(r, 400 * this.reconnectAttempts));\n // leave() may have been called during the backoff — opening a new socket\n // then would strand a connection the caller can no longer close.\n if (this.intentionalClose || this.ended || this.terminal) return;\n try {\n // Tickets are 60s single-purpose grants — always fetch a fresh one\n // (except in dev-override mode, where the dev ticket is reused).\n const grant = this.devGrant ?? (await requestTicket({ roomId: this.code }));\n if (this.intentionalClose || this.ended || this.terminal) return;\n await this.connect(grant);\n // leave() raced the reconnect while the socket was opening: close it.\n if (this.intentionalClose) this.leave();\n } catch (err) {\n void this.reconnect();\n }\n }\n\n send(input: unknown): void {\n this.trySend(input);\n }\n\n trySend(input: unknown): boolean {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;\n this.seq += 1;\n try {\n this.ws.send(JSON.stringify({ t: 'input', seq: this.seq, data: input }));\n return true;\n } catch (err) {\n /* socket raced shut — the close handler owns recovery */\n return false;\n }\n }\n\n on<K extends keyof BBArcadeMpRoomEvents>(\n event: K,\n handler: (data: BBArcadeMpRoomEvents[K]) => void\n ): () => void {\n const list = (this.handlers[event] ??= []);\n const entry = handler as (data: unknown) => void;\n list.push(entry);\n return () => {\n const idx = list.indexOf(entry);\n if (idx !== -1) list.splice(idx, 1);\n };\n }\n\n leave(): void {\n this.intentionalClose = true;\n this.connected = false;\n // A join still waiting on its welcome settles now (not on the welcome\n // timeout), and the welcome handler's intentionalClose guard keeps a late\n // welcome from resurrecting the room.\n if (this.pendingConnect) {\n const pending = this.pendingConnect;\n this.pendingConnect = null;\n clearTimeout(pending.timer);\n pending.reject(sdkError('error'));\n }\n try {\n this.ws?.close(1000);\n } catch (err) {\n /* already closed */\n }\n }\n\n private emit<K extends keyof BBArcadeMpRoomEvents>(event: K, data: BBArcadeMpRoomEvents[K]) {\n const list = this.handlers[event];\n if (!list) return;\n for (const handler of list.slice()) {\n try {\n handler(data);\n } catch (err) {\n setTimeout(() => {\n throw err;\n }, 0);\n }\n }\n }\n}\n\n/**\n * Quick match can lose the last seat of the room it was pointed at (another\n * player connected first, or the match just ended). Those two outcomes are\n * races, not verdicts — re-matchmake and land in the next open room.\n */\nconst MATCH_ATTEMPTS = 3;\n\nfunction normalizeWelcomeTimeoutMs(value: unknown): number {\n if (typeof value !== 'number' || !isFinite(value)) return WELCOME_TIMEOUT_MS;\n return Math.min(MAX_WELCOME_TIMEOUT_MS, Math.max(MIN_WELCOME_TIMEOUT_MS, Math.floor(value)));\n}\n\nasync function matchJoin(joinData: string | null, timeoutMs: number): Promise<BBArcadeMpRoom> {\n let lastError: unknown = sdkError('error');\n for (let attempt = 0; attempt < MATCH_ATTEMPTS; attempt++) {\n // Host/ticket failures reject immediately: they are not seat races.\n const grant = await requestTicket({ matchmake: true });\n if (!grant.roomId) throw sdkError('error');\n try {\n return await new RoomConnection(grant.roomId, null, joinData, timeoutMs).connect(grant);\n } catch (error) {\n lastError = error;\n const detail = (error as SdkError | null)?.detail;\n if (detail !== 'room_full' && detail !== 'room_over') throw error;\n }\n }\n throw lastError;\n}\n\nexport function joinRoom(options?: BBArcadeMpJoinOptions): Promise<BBArcadeMpRoom> {\n const opts = options ?? {};\n if (typeof window === 'undefined' || typeof WebSocket === 'undefined') {\n return Promise.reject(sdkError('unsupported'));\n }\n\n let joinData: string | null;\n try {\n joinData = serializeJoinData(opts.joinData);\n } catch (err) {\n return Promise.reject(err);\n }\n\n const timeoutMs = normalizeWelcomeTimeoutMs(opts.timeoutMs);\n\n if (opts.match) {\n // Quick match picks the room itself — no code, no create, no dev override.\n if (opts.code || opts.create || opts.roomUrl || opts.ticket) {\n return Promise.reject(sdkError('rejected'));\n }\n return matchJoin(joinData, timeoutMs);\n }\n\n const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();\n\n // Dev override: straight to a local room server, no host handshake.\n if (opts.roomUrl && opts.ticket) {\n const devGrant: TicketGrant = {\n ticket: opts.ticket,\n roomUrl: opts.roomUrl,\n expiresAt: 0,\n roomId: code,\n };\n return new RoomConnection(code, devGrant, joinData, timeoutMs).connect(devGrant);\n }\n\n return requestTicket({ roomId: code }).then(grant =>\n new RoomConnection(code, null, joinData, timeoutMs).connect(grant)\n );\n}\n\nexport const multiplayer: BBArcadeMultiplayer = { joinRoom };\n"],"mappings":"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","names":[]}
|
|
@@ -22,6 +22,8 @@ type BBArcadeErrorCode =
|
|
|
22
22
|
/** Rejection reason for save()/load(): a real Error that also carries `code`. */
|
|
23
23
|
interface BBArcadeError extends Error {
|
|
24
24
|
code: BBArcadeErrorCode;
|
|
25
|
+
/** Optional server detail for 'rejected' errors (e.g. 'room_full'). */
|
|
26
|
+
detail?: string;
|
|
25
27
|
}
|
|
26
28
|
/** Rewarded-ads settings accepted by init()/configure(). */
|
|
27
29
|
interface BBArcadeRewardedAdsConfig {
|
|
@@ -40,6 +42,46 @@ interface BBArcadeRewardedAdsConfig {
|
|
|
40
42
|
interface BBArcadeConfig {
|
|
41
43
|
rewardedAds?: BBArcadeRewardedAdsConfig;
|
|
42
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What is backing `window.localStorage` right now.
|
|
47
|
+
* - `native` — a real Storage works here; the SDK changed nothing.
|
|
48
|
+
* - `cloud` — the shim is installed and syncs to the player's Bounty Board save.
|
|
49
|
+
* - `memory` — the shim is installed but nothing can persist (guest, standalone,
|
|
50
|
+
* off-host); reads and writes work for the session and are then discarded.
|
|
51
|
+
*/
|
|
52
|
+
type BBArcadeStorageMode = 'native' | 'cloud' | 'memory';
|
|
53
|
+
/**
|
|
54
|
+
* localStorage compatibility for hosted builds. Bounty-Board-hosted uploads run
|
|
55
|
+
* in an opaque-origin sandbox where `localStorage` THROWS rather than returning
|
|
56
|
+
* empty, which breaks engine exports (GameMaker `ini_*`, Godot, Unity) that
|
|
57
|
+
* assume synchronous storage. The shim replaces it with a Storage-shaped object
|
|
58
|
+
* backed by the SDK's own cloud save, so those builds persist per player and
|
|
59
|
+
* across devices with no engine changes.
|
|
60
|
+
*
|
|
61
|
+
* The script-tag build installs it automatically at load. Module consumers keep
|
|
62
|
+
* a side-effect-free import and call `install()` themselves before boot.
|
|
63
|
+
*/
|
|
64
|
+
interface BBArcadeStorage {
|
|
65
|
+
/**
|
|
66
|
+
* Install the shim if this origin has no working localStorage, and return the
|
|
67
|
+
* resulting mode. Idempotent, and a no-op when native storage works. Call it
|
|
68
|
+
* BEFORE the engine/game script runs.
|
|
69
|
+
*/
|
|
70
|
+
install(): BBArcadeStorageMode;
|
|
71
|
+
/**
|
|
72
|
+
* Resolves once the cloud read has landed (or settled as unpersistable).
|
|
73
|
+
* `getItem` is synchronous but the cloud read is not, so a game that reads
|
|
74
|
+
* saved progress during boot should await this first.
|
|
75
|
+
*/
|
|
76
|
+
ready(): Promise<BBArcadeStorageMode>;
|
|
77
|
+
/**
|
|
78
|
+
* Force pending writes out now instead of waiting for the debounce. Rejects
|
|
79
|
+
* with the same BBArcadeError codes as save().
|
|
80
|
+
*/
|
|
81
|
+
flush(): Promise<void>;
|
|
82
|
+
/** The current backing mode. */
|
|
83
|
+
readonly mode: BBArcadeStorageMode;
|
|
84
|
+
}
|
|
43
85
|
/**
|
|
44
86
|
* The logged-in player's public display identity, as delivered by the Bounty
|
|
45
87
|
* Board host. Display name + avatar ONLY — the host never sends ids, emails,
|
|
@@ -73,7 +115,17 @@ interface BBArcadeLockToHostOptions {
|
|
|
73
115
|
redirect?: string;
|
|
74
116
|
}
|
|
75
117
|
/** Outcome of a rewarded placement. */
|
|
76
|
-
type BBArcadeRewardedAdStatus =
|
|
118
|
+
type BBArcadeRewardedAdStatus =
|
|
119
|
+
/** The player watched the ad to completion — the only status that grants. */
|
|
120
|
+
'viewed'
|
|
121
|
+
/** The player closed the ad early, or a prepared ad ended before it was ever shown. */
|
|
122
|
+
| 'dismissed'
|
|
123
|
+
/** An ad is ready to show (preparation result; terminal only in the legacy shown-but-unreported edge). */
|
|
124
|
+
| 'ready'
|
|
125
|
+
/** No ad could be offered. */
|
|
126
|
+
| 'unavailable'
|
|
127
|
+
/** The placement failed. */
|
|
128
|
+
| 'error';
|
|
77
129
|
/** Resolution value of rewardedAd()/showRewardedAd(). */
|
|
78
130
|
interface BBArcadeRewardedAdResult {
|
|
79
131
|
/** 'viewed' is the only status that should grant the reward. */
|
|
@@ -196,6 +248,15 @@ interface BBArcadeMpJoinOptions {
|
|
|
196
248
|
code?: string;
|
|
197
249
|
/** Create a new room (a share code is generated for you). */
|
|
198
250
|
create?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* Public quick match: join your game's open public room, or start a fresh
|
|
253
|
+
* one when no seat is free. Mutually exclusive with code/create. Rooms fill
|
|
254
|
+
* to the game's player cap (at most 64), and a matched room still exposes
|
|
255
|
+
* room.code so the player can invite friends into the same room. If the
|
|
256
|
+
* last free seat is lost to a race, the SDK re-matchmakes automatically a
|
|
257
|
+
* couple of times before rejecting.
|
|
258
|
+
*/
|
|
259
|
+
match?: boolean;
|
|
199
260
|
/**
|
|
200
261
|
* Game-defined, JSON-serializable data supplied to the authoritative module
|
|
201
262
|
* before it admits this player (avatar/loadout/mode selections, for example).
|
|
@@ -210,6 +271,12 @@ interface BBArcadeMpJoinOptions {
|
|
|
210
271
|
*/
|
|
211
272
|
roomUrl?: string;
|
|
212
273
|
ticket?: string;
|
|
274
|
+
/**
|
|
275
|
+
* How long to wait for the room server's welcome before the join rejects
|
|
276
|
+
* with code 'error', in ms (default 10000, clamped to 1000–60000). Applies
|
|
277
|
+
* to the initial join and to every automatic reconnect attempt.
|
|
278
|
+
*/
|
|
279
|
+
timeoutMs?: number;
|
|
213
280
|
}
|
|
214
281
|
/** Events a multiplayer room emits. */
|
|
215
282
|
interface BBArcadeMpRoomEvents {
|
|
@@ -255,6 +322,13 @@ interface BBArcadeMpRoom {
|
|
|
255
322
|
state: unknown;
|
|
256
323
|
/** Whether the room currently has an accepted WebSocket connection. */
|
|
257
324
|
connected: boolean;
|
|
325
|
+
/**
|
|
326
|
+
* Join-handshake latency in ms (socket open → server welcome, so ticket
|
|
327
|
+
* verification plus one round trip), refreshed on every successful
|
|
328
|
+
* (re)connect; null until the first welcome arrives. A tuning estimate for
|
|
329
|
+
* render-side smoothing, not a measured RTT.
|
|
330
|
+
*/
|
|
331
|
+
latencyMs: number | null;
|
|
258
332
|
/** Send an INPUT to the server (never authoritative state — the server simulates). */
|
|
259
333
|
send(input: unknown): void;
|
|
260
334
|
/** Like send(), but reports false when disconnected or the socket raced closed. */
|
|
@@ -286,6 +360,9 @@ interface BBArcadeSDK {
|
|
|
286
360
|
/**
|
|
287
361
|
* Refuse to run unless embedded on a Bounty Board host (anti
|
|
288
362
|
* scrape-and-reupload deterrent — not DRM). Call once, early, before boot.
|
|
363
|
+
* When the embedder can't be determined from browser signals (opaque-origin
|
|
364
|
+
* sandboxed builds), the SDK asks the embedding page and blocks if nothing
|
|
365
|
+
* answers within ~5 seconds.
|
|
289
366
|
*/
|
|
290
367
|
lockToHost(options?: BBArcadeLockToHostOptions): void;
|
|
291
368
|
/**
|
|
@@ -353,14 +430,23 @@ interface BBArcadeSDK {
|
|
|
353
430
|
* Save the player's progress (a string blob, ~1MB max) to their Bounty Board
|
|
354
431
|
* account. Requires a Bounty-Board-hosted build upload and a logged-in
|
|
355
432
|
* player; rejects with a BBArcadeError otherwise (code 'unsupported' /
|
|
356
|
-
* 'unauthenticated' / 'too_large' / 'rejected' / 'error').
|
|
433
|
+
* 'unauthenticated' / 'too_large' / 'rejected' / 'error'). Oversized blobs
|
|
434
|
+
* reject 'too_large' immediately (client-side precheck against the same 1
|
|
435
|
+
* MiB cap the server enforces); a host that never answers rejects 'error'
|
|
436
|
+
* after a 15s request timeout.
|
|
357
437
|
*/
|
|
358
438
|
save(blob: string): Promise<void>;
|
|
359
439
|
/**
|
|
360
440
|
* Load the player's saved blob. Resolves with the string, or null when there
|
|
361
|
-
* is no save yet. Rejects like save() ('unauthenticated' when logged out
|
|
441
|
+
* is no save yet. Rejects like save() ('unauthenticated' when logged out,
|
|
442
|
+
* 'error' after the same 15s request timeout when no host answers).
|
|
362
443
|
*/
|
|
363
444
|
load(): Promise<string | null>;
|
|
445
|
+
/**
|
|
446
|
+
* localStorage compatibility for hosted builds — the escape hatch for engine
|
|
447
|
+
* exports that can't be rewired to call save()/load(). See BBArcadeStorage.
|
|
448
|
+
*/
|
|
449
|
+
storage: BBArcadeStorage;
|
|
364
450
|
/**
|
|
365
451
|
* The logged-in player's display identity for in-game UI. Resolves
|
|
366
452
|
* { name, avatarUrl } once the host's config handshake completes (or after
|
|
@@ -369,6 +455,15 @@ interface BBArcadeSDK {
|
|
|
369
455
|
* display name + avatar only, never ids, emails, or roles.
|
|
370
456
|
*/
|
|
371
457
|
getPlayer(): Promise<BBArcadePlayer | null>;
|
|
458
|
+
/**
|
|
459
|
+
* Subscribe to CHANGES in the player's display identity (e.g. the player
|
|
460
|
+
* logs in or out mid-session and the host pushes a fresh config). The
|
|
461
|
+
* handler runs only when the identity actually changes — subscribing does
|
|
462
|
+
* not replay the current value; call getPlayer() for that. Receives the
|
|
463
|
+
* same { name, avatarUrl } | null shape as getPlayer(). Returns an
|
|
464
|
+
* unsubscribe function.
|
|
465
|
+
*/
|
|
466
|
+
onPlayerChange(handler: (player: BBArcadePlayer | null) => void): () => void;
|
|
372
467
|
/**
|
|
373
468
|
* Deterministic A/B variant for this player (even split; stable per
|
|
374
469
|
* player+game+key). Resolves the alphabetically-first variant as the
|
|
@@ -386,4 +481,4 @@ interface BBArcadeSDK {
|
|
|
386
481
|
version: number;
|
|
387
482
|
}
|
|
388
483
|
|
|
389
|
-
export type { BBArcadeSDK as B, BBArcadeConfig as a, BBArcadeError as b, BBArcadeErrorCode as c, BBArcadeLockToHostOptions as d, BBArcadePlayer as e, BBArcadePrepareRewardedAdOptions as f, BBArcadePreparedRewardedAd as g, BBArcadeRewardedAdOptions as h, BBArcadeRewardedAdPreparation as i, BBArcadeRewardedAdPrepareFailure as j, BBArcadeRewardedAdResult as k, BBArcadeRewardedAdStatus as l, BBArcadeRewardedAdsConfig as m, BBArcadeRewardedBreakOptions as n,
|
|
484
|
+
export type { BBArcadeSDK as B, BBArcadeConfig as a, BBArcadeError as b, BBArcadeErrorCode as c, BBArcadeLockToHostOptions as d, BBArcadePlayer as e, BBArcadePrepareRewardedAdOptions as f, BBArcadePreparedRewardedAd as g, BBArcadeRewardedAdOptions as h, BBArcadeRewardedAdPreparation as i, BBArcadeRewardedAdPrepareFailure as j, BBArcadeRewardedAdResult as k, BBArcadeRewardedAdStatus as l, BBArcadeRewardedAdsConfig as m, BBArcadeRewardedBreakOptions as n, BBArcadeStorage as o, BBArcadeStorageMode as p, BBArcadeMpJoinOptions as q, BBArcadeMpRoom as r, BBArcadeMultiplayer as s, BBArcadeMpPlayer as t, BBArcadeMpResult as u, BBArcadeMpRoomEvents as v };
|