dignity.js 0.5.2 → 0.5.4

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.
@@ -1,68 +0,0 @@
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
- }
@@ -1,132 +0,0 @@
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
- }
@@ -1,154 +0,0 @@
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
- }
@@ -1,123 +0,0 @@
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
- }
@@ -1,50 +0,0 @@
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
- }
@@ -1,42 +0,0 @@
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 };
@@ -1,124 +0,0 @@
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
- }