dignity.js 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -21
- package/docs/index.html +3 -3
- package/docs/openapi-like.json +1 -1
- package/package.json +16 -3
- package/docs/chess/assets/chess-app.js +0 -58022
- package/docs/chess/assets/chess-app.js.map +0 -7
- package/docs/chess/assets/chess.css +0 -584
- package/docs/chess/favicon.ico +0 -0
- package/docs/chess/index.html +0 -16
- package/docs/chess/src/App.jsx +0 -128
- package/docs/chess/src/components/Board3D.jsx +0 -364
- package/docs/chess/src/components/GameView.jsx +0 -847
- package/docs/chess/src/components/JoinGate.jsx +0 -68
- package/docs/chess/src/components/LinkPanel.jsx +0 -132
- package/docs/chess/src/components/Lobby.jsx +0 -154
- package/docs/chess/src/components/MovePanel.jsx +0 -123
- package/docs/chess/src/lib/audio.js +0 -50
- package/docs/chess/src/lib/dignitySetup.js +0 -42
- package/docs/chess/src/lib/links.js +0 -124
- package/docs/chess/src/lib/localGames.js +0 -160
- package/docs/chess/src/lib/p2pDebug.js +0 -192
- package/docs/chess/src/main.jsx +0 -5
|
@@ -1,847 +0,0 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { Chess } from 'chess.js';
|
|
3
|
-
import {
|
|
4
|
-
useDignity,
|
|
5
|
-
useObject,
|
|
6
|
-
usePeers,
|
|
7
|
-
useDiscovery,
|
|
8
|
-
} from '../../../../src/react/index.js';
|
|
9
|
-
import { createDignityConfig, attachPersistence } from '../lib/dignitySetup.js';
|
|
10
|
-
import { buildLinks, randomToken, scopeForGame, withHostPeerInHash, connectToRoomPeer, hostPeerFromRoute } from '../lib/links.js';
|
|
11
|
-
import {
|
|
12
|
-
attachNodeDebugListeners,
|
|
13
|
-
connectionSnapshot,
|
|
14
|
-
dumpJoinState,
|
|
15
|
-
installGlobalDebug,
|
|
16
|
-
p2pError,
|
|
17
|
-
p2pLog,
|
|
18
|
-
p2pWarn
|
|
19
|
-
} from '../lib/p2pDebug.js';
|
|
20
|
-
import { saveLocalGameSession } from '../lib/localGames.js';
|
|
21
|
-
import {
|
|
22
|
-
playCaptureSound,
|
|
23
|
-
playCheckSound,
|
|
24
|
-
playGameStartSound,
|
|
25
|
-
playMoveSound,
|
|
26
|
-
resumeAudio
|
|
27
|
-
} from '../lib/audio.js';
|
|
28
|
-
import Board3D from './Board3D.jsx';
|
|
29
|
-
import LinkPanel from './LinkPanel.jsx';
|
|
30
|
-
import MovePanel from './MovePanel.jsx';
|
|
31
|
-
|
|
32
|
-
const COLLECTION = 'chess-matches';
|
|
33
|
-
const START_FEN = new Chess().fen();
|
|
34
|
-
|
|
35
|
-
function canResume(route, game) {
|
|
36
|
-
return route.resumeToken && game?.data?.resumeToken === route.resumeToken;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function canWatch(route, game) {
|
|
40
|
-
return route.watchToken && game?.data?.watchToken === route.watchToken;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function canJoin(route, game) {
|
|
44
|
-
return (
|
|
45
|
-
route.role === 'join'
|
|
46
|
-
&& route.joinToken
|
|
47
|
-
&& game?.data?.joinToken === route.joinToken
|
|
48
|
-
&& !game?.data?.joinTokenUsed
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export default function GameView({ route, nodeId, nickname, onBack }) {
|
|
53
|
-
const scope = scopeForGame(route.gameId);
|
|
54
|
-
const roomKey = route.roomKey;
|
|
55
|
-
|
|
56
|
-
p2pLog('GameView mount', { role: route.role, nodeId, gameId: route.gameId, hostPeer: route.hostPeer });
|
|
57
|
-
|
|
58
|
-
const dignityConfig = useMemo(
|
|
59
|
-
() => createDignityConfig({
|
|
60
|
-
nodeId,
|
|
61
|
-
roomKey,
|
|
62
|
-
scope,
|
|
63
|
-
role: route.role
|
|
64
|
-
}),
|
|
65
|
-
[nodeId, roomKey, scope, route.role]
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const { node, status, error } = useDignity(dignityConfig);
|
|
69
|
-
const game = useObject(node, COLLECTION, route.gameId);
|
|
70
|
-
const [roomConnected, setRoomConnected] = useState(route.role === 'host');
|
|
71
|
-
const [connectionCount, setConnectionCount] = useState(0);
|
|
72
|
-
const [notice, setNotice] = useState('');
|
|
73
|
-
const [selectedSquare, setSelectedSquare] = useState(null);
|
|
74
|
-
const [legalTargets, setLegalTargets] = useState([]);
|
|
75
|
-
const [creating, setCreating] = useState(false);
|
|
76
|
-
const creatingRef = React.useRef(false);
|
|
77
|
-
|
|
78
|
-
const remoteHostPeer = hostPeerFromRoute(route, game, node?.nodeId);
|
|
79
|
-
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (!node) {
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return attachNodeDebugListeners(node, {
|
|
86
|
-
role: route.role,
|
|
87
|
-
scope,
|
|
88
|
-
gameId: route.gameId,
|
|
89
|
-
collection: COLLECTION
|
|
90
|
-
});
|
|
91
|
-
}, [node, route.role, scope, route.gameId]);
|
|
92
|
-
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (status === 'error' && error) {
|
|
95
|
-
p2pError('dignity start failed', error);
|
|
96
|
-
} else if (status === 'running') {
|
|
97
|
-
p2pLog('dignity running', connectionSnapshot(node));
|
|
98
|
-
}
|
|
99
|
-
}, [status, error, node]);
|
|
100
|
-
|
|
101
|
-
useEffect(() => {
|
|
102
|
-
if (!node || status !== 'running' || route.role === 'host') {
|
|
103
|
-
if (route.role === 'host') {
|
|
104
|
-
setRoomConnected(true);
|
|
105
|
-
}
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (!remoteHostPeer) {
|
|
110
|
-
p2pWarn('joiner missing host peer target', { routeHost: route.hostPeer, whitePlayerId: game?.data?.whitePlayerId });
|
|
111
|
-
setRoomConnected(false);
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let cancelled = false;
|
|
116
|
-
|
|
117
|
-
async function maintainHostConnection() {
|
|
118
|
-
if (cancelled) {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const result = await connectToRoomPeer(node, remoteHostPeer);
|
|
123
|
-
if (!cancelled) {
|
|
124
|
-
setRoomConnected(result.ok);
|
|
125
|
-
setConnectionCount(node.networkAdapter?.getOpenConnectionCount?.() || 0);
|
|
126
|
-
if (result.ok) {
|
|
127
|
-
p2pLog('connected to host peer', { host: remoteHostPeer, open: result.open });
|
|
128
|
-
} else {
|
|
129
|
-
p2pWarn('host connect attempt failed', { host: remoteHostPeer, ...result });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
maintainHostConnection();
|
|
135
|
-
const timer = setInterval(maintainHostConnection, 3000);
|
|
136
|
-
|
|
137
|
-
return () => {
|
|
138
|
-
cancelled = true;
|
|
139
|
-
clearInterval(timer);
|
|
140
|
-
};
|
|
141
|
-
}, [node, status, route.role, remoteHostPeer, route.hostPeer, game?.data?.whitePlayerId]);
|
|
142
|
-
|
|
143
|
-
const discoveryOptions = useMemo(
|
|
144
|
-
() => {
|
|
145
|
-
if (!node || status !== 'running') {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (route.role !== 'host' && !remoteHostPeer) {
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
metadata: {
|
|
155
|
-
nickname,
|
|
156
|
-
role: route.role,
|
|
157
|
-
joinToken: route.joinToken || null
|
|
158
|
-
},
|
|
159
|
-
bootstrapPeerIds: route.role !== 'host' && remoteHostPeer ? [remoteHostPeer] : [],
|
|
160
|
-
heartbeatIntervalMs: 12000,
|
|
161
|
-
ttlMs: 45000
|
|
162
|
-
};
|
|
163
|
-
},
|
|
164
|
-
[node, status, route.role, route.joinToken, remoteHostPeer, nickname]
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
const { joined } = useDiscovery(node, scope, discoveryOptions);
|
|
168
|
-
const peers = usePeers(node, scope, { includeSelf: false });
|
|
169
|
-
|
|
170
|
-
useEffect(() => {
|
|
171
|
-
if (joined) {
|
|
172
|
-
p2pLog('discovery joined', { scope, role: route.role, metadata: discoveryOptions?.metadata });
|
|
173
|
-
}
|
|
174
|
-
}, [joined, scope, route.role, discoveryOptions]);
|
|
175
|
-
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (peers.length) {
|
|
178
|
-
p2pLog('peers updated', peers.map((p) => ({
|
|
179
|
-
id: p.peerId,
|
|
180
|
-
role: p.metadata?.role,
|
|
181
|
-
nick: p.metadata?.nickname
|
|
182
|
-
})));
|
|
183
|
-
}
|
|
184
|
-
}, [peers]);
|
|
185
|
-
|
|
186
|
-
useEffect(() => {
|
|
187
|
-
if (!node || route.role !== 'host') {
|
|
188
|
-
return undefined;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
peers.forEach((peer) => {
|
|
192
|
-
connectToRoomPeer(node, peer.peerId).then((result) => {
|
|
193
|
-
if (!result.ok) {
|
|
194
|
-
p2pWarn('host connect to peer failed', { peer: peer.peerId, ...result });
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const timer = setInterval(() => {
|
|
200
|
-
setConnectionCount(node.networkAdapter?.getOpenConnectionCount?.() || 0);
|
|
201
|
-
}, 2000);
|
|
202
|
-
|
|
203
|
-
return () => clearInterval(timer);
|
|
204
|
-
}, [node, route.role, peers]);
|
|
205
|
-
|
|
206
|
-
const ensureHostGame = useCallback(async () => {
|
|
207
|
-
if (!node || route.role !== 'host' || creatingRef.current) {
|
|
208
|
-
return node?.read(COLLECTION, route.gameId) || null;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const existing = node.read(COLLECTION, route.gameId);
|
|
212
|
-
if (existing) {
|
|
213
|
-
return existing;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
creatingRef.current = true;
|
|
217
|
-
setCreating(true);
|
|
218
|
-
|
|
219
|
-
const joinToken = route.joinToken || randomToken();
|
|
220
|
-
const watchToken = route.watchToken || randomToken();
|
|
221
|
-
const resumeToken = route.resumeToken || randomToken();
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
await node.create(
|
|
225
|
-
COLLECTION,
|
|
226
|
-
{
|
|
227
|
-
fen: START_FEN,
|
|
228
|
-
status: 'waiting',
|
|
229
|
-
whitePlayerId: node.nodeId,
|
|
230
|
-
blackPlayerId: null,
|
|
231
|
-
joinToken,
|
|
232
|
-
joinTokenUsed: false,
|
|
233
|
-
watchToken,
|
|
234
|
-
resumeToken,
|
|
235
|
-
moveHistory: [],
|
|
236
|
-
turn: 'w',
|
|
237
|
-
winner: null,
|
|
238
|
-
createdBy: nickname
|
|
239
|
-
},
|
|
240
|
-
{
|
|
241
|
-
id: route.gameId,
|
|
242
|
-
broadcastScope: scope
|
|
243
|
-
}
|
|
244
|
-
);
|
|
245
|
-
p2pLog('game created on host', { gameId: route.gameId, whitePlayerId: node.nodeId, joinToken: `${joinToken.slice(0, 6)}…` });
|
|
246
|
-
setNotice('Game created. Share the opponent link to start.');
|
|
247
|
-
playGameStartSound();
|
|
248
|
-
} catch (createError) {
|
|
249
|
-
p2pError('game create failed', createError);
|
|
250
|
-
setNotice(createError.message);
|
|
251
|
-
return null;
|
|
252
|
-
} finally {
|
|
253
|
-
creatingRef.current = false;
|
|
254
|
-
setCreating(false);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return node.read(COLLECTION, route.gameId);
|
|
258
|
-
}, [node, route.role, route.gameId, route.joinToken, route.watchToken, route.resumeToken, scope, nickname]);
|
|
259
|
-
|
|
260
|
-
const chess = useMemo(() => {
|
|
261
|
-
const instance = new Chess();
|
|
262
|
-
if (game?.data?.fen) {
|
|
263
|
-
instance.load(game.data.fen);
|
|
264
|
-
}
|
|
265
|
-
return instance;
|
|
266
|
-
}, [game?.data?.fen, game?.version]);
|
|
267
|
-
|
|
268
|
-
const myColor = useMemo(() => {
|
|
269
|
-
if (!node || !game) {
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
if (game.data.whitePlayerId === node.nodeId) {
|
|
273
|
-
return 'w';
|
|
274
|
-
}
|
|
275
|
-
if (game.data.blackPlayerId === node.nodeId) {
|
|
276
|
-
return 'b';
|
|
277
|
-
}
|
|
278
|
-
if (route.role === 'watch' || (route.role === 'join' && !game.data.joinTokenUsed)) {
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
return null;
|
|
282
|
-
}, [node, game, route.role]);
|
|
283
|
-
|
|
284
|
-
const isSpectator = route.role === 'watch' || (route.role !== 'host' && route.role !== 'join' && !myColor);
|
|
285
|
-
const roleBadge = route.role === 'join'
|
|
286
|
-
? (myColor === 'b' ? 'Black' : 'Joining…')
|
|
287
|
-
: route.role === 'host'
|
|
288
|
-
? (myColor === 'w' ? 'White' : 'Host')
|
|
289
|
-
: isSpectator
|
|
290
|
-
? 'Spectator'
|
|
291
|
-
: myColor === 'w'
|
|
292
|
-
? 'White'
|
|
293
|
-
: myColor === 'b'
|
|
294
|
-
? 'Black'
|
|
295
|
-
: route.role;
|
|
296
|
-
const canMove = Boolean(
|
|
297
|
-
myColor
|
|
298
|
-
&& game?.data?.status === 'playing'
|
|
299
|
-
&& game.data.turn === myColor
|
|
300
|
-
&& !isSpectator
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
useEffect(() => {
|
|
304
|
-
if (!node) {
|
|
305
|
-
return undefined;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
let persistence;
|
|
309
|
-
attachPersistence(node).then((instance) => {
|
|
310
|
-
persistence = instance;
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
return () => {
|
|
314
|
-
persistence?.detach?.();
|
|
315
|
-
};
|
|
316
|
-
}, [node]);
|
|
317
|
-
|
|
318
|
-
useEffect(() => {
|
|
319
|
-
if (!node || route.role !== 'host') {
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
withHostPeerInHash(node.nodeId);
|
|
324
|
-
}, [node, route.role]);
|
|
325
|
-
|
|
326
|
-
useEffect(() => {
|
|
327
|
-
if (!node) {
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
peers.forEach((peer) => {
|
|
332
|
-
if (route.role === 'host') {
|
|
333
|
-
connectToRoomPeer(node, peer.peerId);
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
}, [node, route.role, peers]);
|
|
337
|
-
|
|
338
|
-
useEffect(() => {
|
|
339
|
-
if (!node || status !== 'running' || route.role !== 'host') {
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (node.read(COLLECTION, route.gameId)) {
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
ensureHostGame();
|
|
348
|
-
}, [node, status, route.role, route.gameId, ensureHostGame]);
|
|
349
|
-
|
|
350
|
-
const completeJoin = useCallback(async (joinerPeerId, joinToken) => {
|
|
351
|
-
if (!node || route.role !== 'host') {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
let current = node.read(COLLECTION, route.gameId);
|
|
356
|
-
if (!current) {
|
|
357
|
-
p2pLog('completeJoin: creating missing host game before accept');
|
|
358
|
-
current = await ensureHostGame();
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (!current || current.data.joinTokenUsed || current.data.joinToken !== joinToken) {
|
|
362
|
-
p2pWarn('completeJoin skipped', {
|
|
363
|
-
hasGame: Boolean(current),
|
|
364
|
-
joinTokenUsed: current?.data?.joinTokenUsed,
|
|
365
|
-
tokenMatch: current?.data?.joinToken === joinToken,
|
|
366
|
-
joinerPeerId
|
|
367
|
-
});
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
p2pLog('completeJoin accepting', { joinerPeerId, joinToken: `${joinToken.slice(0, 6)}…` });
|
|
372
|
-
const connectResult = await connectToRoomPeer(node, joinerPeerId);
|
|
373
|
-
if (!connectResult.ok) {
|
|
374
|
-
p2pWarn('completeJoin connect to joiner failed', connectResult);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
await node.updateWithRetry(COLLECTION, route.gameId, (existing) => ({
|
|
378
|
-
...existing.data,
|
|
379
|
-
blackPlayerId: joinerPeerId,
|
|
380
|
-
joinTokenUsed: true,
|
|
381
|
-
status: 'playing'
|
|
382
|
-
}), {
|
|
383
|
-
collaborators: [current.data.whitePlayerId, joinerPeerId],
|
|
384
|
-
broadcastScope: scope,
|
|
385
|
-
connectToPeers: [joinerPeerId]
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
await node.pushRecordSnapshot(COLLECTION, route.gameId, {
|
|
389
|
-
broadcastScope: scope,
|
|
390
|
-
connectToPeers: [joinerPeerId]
|
|
391
|
-
});
|
|
392
|
-
p2pLog('completeJoin snapshot pushed', { joinerPeerId, gameId: route.gameId });
|
|
393
|
-
|
|
394
|
-
setNotice('Opponent joined. Game started.');
|
|
395
|
-
p2pLog('completeJoin done — game playing', { blackPlayerId: joinerPeerId });
|
|
396
|
-
playGameStartSound();
|
|
397
|
-
}, [node, route.role, route.gameId, scope, ensureHostGame]);
|
|
398
|
-
|
|
399
|
-
useEffect(() => {
|
|
400
|
-
if (!node || route.role !== 'host') {
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const current = node.read(COLLECTION, route.gameId);
|
|
405
|
-
if (!current || current.data.joinTokenUsed) {
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
peers.forEach((peer) => {
|
|
410
|
-
if (peer.metadata?.role === 'join' && peer.metadata?.joinToken === current.data.joinToken) {
|
|
411
|
-
completeJoin(peer.peerId, peer.metadata.joinToken);
|
|
412
|
-
}
|
|
413
|
-
});
|
|
414
|
-
}, [peers, node, route.role, route.gameId, completeJoin]);
|
|
415
|
-
|
|
416
|
-
useEffect(() => {
|
|
417
|
-
if (!node || route.role !== 'host') {
|
|
418
|
-
return undefined;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const handleMessage = async (message) => {
|
|
422
|
-
if (message.type !== 'claim-seat') {
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
p2pLog('host received claim-seat', message);
|
|
427
|
-
|
|
428
|
-
let current = node.read(COLLECTION, route.gameId);
|
|
429
|
-
if (!current) {
|
|
430
|
-
p2pLog('claim-seat: host game missing, creating now');
|
|
431
|
-
current = await ensureHostGame();
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
if (!current || current.data.joinTokenUsed) {
|
|
435
|
-
p2pWarn('claim-seat ignored', {
|
|
436
|
-
hasGame: Boolean(current),
|
|
437
|
-
joinTokenUsed: current?.data?.joinTokenUsed
|
|
438
|
-
});
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const joinToken = message.payload?.joinToken;
|
|
443
|
-
const joinerPeerId = message.payload?.peerId || message.senderId;
|
|
444
|
-
if (joinToken === current.data.joinToken) {
|
|
445
|
-
await completeJoin(joinerPeerId, joinToken);
|
|
446
|
-
} else {
|
|
447
|
-
p2pWarn('claim-seat token mismatch', {
|
|
448
|
-
expected: `${current.data.joinToken?.slice(0, 6)}…`,
|
|
449
|
-
got: `${joinToken?.slice(0, 6)}…`
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
node.on('message', handleMessage);
|
|
455
|
-
return () => node.off('message', handleMessage);
|
|
456
|
-
}, [node, route.role, route.gameId, completeJoin, ensureHostGame]);
|
|
457
|
-
|
|
458
|
-
useEffect(() => {
|
|
459
|
-
if (!node || route.role !== 'join' || !route.joinToken || status !== 'running' || !joined) {
|
|
460
|
-
return undefined;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const current = node.read(COLLECTION, route.gameId);
|
|
464
|
-
if (current?.data?.blackPlayerId === node.nodeId) {
|
|
465
|
-
return undefined;
|
|
466
|
-
}
|
|
467
|
-
if (current?.data?.joinTokenUsed && current.data.blackPlayerId !== node.nodeId) {
|
|
468
|
-
return undefined;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
let cancelled = false;
|
|
472
|
-
|
|
473
|
-
async function requestSeat() {
|
|
474
|
-
if (cancelled) {
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const connectResult = await connectToRoomPeer(node, remoteHostPeer);
|
|
479
|
-
p2pLog('joiner requestSeat', {
|
|
480
|
-
host: remoteHostPeer,
|
|
481
|
-
connect: connectResult,
|
|
482
|
-
links: node.networkAdapter?.getOpenConnectionCount?.() || 0
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
await node.broadcastMessage('claim-seat', {
|
|
487
|
-
joinToken: route.joinToken,
|
|
488
|
-
peerId: node.nodeId,
|
|
489
|
-
nickname
|
|
490
|
-
}, {
|
|
491
|
-
broadcastScope: scope,
|
|
492
|
-
connectToPeers: remoteHostPeer ? [remoteHostPeer] : []
|
|
493
|
-
});
|
|
494
|
-
p2pLog('claim-seat broadcast sent', { scope, joinToken: `${route.joinToken.slice(0, 6)}…` });
|
|
495
|
-
} catch (broadcastError) {
|
|
496
|
-
p2pError('claim-seat broadcast failed', {
|
|
497
|
-
message: broadcastError?.message,
|
|
498
|
-
connectResult
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
requestSeat();
|
|
504
|
-
setNotice('Connected to host. Requesting Black…');
|
|
505
|
-
|
|
506
|
-
const timer = setInterval(requestSeat, 4000);
|
|
507
|
-
|
|
508
|
-
return () => {
|
|
509
|
-
cancelled = true;
|
|
510
|
-
clearInterval(timer);
|
|
511
|
-
};
|
|
512
|
-
}, [
|
|
513
|
-
node,
|
|
514
|
-
route.role,
|
|
515
|
-
route.joinToken,
|
|
516
|
-
route.gameId,
|
|
517
|
-
status,
|
|
518
|
-
joined,
|
|
519
|
-
remoteHostPeer,
|
|
520
|
-
nickname,
|
|
521
|
-
scope
|
|
522
|
-
]);
|
|
523
|
-
|
|
524
|
-
useEffect(() => {
|
|
525
|
-
if (!game) {
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
if (route.role === 'join' && game.data.blackPlayerId === node?.nodeId) {
|
|
530
|
-
setNotice('You joined as Black.');
|
|
531
|
-
}
|
|
532
|
-
}, [game, route.role, node]);
|
|
533
|
-
|
|
534
|
-
useEffect(() => {
|
|
535
|
-
if (!node) {
|
|
536
|
-
return undefined;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
const dump = () => dumpJoinState({
|
|
540
|
-
route,
|
|
541
|
-
node,
|
|
542
|
-
nodeId,
|
|
543
|
-
scope,
|
|
544
|
-
status,
|
|
545
|
-
error,
|
|
546
|
-
joined,
|
|
547
|
-
roomConnected,
|
|
548
|
-
connectionCount,
|
|
549
|
-
remoteHostPeer,
|
|
550
|
-
peers,
|
|
551
|
-
game,
|
|
552
|
-
myColor
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
installGlobalDebug(dump);
|
|
556
|
-
dump();
|
|
557
|
-
|
|
558
|
-
const timer = setInterval(() => {
|
|
559
|
-
if (game?.data?.status === 'playing') {
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
dump();
|
|
563
|
-
}, 5000);
|
|
564
|
-
|
|
565
|
-
return () => clearInterval(timer);
|
|
566
|
-
}, [
|
|
567
|
-
node,
|
|
568
|
-
nodeId,
|
|
569
|
-
route,
|
|
570
|
-
scope,
|
|
571
|
-
status,
|
|
572
|
-
error,
|
|
573
|
-
joined,
|
|
574
|
-
roomConnected,
|
|
575
|
-
connectionCount,
|
|
576
|
-
remoteHostPeer,
|
|
577
|
-
peers,
|
|
578
|
-
game,
|
|
579
|
-
myColor
|
|
580
|
-
]);
|
|
581
|
-
|
|
582
|
-
useEffect(() => {
|
|
583
|
-
if (!node || !route.gameId || !route.roomKey) {
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
saveLocalGameSession({
|
|
588
|
-
gameId: route.gameId,
|
|
589
|
-
roomKey: route.roomKey,
|
|
590
|
-
role: route.role,
|
|
591
|
-
hostPeer: route.hostPeer || (route.role === 'host' ? node.nodeId : null),
|
|
592
|
-
joinToken: route.joinToken,
|
|
593
|
-
watchToken: route.watchToken,
|
|
594
|
-
resumeToken: route.resumeToken || game?.data?.resumeToken || null,
|
|
595
|
-
nickname,
|
|
596
|
-
localNodeId: node.nodeId,
|
|
597
|
-
status: game?.data?.status || 'waiting',
|
|
598
|
-
winner: game?.data?.winner ?? null,
|
|
599
|
-
moveCount: game?.data?.moveHistory?.length || 0,
|
|
600
|
-
whitePlayerId: game?.data?.whitePlayerId || null,
|
|
601
|
-
blackPlayerId: game?.data?.blackPlayerId || null,
|
|
602
|
-
updatedAt: game?.updatedAt || Date.now()
|
|
603
|
-
});
|
|
604
|
-
}, [node, route, game, nickname]);
|
|
605
|
-
|
|
606
|
-
const regenerateResumeLink = useCallback(async () => {
|
|
607
|
-
if (!node || !game) {
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const resumeToken = randomToken();
|
|
612
|
-
await node.updateWithRetry(COLLECTION, route.gameId, (current) => ({
|
|
613
|
-
...current.data,
|
|
614
|
-
resumeToken
|
|
615
|
-
}), { broadcastScope: scope });
|
|
616
|
-
|
|
617
|
-
const links = buildLinks({
|
|
618
|
-
gameId: route.gameId,
|
|
619
|
-
roomKey,
|
|
620
|
-
hostPeer: node.nodeId,
|
|
621
|
-
joinToken: game.data.joinToken,
|
|
622
|
-
watchToken: game.data.watchToken,
|
|
623
|
-
resumeToken
|
|
624
|
-
});
|
|
625
|
-
await navigator.clipboard.writeText(links.resume);
|
|
626
|
-
setNotice('New resume link copied.');
|
|
627
|
-
}, [node, game, route.gameId, roomKey, scope]);
|
|
628
|
-
|
|
629
|
-
const applyMove = useCallback(async (from, to) => {
|
|
630
|
-
if (!node || !game || !canMove) {
|
|
631
|
-
return;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
await resumeAudio();
|
|
635
|
-
const attempt = new Chess(game.data.fen);
|
|
636
|
-
const move = attempt.move({ from, to, promotion: 'q' });
|
|
637
|
-
if (!move) {
|
|
638
|
-
setSelectedSquare(null);
|
|
639
|
-
setLegalTargets([]);
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const nextHistory = [...(game.data.moveHistory || []), move.san];
|
|
644
|
-
const patch = {
|
|
645
|
-
fen: attempt.fen(),
|
|
646
|
-
moveHistory: nextHistory,
|
|
647
|
-
lastMove: { from, to, san: move.san },
|
|
648
|
-
turn: attempt.turn(),
|
|
649
|
-
status: attempt.isGameOver() ? 'finished' : 'playing',
|
|
650
|
-
winner: attempt.isCheckmate()
|
|
651
|
-
? (attempt.turn() === 'w' ? 'b' : 'w')
|
|
652
|
-
: attempt.isDraw() ? 'draw' : null
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
const peerTargets = [game.data.blackPlayerId, game.data.whitePlayerId]
|
|
656
|
-
.filter((peerId) => peerId && peerId !== node.nodeId);
|
|
657
|
-
|
|
658
|
-
await node.updateWithRetry(COLLECTION, route.gameId, (current) => ({
|
|
659
|
-
...current.data,
|
|
660
|
-
...patch
|
|
661
|
-
}), {
|
|
662
|
-
broadcastScope: scope,
|
|
663
|
-
connectToPeers: peerTargets
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
if (move.captured) {
|
|
667
|
-
playCaptureSound();
|
|
668
|
-
} else {
|
|
669
|
-
playMoveSound();
|
|
670
|
-
}
|
|
671
|
-
if (attempt.inCheck()) {
|
|
672
|
-
playCheckSound();
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
setSelectedSquare(null);
|
|
676
|
-
setLegalTargets([]);
|
|
677
|
-
}, [node, game, canMove, route.gameId, scope]);
|
|
678
|
-
|
|
679
|
-
const handleSquareClick = useCallback((square) => {
|
|
680
|
-
if (!canMove) {
|
|
681
|
-
return;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
if (selectedSquare && legalTargets.includes(square)) {
|
|
685
|
-
applyMove(selectedSquare, square);
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const piece = chess.get(square);
|
|
690
|
-
if (!piece || piece.color !== myColor) {
|
|
691
|
-
setSelectedSquare(null);
|
|
692
|
-
setLegalTargets([]);
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
const moves = chess.moves({ square, verbose: true });
|
|
697
|
-
setSelectedSquare(square);
|
|
698
|
-
setLegalTargets(moves.map((move) => move.to));
|
|
699
|
-
}, [canMove, selectedSquare, legalTargets, chess, myColor, applyMove]);
|
|
700
|
-
|
|
701
|
-
if (!roomKey) {
|
|
702
|
-
return <p className="error">Missing room key in link.</p>;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
if (route.role === 'join' && !remoteHostPeer) {
|
|
706
|
-
return (
|
|
707
|
-
<section className="panel error-panel">
|
|
708
|
-
<h2>Invalid opponent link</h2>
|
|
709
|
-
<p>
|
|
710
|
-
This join link is missing the host peer id. Ask the host to copy a fresh opponent link
|
|
711
|
-
from the Share links panel (links generated after the host connects).
|
|
712
|
-
</p>
|
|
713
|
-
<button type="button" onClick={onBack}>Back</button>
|
|
714
|
-
</section>
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
if (route.role === 'join' && game && game.data.joinTokenUsed && game.data.blackPlayerId !== node?.nodeId) {
|
|
719
|
-
return (
|
|
720
|
-
<section className="panel error-panel">
|
|
721
|
-
<h2>Join link expired</h2>
|
|
722
|
-
<p>This opponent link was already used. Request a resume link from a player.</p>
|
|
723
|
-
<button type="button" onClick={onBack}>Back</button>
|
|
724
|
-
</section>
|
|
725
|
-
);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
if (route.role === 'watch' && game && !canWatch(route, game) && route.watchToken) {
|
|
729
|
-
return (
|
|
730
|
-
<section className="panel error-panel">
|
|
731
|
-
<h2>Invalid spectator link</h2>
|
|
732
|
-
<button type="button" onClick={onBack}>Back</button>
|
|
733
|
-
</section>
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
if (route.role === 'resume' && game && !canResume(route, game)) {
|
|
738
|
-
return (
|
|
739
|
-
<section className="panel error-panel">
|
|
740
|
-
<h2>Invalid resume link</h2>
|
|
741
|
-
<button type="button" onClick={onBack}>Back</button>
|
|
742
|
-
</section>
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
return (
|
|
747
|
-
<div className="game-layout">
|
|
748
|
-
<header className="game-header">
|
|
749
|
-
<button type="button" className="ghost" onClick={onBack}>← Lobby</button>
|
|
750
|
-
<div>
|
|
751
|
-
<h2>Game {route.gameId}</h2>
|
|
752
|
-
<p className="status-line">
|
|
753
|
-
Network: {status}
|
|
754
|
-
{joined ? ' · in room' : roomConnected ? ' · connecting room…' : ' · waiting for host peer…'}
|
|
755
|
-
{remoteHostPeer ? ` · host ${remoteHostPeer}` : ''}
|
|
756
|
-
{connectionCount ? ` · ${connectionCount} link(s)` : ''}
|
|
757
|
-
{error ? ` · ${error.message}` : ''}
|
|
758
|
-
</p>
|
|
759
|
-
</div>
|
|
760
|
-
<div className="badge">{roleBadge}</div>
|
|
761
|
-
</header>
|
|
762
|
-
|
|
763
|
-
{notice ? <p className="notice">{notice}</p> : null}
|
|
764
|
-
|
|
765
|
-
{route.role === 'host' && route.joinToken && route.watchToken ? (
|
|
766
|
-
<LinkPanel
|
|
767
|
-
prominent
|
|
768
|
-
audience="host"
|
|
769
|
-
gameId={route.gameId}
|
|
770
|
-
roomKey={roomKey}
|
|
771
|
-
hostPeer={node?.nodeId}
|
|
772
|
-
game={game}
|
|
773
|
-
joinToken={route.joinToken}
|
|
774
|
-
watchToken={route.watchToken}
|
|
775
|
-
resumeToken={route.resumeToken}
|
|
776
|
-
onRegenerateResume={game ? regenerateResumeLink : undefined}
|
|
777
|
-
/>
|
|
778
|
-
) : null}
|
|
779
|
-
|
|
780
|
-
{route.role === 'join' && myColor === 'b' && game ? (
|
|
781
|
-
<LinkPanel
|
|
782
|
-
prominent
|
|
783
|
-
audience="player"
|
|
784
|
-
gameId={route.gameId}
|
|
785
|
-
roomKey={roomKey}
|
|
786
|
-
hostPeer={remoteHostPeer}
|
|
787
|
-
game={game}
|
|
788
|
-
watchToken={route.watchToken}
|
|
789
|
-
resumeToken={route.resumeToken}
|
|
790
|
-
/>
|
|
791
|
-
) : null}
|
|
792
|
-
|
|
793
|
-
<div className="game-grid">
|
|
794
|
-
<Board3D
|
|
795
|
-
fen={game?.data?.fen || START_FEN}
|
|
796
|
-
selectedSquare={selectedSquare}
|
|
797
|
-
legalTargets={legalTargets}
|
|
798
|
-
onSquareClick={handleSquareClick}
|
|
799
|
-
orientation={myColor || 'w'}
|
|
800
|
-
interactive={canMove}
|
|
801
|
-
/>
|
|
802
|
-
|
|
803
|
-
<aside className="side-panel">
|
|
804
|
-
<MovePanel
|
|
805
|
-
chess={chess}
|
|
806
|
-
canMove={canMove}
|
|
807
|
-
myColor={myColor}
|
|
808
|
-
selectedSquare={selectedSquare}
|
|
809
|
-
legalTargets={legalTargets}
|
|
810
|
-
onSquareClick={handleSquareClick}
|
|
811
|
-
gameStatus={game?.data?.status || 'waiting'}
|
|
812
|
-
turn={game?.data?.turn || 'w'}
|
|
813
|
-
roomConnected={roomConnected}
|
|
814
|
-
connectionCount={connectionCount}
|
|
815
|
-
/>
|
|
816
|
-
|
|
817
|
-
<section className="panel">
|
|
818
|
-
<h3>Players</h3>
|
|
819
|
-
<ul>
|
|
820
|
-
<li>White: {game?.data?.whitePlayerId || '—'}</li>
|
|
821
|
-
<li>Black: {game?.data?.blackPlayerId || 'waiting…'}</li>
|
|
822
|
-
</ul>
|
|
823
|
-
<h4>Peers ({peers.length})</h4>
|
|
824
|
-
<ul>
|
|
825
|
-
{peers.map((peer) => (
|
|
826
|
-
<li key={peer.peerId}>
|
|
827
|
-
{peer.metadata?.nickname || peer.peerId}
|
|
828
|
-
{' '}
|
|
829
|
-
<span className="muted">({peer.metadata?.role || 'peer'})</span>
|
|
830
|
-
</li>
|
|
831
|
-
))}
|
|
832
|
-
</ul>
|
|
833
|
-
</section>
|
|
834
|
-
|
|
835
|
-
<section className="panel moves">
|
|
836
|
-
<h3>Moves</h3>
|
|
837
|
-
<ol>
|
|
838
|
-
{(game?.data?.moveHistory || []).map((move) => (
|
|
839
|
-
<li key={move}>{move}</li>
|
|
840
|
-
))}
|
|
841
|
-
</ol>
|
|
842
|
-
</section>
|
|
843
|
-
</aside>
|
|
844
|
-
</div>
|
|
845
|
-
</div>
|
|
846
|
-
);
|
|
847
|
-
}
|