@bountyboard/arcade-sdk 1.0.1 → 1.1.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.
@@ -5,6 +5,7 @@ import {
5
5
  // src/multiplayer.ts
6
6
  var WELCOME_TIMEOUT_MS = 1e4;
7
7
  var MAX_RECONNECT_ATTEMPTS = 3;
8
+ var MAX_JOIN_DATA_BYTES = 1024;
8
9
  var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
9
10
  var CODE_LENGTH = 4;
10
11
  function sdkError(code) {
@@ -26,15 +27,42 @@ function randomChars(length) {
26
27
  function generateCode() {
27
28
  return randomChars(CODE_LENGTH);
28
29
  }
29
- var guestKey = null;
30
- function getGuestKey() {
31
- if (!guestKey) guestKey = randomChars(16);
32
- return guestKey;
30
+ function utf8ByteLength(value) {
31
+ let bytes = 0;
32
+ for (let i = 0; i < value.length; i++) {
33
+ const code = value.charCodeAt(i);
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;
45
+ }
46
+ return bytes;
47
+ }
48
+ function serializeJoinData(data) {
49
+ if (data === void 0) return null;
50
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw sdkError("rejected");
51
+ let serialized;
52
+ try {
53
+ serialized = JSON.stringify(data);
54
+ } catch (e) {
55
+ throw sdkError("rejected");
56
+ }
57
+ if (!serialized || utf8ByteLength(serialized) > MAX_JOIN_DATA_BYTES) {
58
+ throw sdkError("rejected");
59
+ }
60
+ return serialized;
33
61
  }
34
62
  function requestTicket(roomId) {
35
63
  const request = BBArcade._request;
36
64
  if (typeof request !== "function") return Promise.reject(sdkError("unsupported"));
37
- return request("mp_ticket", { roomId, guestKey: getGuestKey() }).then((grant) => {
65
+ return request("mp_ticket", { roomId }).then((grant) => {
38
66
  const g = grant;
39
67
  if (!g || typeof g.ticket !== "string" || typeof g.roomUrl !== "string") {
40
68
  throw sdkError("error");
@@ -43,10 +71,11 @@ function requestTicket(roomId) {
43
71
  });
44
72
  }
45
73
  var RoomConnection = class {
46
- constructor(code, devGrant) {
74
+ constructor(code, devGrant, joinData) {
47
75
  this.playerId = "";
48
76
  this.players = [];
49
77
  this.state = null;
78
+ this.connected = false;
50
79
  this.ws = null;
51
80
  // Keyed by event name; values are erased to (unknown) => void internally and
52
81
  // retyped at the on()/emit() boundary, which keeps the public API strictly
@@ -61,12 +90,13 @@ var RoomConnection = class {
61
90
  this.reconnectAttempts = 0;
62
91
  this.code = code;
63
92
  this.devGrant = devGrant;
93
+ this.joinData = joinData;
64
94
  }
65
95
  /** Connect and resolve on the server's welcome (post-ticket-verification). */
66
96
  connect(grant) {
67
97
  return new Promise((resolve, reject) => {
68
98
  let settled = false;
69
- const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket);
99
+ const url = grant.roomUrl + (grant.roomUrl.indexOf("?") === -1 ? "?" : "&") + "ticket=" + encodeURIComponent(grant.ticket) + (this.joinData === null ? "" : "&join=" + encodeURIComponent(this.joinData));
70
100
  let ws;
71
101
  try {
72
102
  ws = new WebSocket(url);
@@ -94,15 +124,18 @@ var RoomConnection = class {
94
124
  }
95
125
  if (!msg || typeof msg.t !== "string") return;
96
126
  if (msg.t === "welcome") {
127
+ const reconnected = this.reconnectAttempts > 0;
97
128
  this.playerId = String((_a = msg.playerId) != null ? _a : "");
98
129
  this.players = Array.isArray(msg.players) ? msg.players : [];
99
130
  this.state = (_b = msg.state) != null ? _b : null;
131
+ this.connected = true;
100
132
  this.reconnectAttempts = 0;
101
133
  if (!settled) {
102
134
  settled = true;
103
135
  clearTimeout(welcomeTimer);
104
136
  resolve(this);
105
137
  }
138
+ if (reconnected) this.emit("connection", { connected: true, reconnecting: false });
106
139
  return;
107
140
  }
108
141
  if (msg.t === "snapshot") {
@@ -152,6 +185,7 @@ var RoomConnection = class {
152
185
  }
153
186
  };
154
187
  ws.onclose = () => {
188
+ this.connected = false;
155
189
  if (!settled) {
156
190
  settled = true;
157
191
  clearTimeout(welcomeTimer);
@@ -159,6 +193,7 @@ var RoomConnection = class {
159
193
  return;
160
194
  }
161
195
  if (this.intentionalClose || this.ended || this.terminal) {
196
+ this.emit("connection", { connected: false, reconnecting: false });
162
197
  this.emit("close", { reconnecting: false });
163
198
  return;
164
199
  }
@@ -176,6 +211,7 @@ var RoomConnection = class {
176
211
  return;
177
212
  }
178
213
  this.reconnectAttempts += 1;
214
+ this.emit("connection", { connected: false, reconnecting: true });
179
215
  this.emit("close", { reconnecting: true });
180
216
  await new Promise((r) => setTimeout(r, 400 * this.reconnectAttempts));
181
217
  if (this.intentionalClose || this.ended || this.terminal) return;
@@ -189,11 +225,16 @@ var RoomConnection = class {
189
225
  }
190
226
  }
191
227
  send(input) {
192
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
228
+ this.trySend(input);
229
+ }
230
+ trySend(input) {
231
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
193
232
  this.seq += 1;
194
233
  try {
195
234
  this.ws.send(JSON.stringify({ t: "input", seq: this.seq, data: input }));
235
+ return true;
196
236
  } catch (err) {
237
+ return false;
197
238
  }
198
239
  }
199
240
  on(event, handler) {
@@ -209,6 +250,7 @@ var RoomConnection = class {
209
250
  leave() {
210
251
  var _a;
211
252
  this.intentionalClose = true;
253
+ this.connected = false;
212
254
  try {
213
255
  (_a = this.ws) == null ? void 0 : _a.close(1e3);
214
256
  } catch (err) {
@@ -234,11 +276,17 @@ function joinRoom(options) {
234
276
  return Promise.reject(sdkError("unsupported"));
235
277
  }
236
278
  const code = opts.create || !opts.code ? generateCode() : opts.code.toUpperCase();
279
+ let joinData;
280
+ try {
281
+ joinData = serializeJoinData(opts.joinData);
282
+ } catch (err) {
283
+ return Promise.reject(err);
284
+ }
237
285
  if (opts.roomUrl && opts.ticket) {
238
286
  const devGrant = { ticket: opts.ticket, roomUrl: opts.roomUrl, expiresAt: 0 };
239
- return new RoomConnection(code, devGrant).connect(devGrant);
287
+ return new RoomConnection(code, devGrant, joinData).connect(devGrant);
240
288
  }
241
- return requestTicket(code).then((grant) => new RoomConnection(code, null).connect(grant));
289
+ return requestTicket(code).then((grant) => new RoomConnection(code, null, joinData).connect(grant));
242
290
  }
243
291
  var multiplayer = { joinRoom };
244
292
  export {
@@ -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;\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\n// Stable for the page load so a GUEST's reconnect (which mints a fresh ticket)\n// resumes the same room seat: the ticket API folds this key into the guest\n// subject instead of rolling a new random one per ticket. Logged-in players\n// don't need it (their subject is the stable profile id).\nlet guestKey: string | null = null;\nfunction getGuestKey(): string {\n if (!guestKey) guestKey = randomChars(16);\n return guestKey;\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, guestKey: getGuestKey() }).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\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\n constructor(code: string, devGrant: TicketGrant | null) {\n this.code = code;\n this.devGrant = devGrant;\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 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 this.playerId = String(msg.playerId ?? '');\n this.players = Array.isArray(msg.players) ? (msg.players as BBArcadeMpPlayer[]) : [];\n this.state = msg.state ?? null;\n this.reconnectAttempts = 0;\n if (!settled) {\n settled = true;\n clearTimeout(welcomeTimer);\n resolve(this);\n }\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 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('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('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 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;\n this.seq += 1;\n try {\n this.ws.send(JSON.stringify({ t: 'input', seq: this.seq, data: input }));\n } catch (err) {\n /* socket raced shut — the close handler owns recovery */\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 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\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).connect(devGrant);\n }\n\n return requestTicket(code).then(grant => new RoomConnection(code, null).connect(grant));\n}\n\nexport const multiplayer: BBArcadeMultiplayer = { joinRoom };\n"],"mappings":";;;;;AAiCA,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAE/B,IAAM,gBAAgB;AACtB,IAAM,cAAc;AAYpB,SAAS,SAAS,MAAmC;AACnD,QAAM,MAAM,IAAI,MAAM,IAAI;AAC1B,MAAI,OAAO;AACX,SAAO;AACT;AAEA,SAAS,YAAY,QAAwB;AAC3C,QAAM,UAAU,IAAI,WAAW,MAAM;AACrC,MAAI,OAAO,WAAW,eAAe,OAAO,OAAO,oBAAoB,YAAY;AACjF,WAAO,gBAAgB,OAAO;AAAA,EAChC,OAAO;AACL,aAAS,IAAI,GAAG,IAAI,QAAQ,IAAK,SAAQ,CAAC,IAAI,KAAK,MAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EAC9E;AACA,MAAI,MAAM;AAEV,WAAS,IAAI,GAAG,IAAI,QAAQ,IAAK,QAAO,cAAc,QAAQ,CAAC,IAAI,cAAc,MAAM;AACvF,SAAO;AACT;AAEA,SAAS,eAAuB;AAC9B,SAAO,YAAY,WAAW;AAChC;AAMA,IAAI,WAA0B;AAC9B,SAAS,cAAsB;AAC7B,MAAI,CAAC,SAAU,YAAW,YAAY,EAAE;AACxC,SAAO;AACT;AAGA,SAAS,cAAc,QAAsC;AAC3D,QAAM,UACJ,SACA;AACF,MAAI,OAAO,YAAY,WAAY,QAAO,QAAQ,OAAO,SAAS,aAAa,CAAC;AAChF,SAAO,QAAQ,aAAa,EAAE,QAAQ,UAAU,YAAY,EAAE,CAAC,EAAE,KAAK,WAAS;AAC7E,UAAM,IAAI;AACV,QAAI,CAAC,KAAK,OAAO,EAAE,WAAW,YAAY,OAAO,EAAE,YAAY,UAAU;AACvE,YAAM,SAAS,OAAO;AAAA,IACxB;AACA,WAAO,EAAE,QAAQ,EAAE,QAAQ,SAAS,EAAE,SAAS,WAAW,OAAO,EAAE,SAAS,KAAK,EAAE;AAAA,EACrF,CAAC;AACH;AAEA,IAAM,iBAAN,MAA+C;AAAA,EAoB7C,YAAY,MAAc,UAA8B;AAlBxD,oBAAW;AACX,mBAA8B,CAAC;AAC/B,iBAAiB;AAEjB,SAAQ,KAAuB;AAI/B;AAAA;AAAA;AAAA,SAAQ,WAAuE,CAAC;AAChF,SAAQ,MAAM;AACd,SAAQ,mBAAmB;AAC3B,SAAQ,QAAQ;AAGhB;AAAA;AAAA,SAAQ,WAAW;AACnB,SAAQ,oBAAoB;AAI1B,SAAK,OAAO;AACZ,SAAK,WAAW;AAAA,EAClB;AAAA;AAAA,EAGA,QAAQ,OAAmC;AACzC,WAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAI,UAAU;AACd,YAAM,MACJ,MAAM,WACL,MAAM,QAAQ,QAAQ,GAAG,MAAM,KAAK,MAAM,OAC3C,YACA,mBAAmB,MAAM,MAAM;AACjC,UAAI;AACJ,UAAI;AACF,aAAK,IAAI,UAAU,GAAG;AAAA,MACxB,SAAS,KAAK;AACZ,eAAO,OAAO,SAAS,OAAO,CAAC;AAAA,MACjC;AACA,WAAK,KAAK;AAEV,YAAM,eAAe,WAAW,MAAM;AACpC,YAAI,QAAS;AACb,kBAAU;AACV,aAAK,WAAW;AAChB,YAAI;AACF,aAAG,MAAM;AAAA,QACX,SAAS,KAAK;AAAA,QAEd;AACA,eAAO,SAAS,OAAO,CAAC;AAAA,MAC1B,GAAG,kBAAkB;AAErB,SAAG,YAAY,WAAS;AAvJ9B;AAwJQ,YAAI;AACJ,YAAI;AACF,gBAAM,KAAK,MAAM,OAAO,MAAM,IAAI,CAAC;AAAA,QACrC,SAAS,KAAK;AACZ;AAAA,QACF;AACA,YAAI,CAAC,OAAO,OAAO,IAAI,MAAM,SAAU;AAEvC,YAAI,IAAI,MAAM,WAAW;AACvB,eAAK,WAAW,QAAO,SAAI,aAAJ,YAAgB,EAAE;AACzC,eAAK,UAAU,MAAM,QAAQ,IAAI,OAAO,IAAK,IAAI,UAAiC,CAAC;AACnF,eAAK,SAAQ,SAAI,UAAJ,YAAa;AAC1B,eAAK,oBAAoB;AACzB,cAAI,CAAC,SAAS;AACZ,sBAAU;AACV,yBAAa,YAAY;AACzB,oBAAQ,IAAI;AAAA,UACd;AACA;AAAA,QACF;AACA,YAAI,IAAI,MAAM,YAAY;AACxB,eAAK,SAAQ,SAAI,UAAJ,YAAa,KAAK;AAC/B,eAAK,KAAK,YAAY,EAAE,MAAM,OAAO,IAAI,IAAI,KAAK,GAAG,OAAO,KAAK,MAAM,CAAC;AACxE;AAAA,QACF;AACA,YAAI,IAAI,MAAM,eAAe;AAC3B,gBAAM,SAAS,IAAI;AACnB,cAAI,UAAU,OAAO,MAAM,CAAC,KAAK,QAAQ,KAAK,OAAK,EAAE,OAAO,OAAO,EAAE,GAAG;AACtE,iBAAK,QAAQ,KAAK,MAAM;AAAA,UAC1B;AACA,eAAK,KAAK,cAAc,EAAE,OAAO,CAAC;AAClC;AAAA,QACF;AACA,YAAI,IAAI,MAAM,gBAAgB;AAC5B,gBAAM,WAAW,QAAO,SAAI,aAAJ,YAAgB,EAAE;AAC1C,eAAK,UAAU,KAAK,QAAQ,OAAO,OAAK,EAAE,OAAO,QAAQ;AACzD,eAAK,KAAK,eAAe,EAAE,SAAS,CAAC;AACrC;AAAA,QACF;AACA,YAAI,IAAI,MAAM,SAAS;AACrB,eAAK,KAAK,SAAS,IAAI,IAAI;AAC3B;AAAA,QACF;AACA,YAAI,IAAI,MAAM,aAAa;AACzB,eAAK,QAAQ;AACb,eAAK,KAAK,OAAO;AAAA,YACf,SAAS,MAAM,QAAQ,IAAI,OAAO,IAAK,IAAI,UAAiC,CAAC;AAAA,UAC/E,CAAC;AACD;AAAA,QACF;AACA,YAAI,IAAI,MAAM,SAAS;AACrB,gBAAM,OAAO,QAAO,SAAI,SAAJ,YAAY,OAAO;AACvC,cAAI,CAAC,SAAS;AAGZ,sBAAU;AACV,iBAAK,WAAW;AAChB,yBAAa,YAAY;AACzB,gBAAI;AACF,iBAAG,MAAM;AAAA,YACX,SAAS,KAAK;AAAA,YAEd;AACA,mBAAO,SAAS,SAAS,oBAAoB,oBAAoB,UAAU,CAAC;AAC5E;AAAA,UACF;AACA,eAAK,KAAK,SAAS,EAAE,KAAK,CAAC;AAAA,QAC7B;AAAA,MACF;AAEA,SAAG,UAAU,MAAM;AACjB,YAAI,CAAC,SAAS;AACZ,oBAAU;AACV,uBAAa,YAAY;AACzB,iBAAO,SAAS,OAAO,CAAC;AACxB;AAAA,QACF;AACA,YAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,UAAU;AACxD,eAAK,KAAK,SAAS,EAAE,cAAc,MAAM,CAAC;AAC1C;AAAA,QACF;AACA,aAAK,KAAK,UAAU;AAAA,MACtB;AAEA,SAAG,UAAU,MAAM;AAAA,MAEnB;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,YAA2B;AAlP3C;AAmPI,QAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,SAAU;AAC1D,QAAI,KAAK,qBAAqB,wBAAwB;AACpD,WAAK,KAAK,SAAS,EAAE,cAAc,MAAM,CAAC;AAC1C;AAAA,IACF;AACA,SAAK,qBAAqB;AAC1B,SAAK,KAAK,SAAS,EAAE,cAAc,KAAK,CAAC;AACzC,UAAM,IAAI,QAAQ,OAAK,WAAW,GAAG,MAAM,KAAK,iBAAiB,CAAC;AAGlE,QAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,SAAU;AAC1D,QAAI;AAGF,YAAM,SAAQ,UAAK,aAAL,YAAkB,MAAM,cAAc,KAAK,IAAI;AAC7D,UAAI,KAAK,oBAAoB,KAAK,SAAS,KAAK,SAAU;AAC1D,YAAM,KAAK,QAAQ,KAAK;AAExB,UAAI,KAAK,iBAAkB,MAAK,MAAM;AAAA,IACxC,SAAS,KAAK;AACZ,WAAK,KAAK,UAAU;AAAA,IACtB;AAAA,EACF;AAAA,EAEA,KAAK,OAAsB;AACzB,QAAI,CAAC,KAAK,MAAM,KAAK,GAAG,eAAe,UAAU,KAAM;AACvD,SAAK,OAAO;AACZ,QAAI;AACF,WAAK,GAAG,KAAK,KAAK,UAAU,EAAE,GAAG,SAAS,KAAK,KAAK,KAAK,MAAM,MAAM,CAAC,CAAC;AAAA,IACzE,SAAS,KAAK;AAAA,IAEd;AAAA,EACF;AAAA,EAEA,GACE,OACA,SACY;AAxRhB;AAyRI,UAAM,QAAQ,gBAAK,UAAL,mCAAyB,CAAC;AACxC,UAAM,QAAQ;AACd,SAAK,KAAK,KAAK;AACf,WAAO,MAAM;AACX,YAAM,MAAM,KAAK,QAAQ,KAAK;AAC9B,UAAI,QAAQ,GAAI,MAAK,OAAO,KAAK,CAAC;AAAA,IACpC;AAAA,EACF;AAAA,EAEA,QAAc;AAlShB;AAmSI,SAAK,mBAAmB;AACxB,QAAI;AACF,iBAAK,OAAL,mBAAS,MAAM;AAAA,IACjB,SAAS,KAAK;AAAA,IAEd;AAAA,EACF;AAAA,EAEQ,KAA2C,OAAU,MAA+B;AAC1F,UAAM,OAAO,KAAK,SAAS,KAAK;AAChC,QAAI,CAAC,KAAM;AACX,eAAW,WAAW,KAAK,MAAM,GAAG;AAClC,UAAI;AACF,gBAAQ,IAAI;AAAA,MACd,SAAS,KAAK;AACZ,mBAAW,MAAM;AACf,gBAAM;AAAA,QACR,GAAG,CAAC;AAAA,MACN;AAAA,IACF;AAAA,EACF;AACF;AAEO,SAAS,SAAS,SAA0D;AACjF,QAAM,OAAO,4BAAW,CAAC;AACzB,MAAI,OAAO,WAAW,eAAe,OAAO,cAAc,aAAa;AACrE,WAAO,QAAQ,OAAO,SAAS,aAAa,CAAC;AAAA,EAC/C;AAEA,QAAM,OAAO,KAAK,UAAU,CAAC,KAAK,OAAO,aAAa,IAAI,KAAK,KAAK,YAAY;AAGhF,MAAI,KAAK,WAAW,KAAK,QAAQ;AAC/B,UAAM,WAAwB,EAAE,QAAQ,KAAK,QAAQ,SAAS,KAAK,SAAS,WAAW,EAAE;AACzF,WAAO,IAAI,eAAe,MAAM,QAAQ,EAAE,QAAQ,QAAQ;AAAA,EAC5D;AAEA,SAAO,cAAc,IAAI,EAAE,KAAK,WAAS,IAAI,eAAe,MAAM,IAAI,EAAE,QAAQ,KAAK,CAAC;AACxF;AAEO,IAAM,cAAmC,EAAE,SAAS;","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 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":[]}
@@ -196,6 +196,13 @@ interface BBArcadeMpJoinOptions {
196
196
  code?: string;
197
197
  /** Create a new room (a share code is generated for you). */
198
198
  create?: boolean;
199
+ /**
200
+ * Game-defined, JSON-serializable data supplied to the authoritative module
201
+ * before it admits this player (avatar/loadout/mode selections, for example).
202
+ * The room server treats this as untrusted input and limits it to 1 KiB.
203
+ * Never put credentials or other secrets here.
204
+ */
205
+ joinData?: Readonly<Record<string, unknown>>;
199
206
  /**
200
207
  * Local development only: connect straight to a room server (e.g. wrangler
201
208
  * dev with DEV_ALLOW_UNSIGNED=1) instead of asking the Bounty Board host for
@@ -226,6 +233,11 @@ interface BBArcadeMpRoomEvents {
226
233
  error: {
227
234
  code: string;
228
235
  };
236
+ /** Connection state, including successful automatic reconnection. */
237
+ connection: {
238
+ connected: boolean;
239
+ reconnecting: boolean;
240
+ };
229
241
  /** Connection dropped. reconnecting: true means the SDK is retrying. */
230
242
  close: {
231
243
  reconnecting: boolean;
@@ -241,8 +253,12 @@ interface BBArcadeMpRoom {
241
253
  players: BBArcadeMpPlayer[];
242
254
  /** Latest authoritative state snapshot (as filtered for you). */
243
255
  state: unknown;
256
+ /** Whether the room currently has an accepted WebSocket connection. */
257
+ connected: boolean;
244
258
  /** Send an INPUT to the server (never authoritative state — the server simulates). */
245
259
  send(input: unknown): void;
260
+ /** Like send(), but reports false when disconnected or the socket raced closed. */
261
+ trySend(input: unknown): boolean;
246
262
  /** Subscribe to a room event; returns an unsubscribe function. */
247
263
  on<K extends keyof BBArcadeMpRoomEvents>(event: K, handler: (data: BBArcadeMpRoomEvents[K]) => void): () => void;
248
264
  /** Leave the room and close the connection (no reconnect). */
@@ -196,6 +196,13 @@ interface BBArcadeMpJoinOptions {
196
196
  code?: string;
197
197
  /** Create a new room (a share code is generated for you). */
198
198
  create?: boolean;
199
+ /**
200
+ * Game-defined, JSON-serializable data supplied to the authoritative module
201
+ * before it admits this player (avatar/loadout/mode selections, for example).
202
+ * The room server treats this as untrusted input and limits it to 1 KiB.
203
+ * Never put credentials or other secrets here.
204
+ */
205
+ joinData?: Readonly<Record<string, unknown>>;
199
206
  /**
200
207
  * Local development only: connect straight to a room server (e.g. wrangler
201
208
  * dev with DEV_ALLOW_UNSIGNED=1) instead of asking the Bounty Board host for
@@ -226,6 +233,11 @@ interface BBArcadeMpRoomEvents {
226
233
  error: {
227
234
  code: string;
228
235
  };
236
+ /** Connection state, including successful automatic reconnection. */
237
+ connection: {
238
+ connected: boolean;
239
+ reconnecting: boolean;
240
+ };
229
241
  /** Connection dropped. reconnecting: true means the SDK is retrying. */
230
242
  close: {
231
243
  reconnecting: boolean;
@@ -241,8 +253,12 @@ interface BBArcadeMpRoom {
241
253
  players: BBArcadeMpPlayer[];
242
254
  /** Latest authoritative state snapshot (as filtered for you). */
243
255
  state: unknown;
256
+ /** Whether the room currently has an accepted WebSocket connection. */
257
+ connected: boolean;
244
258
  /** Send an INPUT to the server (never authoritative state — the server simulates). */
245
259
  send(input: unknown): void;
260
+ /** Like send(), but reports false when disconnected or the socket raced closed. */
261
+ trySend(input: unknown): boolean;
246
262
  /** Subscribe to a room event; returns an unsubscribe function. */
247
263
  on<K extends keyof BBArcadeMpRoomEvents>(event: K, handler: (data: BBArcadeMpRoomEvents[K]) => void): () => void;
248
264
  /** Leave the room and close the connection (no reconnect). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bountyboard/arcade-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Bounty Board Arcade SDK — leaderboards, cloud saves, rewarded ads, A/B variants, and multiplayer rooms for games on bountyboard.gg",
5
5
  "keywords": [
6
6
  "bountyboard",
@@ -45,12 +45,14 @@
45
45
  "dist",
46
46
  "AGENTS.md",
47
47
  "docs",
48
- "README.md"
48
+ "README.md",
49
+ "CHANGELOG.md"
49
50
  ],
50
51
  "sideEffects": true,
51
52
  "scripts": {
52
53
  "build": "tsup && node scripts/build-global-dts.mjs",
53
- "clean": "rm -rf dist"
54
+ "clean": "rm -rf dist",
55
+ "prepack": "pnpm build"
54
56
  },
55
57
  "devDependencies": {
56
58
  "tsup": "^8.0.0",