dignity.js 0.4.0 → 0.5.2
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 +83 -2
- package/dist/dignity.cjs.js +542 -21
- package/dist/dignity.cjs.js.map +4 -4
- package/dist/dignity.esm.js +542 -21
- package/dist/dignity.esm.js.map +3 -3
- package/dist/dignity.min.js +18 -18
- package/docs/assets/dignity.esm.js +11205 -0
- package/docs/assets/favicon.svg +8 -0
- package/docs/chess/assets/chess-app.js +58022 -0
- package/docs/chess/assets/chess-app.js.map +7 -0
- package/docs/chess/assets/chess.css +584 -0
- package/docs/chess/favicon.ico +0 -0
- package/docs/chess/index.html +16 -0
- package/docs/chess/src/App.jsx +128 -0
- package/docs/chess/src/components/Board3D.jsx +364 -0
- package/docs/chess/src/components/GameView.jsx +847 -0
- package/docs/chess/src/components/JoinGate.jsx +68 -0
- package/docs/chess/src/components/LinkPanel.jsx +132 -0
- package/docs/chess/src/components/Lobby.jsx +154 -0
- package/docs/chess/src/components/MovePanel.jsx +123 -0
- package/docs/chess/src/lib/audio.js +50 -0
- package/docs/chess/src/lib/dignitySetup.js +42 -0
- package/docs/chess/src/lib/links.js +124 -0
- package/docs/chess/src/lib/localGames.js +160 -0
- package/docs/chess/src/lib/p2pDebug.js +192 -0
- package/docs/chess/src/main.jsx +5 -0
- package/docs/favicon.ico +0 -0
- package/docs/index.html +7 -3
- package/docs/openapi-like.json +35 -6
- package/examples/decentralized-chess-lite.js +52 -30
- package/package.json +13 -5
- package/src/core/dignity-p2p.js +388 -16
- package/src/index.js +6 -0
- package/src/network/peerjs-network.js +234 -0
- package/src/persistence/indexeddb-persistence.js +2 -0
- package/src/react/index.js +143 -1
- package/src/signaling/parse-peerjs-url.js +24 -0
- package/src/signaling/peerjs-signaling-provider.js +2 -8
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const ROLE_COPY = {
|
|
4
|
+
host: {
|
|
5
|
+
title: 'Start game',
|
|
6
|
+
action: 'Start as White',
|
|
7
|
+
hint: 'You will play White. Choose your name before connecting to the room.'
|
|
8
|
+
},
|
|
9
|
+
join: {
|
|
10
|
+
title: 'Join as opponent',
|
|
11
|
+
action: 'Join as Black',
|
|
12
|
+
hint: 'Choose your name now — it cannot be changed after connecting without reconnecting.'
|
|
13
|
+
},
|
|
14
|
+
watch: {
|
|
15
|
+
title: 'Watch game',
|
|
16
|
+
action: 'Enter as spectator',
|
|
17
|
+
hint: 'Pick a display name for the spectator list.'
|
|
18
|
+
},
|
|
19
|
+
resume: {
|
|
20
|
+
title: 'Resume game',
|
|
21
|
+
action: 'Reconnect',
|
|
22
|
+
hint: 'Use the same name you played with before, if possible.'
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default function JoinGate({ route, defaultNickname, onConfirm, onBack }) {
|
|
27
|
+
const [name, setName] = useState(defaultNickname);
|
|
28
|
+
const copy = ROLE_COPY[route.role] || ROLE_COPY.resume;
|
|
29
|
+
|
|
30
|
+
function handleSubmit(event) {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
const trimmed = name.trim();
|
|
33
|
+
if (!trimmed) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
onConfirm(trimmed);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<section className="join-gate">
|
|
41
|
+
<div className="join-gate__card panel">
|
|
42
|
+
<p className="eyebrow">Game {route.gameId}</p>
|
|
43
|
+
<h2>{copy.title}</h2>
|
|
44
|
+
<p>{copy.hint}</p>
|
|
45
|
+
|
|
46
|
+
<form onSubmit={handleSubmit}>
|
|
47
|
+
<label className="join-gate__field">
|
|
48
|
+
Your nickname
|
|
49
|
+
<input
|
|
50
|
+
value={name}
|
|
51
|
+
onChange={(event) => setName(event.target.value)}
|
|
52
|
+
placeholder="Nickname"
|
|
53
|
+
autoFocus
|
|
54
|
+
maxLength={32}
|
|
55
|
+
/>
|
|
56
|
+
</label>
|
|
57
|
+
|
|
58
|
+
<div className="join-gate__actions">
|
|
59
|
+
<button type="button" className="ghost" onClick={onBack}>← Back</button>
|
|
60
|
+
<button type="submit" className="primary" disabled={!name.trim()}>
|
|
61
|
+
{copy.action}
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</form>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { buildLinks } from '../lib/links';
|
|
3
|
+
|
|
4
|
+
export default function LinkPanel({
|
|
5
|
+
gameId,
|
|
6
|
+
roomKey,
|
|
7
|
+
hostPeer,
|
|
8
|
+
game,
|
|
9
|
+
joinToken: joinTokenProp,
|
|
10
|
+
watchToken: watchTokenProp,
|
|
11
|
+
resumeToken: resumeTokenProp,
|
|
12
|
+
onRegenerateResume,
|
|
13
|
+
prominent = false,
|
|
14
|
+
audience = 'host'
|
|
15
|
+
}) {
|
|
16
|
+
const joinToken = game?.data?.joinToken || joinTokenProp;
|
|
17
|
+
const watchToken = game?.data?.watchToken || watchTokenProp;
|
|
18
|
+
const resumeToken = game?.data?.resumeToken || resumeTokenProp;
|
|
19
|
+
const [copied, setCopied] = useState('');
|
|
20
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
21
|
+
const isPlayer = audience === 'player';
|
|
22
|
+
|
|
23
|
+
const links = buildLinks({
|
|
24
|
+
gameId,
|
|
25
|
+
roomKey,
|
|
26
|
+
hostPeer,
|
|
27
|
+
joinToken,
|
|
28
|
+
watchToken,
|
|
29
|
+
resumeToken
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const joinExpired = Boolean(game?.data?.joinTokenUsed);
|
|
33
|
+
const ready = isPlayer
|
|
34
|
+
? Boolean(hostPeer && watchToken && resumeToken)
|
|
35
|
+
: Boolean(hostPeer && joinToken && watchToken && resumeToken);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (prominent && (joinExpired || isPlayer)) {
|
|
39
|
+
setCollapsed(true);
|
|
40
|
+
}
|
|
41
|
+
}, [prominent, joinExpired, isPlayer]);
|
|
42
|
+
|
|
43
|
+
async function copyLink(label, url) {
|
|
44
|
+
if (!url) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await navigator.clipboard.writeText(url);
|
|
48
|
+
setCopied(label);
|
|
49
|
+
setTimeout(() => setCopied(''), 2000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const panelClass = [
|
|
53
|
+
'link-panel',
|
|
54
|
+
prominent ? 'link-panel--prominent' : '',
|
|
55
|
+
collapsed ? 'link-panel--collapsed' : ''
|
|
56
|
+
].filter(Boolean).join(' ');
|
|
57
|
+
|
|
58
|
+
const summary = !ready
|
|
59
|
+
? (hostPeer ? 'Preparing share links…' : 'Waiting for host peer…')
|
|
60
|
+
: isPlayer
|
|
61
|
+
? 'Spectator and resume links ready for your friends.'
|
|
62
|
+
: joinExpired
|
|
63
|
+
? 'Opponent joined — spectator and resume links available.'
|
|
64
|
+
: 'Opponent, spectator, and resume links ready.';
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<section className={panelClass}>
|
|
68
|
+
<div className="link-panel__head">
|
|
69
|
+
<h3>Share links</h3>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
className="ghost link-panel__toggle"
|
|
73
|
+
onClick={() => setCollapsed((value) => !value)}
|
|
74
|
+
aria-expanded={!collapsed}
|
|
75
|
+
>
|
|
76
|
+
{collapsed ? 'Show' : 'Hide'}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{collapsed ? (
|
|
81
|
+
<p className="link-panel__summary">{summary}</p>
|
|
82
|
+
) : null}
|
|
83
|
+
|
|
84
|
+
<div className="link-panel__body">
|
|
85
|
+
{!ready ? (
|
|
86
|
+
<p className="link-panel__hint">
|
|
87
|
+
{hostPeer
|
|
88
|
+
? 'Preparing share links…'
|
|
89
|
+
: 'Connecting to host… links appear once the game syncs.'}
|
|
90
|
+
</p>
|
|
91
|
+
) : (
|
|
92
|
+
<>
|
|
93
|
+
<p className="link-panel__hint">
|
|
94
|
+
{isPlayer
|
|
95
|
+
? 'Invite friends to watch live with the spectator link.'
|
|
96
|
+
: 'Send the opponent link to your friend. Spectators can watch without playing. The opponent link expires after the first join.'}
|
|
97
|
+
</p>
|
|
98
|
+
|
|
99
|
+
{!isPlayer ? (
|
|
100
|
+
<div className="link-row">
|
|
101
|
+
<label>Opponent {joinExpired ? '(expired)' : ''}</label>
|
|
102
|
+
<input readOnly value={links.join} onFocus={(e) => e.target.select()} />
|
|
103
|
+
<button type="button" disabled={joinExpired} onClick={() => copyLink('join', links.join)}>
|
|
104
|
+
{copied === 'join' ? 'Copied' : 'Copy'}
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
) : null}
|
|
108
|
+
|
|
109
|
+
<div className="link-row">
|
|
110
|
+
<label>Spectators</label>
|
|
111
|
+
<input readOnly value={links.watch} onFocus={(e) => e.target.select()} />
|
|
112
|
+
<button type="button" onClick={() => copyLink('watch', links.watch)}>
|
|
113
|
+
{copied === 'watch' ? 'Copied' : 'Copy'}
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="link-row">
|
|
118
|
+
<label>Resume game</label>
|
|
119
|
+
<input readOnly value={links.resume} onFocus={(e) => e.target.select()} />
|
|
120
|
+
<button type="button" onClick={() => copyLink('resume', links.resume)}>
|
|
121
|
+
{copied === 'resume' ? 'Copied' : 'Copy'}
|
|
122
|
+
</button>
|
|
123
|
+
{!isPlayer && onRegenerateResume ? (
|
|
124
|
+
<button type="button" className="secondary" onClick={onRegenerateResume}>New resume link</button>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</section>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { generateGameId } from '../lib/links.js';
|
|
3
|
+
import {
|
|
4
|
+
formatGameStatus,
|
|
5
|
+
formatRoleLabel,
|
|
6
|
+
listLocalGames,
|
|
7
|
+
sessionResumeHash
|
|
8
|
+
} from '../lib/localGames.js';
|
|
9
|
+
|
|
10
|
+
function GameList({ title, games, emptyText, onOpen }) {
|
|
11
|
+
if (!games.length) {
|
|
12
|
+
return (
|
|
13
|
+
<section className="lobby__games panel">
|
|
14
|
+
<h2>{title}</h2>
|
|
15
|
+
<p className="muted">{emptyText}</p>
|
|
16
|
+
</section>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<section className="lobby__games panel">
|
|
22
|
+
<h2>{title}</h2>
|
|
23
|
+
<ul className="game-list">
|
|
24
|
+
{games.map((game) => (
|
|
25
|
+
<li key={game.gameId} className="game-list__item">
|
|
26
|
+
<div className="game-list__meta">
|
|
27
|
+
<strong>{game.gameId}</strong>
|
|
28
|
+
<span className="muted">{formatRoleLabel(game)}</span>
|
|
29
|
+
<span>{formatGameStatus(game)}</span>
|
|
30
|
+
<span className="muted">
|
|
31
|
+
{new Date(game.updatedAt || Date.now()).toLocaleString()}
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
<button type="button" className="secondary" onClick={() => onOpen(game)}>
|
|
35
|
+
{game.status === 'finished' ? 'Review' : 'Continue'}
|
|
36
|
+
</button>
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
</section>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function Lobby({
|
|
45
|
+
nickname,
|
|
46
|
+
onNicknameChange,
|
|
47
|
+
onCreate,
|
|
48
|
+
onJoinPaste,
|
|
49
|
+
onOpenGame
|
|
50
|
+
}) {
|
|
51
|
+
const [pasteValue, setPasteValue] = useState('');
|
|
52
|
+
const [activeGames, setActiveGames] = useState([]);
|
|
53
|
+
const [finishedGames, setFinishedGames] = useState([]);
|
|
54
|
+
const [loadingGames, setLoadingGames] = useState(true);
|
|
55
|
+
|
|
56
|
+
async function refreshGames() {
|
|
57
|
+
setLoadingGames(true);
|
|
58
|
+
try {
|
|
59
|
+
const { active, finished } = await listLocalGames();
|
|
60
|
+
setActiveGames(active);
|
|
61
|
+
setFinishedGames(finished.slice(0, 12));
|
|
62
|
+
} finally {
|
|
63
|
+
setLoadingGames(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
refreshGames();
|
|
69
|
+
window.addEventListener('focus', refreshGames);
|
|
70
|
+
return () => window.removeEventListener('focus', refreshGames);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
function handleOpenGame(game) {
|
|
74
|
+
onOpenGame(sessionResumeHash(game));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="lobby-layout">
|
|
79
|
+
<section className="lobby lobby__top">
|
|
80
|
+
<div className="lobby__hero">
|
|
81
|
+
<p className="eyebrow">Decentralized demo</p>
|
|
82
|
+
<h1>3D Chess on dignity.js</h1>
|
|
83
|
+
<p>
|
|
84
|
+
Peer-to-peer chess over Cloudflare PeerJS signaling, encrypted room scopes,
|
|
85
|
+
IndexedDB persistence, and React hooks.
|
|
86
|
+
</p>
|
|
87
|
+
<label className="lobby__nickname">
|
|
88
|
+
Your nickname
|
|
89
|
+
<input
|
|
90
|
+
value={nickname}
|
|
91
|
+
onChange={(event) => onNicknameChange(event.target.value)}
|
|
92
|
+
placeholder="Nickname"
|
|
93
|
+
maxLength={32}
|
|
94
|
+
/>
|
|
95
|
+
</label>
|
|
96
|
+
<button type="button" className="primary" onClick={() => onCreate(generateGameId())}>
|
|
97
|
+
Start new game
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="lobby__join">
|
|
102
|
+
<h2>Join from link</h2>
|
|
103
|
+
<p>Paste a host, opponent, spectator, or resume link. You will choose or confirm your name on the next screen.</p>
|
|
104
|
+
<textarea
|
|
105
|
+
rows={4}
|
|
106
|
+
value={pasteValue}
|
|
107
|
+
onChange={(event) => setPasteValue(event.target.value)}
|
|
108
|
+
placeholder="https://…/chess/#game=…&role=join…"
|
|
109
|
+
/>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className="secondary"
|
|
113
|
+
onClick={() => {
|
|
114
|
+
if (!pasteValue.trim()) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
onJoinPaste(pasteValue.trim());
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
Open link
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
|
|
125
|
+
<section className="lobby__history">
|
|
126
|
+
<div className="lobby__history-head">
|
|
127
|
+
<h2>Your games on this device</h2>
|
|
128
|
+
<button type="button" className="ghost" onClick={refreshGames} disabled={loadingGames}>
|
|
129
|
+
Refresh
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{loadingGames ? (
|
|
134
|
+
<p className="muted">Loading saved games…</p>
|
|
135
|
+
) : (
|
|
136
|
+
<div className="lobby__history-grid">
|
|
137
|
+
<GameList
|
|
138
|
+
title="Active"
|
|
139
|
+
games={activeGames}
|
|
140
|
+
emptyText="No active games. Start one or join from a link."
|
|
141
|
+
onOpen={handleOpenGame}
|
|
142
|
+
/>
|
|
143
|
+
<GameList
|
|
144
|
+
title="Finished"
|
|
145
|
+
games={finishedGames}
|
|
146
|
+
emptyText="No finished games yet."
|
|
147
|
+
onOpen={handleOpenGame}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</section>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
const PIECE_SYMBOLS = {
|
|
4
|
+
wp: '♙', wn: '♘', wb: '♗', wr: '♖', wq: '♕', wk: '♔',
|
|
5
|
+
bp: '♟', bn: '♞', bb: '♝', br: '♜', bq: '♛', bk: '♚'
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function pieceSymbol(piece) {
|
|
9
|
+
if (!piece) {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
return PIECE_SYMBOLS[`${piece.color}${piece.type}`] || piece.type;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function MovePanel({
|
|
16
|
+
chess,
|
|
17
|
+
canMove,
|
|
18
|
+
myColor,
|
|
19
|
+
selectedSquare,
|
|
20
|
+
legalTargets,
|
|
21
|
+
onSquareClick,
|
|
22
|
+
gameStatus,
|
|
23
|
+
turn,
|
|
24
|
+
roomConnected = true,
|
|
25
|
+
connectionCount = 0
|
|
26
|
+
}) {
|
|
27
|
+
const files = 'abcdefgh';
|
|
28
|
+
const ranks = myColor === 'b' ? [1, 2, 3, 4, 5, 6, 7, 8] : [8, 7, 6, 5, 4, 3, 2, 1];
|
|
29
|
+
|
|
30
|
+
let statusText = 'Spectating';
|
|
31
|
+
if (!roomConnected) {
|
|
32
|
+
statusText = 'Connecting to host peer…';
|
|
33
|
+
} else if (canMove) {
|
|
34
|
+
statusText = 'Your turn — click a piece, then a destination square.';
|
|
35
|
+
} else if (myColor && gameStatus === 'playing') {
|
|
36
|
+
statusText = turn === myColor ? 'Syncing…' : 'Waiting for opponent…';
|
|
37
|
+
} else if (gameStatus === 'waiting') {
|
|
38
|
+
statusText = connectionCount > 0
|
|
39
|
+
? 'Waiting for opponent to join…'
|
|
40
|
+
: 'Connecting P2P link…';
|
|
41
|
+
} else if (gameStatus === 'finished') {
|
|
42
|
+
statusText = 'Game over.';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section className="panel move-panel">
|
|
47
|
+
<h3>Move</h3>
|
|
48
|
+
<p className="move-panel__status">{statusText}</p>
|
|
49
|
+
|
|
50
|
+
{selectedSquare ? (
|
|
51
|
+
<p className="move-panel__selected">
|
|
52
|
+
Selected: <strong>{selectedSquare.toUpperCase()}</strong>
|
|
53
|
+
{legalTargets.length ? ` · ${legalTargets.length} legal move(s)` : ''}
|
|
54
|
+
</p>
|
|
55
|
+
) : null}
|
|
56
|
+
|
|
57
|
+
<div className="mini-board" aria-label="Chess board">
|
|
58
|
+
{ranks.map((rank) => (
|
|
59
|
+
<div key={rank} className="mini-board__row">
|
|
60
|
+
<span className="mini-board__rank">{rank}</span>
|
|
61
|
+
{files.split('').map((file) => {
|
|
62
|
+
const square = `${file}${rank}`;
|
|
63
|
+
const piece = chess.get(square);
|
|
64
|
+
const isLight = (file.charCodeAt(0) - 97 + rank) % 2 === 0;
|
|
65
|
+
const isSelected = selectedSquare === square;
|
|
66
|
+
const isTarget = legalTargets.includes(square);
|
|
67
|
+
const isOwnPiece = piece && piece.color === myColor;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
key={square}
|
|
72
|
+
type="button"
|
|
73
|
+
title={square}
|
|
74
|
+
className={[
|
|
75
|
+
'mini-board__sq',
|
|
76
|
+
isLight ? 'light' : 'dark',
|
|
77
|
+
isSelected ? 'selected' : '',
|
|
78
|
+
isTarget ? 'target' : '',
|
|
79
|
+
isOwnPiece ? 'own' : ''
|
|
80
|
+
].filter(Boolean).join(' ')}
|
|
81
|
+
onClick={() => onSquareClick(square)}
|
|
82
|
+
disabled={!canMove && !isTarget}
|
|
83
|
+
>
|
|
84
|
+
{piece ? (
|
|
85
|
+
<span className={`mini-board__piece mini-board__piece--${piece.color}`}>
|
|
86
|
+
{pieceSymbol(piece)}
|
|
87
|
+
</span>
|
|
88
|
+
) : null}
|
|
89
|
+
</button>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
<div className="mini-board__files">
|
|
95
|
+
<span className="mini-board__rank" aria-hidden="true" />
|
|
96
|
+
{files.split('').map((file) => (
|
|
97
|
+
<span key={file} className="mini-board__file">{file}</span>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{canMove && legalTargets.length > 0 ? (
|
|
103
|
+
<div className="move-targets">
|
|
104
|
+
<p className="muted">Quick moves</p>
|
|
105
|
+
<div className="move-targets__list">
|
|
106
|
+
{legalTargets.map((square) => (
|
|
107
|
+
<button
|
|
108
|
+
key={square}
|
|
109
|
+
type="button"
|
|
110
|
+
className="secondary"
|
|
111
|
+
onClick={() => onSquareClick(square)}
|
|
112
|
+
>
|
|
113
|
+
{selectedSquare}
|
|
114
|
+
→
|
|
115
|
+
{square}
|
|
116
|
+
</button>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
) : null}
|
|
121
|
+
</section>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
let audioContext;
|
|
2
|
+
|
|
3
|
+
function getContext() {
|
|
4
|
+
if (!audioContext) {
|
|
5
|
+
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
6
|
+
}
|
|
7
|
+
return audioContext;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function tone({ frequency, duration = 0.12, type = 'sine', gain = 0.04 }) {
|
|
11
|
+
const ctx = getContext();
|
|
12
|
+
const oscillator = ctx.createOscillator();
|
|
13
|
+
const volume = ctx.createGain();
|
|
14
|
+
|
|
15
|
+
oscillator.type = type;
|
|
16
|
+
oscillator.frequency.value = frequency;
|
|
17
|
+
volume.gain.value = gain;
|
|
18
|
+
|
|
19
|
+
oscillator.connect(volume);
|
|
20
|
+
volume.connect(ctx.destination);
|
|
21
|
+
|
|
22
|
+
oscillator.start();
|
|
23
|
+
oscillator.stop(ctx.currentTime + duration);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function playMoveSound() {
|
|
27
|
+
tone({ frequency: 280, duration: 0.08, type: 'triangle' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function playCaptureSound() {
|
|
31
|
+
tone({ frequency: 180, duration: 0.14, type: 'square', gain: 0.05 });
|
|
32
|
+
setTimeout(() => tone({ frequency: 120, duration: 0.1, type: 'square', gain: 0.04 }), 70);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function playCheckSound() {
|
|
36
|
+
tone({ frequency: 520, duration: 0.18, type: 'sawtooth', gain: 0.03 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function playGameStartSound() {
|
|
40
|
+
[220, 330, 440].forEach((frequency, index) => {
|
|
41
|
+
setTimeout(() => tone({ frequency, duration: 0.16, type: 'triangle', gain: 0.035 }), index * 90);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function resumeAudio() {
|
|
46
|
+
const ctx = getContext();
|
|
47
|
+
if (ctx.state === 'suspended') {
|
|
48
|
+
await ctx.resume();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DignityP2P,
|
|
3
|
+
createPeerJSNetworkAdapter,
|
|
4
|
+
IndexedDBPersistence,
|
|
5
|
+
DEFAULT_CLOUDFLARE_SIGNALING_URLS
|
|
6
|
+
} from '../../../../src/index.js';
|
|
7
|
+
|
|
8
|
+
export function createDignityConfig({ nodeId, roomKey, scope, nickname, role }) {
|
|
9
|
+
const networkAdapter = createPeerJSNetworkAdapter({
|
|
10
|
+
urls: DEFAULT_CLOUDFLARE_SIGNALING_URLS
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (typeof console !== 'undefined') {
|
|
14
|
+
console.log('[chess-p2p] signaling urls', DEFAULT_CLOUDFLARE_SIGNALING_URLS);
|
|
15
|
+
console.log('[chess-p2p] dignity config', { nodeId, scope, role, roomKeyLen: roomKey?.length });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
nodeId,
|
|
20
|
+
networkAdapter,
|
|
21
|
+
security: {
|
|
22
|
+
appPassword: roomKey,
|
|
23
|
+
powTargetMs: 250,
|
|
24
|
+
broadcastPasswords: {
|
|
25
|
+
[scope]: roomKey,
|
|
26
|
+
default: roomKey
|
|
27
|
+
},
|
|
28
|
+
resolveBroadcastScope: () => scope,
|
|
29
|
+
discoveryHeartbeatMs: 12000,
|
|
30
|
+
presenceTtlMs: 36000
|
|
31
|
+
},
|
|
32
|
+
__meta: { nickname, role, scope, roomKey }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function attachPersistence(node, collections = ['chess-matches']) {
|
|
37
|
+
const persistence = new IndexedDBPersistence({ collections });
|
|
38
|
+
await persistence.attach(node);
|
|
39
|
+
return persistence;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { DignityP2P, IndexedDBPersistence };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export function randomToken(length = 12) {
|
|
2
|
+
const bytes = new Uint8Array(length);
|
|
3
|
+
crypto.getRandomValues(bytes);
|
|
4
|
+
return Array.from(bytes, (byte) => byte.toString(36).padStart(2, '0')).join('').slice(0, length);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const CHESS_LEGENDS = [
|
|
8
|
+
'Fischer', 'Spassky', 'Karpov', 'Kasparov', 'Carlsen', 'Anand', 'Tal', 'Botvinnik',
|
|
9
|
+
'Capablanca', 'Alekhine', 'Morphy', 'Anderssen', 'Lasker', 'Petrosian', 'Korchnoi',
|
|
10
|
+
'Polgar', 'Nepo', 'Ding', 'Aronian', 'Kramnik', 'Smyslov', 'Euwe', 'Bronstein',
|
|
11
|
+
'Larsen', 'Steinitz', 'Rubinstein', 'Reshevsky', 'Ivanchuk', 'Firouzja', 'Giri'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const MATCH_NICKNAMES = [
|
|
15
|
+
'Immortal', 'Evergreen', 'Opera', 'Century', 'Candidates'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const CLASSIC_YEARS = [1858, 1927, 1972, 1985, 2013, 2016, 2021, 2023];
|
|
19
|
+
|
|
20
|
+
function pickRandom(items) {
|
|
21
|
+
return items[Math.floor(Math.random() * items.length)];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pickTwoDifferent(items) {
|
|
25
|
+
const first = pickRandom(items);
|
|
26
|
+
let second = pickRandom(items);
|
|
27
|
+
while (second === first) {
|
|
28
|
+
second = pickRandom(items);
|
|
29
|
+
}
|
|
30
|
+
return [first, second];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Human-readable game id from famous player names (URL-safe). */
|
|
34
|
+
export function generateGameId() {
|
|
35
|
+
const style = Math.random();
|
|
36
|
+
const [white, black] = pickTwoDifferent(CHESS_LEGENDS);
|
|
37
|
+
|
|
38
|
+
if (style < 0.45) {
|
|
39
|
+
return `${white}-${black}`;
|
|
40
|
+
}
|
|
41
|
+
if (style < 0.75) {
|
|
42
|
+
return `${white}-vs-${black}`;
|
|
43
|
+
}
|
|
44
|
+
if (style < 0.9) {
|
|
45
|
+
return `${pickRandom(CHESS_LEGENDS)}-${pickRandom(CLASSIC_YEARS)}`;
|
|
46
|
+
}
|
|
47
|
+
return `${pickRandom(MATCH_NICKNAMES)}-${pickRandom(CHESS_LEGENDS)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseRoute() {
|
|
51
|
+
const hash = window.location.hash.replace(/^#/, '');
|
|
52
|
+
const params = Object.fromEntries(new URLSearchParams(hash));
|
|
53
|
+
return {
|
|
54
|
+
gameId: params.game || null,
|
|
55
|
+
role: params.role || 'host',
|
|
56
|
+
roomKey: params.room || null,
|
|
57
|
+
hostPeer: params.host || null,
|
|
58
|
+
joinToken: params.join || null,
|
|
59
|
+
watchToken: params.watch || null,
|
|
60
|
+
resumeToken: params.resume || null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildLinks({ gameId, roomKey, hostPeer, joinToken, watchToken, resumeToken }) {
|
|
65
|
+
const base = `${window.location.origin}${window.location.pathname}`;
|
|
66
|
+
const common = `game=${encodeURIComponent(gameId)}&room=${encodeURIComponent(roomKey)}`;
|
|
67
|
+
const hostParam = hostPeer ? `&host=${encodeURIComponent(hostPeer)}` : '';
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
host: `${base}#${common}&role=host&resume=${encodeURIComponent(resumeToken)}${hostParam}`,
|
|
71
|
+
join: `${base}#${common}&role=join&join=${encodeURIComponent(joinToken)}${hostParam}`,
|
|
72
|
+
watch: `${base}#${common}&role=watch&watch=${encodeURIComponent(watchToken)}${hostParam}`,
|
|
73
|
+
resume: `${base}#${common}&role=resume&resume=${encodeURIComponent(resumeToken)}${hostParam}`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function withHostPeerInHash(hostPeer) {
|
|
78
|
+
if (!hostPeer || typeof window === 'undefined') {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const hash = window.location.hash.replace(/^#/, '');
|
|
83
|
+
const params = new URLSearchParams(hash);
|
|
84
|
+
if (params.get('host') === hostPeer) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
params.set('host', hostPeer);
|
|
89
|
+
window.history.replaceState(null, '', `#${params.toString()}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function scopeForGame(gameId) {
|
|
93
|
+
return `room:chess:${gameId}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function nodeIdForRole(role, gameId) {
|
|
97
|
+
const suffix = randomToken(4);
|
|
98
|
+
const gameBit = gameId.replace(/[^a-zA-Z0-9]/g, '').slice(0, 6);
|
|
99
|
+
const roleBit = role === 'host' ? 'h' : role === 'join' ? 'j' : role === 'watch' ? 'w' : 'r';
|
|
100
|
+
return `c${gameBit}${roleBit}${suffix}`.slice(0, 16);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function hostPeerFromRoute(route, game, localNodeId) {
|
|
104
|
+
return route.hostPeer || game?.data?.whitePlayerId || (route.role === 'host' ? localNodeId : null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function connectToRoomPeer(node, remotePeerId) {
|
|
108
|
+
if (!node?.connectToPeer || !remotePeerId || remotePeerId === node.nodeId) {
|
|
109
|
+
return { ok: false, reason: 'missing-node-or-self' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await node.connectToPeer(remotePeerId);
|
|
114
|
+
const stats = node.getConnectionStats?.() || { openCount: 0, peerIds: [] };
|
|
115
|
+
const open = stats.peerIds.includes(remotePeerId) || stats.openCount > 0;
|
|
116
|
+
return { ok: true, open, stats };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: 'connect-failed',
|
|
121
|
+
message: error?.message || String(error)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|