dignity.js 0.3.0 → 0.5.1

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.
Files changed (43) hide show
  1. package/README.md +142 -4
  2. package/dist/dignity.cjs.js +768 -20
  3. package/dist/dignity.cjs.js.map +4 -4
  4. package/dist/dignity.esm.js +768 -20
  5. package/dist/dignity.esm.js.map +3 -3
  6. package/dist/dignity.min.js +18 -18
  7. package/docs/assets/dignity.esm.js +11205 -0
  8. package/docs/assets/docs.js +47 -0
  9. package/docs/assets/favicon.svg +8 -0
  10. package/docs/assets/highlight/github-dark.min.css +10 -0
  11. package/docs/assets/highlight/github.min.css +10 -0
  12. package/docs/assets/highlight/highlight.min.js +1244 -0
  13. package/docs/assets/styles.css +449 -38
  14. package/docs/chess/assets/chess-app.js +58022 -0
  15. package/docs/chess/assets/chess-app.js.map +7 -0
  16. package/docs/chess/assets/chess.css +584 -0
  17. package/docs/chess/favicon.ico +0 -0
  18. package/docs/chess/index.html +16 -0
  19. package/docs/chess/src/App.jsx +128 -0
  20. package/docs/chess/src/components/Board3D.jsx +364 -0
  21. package/docs/chess/src/components/GameView.jsx +847 -0
  22. package/docs/chess/src/components/JoinGate.jsx +68 -0
  23. package/docs/chess/src/components/LinkPanel.jsx +132 -0
  24. package/docs/chess/src/components/Lobby.jsx +154 -0
  25. package/docs/chess/src/components/MovePanel.jsx +123 -0
  26. package/docs/chess/src/lib/audio.js +50 -0
  27. package/docs/chess/src/lib/dignitySetup.js +42 -0
  28. package/docs/chess/src/lib/links.js +124 -0
  29. package/docs/chess/src/lib/localGames.js +160 -0
  30. package/docs/chess/src/lib/p2pDebug.js +192 -0
  31. package/docs/chess/src/main.jsx +5 -0
  32. package/docs/favicon.ico +0 -0
  33. package/docs/index.html +605 -81
  34. package/docs/openapi-like.json +74 -7
  35. package/examples/decentralized-chess-lite.js +52 -30
  36. package/package.json +30 -4
  37. package/src/core/dignity-p2p.js +466 -15
  38. package/src/index.js +8 -0
  39. package/src/network/peerjs-network.js +234 -0
  40. package/src/persistence/indexeddb-persistence.js +184 -0
  41. package/src/react/index.js +256 -0
  42. package/src/signaling/parse-peerjs-url.js +24 -0
  43. package/src/signaling/peerjs-signaling-provider.js +2 -8
@@ -0,0 +1,847 @@
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
+ }