@aprovan/patchwork-image-boardgameio 0.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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +559 -0
- package/dist/index.js +1392 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
// src/context.ts
|
|
2
|
+
var getReact = () => {
|
|
3
|
+
const win = window;
|
|
4
|
+
if (!win.React) {
|
|
5
|
+
throw new Error(
|
|
6
|
+
"[boardgameio] React not found on window. Ensure React is preloaded."
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
return win.React;
|
|
10
|
+
};
|
|
11
|
+
var SettingsContext = null;
|
|
12
|
+
function getSettingsContext() {
|
|
13
|
+
if (!SettingsContext) {
|
|
14
|
+
SettingsContext = getReact().createContext({});
|
|
15
|
+
}
|
|
16
|
+
return SettingsContext;
|
|
17
|
+
}
|
|
18
|
+
function SettingsProvider({
|
|
19
|
+
settings,
|
|
20
|
+
children
|
|
21
|
+
}) {
|
|
22
|
+
const React = getReact();
|
|
23
|
+
const Context = getSettingsContext();
|
|
24
|
+
return React.createElement(
|
|
25
|
+
Context.Provider,
|
|
26
|
+
{ value: settings },
|
|
27
|
+
children
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
function useSettings() {
|
|
31
|
+
const React = getReact();
|
|
32
|
+
const Context = getSettingsContext();
|
|
33
|
+
return React.useContext(Context);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/p2p/authentication.ts
|
|
37
|
+
function generateCredentials() {
|
|
38
|
+
const bytes = new Uint8Array(64);
|
|
39
|
+
crypto.getRandomValues(bytes);
|
|
40
|
+
return btoa(String.fromCharCode(...bytes));
|
|
41
|
+
}
|
|
42
|
+
function generateKeyPair() {
|
|
43
|
+
const privateKey = new Uint8Array(64);
|
|
44
|
+
crypto.getRandomValues(privateKey);
|
|
45
|
+
const publicKey = privateKey.slice(0, 32);
|
|
46
|
+
return {
|
|
47
|
+
publicKey: btoa(String.fromCharCode(...publicKey)),
|
|
48
|
+
privateKey: btoa(String.fromCharCode(...privateKey))
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function signMessage(message, privateKey) {
|
|
52
|
+
const msgBytes = new TextEncoder().encode(message);
|
|
53
|
+
const keyBytes = Uint8Array.from(atob(privateKey), (c) => c.charCodeAt(0));
|
|
54
|
+
const signature = new Uint8Array(msgBytes.length + 64);
|
|
55
|
+
signature.set(msgBytes);
|
|
56
|
+
signature.set(keyBytes.slice(0, 64), msgBytes.length);
|
|
57
|
+
return btoa(String.fromCharCode(...signature));
|
|
58
|
+
}
|
|
59
|
+
function verifyMessage(signedMessage, publicKey, playerID) {
|
|
60
|
+
try {
|
|
61
|
+
const sigBytes = Uint8Array.from(
|
|
62
|
+
atob(signedMessage),
|
|
63
|
+
(c) => c.charCodeAt(0)
|
|
64
|
+
);
|
|
65
|
+
const keyBytes = Uint8Array.from(atob(publicKey), (c) => c.charCodeAt(0));
|
|
66
|
+
if (sigBytes.length < 64 || keyBytes.length < 32) return false;
|
|
67
|
+
const msgBytes = sigBytes.slice(0, -64);
|
|
68
|
+
const decoded = new TextDecoder().decode(msgBytes);
|
|
69
|
+
return decoded === playerID;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function authenticate(matchID, clientMetadata, db) {
|
|
75
|
+
const { playerID, credentials, message } = clientMetadata;
|
|
76
|
+
const { metadata } = db.fetch(matchID);
|
|
77
|
+
if (!metadata) return false;
|
|
78
|
+
if (playerID === null || playerID === void 0 || !(+playerID in metadata.players)) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const existingCredentials = metadata.players[+playerID]?.credentials;
|
|
82
|
+
const isMessageValid = credentials ? !!message && verifyMessage(message, credentials, playerID) : false;
|
|
83
|
+
if (!existingCredentials && isMessageValid) {
|
|
84
|
+
db.setMetadata(matchID, {
|
|
85
|
+
...metadata,
|
|
86
|
+
players: {
|
|
87
|
+
...metadata.players,
|
|
88
|
+
[+playerID]: { ...metadata.players[+playerID], credentials }
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (!existingCredentials && !credentials) return true;
|
|
94
|
+
if (existingCredentials === credentials && isMessageValid) return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/p2p/db.ts
|
|
99
|
+
var P2PDB = class {
|
|
100
|
+
constructor() {
|
|
101
|
+
this.initialState = /* @__PURE__ */ new Map();
|
|
102
|
+
this.state = /* @__PURE__ */ new Map();
|
|
103
|
+
this.log = /* @__PURE__ */ new Map();
|
|
104
|
+
this.metadata = /* @__PURE__ */ new Map();
|
|
105
|
+
}
|
|
106
|
+
connect() {
|
|
107
|
+
}
|
|
108
|
+
createMatch(matchID, opts) {
|
|
109
|
+
this.initialState.set(matchID, opts.initialState);
|
|
110
|
+
this.state.set(matchID, opts.initialState);
|
|
111
|
+
this.log.set(matchID, opts.initialState._stateID === 0 ? [] : []);
|
|
112
|
+
this.metadata.set(matchID, opts.metadata);
|
|
113
|
+
}
|
|
114
|
+
setState(matchID, state, deltalog) {
|
|
115
|
+
this.state.set(matchID, state);
|
|
116
|
+
if (deltalog) {
|
|
117
|
+
const existing = this.log.get(matchID) ?? [];
|
|
118
|
+
this.log.set(matchID, [...existing, ...deltalog]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
setMetadata(matchID, metadata) {
|
|
122
|
+
this.metadata.set(matchID, metadata);
|
|
123
|
+
}
|
|
124
|
+
fetch(matchID) {
|
|
125
|
+
return {
|
|
126
|
+
state: this.state.get(matchID),
|
|
127
|
+
initialState: this.initialState.get(matchID),
|
|
128
|
+
log: this.log.get(matchID),
|
|
129
|
+
metadata: this.metadata.get(matchID)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
wipe(matchID) {
|
|
133
|
+
this.initialState.delete(matchID);
|
|
134
|
+
this.state.delete(matchID);
|
|
135
|
+
this.log.delete(matchID);
|
|
136
|
+
this.metadata.delete(matchID);
|
|
137
|
+
}
|
|
138
|
+
listMatches() {
|
|
139
|
+
return [...this.metadata.keys()];
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// src/p2p/session-storage.ts
|
|
144
|
+
var STORAGE_PREFIX = "p2p-session:";
|
|
145
|
+
var SESSION_TTL_MS = 5 * 60 * 1e3;
|
|
146
|
+
function getStorageKey(matchID) {
|
|
147
|
+
return `${STORAGE_PREFIX}${matchID}`;
|
|
148
|
+
}
|
|
149
|
+
function hasValidSession(matchID) {
|
|
150
|
+
const session = loadSession(matchID);
|
|
151
|
+
return session !== null;
|
|
152
|
+
}
|
|
153
|
+
function loadSession(matchID) {
|
|
154
|
+
try {
|
|
155
|
+
const key = getStorageKey(matchID);
|
|
156
|
+
const raw = localStorage.getItem(key);
|
|
157
|
+
if (!raw) return null;
|
|
158
|
+
const session = JSON.parse(raw);
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (now - session.updatedAt > SESSION_TTL_MS) {
|
|
161
|
+
console.log("[P2PSessionStorage] Session expired, removing:", matchID);
|
|
162
|
+
removeSession(matchID);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
console.log("[P2PSessionStorage] Loaded session:", {
|
|
166
|
+
matchID,
|
|
167
|
+
stateID: session.state._stateID,
|
|
168
|
+
age: Math.round((now - session.updatedAt) / 1e3) + "s"
|
|
169
|
+
});
|
|
170
|
+
return session;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error("[P2PSessionStorage] Failed to load session:", err);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function saveSession(session) {
|
|
177
|
+
try {
|
|
178
|
+
const key = getStorageKey(session.matchID);
|
|
179
|
+
session.updatedAt = Date.now();
|
|
180
|
+
const serialized = JSON.stringify(session);
|
|
181
|
+
localStorage.setItem(key, serialized);
|
|
182
|
+
console.log("[P2PSessionStorage] Saved session:", {
|
|
183
|
+
matchID: session.matchID,
|
|
184
|
+
stateID: session.state._stateID,
|
|
185
|
+
gameName: session.gameName,
|
|
186
|
+
sizeBytes: serialized.length
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error("[P2PSessionStorage] Failed to save session:", err);
|
|
190
|
+
if (err instanceof DOMException && err.name === "QuotaExceededError") {
|
|
191
|
+
console.warn("[P2PSessionStorage] Storage quota exceeded, cleaning up old sessions");
|
|
192
|
+
cleanupExpiredSessions();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function updateSessionState(matchID, state, log) {
|
|
197
|
+
const session = loadSession(matchID);
|
|
198
|
+
if (!session) return;
|
|
199
|
+
session.state = state;
|
|
200
|
+
if (log) session.log = log;
|
|
201
|
+
saveSession(session);
|
|
202
|
+
}
|
|
203
|
+
function removeSession(matchID) {
|
|
204
|
+
try {
|
|
205
|
+
const key = getStorageKey(matchID);
|
|
206
|
+
localStorage.removeItem(key);
|
|
207
|
+
console.log("[P2PSessionStorage] Removed session:", matchID);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error("[P2PSessionStorage] Failed to remove session:", err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function cleanupExpiredSessions() {
|
|
213
|
+
try {
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const keysToRemove = [];
|
|
216
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
217
|
+
const key = localStorage.key(i);
|
|
218
|
+
if (!key?.startsWith(STORAGE_PREFIX)) continue;
|
|
219
|
+
try {
|
|
220
|
+
const raw = localStorage.getItem(key);
|
|
221
|
+
if (!raw) continue;
|
|
222
|
+
const session = JSON.parse(raw);
|
|
223
|
+
if (now - session.updatedAt > SESSION_TTL_MS) {
|
|
224
|
+
keysToRemove.push(key);
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
keysToRemove.push(key);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const key of keysToRemove) {
|
|
231
|
+
localStorage.removeItem(key);
|
|
232
|
+
console.log("[P2PSessionStorage] Cleaned up expired session:", key);
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error("[P2PSessionStorage] Cleanup failed:", err);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/p2p/host.ts
|
|
240
|
+
var P2PHost = class {
|
|
241
|
+
constructor({
|
|
242
|
+
game,
|
|
243
|
+
numPlayers = 2,
|
|
244
|
+
matchID
|
|
245
|
+
}) {
|
|
246
|
+
this.clients = /* @__PURE__ */ new Map();
|
|
247
|
+
this.hostClient = null;
|
|
248
|
+
this.matchID = matchID;
|
|
249
|
+
this.game = game;
|
|
250
|
+
this.numPlayers = numPlayers;
|
|
251
|
+
this.db = new P2PDB();
|
|
252
|
+
const gameName = game.name ?? "unknown";
|
|
253
|
+
console.log("[P2PHost] Checking for existing session:", { matchID, gameName });
|
|
254
|
+
const existingSession = loadSession(matchID);
|
|
255
|
+
if (existingSession) {
|
|
256
|
+
console.log("[P2PHost] Found existing session:", {
|
|
257
|
+
matchID,
|
|
258
|
+
sessionGameName: existingSession.gameName,
|
|
259
|
+
expectedGameName: gameName,
|
|
260
|
+
stateID: existingSession.state._stateID
|
|
261
|
+
});
|
|
262
|
+
if (existingSession.gameName === gameName) {
|
|
263
|
+
console.log("[P2PHost] Restoring session from storage");
|
|
264
|
+
this.state = existingSession.state;
|
|
265
|
+
this.db.createMatch(matchID, {
|
|
266
|
+
initialState: existingSession.initialState,
|
|
267
|
+
metadata: existingSession.metadata
|
|
268
|
+
});
|
|
269
|
+
this.db.setState(matchID, existingSession.state, existingSession.log);
|
|
270
|
+
saveSession(existingSession);
|
|
271
|
+
return;
|
|
272
|
+
} else {
|
|
273
|
+
console.log("[P2PHost] Game name mismatch, creating new session");
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
console.log("[P2PHost] No existing session found, creating new");
|
|
277
|
+
}
|
|
278
|
+
const initialState = this.createInitialState();
|
|
279
|
+
this.state = initialState;
|
|
280
|
+
const players = {};
|
|
281
|
+
for (let i = 0; i < numPlayers; i++) {
|
|
282
|
+
players[i] = { id: i };
|
|
283
|
+
}
|
|
284
|
+
const metadata = {
|
|
285
|
+
gameName: game.name ?? "unknown",
|
|
286
|
+
players,
|
|
287
|
+
createdAt: Date.now(),
|
|
288
|
+
updatedAt: Date.now()
|
|
289
|
+
};
|
|
290
|
+
this.db.createMatch(matchID, {
|
|
291
|
+
initialState,
|
|
292
|
+
metadata
|
|
293
|
+
});
|
|
294
|
+
this.persistSession(initialState, [], metadata);
|
|
295
|
+
}
|
|
296
|
+
persistSession(state, log, metadata) {
|
|
297
|
+
const existingSession = loadSession(this.matchID);
|
|
298
|
+
const session = {
|
|
299
|
+
matchID: this.matchID,
|
|
300
|
+
gameName: this.game.name ?? "unknown",
|
|
301
|
+
numPlayers: this.numPlayers,
|
|
302
|
+
state,
|
|
303
|
+
initialState: existingSession?.initialState ?? state,
|
|
304
|
+
log,
|
|
305
|
+
metadata: metadata ?? existingSession?.metadata ?? {
|
|
306
|
+
gameName: this.game.name ?? "unknown",
|
|
307
|
+
players: {},
|
|
308
|
+
createdAt: Date.now(),
|
|
309
|
+
updatedAt: Date.now()
|
|
310
|
+
},
|
|
311
|
+
createdAt: existingSession?.createdAt ?? Date.now(),
|
|
312
|
+
updatedAt: Date.now()
|
|
313
|
+
};
|
|
314
|
+
saveSession(session);
|
|
315
|
+
}
|
|
316
|
+
createInitialState() {
|
|
317
|
+
const activePlayers = {};
|
|
318
|
+
for (let i = 0; i < this.numPlayers; i++) {
|
|
319
|
+
activePlayers[String(i)] = "";
|
|
320
|
+
}
|
|
321
|
+
const ctx = {
|
|
322
|
+
numPlayers: this.numPlayers,
|
|
323
|
+
turn: 1,
|
|
324
|
+
currentPlayer: "0",
|
|
325
|
+
playOrder: Array.from({ length: this.numPlayers }, (_, i) => String(i)),
|
|
326
|
+
playOrderPos: 0,
|
|
327
|
+
phase: "",
|
|
328
|
+
activePlayers
|
|
329
|
+
};
|
|
330
|
+
const G = this.game.setup?.({ ctx, ...ctx }) ?? {};
|
|
331
|
+
return {
|
|
332
|
+
G,
|
|
333
|
+
ctx,
|
|
334
|
+
plugins: {},
|
|
335
|
+
_stateID: 0,
|
|
336
|
+
_undo: [],
|
|
337
|
+
_redo: []
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
registerClient(client) {
|
|
341
|
+
if (!authenticate(this.matchID, client.metadata, this.db)) {
|
|
342
|
+
console.log(
|
|
343
|
+
"[P2PHost] Client auth failed for playerID:",
|
|
344
|
+
client.metadata.playerID
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
console.log(
|
|
349
|
+
"[P2PHost] Registered client for playerID:",
|
|
350
|
+
client.metadata.playerID
|
|
351
|
+
);
|
|
352
|
+
this.clients.set(client, client);
|
|
353
|
+
this.syncClient(client);
|
|
354
|
+
}
|
|
355
|
+
registerHostClient(client) {
|
|
356
|
+
console.log(
|
|
357
|
+
"[P2PHost] Registered host client for playerID:",
|
|
358
|
+
client.metadata.playerID
|
|
359
|
+
);
|
|
360
|
+
this.hostClient = client;
|
|
361
|
+
this.syncClient(client);
|
|
362
|
+
}
|
|
363
|
+
unregisterClient(client) {
|
|
364
|
+
this.clients.delete(client);
|
|
365
|
+
}
|
|
366
|
+
processAction(client, data) {
|
|
367
|
+
switch (data.type) {
|
|
368
|
+
case "sync":
|
|
369
|
+
this.syncClient(client);
|
|
370
|
+
break;
|
|
371
|
+
case "update":
|
|
372
|
+
this.handleUpdate(data.args);
|
|
373
|
+
break;
|
|
374
|
+
case "chat":
|
|
375
|
+
this.broadcastChat(data.args);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
syncClient(client) {
|
|
380
|
+
const { state, log } = this.db.fetch(this.matchID);
|
|
381
|
+
const playerID = client.metadata.playerID;
|
|
382
|
+
const filteredState = this.filterStateForPlayer(state, playerID);
|
|
383
|
+
client.send({
|
|
384
|
+
type: "sync",
|
|
385
|
+
args: [this.matchID, { state: filteredState, log }]
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
filterStateForPlayer(state, _playerID) {
|
|
389
|
+
return state;
|
|
390
|
+
}
|
|
391
|
+
handleUpdate([matchID, , action]) {
|
|
392
|
+
if (matchID !== this.matchID) return;
|
|
393
|
+
const currentState = this.state;
|
|
394
|
+
if (!currentState) return;
|
|
395
|
+
const moveName = action.payload?.type;
|
|
396
|
+
const moveArgs = action.payload?.args ?? [];
|
|
397
|
+
const playerID = action.payload?.playerID;
|
|
398
|
+
const ctxCurrentPlayer = currentState.ctx.currentPlayer;
|
|
399
|
+
const gCurrent = currentState.G?.current;
|
|
400
|
+
const currentPlayer = gCurrent !== void 0 ? String(gCurrent) : ctxCurrentPlayer;
|
|
401
|
+
console.log("[P2PHost] handleUpdate:", { moveName, moveArgs, playerID, currentPlayer });
|
|
402
|
+
if (playerID !== currentPlayer) {
|
|
403
|
+
console.log("[P2PHost] Rejected move: wrong player", { playerID, currentPlayer });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (moveName && this.game.moves?.[moveName]) {
|
|
407
|
+
const move = this.game.moves[moveName];
|
|
408
|
+
const ctx = { ...currentState.ctx, playerID };
|
|
409
|
+
const G = JSON.parse(JSON.stringify(currentState.G));
|
|
410
|
+
console.log(
|
|
411
|
+
"[P2PHost] Before move, G.players:",
|
|
412
|
+
G.players?.map((p) => ({
|
|
413
|
+
id: p.id,
|
|
414
|
+
bet: p.bet
|
|
415
|
+
}))
|
|
416
|
+
);
|
|
417
|
+
if (typeof move === "function") {
|
|
418
|
+
move({ G, ctx }, ...moveArgs);
|
|
419
|
+
}
|
|
420
|
+
console.log(
|
|
421
|
+
"[P2PHost] After move, G.players:",
|
|
422
|
+
G.players?.map((p) => ({
|
|
423
|
+
id: p.id,
|
|
424
|
+
bet: p.bet
|
|
425
|
+
}))
|
|
426
|
+
);
|
|
427
|
+
const newState = {
|
|
428
|
+
...currentState,
|
|
429
|
+
G,
|
|
430
|
+
_stateID: currentState._stateID + 1
|
|
431
|
+
};
|
|
432
|
+
this.state = newState;
|
|
433
|
+
this.db.setState(this.matchID, newState);
|
|
434
|
+
const { log } = this.db.fetch(this.matchID);
|
|
435
|
+
updateSessionState(this.matchID, newState, log);
|
|
436
|
+
console.log(
|
|
437
|
+
"[P2PHost] State updated, broadcasting to",
|
|
438
|
+
this.clients.size,
|
|
439
|
+
"clients"
|
|
440
|
+
);
|
|
441
|
+
this.broadcastState();
|
|
442
|
+
} else {
|
|
443
|
+
console.log(
|
|
444
|
+
"[P2PHost] Move not found:",
|
|
445
|
+
moveName,
|
|
446
|
+
"Available:",
|
|
447
|
+
Object.keys(this.game.moves || {})
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
broadcastState() {
|
|
452
|
+
const { state, log } = this.db.fetch(this.matchID);
|
|
453
|
+
if (this.hostClient) {
|
|
454
|
+
const playerID = this.hostClient.metadata.playerID;
|
|
455
|
+
const filteredState = this.filterStateForPlayer(state, playerID);
|
|
456
|
+
this.hostClient.send({
|
|
457
|
+
type: "sync",
|
|
458
|
+
args: [this.matchID, { state: filteredState, log }]
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
for (const client of this.clients.values()) {
|
|
462
|
+
const playerID = client.metadata.playerID;
|
|
463
|
+
const filteredState = this.filterStateForPlayer(state, playerID);
|
|
464
|
+
client.send({
|
|
465
|
+
type: "sync",
|
|
466
|
+
args: [this.matchID, { state: filteredState, log }]
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
broadcastChat(args) {
|
|
471
|
+
if (this.hostClient) {
|
|
472
|
+
this.hostClient.send({ type: "chat", args });
|
|
473
|
+
}
|
|
474
|
+
for (const client of this.clients.values()) {
|
|
475
|
+
client.send({ type: "chat", args });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// src/p2p/transport.ts
|
|
481
|
+
var DEFAULT_ICE_SERVERS = [
|
|
482
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
483
|
+
{ urls: "stun:stun1.l.google.com:19302" },
|
|
484
|
+
{ urls: "stun:stun2.l.google.com:19302" },
|
|
485
|
+
// Free TURN servers from Open Relay Project
|
|
486
|
+
{
|
|
487
|
+
urls: "turn:openrelay.metered.ca:80",
|
|
488
|
+
username: "openrelayproject",
|
|
489
|
+
credential: "openrelayproject"
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
urls: "turn:openrelay.metered.ca:443",
|
|
493
|
+
username: "openrelayproject",
|
|
494
|
+
credential: "openrelayproject"
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
urls: "turn:openrelay.metered.ca:443?transport=tcp",
|
|
498
|
+
username: "openrelayproject",
|
|
499
|
+
credential: "openrelayproject"
|
|
500
|
+
}
|
|
501
|
+
];
|
|
502
|
+
var P2PTransport = class {
|
|
503
|
+
constructor(config, opts = {}) {
|
|
504
|
+
this.peer = null;
|
|
505
|
+
this.connection = null;
|
|
506
|
+
this.host = null;
|
|
507
|
+
this.connected = false;
|
|
508
|
+
this.connectionStatusCallbacks = /* @__PURE__ */ new Set();
|
|
509
|
+
this.hostRetryCount = 0;
|
|
510
|
+
this.maxHostRetries = 3;
|
|
511
|
+
this.lastPeerConfig = null;
|
|
512
|
+
this.retryCount = 0;
|
|
513
|
+
this.maxRetries = 6;
|
|
514
|
+
// Increased from 3 to handle host reconnection
|
|
515
|
+
this.retryDelayMs = 3e3;
|
|
516
|
+
console.log("[P2PTransport] Constructor called with config:", {
|
|
517
|
+
gameName: config.gameName,
|
|
518
|
+
playerID: config.playerID,
|
|
519
|
+
matchID: config.matchID,
|
|
520
|
+
numPlayers: config.numPlayers,
|
|
521
|
+
credentials: config.credentials ? "(present)" : "(none)"
|
|
522
|
+
});
|
|
523
|
+
console.log("[P2PTransport] Options:", {
|
|
524
|
+
isHost: opts.isHost
|
|
525
|
+
});
|
|
526
|
+
this.gameName = config.gameName;
|
|
527
|
+
this.playerID = config.playerID;
|
|
528
|
+
this.matchID = config.matchID;
|
|
529
|
+
this.numPlayers = config.numPlayers;
|
|
530
|
+
this.game = config.game;
|
|
531
|
+
this.transportDataCallback = config.transportDataCallback;
|
|
532
|
+
this.isHost = opts.isHost ?? false;
|
|
533
|
+
this.peerOptions = opts.peerOptions;
|
|
534
|
+
this.onError = opts.onError;
|
|
535
|
+
this.setCredentials(config.credentials);
|
|
536
|
+
}
|
|
537
|
+
get hostID() {
|
|
538
|
+
return `boardgameio-${this.gameName}-matchid-${this.matchID}`;
|
|
539
|
+
}
|
|
540
|
+
setCredentials(credentials) {
|
|
541
|
+
if (!credentials) {
|
|
542
|
+
const { publicKey, privateKey } = generateKeyPair();
|
|
543
|
+
this.credentials = publicKey;
|
|
544
|
+
this.privateKey = privateKey;
|
|
545
|
+
} else {
|
|
546
|
+
this.credentials = credentials;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
get metadata() {
|
|
550
|
+
return {
|
|
551
|
+
playerID: this.playerID,
|
|
552
|
+
credentials: this.credentials,
|
|
553
|
+
message: this.privateKey && this.playerID ? signMessage(this.playerID, this.privateKey) : void 0
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
connect() {
|
|
557
|
+
cleanupExpiredSessions();
|
|
558
|
+
const Peer = window.Peer;
|
|
559
|
+
if (!Peer) {
|
|
560
|
+
this.onError?.(new Error("PeerJS not loaded"));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const globalPeerConfig = window.__peerConfig ?? {};
|
|
564
|
+
const iceServers = globalPeerConfig.iceServers && globalPeerConfig.iceServers.length > 0 ? globalPeerConfig.iceServers : DEFAULT_ICE_SERVERS;
|
|
565
|
+
const baseConfig = {
|
|
566
|
+
host: globalPeerConfig.host,
|
|
567
|
+
port: globalPeerConfig.port,
|
|
568
|
+
path: globalPeerConfig.path,
|
|
569
|
+
secure: globalPeerConfig.secure,
|
|
570
|
+
debug: 2,
|
|
571
|
+
// Warnings and errors
|
|
572
|
+
config: {
|
|
573
|
+
iceServers
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
const peerConfig = {
|
|
577
|
+
...baseConfig,
|
|
578
|
+
...this.peerOptions,
|
|
579
|
+
config: {
|
|
580
|
+
...baseConfig.config,
|
|
581
|
+
...this.peerOptions && this.peerOptions.config ? this.peerOptions.config : {}
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
const isReconnection = this.isHost && hasValidSession(this.matchID);
|
|
585
|
+
console.log(
|
|
586
|
+
`[P2PTransport] Connecting as ${this.isHost ? "HOST" : "CLIENT"}, hostID: ${this.hostID}${isReconnection ? " (reconnecting)" : ""}`
|
|
587
|
+
);
|
|
588
|
+
if (isReconnection) {
|
|
589
|
+
console.log("[P2PTransport] Host reconnecting, waiting for PeerJS ID to clear...");
|
|
590
|
+
setTimeout(() => this.createPeer(Peer, peerConfig), 2e3);
|
|
591
|
+
} else {
|
|
592
|
+
this.createPeer(Peer, peerConfig);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
createPeer(Peer, peerConfig) {
|
|
596
|
+
this.lastPeerConfig = peerConfig;
|
|
597
|
+
this.peer = new Peer(this.isHost ? this.hostID : void 0, peerConfig);
|
|
598
|
+
this.peer.on("open", (id) => {
|
|
599
|
+
console.log(`[P2PTransport] Peer opened with ID: ${id}`);
|
|
600
|
+
if (this.isHost) {
|
|
601
|
+
this.host = new P2PHost({
|
|
602
|
+
game: this.game,
|
|
603
|
+
numPlayers: this.numPlayers,
|
|
604
|
+
matchID: this.matchID
|
|
605
|
+
});
|
|
606
|
+
this.host.registerHostClient({
|
|
607
|
+
metadata: this.metadata,
|
|
608
|
+
send: (data) => this.notifyClient(data)
|
|
609
|
+
});
|
|
610
|
+
console.log("[P2PTransport] Host ready, waiting for connections");
|
|
611
|
+
this.onConnect();
|
|
612
|
+
} else {
|
|
613
|
+
console.log(`[P2PTransport] Client connecting to host: ${this.hostID}`);
|
|
614
|
+
this.connectToHost();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
this.peer.on("connection", (conn) => {
|
|
618
|
+
console.log(
|
|
619
|
+
"[P2PTransport] Incoming connection from:",
|
|
620
|
+
conn.peer
|
|
621
|
+
);
|
|
622
|
+
const dataConn = conn;
|
|
623
|
+
if (!this.host) return;
|
|
624
|
+
const client = {
|
|
625
|
+
metadata: { playerID: null },
|
|
626
|
+
send: (data) => dataConn.send(data)
|
|
627
|
+
};
|
|
628
|
+
dataConn.on("open", () => {
|
|
629
|
+
console.log("[P2PTransport] Data connection opened");
|
|
630
|
+
this.host?.registerClient(client);
|
|
631
|
+
});
|
|
632
|
+
dataConn.on("data", (data) => {
|
|
633
|
+
const action = data;
|
|
634
|
+
if (action.type === "sync" && "metadata" in data) {
|
|
635
|
+
client.metadata = data.metadata;
|
|
636
|
+
}
|
|
637
|
+
this.host?.processAction(client, action);
|
|
638
|
+
});
|
|
639
|
+
dataConn.on("close", () => {
|
|
640
|
+
this.host?.unregisterClient(client);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
this.peer.on("error", (err) => {
|
|
644
|
+
const error = err;
|
|
645
|
+
console.error("[P2PTransport] Peer error:", error.type, error.message);
|
|
646
|
+
if (error.type === "peer-unavailable") {
|
|
647
|
+
console.error(
|
|
648
|
+
"[P2PTransport] Host peer not found. Is the host connected?"
|
|
649
|
+
);
|
|
650
|
+
this.onError?.(error);
|
|
651
|
+
} else if (error.type === "unavailable-id") {
|
|
652
|
+
if (this.isHost && this.hostRetryCount < this.maxHostRetries) {
|
|
653
|
+
this.hostRetryCount++;
|
|
654
|
+
const delay = 2e3 * this.hostRetryCount;
|
|
655
|
+
console.log(
|
|
656
|
+
`[P2PTransport] Peer ID unavailable, retrying in ${delay}ms (${this.hostRetryCount}/${this.maxHostRetries})...`
|
|
657
|
+
);
|
|
658
|
+
this.peer?.destroy();
|
|
659
|
+
this.peer = null;
|
|
660
|
+
setTimeout(() => {
|
|
661
|
+
const Peer2 = window.Peer;
|
|
662
|
+
if (Peer2 && this.lastPeerConfig) {
|
|
663
|
+
this.createPeer(Peer2, this.lastPeerConfig);
|
|
664
|
+
}
|
|
665
|
+
}, delay);
|
|
666
|
+
} else {
|
|
667
|
+
console.error("[P2PTransport] Peer ID already taken, max retries reached");
|
|
668
|
+
this.onError?.(error);
|
|
669
|
+
}
|
|
670
|
+
} else {
|
|
671
|
+
this.onError?.(error);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
this.peer.on("close", () => {
|
|
675
|
+
console.log("[P2PTransport] Peer connection closed");
|
|
676
|
+
this.connected = false;
|
|
677
|
+
this.notifyConnectionStatus(false);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
// Delay between retries
|
|
681
|
+
connectToHost() {
|
|
682
|
+
if (!this.peer) return;
|
|
683
|
+
console.log(
|
|
684
|
+
`[P2PTransport] Attempting connection to host (attempt ${this.retryCount + 1}/${this.maxRetries + 1})`
|
|
685
|
+
);
|
|
686
|
+
this.connection = this.peer.connect(this.hostID, {
|
|
687
|
+
reliable: true,
|
|
688
|
+
serialization: "json"
|
|
689
|
+
});
|
|
690
|
+
const connectionTimeout = setTimeout(() => {
|
|
691
|
+
if (!this.connected && this.connection) {
|
|
692
|
+
this.retryCount++;
|
|
693
|
+
if (this.retryCount <= this.maxRetries) {
|
|
694
|
+
console.warn(
|
|
695
|
+
`[P2PTransport] Connection timeout, retrying in ${this.retryDelayMs}ms (${this.retryCount}/${this.maxRetries})...`
|
|
696
|
+
);
|
|
697
|
+
this.connection.close();
|
|
698
|
+
setTimeout(() => this.connectToHost(), this.retryDelayMs);
|
|
699
|
+
} else {
|
|
700
|
+
console.error(
|
|
701
|
+
"[P2PTransport] Max retries reached. Could not connect to host."
|
|
702
|
+
);
|
|
703
|
+
this.onError?.(
|
|
704
|
+
new Error(
|
|
705
|
+
"Could not connect to host after multiple attempts. Is the host online?"
|
|
706
|
+
)
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}, 1e4);
|
|
711
|
+
this.connection.on("open", () => {
|
|
712
|
+
clearTimeout(connectionTimeout);
|
|
713
|
+
console.log("[P2PTransport] Connected to host successfully!");
|
|
714
|
+
this.retryCount = 0;
|
|
715
|
+
this.connection?.send({ type: "sync", metadata: this.metadata });
|
|
716
|
+
this.onConnect();
|
|
717
|
+
});
|
|
718
|
+
this.connection.on("data", (data) => {
|
|
719
|
+
this.notifyClient(data);
|
|
720
|
+
});
|
|
721
|
+
this.connection.on("close", () => {
|
|
722
|
+
clearTimeout(connectionTimeout);
|
|
723
|
+
console.log("[P2PTransport] Connection to host closed");
|
|
724
|
+
this.connected = false;
|
|
725
|
+
this.notifyConnectionStatus(false);
|
|
726
|
+
});
|
|
727
|
+
this.connection.on("error", (err) => {
|
|
728
|
+
clearTimeout(connectionTimeout);
|
|
729
|
+
const error = err;
|
|
730
|
+
console.error(
|
|
731
|
+
"[P2PTransport] Connection error:",
|
|
732
|
+
error.type,
|
|
733
|
+
error.message
|
|
734
|
+
);
|
|
735
|
+
this.onError?.(error);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
onConnect() {
|
|
739
|
+
this.connected = true;
|
|
740
|
+
this.notifyConnectionStatus(true);
|
|
741
|
+
this.requestSync();
|
|
742
|
+
}
|
|
743
|
+
notifyConnectionStatus(connected) {
|
|
744
|
+
for (const callback of this.connectionStatusCallbacks) {
|
|
745
|
+
callback(connected);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
notifyClient(data) {
|
|
749
|
+
this.transportDataCallback(data);
|
|
750
|
+
}
|
|
751
|
+
disconnect() {
|
|
752
|
+
this.connection?.close();
|
|
753
|
+
this.peer?.destroy();
|
|
754
|
+
this.peer = null;
|
|
755
|
+
this.connection = null;
|
|
756
|
+
this.host = null;
|
|
757
|
+
this.connected = false;
|
|
758
|
+
this.notifyConnectionStatus(false);
|
|
759
|
+
}
|
|
760
|
+
requestSync() {
|
|
761
|
+
if (this.isHost && this.host) {
|
|
762
|
+
this.host.processAction(
|
|
763
|
+
{
|
|
764
|
+
metadata: this.metadata,
|
|
765
|
+
send: (d) => this.notifyClient(d)
|
|
766
|
+
},
|
|
767
|
+
{ type: "sync" }
|
|
768
|
+
);
|
|
769
|
+
} else {
|
|
770
|
+
this.connection?.send({ type: "sync", metadata: this.metadata });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
sendAction(state, action) {
|
|
774
|
+
const msg = {
|
|
775
|
+
type: "update",
|
|
776
|
+
args: [this.matchID, state, action]
|
|
777
|
+
};
|
|
778
|
+
if (this.isHost && this.host) {
|
|
779
|
+
this.host.processAction(
|
|
780
|
+
{
|
|
781
|
+
metadata: this.metadata,
|
|
782
|
+
send: (d) => this.notifyClient(d)
|
|
783
|
+
},
|
|
784
|
+
msg
|
|
785
|
+
);
|
|
786
|
+
} else {
|
|
787
|
+
this.connection?.send(msg);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
sendChatMessage(matchID, chatMessage) {
|
|
791
|
+
const msg = {
|
|
792
|
+
type: "chat",
|
|
793
|
+
args: [matchID, chatMessage, this.credentials]
|
|
794
|
+
};
|
|
795
|
+
if (this.isHost && this.host) {
|
|
796
|
+
this.host.processAction(
|
|
797
|
+
{
|
|
798
|
+
metadata: this.metadata,
|
|
799
|
+
send: (d) => this.notifyClient(d)
|
|
800
|
+
},
|
|
801
|
+
msg
|
|
802
|
+
);
|
|
803
|
+
} else {
|
|
804
|
+
this.connection?.send(msg);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
updateMatchID(id) {
|
|
808
|
+
this.matchID = id;
|
|
809
|
+
this.disconnect();
|
|
810
|
+
this.connect();
|
|
811
|
+
}
|
|
812
|
+
updatePlayerID(id) {
|
|
813
|
+
this.playerID = id;
|
|
814
|
+
this.disconnect();
|
|
815
|
+
this.connect();
|
|
816
|
+
}
|
|
817
|
+
updateCredentials(credentials) {
|
|
818
|
+
this.setCredentials(credentials);
|
|
819
|
+
this.disconnect();
|
|
820
|
+
this.connect();
|
|
821
|
+
}
|
|
822
|
+
isConnected() {
|
|
823
|
+
return this.connected;
|
|
824
|
+
}
|
|
825
|
+
subscribeToConnectionStatus(callback) {
|
|
826
|
+
this.connectionStatusCallbacks.add(callback);
|
|
827
|
+
callback(this.connected);
|
|
828
|
+
return () => {
|
|
829
|
+
this.connectionStatusCallbacks.delete(callback);
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
function createP2PTransport(opts = {}) {
|
|
834
|
+
return (config) => new P2PTransport(config, opts);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/bot-manager.ts
|
|
838
|
+
var DIFFICULTY_PRESETS = {
|
|
839
|
+
easy: {
|
|
840
|
+
strategy: "random",
|
|
841
|
+
mctsIterations: 0,
|
|
842
|
+
delay: 400,
|
|
843
|
+
minDelay: 200,
|
|
844
|
+
mistakeRate: 0.3
|
|
845
|
+
},
|
|
846
|
+
medium: {
|
|
847
|
+
strategy: "mcts",
|
|
848
|
+
mctsIterations: 500,
|
|
849
|
+
delay: 800,
|
|
850
|
+
minDelay: 400,
|
|
851
|
+
mistakeRate: 0.1
|
|
852
|
+
},
|
|
853
|
+
hard: {
|
|
854
|
+
strategy: "mcts",
|
|
855
|
+
mctsIterations: 2e3,
|
|
856
|
+
delay: 1200,
|
|
857
|
+
minDelay: 600,
|
|
858
|
+
mistakeRate: 0
|
|
859
|
+
},
|
|
860
|
+
custom: {
|
|
861
|
+
strategy: "mcts",
|
|
862
|
+
mctsIterations: 1e3,
|
|
863
|
+
delay: 800,
|
|
864
|
+
minDelay: 0,
|
|
865
|
+
mistakeRate: 0
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
function resolveBotConfig(inputs, gameAiDifficulty) {
|
|
869
|
+
const difficulty = inputs.botDifficulty ?? "medium";
|
|
870
|
+
const globalPreset = DIFFICULTY_PRESETS[difficulty];
|
|
871
|
+
const gamePreset = gameAiDifficulty?.[difficulty] ?? {};
|
|
872
|
+
return {
|
|
873
|
+
strategy: inputs.botStrategy ?? gamePreset.strategy ?? globalPreset.strategy,
|
|
874
|
+
mctsIterations: inputs.mctsIterations ?? gamePreset.mctsIterations ?? globalPreset.mctsIterations,
|
|
875
|
+
delay: inputs.botDelay ?? gamePreset.delay ?? globalPreset.delay,
|
|
876
|
+
minDelay: inputs.botMinDelay ?? gamePreset.minDelay ?? globalPreset.minDelay,
|
|
877
|
+
mistakeRate: inputs.botMistakeRate ?? gamePreset.mistakeRate ?? globalPreset.mistakeRate,
|
|
878
|
+
customBot: inputs.customBot
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function computeBotPlayerIDs(numPlayers, botCount, humanPlayerID) {
|
|
882
|
+
const botIDs = [];
|
|
883
|
+
for (let i = 0; i < numPlayers && botIDs.length < botCount; i++) {
|
|
884
|
+
const id = String(i);
|
|
885
|
+
if (id !== humanPlayerID) {
|
|
886
|
+
botIDs.push(id);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return botIDs;
|
|
890
|
+
}
|
|
891
|
+
var BotManager = class {
|
|
892
|
+
constructor(game, config) {
|
|
893
|
+
this.game = game;
|
|
894
|
+
this.bot = null;
|
|
895
|
+
this.pendingMove = null;
|
|
896
|
+
this.stateListeners = /* @__PURE__ */ new Set();
|
|
897
|
+
this.currentState = { isThinking: false, thinkingPlayer: null };
|
|
898
|
+
this.moveInProgress = false;
|
|
899
|
+
this.config = config;
|
|
900
|
+
this.initializeBot();
|
|
901
|
+
}
|
|
902
|
+
initializeBot() {
|
|
903
|
+
const { BoardgameAI } = window;
|
|
904
|
+
const enumerate = this.game.ai?.enumerate;
|
|
905
|
+
if (!enumerate) {
|
|
906
|
+
console.warn(
|
|
907
|
+
"[BotManager] game.ai.enumerate is required for bot support"
|
|
908
|
+
);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
switch (this.config.strategy) {
|
|
912
|
+
case "mcts":
|
|
913
|
+
if (BoardgameAI?.MCTSBot) {
|
|
914
|
+
this.bot = new BoardgameAI.MCTSBot({
|
|
915
|
+
game: this.game,
|
|
916
|
+
enumerate,
|
|
917
|
+
iterations: this.config.mctsIterations
|
|
918
|
+
});
|
|
919
|
+
} else {
|
|
920
|
+
console.warn(
|
|
921
|
+
"[BotManager] MCTSBot not available, falling back to RandomBot"
|
|
922
|
+
);
|
|
923
|
+
if (BoardgameAI?.RandomBot) {
|
|
924
|
+
this.bot = new BoardgameAI.RandomBot({
|
|
925
|
+
game: this.game,
|
|
926
|
+
enumerate
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
break;
|
|
931
|
+
case "random":
|
|
932
|
+
if (BoardgameAI?.RandomBot) {
|
|
933
|
+
this.bot = new BoardgameAI.RandomBot({
|
|
934
|
+
game: this.game,
|
|
935
|
+
enumerate
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
break;
|
|
939
|
+
case "custom":
|
|
940
|
+
if (this.config.customBot) {
|
|
941
|
+
this.bot = this.config.customBot();
|
|
942
|
+
} else {
|
|
943
|
+
console.error(
|
|
944
|
+
"[BotManager] Custom strategy requires customBot factory"
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
if (!this.bot) {
|
|
950
|
+
console.warn(
|
|
951
|
+
"[BotManager] Could not initialize bot. AI module may not be loaded."
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Select a move, potentially with mistakes for easier difficulties.
|
|
957
|
+
*/
|
|
958
|
+
async selectMove(state, playerID) {
|
|
959
|
+
const enumerate = this.game.ai?.enumerate;
|
|
960
|
+
if (!enumerate) {
|
|
961
|
+
console.log("[BotManager] No enumerate function");
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
const legalMoves = enumerate(state.G, state.ctx);
|
|
965
|
+
console.log("[BotManager] Legal moves:", legalMoves);
|
|
966
|
+
if (legalMoves.length === 0) {
|
|
967
|
+
console.log("[BotManager] No legal moves available");
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
if (this.config.mistakeRate > 0 && Math.random() < this.config.mistakeRate) {
|
|
971
|
+
const randomIndex = Math.floor(Math.random() * legalMoves.length);
|
|
972
|
+
const randomMove = legalMoves[randomIndex];
|
|
973
|
+
return {
|
|
974
|
+
action: {
|
|
975
|
+
payload: {
|
|
976
|
+
type: randomMove.move,
|
|
977
|
+
args: randomMove.args
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
if (!this.bot) {
|
|
983
|
+
const randomIndex = Math.floor(Math.random() * legalMoves.length);
|
|
984
|
+
const randomMove = legalMoves[randomIndex];
|
|
985
|
+
return {
|
|
986
|
+
action: {
|
|
987
|
+
payload: {
|
|
988
|
+
type: randomMove.move,
|
|
989
|
+
args: randomMove.args
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const result = await this.bot.play(state, playerID);
|
|
995
|
+
if (!result?.action && legalMoves.length > 0) {
|
|
996
|
+
console.log("[BotManager] Bot returned null action, falling back to random selection");
|
|
997
|
+
const randomIndex = Math.floor(Math.random() * legalMoves.length);
|
|
998
|
+
const randomMove = legalMoves[randomIndex];
|
|
999
|
+
return {
|
|
1000
|
+
action: {
|
|
1001
|
+
payload: {
|
|
1002
|
+
type: randomMove.move,
|
|
1003
|
+
args: randomMove.args
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
return result;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Check if it's a bot's turn and schedule a move if so.
|
|
1012
|
+
* Call this after every state update.
|
|
1013
|
+
*/
|
|
1014
|
+
async maybePlayBot(state, botPlayerIDs, makeMove) {
|
|
1015
|
+
const { ctx } = state;
|
|
1016
|
+
console.log("[BotManager] maybePlayBot called:", {
|
|
1017
|
+
currentPlayer: ctx.currentPlayer,
|
|
1018
|
+
botPlayerIDs,
|
|
1019
|
+
moveInProgress: this.moveInProgress,
|
|
1020
|
+
gameover: ctx.gameover
|
|
1021
|
+
});
|
|
1022
|
+
if (this.moveInProgress) {
|
|
1023
|
+
console.log("[BotManager] Skipping - move already in progress");
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const isBotTurn = botPlayerIDs.includes(ctx.currentPlayer);
|
|
1027
|
+
if (!isBotTurn || ctx.gameover) {
|
|
1028
|
+
console.log("[BotManager] Not bot turn or gameover:", { isBotTurn, gameover: ctx.gameover });
|
|
1029
|
+
this.cancelPendingMove();
|
|
1030
|
+
this.notifyState({ isThinking: false, thinkingPlayer: null });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
this.moveInProgress = true;
|
|
1034
|
+
this.notifyState({ isThinking: true, thinkingPlayer: ctx.currentPlayer });
|
|
1035
|
+
const delay = this.config.minDelay > 0 ? this.config.minDelay + Math.random() * (this.config.delay - this.config.minDelay) : this.config.delay;
|
|
1036
|
+
try {
|
|
1037
|
+
console.log("[BotManager] Selecting move for player:", ctx.currentPlayer);
|
|
1038
|
+
const action = await this.selectMove(state, ctx.currentPlayer);
|
|
1039
|
+
console.log("[BotManager] Selected action:", action);
|
|
1040
|
+
if (!action?.action) {
|
|
1041
|
+
console.log("[BotManager] No action returned, skipping");
|
|
1042
|
+
this.notifyState({ isThinking: false, thinkingPlayer: null });
|
|
1043
|
+
this.moveInProgress = false;
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
console.log("[BotManager] Scheduling move after delay:", delay);
|
|
1047
|
+
this.pendingMove = setTimeout(() => {
|
|
1048
|
+
const { type, args } = action.action.payload;
|
|
1049
|
+
console.log("[BotManager] Executing move:", { type, args });
|
|
1050
|
+
makeMove(type, ...args);
|
|
1051
|
+
this.notifyState({ isThinking: false, thinkingPlayer: null });
|
|
1052
|
+
this.moveInProgress = false;
|
|
1053
|
+
}, delay);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
console.error("[BotManager] Error computing move:", error);
|
|
1056
|
+
this.notifyState({ isThinking: false, thinkingPlayer: null });
|
|
1057
|
+
this.moveInProgress = false;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/** Subscribe to bot state changes */
|
|
1061
|
+
subscribe(listener) {
|
|
1062
|
+
this.stateListeners.add(listener);
|
|
1063
|
+
listener(this.currentState);
|
|
1064
|
+
return () => this.stateListeners.delete(listener);
|
|
1065
|
+
}
|
|
1066
|
+
/** Get current bot state */
|
|
1067
|
+
getState() {
|
|
1068
|
+
return this.currentState;
|
|
1069
|
+
}
|
|
1070
|
+
notifyState(state) {
|
|
1071
|
+
this.currentState = state;
|
|
1072
|
+
this.stateListeners.forEach((fn) => fn(state));
|
|
1073
|
+
}
|
|
1074
|
+
cancelPendingMove() {
|
|
1075
|
+
if (this.pendingMove) {
|
|
1076
|
+
clearTimeout(this.pendingMove);
|
|
1077
|
+
this.pendingMove = null;
|
|
1078
|
+
}
|
|
1079
|
+
this.moveInProgress = false;
|
|
1080
|
+
}
|
|
1081
|
+
dispose() {
|
|
1082
|
+
this.cancelPendingMove();
|
|
1083
|
+
this.stateListeners.clear();
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// src/mount.ts
|
|
1088
|
+
function createGameMount(game, Board, options = {}) {
|
|
1089
|
+
const {
|
|
1090
|
+
numPlayers: defaultNumPlayers = game.maxPlayers ?? game.minPlayers ?? 2,
|
|
1091
|
+
defaultPlayerID = "0"
|
|
1092
|
+
} = options;
|
|
1093
|
+
return (container, inputs = {}) => {
|
|
1094
|
+
const { BoardgameReact, BoardgameMultiplayer, React: R, ReactDOM } = window;
|
|
1095
|
+
if (!BoardgameReact || !R || !ReactDOM) {
|
|
1096
|
+
console.error(
|
|
1097
|
+
"[boardgameio] Missing globals: BoardgameReact, React, or ReactDOM"
|
|
1098
|
+
);
|
|
1099
|
+
container.innerHTML = '<div style="color: red; padding: 16px;">Missing required dependencies</div>';
|
|
1100
|
+
return () => {
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
const numPlayers = inputs.numPlayers ?? defaultNumPlayers;
|
|
1104
|
+
const multiplayerConfig = inputs.multiplayer;
|
|
1105
|
+
const isMultiplayer = !!multiplayerConfig?.matchID;
|
|
1106
|
+
const botCountInput = inputs["bot-count"] ?? inputs.botCount;
|
|
1107
|
+
const botCount = typeof botCountInput === "number" ? botCountInput : isMultiplayer ? 0 : numPlayers - 1;
|
|
1108
|
+
const playerID = inputs.playerID ?? multiplayerConfig?.playerID ?? defaultPlayerID;
|
|
1109
|
+
const botPlayerIDs = computeBotPlayerIDs(numPlayers, botCount, playerID);
|
|
1110
|
+
console.log("[boardgameio] Bot configuration:", {
|
|
1111
|
+
numPlayers,
|
|
1112
|
+
botCount,
|
|
1113
|
+
botCountInput,
|
|
1114
|
+
isMultiplayer,
|
|
1115
|
+
playerID,
|
|
1116
|
+
botPlayerIDs,
|
|
1117
|
+
hasBotEnumerate: !!game.ai?.enumerate,
|
|
1118
|
+
inputsBotCount: inputs["bot-count"],
|
|
1119
|
+
hasMultiplayerConfig: !!multiplayerConfig
|
|
1120
|
+
});
|
|
1121
|
+
const botDifficulty = inputs.botDifficulty ?? "medium";
|
|
1122
|
+
const botConfig = resolveBotConfig(inputs, game.ai?.difficulty);
|
|
1123
|
+
const hasBots = botCount > 0 && game.ai?.enumerate;
|
|
1124
|
+
let botManager = null;
|
|
1125
|
+
if (hasBots) {
|
|
1126
|
+
botManager = new BotManager(game, botConfig);
|
|
1127
|
+
}
|
|
1128
|
+
let multiplayer;
|
|
1129
|
+
if (isMultiplayer && multiplayerConfig) {
|
|
1130
|
+
const isHost = multiplayerConfig.isHost ?? playerID === "0";
|
|
1131
|
+
multiplayer = createP2PTransport({
|
|
1132
|
+
isHost
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
const WrappedBoard = (props) => {
|
|
1136
|
+
const [botState, setBotState] = R.useState({
|
|
1137
|
+
isThinking: false,
|
|
1138
|
+
thinkingPlayer: null
|
|
1139
|
+
});
|
|
1140
|
+
R.useEffect(() => {
|
|
1141
|
+
if (!botManager) return;
|
|
1142
|
+
return botManager.subscribe(setBotState);
|
|
1143
|
+
}, []);
|
|
1144
|
+
R.useEffect(() => {
|
|
1145
|
+
if (isMultiplayer) {
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (!botManager || !props.G) {
|
|
1149
|
+
console.log("[boardgameio] Bot effect skipped:", {
|
|
1150
|
+
hasBotManager: !!botManager,
|
|
1151
|
+
hasG: !!props.G
|
|
1152
|
+
});
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const gState = props.G;
|
|
1156
|
+
const ctxState = props.ctx;
|
|
1157
|
+
const currentPlayer = gState.current !== void 0 ? String(gState.current) : ctxState.currentPlayer;
|
|
1158
|
+
const gameover = gState.winner !== void 0 ? gState.winner !== null : ctxState.gameover;
|
|
1159
|
+
const isBotTurn = botPlayerIDs.includes(currentPlayer);
|
|
1160
|
+
console.log("[boardgameio] Bot effect running:", {
|
|
1161
|
+
currentPlayer,
|
|
1162
|
+
botPlayerIDs,
|
|
1163
|
+
isBotTurn,
|
|
1164
|
+
gameover
|
|
1165
|
+
});
|
|
1166
|
+
const state = {
|
|
1167
|
+
G: props.G,
|
|
1168
|
+
ctx: {
|
|
1169
|
+
...ctxState,
|
|
1170
|
+
currentPlayer,
|
|
1171
|
+
gameover
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
botManager.maybePlayBot(state, botPlayerIDs, (type, ...args) => {
|
|
1175
|
+
console.log("[boardgameio] Bot making move:", { type, args });
|
|
1176
|
+
const moves = props.moves;
|
|
1177
|
+
moves[type]?.(...args);
|
|
1178
|
+
});
|
|
1179
|
+
}, [
|
|
1180
|
+
props.G,
|
|
1181
|
+
props.ctx?.currentPlayer,
|
|
1182
|
+
props.ctx?.turn,
|
|
1183
|
+
props.G?.current
|
|
1184
|
+
]);
|
|
1185
|
+
return R.createElement(Board, {
|
|
1186
|
+
...props,
|
|
1187
|
+
isMultiplayer,
|
|
1188
|
+
botState,
|
|
1189
|
+
botPlayerIDs,
|
|
1190
|
+
botCount,
|
|
1191
|
+
botDifficulty
|
|
1192
|
+
});
|
|
1193
|
+
};
|
|
1194
|
+
const GameClient = BoardgameReact.Client({
|
|
1195
|
+
game,
|
|
1196
|
+
board: WrappedBoard,
|
|
1197
|
+
numPlayers,
|
|
1198
|
+
...multiplayer ? { multiplayer } : {}
|
|
1199
|
+
});
|
|
1200
|
+
const GameWithSettings = () => {
|
|
1201
|
+
const clientProps = { playerID };
|
|
1202
|
+
if (isMultiplayer && multiplayerConfig) {
|
|
1203
|
+
clientProps.matchID = multiplayerConfig.matchID;
|
|
1204
|
+
clientProps.credentials = multiplayerConfig.credentials;
|
|
1205
|
+
console.log("[boardgameio] Multiplayer props:", {
|
|
1206
|
+
matchID: multiplayerConfig.matchID,
|
|
1207
|
+
playerID,
|
|
1208
|
+
isHost: multiplayerConfig.isHost ?? playerID === "0"
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
return R.createElement(
|
|
1212
|
+
SettingsProvider,
|
|
1213
|
+
{ settings: inputs },
|
|
1214
|
+
R.createElement(GameClient, clientProps)
|
|
1215
|
+
);
|
|
1216
|
+
};
|
|
1217
|
+
const root = ReactDOM.createRoot(container);
|
|
1218
|
+
root.render(R.createElement(GameWithSettings));
|
|
1219
|
+
return () => {
|
|
1220
|
+
botManager?.dispose();
|
|
1221
|
+
root.unmount();
|
|
1222
|
+
};
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
function createMountFromExports(module, manifest) {
|
|
1226
|
+
const { game, app, default: defaultExport } = module;
|
|
1227
|
+
if (!game || typeof game.setup !== "function") {
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
const Board = app || defaultExport;
|
|
1231
|
+
if (!Board || typeof Board !== "function") {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
const numPlayers = manifest?.players?.max ?? manifest?.players?.min ?? game.maxPlayers ?? game.minPlayers ?? 2;
|
|
1235
|
+
return createGameMount(game, Board, { numPlayers });
|
|
1236
|
+
}
|
|
1237
|
+
function injectMountHelper() {
|
|
1238
|
+
const win = window;
|
|
1239
|
+
win.createGameMount = createGameMount;
|
|
1240
|
+
win.createMountFromExports = createMountFromExports;
|
|
1241
|
+
win.useSettings = useSettings;
|
|
1242
|
+
win.SettingsProvider = SettingsProvider;
|
|
1243
|
+
}
|
|
1244
|
+
async function mount(module, container, inputs) {
|
|
1245
|
+
const { React: R, ReactDOM } = window;
|
|
1246
|
+
if (typeof module.mount === "function") {
|
|
1247
|
+
const mountFn = module.mount;
|
|
1248
|
+
const result = await mountFn(container, inputs);
|
|
1249
|
+
if (typeof result === "function") {
|
|
1250
|
+
return result;
|
|
1251
|
+
}
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const game = module.game;
|
|
1255
|
+
if (game && typeof game.setup === "function") {
|
|
1256
|
+
const Board = module.app || module.default;
|
|
1257
|
+
if (Board && typeof Board === "function") {
|
|
1258
|
+
const numPlayers = inputs.numPlayers ?? game.maxPlayers ?? game.minPlayers ?? 2;
|
|
1259
|
+
const multiplayerInput = inputs.multiplayer;
|
|
1260
|
+
if (multiplayerInput?.matchID) {
|
|
1261
|
+
await ensurePeerJS();
|
|
1262
|
+
}
|
|
1263
|
+
const gameMount = createGameMount(game, Board, { numPlayers });
|
|
1264
|
+
return gameMount(container, inputs);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (typeof module.default === "function" && R && ReactDOM) {
|
|
1268
|
+
const Component = module.default;
|
|
1269
|
+
const root = ReactDOM.createRoot(container);
|
|
1270
|
+
root.render(R.createElement(Component, inputs));
|
|
1271
|
+
return () => root.unmount();
|
|
1272
|
+
}
|
|
1273
|
+
console.warn(
|
|
1274
|
+
"[boardgameio] Widget does not export a recognized entry point (mount, game+app, or default)"
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/setup.ts
|
|
1279
|
+
var tailwindLoadPromise = null;
|
|
1280
|
+
var peerJSLoadPromise = null;
|
|
1281
|
+
var mountHelperInjected = false;
|
|
1282
|
+
async function setup(container, options = {}) {
|
|
1283
|
+
const { cssRuntime = true, multiplayer = false } = options;
|
|
1284
|
+
if (!mountHelperInjected) {
|
|
1285
|
+
injectMountHelper();
|
|
1286
|
+
mountHelperInjected = true;
|
|
1287
|
+
}
|
|
1288
|
+
if (cssRuntime && !tailwindLoadPromise) {
|
|
1289
|
+
tailwindLoadPromise = loadTailwindPlayCDN();
|
|
1290
|
+
}
|
|
1291
|
+
if (multiplayer && !peerJSLoadPromise) {
|
|
1292
|
+
peerJSLoadPromise = loadPeerJS();
|
|
1293
|
+
}
|
|
1294
|
+
await Promise.all([tailwindLoadPromise, peerJSLoadPromise].filter(Boolean));
|
|
1295
|
+
}
|
|
1296
|
+
function cleanup(container) {
|
|
1297
|
+
}
|
|
1298
|
+
async function loadTailwindPlayCDN() {
|
|
1299
|
+
if (document.querySelector('script[src*="tailwindcss.com/play"]')) {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const script = document.createElement("script");
|
|
1303
|
+
script.src = "https://cdn.tailwindcss.com";
|
|
1304
|
+
script.async = true;
|
|
1305
|
+
return new Promise((resolve, reject) => {
|
|
1306
|
+
script.onload = () => resolve();
|
|
1307
|
+
script.onerror = () => reject(new Error("Failed to load Tailwind CDN"));
|
|
1308
|
+
document.head.appendChild(script);
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
async function loadPeerJS() {
|
|
1312
|
+
if (window.Peer) {
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
if (document.querySelector('script[src*="peerjs"]')) {
|
|
1316
|
+
return new Promise((resolve) => {
|
|
1317
|
+
const check = () => {
|
|
1318
|
+
if (window.Peer) resolve();
|
|
1319
|
+
else setTimeout(check, 50);
|
|
1320
|
+
};
|
|
1321
|
+
check();
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
const script = document.createElement("script");
|
|
1325
|
+
script.src = "https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js";
|
|
1326
|
+
script.async = true;
|
|
1327
|
+
return new Promise((resolve, reject) => {
|
|
1328
|
+
script.onload = () => resolve();
|
|
1329
|
+
script.onerror = () => reject(new Error("Failed to load PeerJS"));
|
|
1330
|
+
document.head.appendChild(script);
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
async function ensurePeerJS() {
|
|
1334
|
+
if (!peerJSLoadPromise) {
|
|
1335
|
+
peerJSLoadPromise = loadPeerJS();
|
|
1336
|
+
}
|
|
1337
|
+
await peerJSLoadPromise;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// src/multiplayer.ts
|
|
1341
|
+
function getMultiplayer(config, game) {
|
|
1342
|
+
const BoardgameMultiplayer = window.BoardgameMultiplayer;
|
|
1343
|
+
const BoardgameAI = window.BoardgameAI;
|
|
1344
|
+
if (config.isMultiplayer) {
|
|
1345
|
+
return void 0;
|
|
1346
|
+
}
|
|
1347
|
+
const bots = {};
|
|
1348
|
+
const botCount = config.botCount ?? 0;
|
|
1349
|
+
if (botCount > 0 && game?.ai && BoardgameAI?.MCTSBot) {
|
|
1350
|
+
for (let i = 1; i <= botCount; i++) {
|
|
1351
|
+
bots[String(i)] = BoardgameAI.MCTSBot;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (!BoardgameMultiplayer?.Local) {
|
|
1355
|
+
return void 0;
|
|
1356
|
+
}
|
|
1357
|
+
return BoardgameMultiplayer.Local({
|
|
1358
|
+
storageKey: `zolvery:bgio:${config.appId}`,
|
|
1359
|
+
...Object.keys(bots).length > 0 ? { bots } : {}
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
function generateMatchID() {
|
|
1363
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
1364
|
+
return Array.from(
|
|
1365
|
+
{ length: 6 },
|
|
1366
|
+
() => chars[Math.floor(Math.random() * chars.length)]
|
|
1367
|
+
).join("");
|
|
1368
|
+
}
|
|
1369
|
+
export {
|
|
1370
|
+
BotManager,
|
|
1371
|
+
DIFFICULTY_PRESETS,
|
|
1372
|
+
P2PDB,
|
|
1373
|
+
P2PHost,
|
|
1374
|
+
P2PTransport,
|
|
1375
|
+
SettingsProvider,
|
|
1376
|
+
cleanup,
|
|
1377
|
+
computeBotPlayerIDs,
|
|
1378
|
+
createGameMount,
|
|
1379
|
+
createMountFromExports,
|
|
1380
|
+
createP2PTransport,
|
|
1381
|
+
ensurePeerJS,
|
|
1382
|
+
generateCredentials,
|
|
1383
|
+
generateKeyPair,
|
|
1384
|
+
generateMatchID,
|
|
1385
|
+
getMultiplayer,
|
|
1386
|
+
injectMountHelper,
|
|
1387
|
+
mount,
|
|
1388
|
+
resolveBotConfig,
|
|
1389
|
+
setup,
|
|
1390
|
+
useSettings
|
|
1391
|
+
};
|
|
1392
|
+
//# sourceMappingURL=index.js.map
|