@roster-lock/match-agent 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/dist/assets/index-DeYshswm.js +125 -0
- package/client/dist/assets/index-gxAuIfGQ.css +1 -0
- package/client/dist/index.html +13 -0
- package/client/index.html +12 -0
- package/client/node_modules/.bin/terser +17 -0
- package/client/node_modules/.bin/tsc +17 -0
- package/client/node_modules/.bin/vite +17 -0
- package/client/node_modules/.vite/deps/@rjsf_core.js +6609 -0
- package/client/node_modules/.vite/deps/@rjsf_core.js.map +7 -0
- package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js +13 -0
- package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js.map +7 -0
- package/client/node_modules/.vite/deps/@roster-lock_ts-client.js +419 -0
- package/client/node_modules/.vite/deps/@roster-lock_ts-client.js.map +7 -0
- package/client/node_modules/.vite/deps/@roster-lock_types.js +5 -0
- package/client/node_modules/.vite/deps/@roster-lock_types.js.map +7 -0
- package/client/node_modules/.vite/deps/@roster-lock_utils.js +5 -0
- package/client/node_modules/.vite/deps/@roster-lock_utils.js.map +7 -0
- package/client/node_modules/.vite/deps/_metadata.json +97 -0
- package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js +16039 -0
- package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-5KLROPUR.js +292 -0
- package/client/node_modules/.vite/deps/chunk-5KLROPUR.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js +280 -0
- package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-I55GDNGV.js +1004 -0
- package/client/node_modules/.vite/deps/chunk-I55GDNGV.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js +2337 -0
- package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js +466 -0
- package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js.map +7 -0
- package/client/node_modules/.vite/deps/chunk-WMTNK64S.js +52 -0
- package/client/node_modules/.vite/deps/chunk-WMTNK64S.js.map +7 -0
- package/client/node_modules/.vite/deps/package.json +3 -0
- package/client/node_modules/.vite/deps/react-dom.js +6 -0
- package/client/node_modules/.vite/deps/react-dom.js.map +7 -0
- package/client/node_modules/.vite/deps/react-dom_client.js +20217 -0
- package/client/node_modules/.vite/deps/react-dom_client.js.map +7 -0
- package/client/node_modules/.vite/deps/react-router-dom.js +14702 -0
- package/client/node_modules/.vite/deps/react-router-dom.js.map +7 -0
- package/client/node_modules/.vite/deps/react.js +5 -0
- package/client/node_modules/.vite/deps/react.js.map +7 -0
- package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js +278 -0
- package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
- package/client/node_modules/.vite/deps/react_jsx-runtime.js +6 -0
- package/client/node_modules/.vite/deps/react_jsx-runtime.js.map +7 -0
- package/client/node_modules/.vite/deps_temp_ab51ebe9/package.json +3 -0
- package/client/package.json +30 -0
- package/client/src/App.tsx +56 -0
- package/client/src/NavBar.tsx +13 -0
- package/client/src/api/matchAgent.ts +159 -0
- package/client/src/bridge/hostBridge.ts +149 -0
- package/client/src/components/GameLauncherSettingsForm.tsx +140 -0
- package/client/src/components/InstallGameLauncherLightbox.tsx +31 -0
- package/client/src/components/Lightbox.tsx +15 -0
- package/client/src/components/RequireConnection.tsx +9 -0
- package/client/src/components/Selection/PieceCard.tsx +36 -0
- package/client/src/components/Selection/PieceTypeSection.tsx +112 -0
- package/client/src/components/Selection/PieceTypeTabs.tsx +31 -0
- package/client/src/components/Selection/PlayerSelectionPanel.tsx +40 -0
- package/client/src/components/Selection/index.tsx +147 -0
- package/client/src/components/Selection/selectionPlan.ts +75 -0
- package/client/src/context/DownloadSessionContext.tsx +48 -0
- package/client/src/context/IdentityContext.tsx +53 -0
- package/client/src/context/JoinSettingsContext.tsx +83 -0
- package/client/src/context/MatchAgentContext.tsx +85 -0
- package/client/src/hooks/useCursorInput.ts +55 -0
- package/client/src/hooks/useGamepads.ts +31 -0
- package/client/src/main.tsx +10 -0
- package/client/src/pages/Connect/index.tsx +46 -0
- package/client/src/pages/Download/index.tsx +175 -0
- package/client/src/pages/Game/index.tsx +65 -0
- package/client/src/pages/GameLauncher/Detail.tsx +12 -0
- package/client/src/pages/GameLauncher/index.tsx +32 -0
- package/client/src/pages/JoinSettings/index.tsx +58 -0
- package/client/src/pages/MatchMaking/index.tsx +190 -0
- package/client/src/styles/global.css +355 -0
- package/client/tsconfig.json +17 -0
- package/client/tsconfig.tsbuildinfo +1 -0
- package/client/vite.config.ts +33 -0
- package/dist/config/manage.js.map +1 -1
- package/dist/handle-room/version-1/game-launcher.js +181 -0
- package/dist/handle-room/version-1/game-launcher.js.map +1 -0
- package/dist/handle-room/version-1/index.js +15 -2
- package/dist/handle-room/version-1/index.js.map +1 -1
- package/dist/handle-room/version-1/piece-sort.js +1 -0
- package/dist/handle-room/version-1/piece-sort.js.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/serve-client.js +88 -0
- package/dist/serve-client.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/package.json +12 -6
- package/src/config/manage.ts +5 -1
- package/src/handle-room/version-1/game-launcher.ts +263 -0
- package/src/handle-room/version-1/globals/types.ts +24 -0
- package/src/handle-room/version-1/index.ts +23 -3
- package/src/handle-room/version-1/piece-sort.ts +1 -1
- package/src/index.ts +25 -5
- package/src/serve-client.ts +100 -0
- package/src/server.ts +1 -1
- package/tests/integration/game-launcher-list.test.ts +77 -0
- package/tests/integration/game-launcher-settings.test.ts +138 -0
- package/tests/integration/game-launcher-start.test.ts +81 -0
- package/tests/integration/helpers/fakePluginRuntime.ts +26 -1
- package/tests/integration/helpers/server.ts +8 -2
- package/src/index.old.ts +0 -71
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { syncDownloadOverWebSocket, getMachines } from "@roster-lock/ts-client";
|
|
4
|
+
import {
|
|
5
|
+
ROSTERLOCK_DOWNLOAD_STATE, RosterLockDownloadUpdate, RosterLockV1SyncDLRequestUserToClient,
|
|
6
|
+
RosterLockV1SyncDLResult,
|
|
7
|
+
} from "@roster-lock/types";
|
|
8
|
+
import { useIdentity } from "../../context/IdentityContext";
|
|
9
|
+
import { useMatchAgent } from "../../context/MatchAgentContext";
|
|
10
|
+
import { useDownloadSession } from "../../context/DownloadSessionContext";
|
|
11
|
+
import { startGameLauncher } from "../../api/matchAgent";
|
|
12
|
+
|
|
13
|
+
type PieceProgress = { pieceType: string, status: ROSTERLOCK_DOWNLOAD_STATE, progress: number, error?: string };
|
|
14
|
+
type MachineRow = { machineId: string, publicKey: string, displayName: string, playerCount: number, connected: boolean };
|
|
15
|
+
|
|
16
|
+
// Reused by every matchmaking algorithm, not just /match-making/rooms - once
|
|
17
|
+
// a match maker hands off a relay room, this is the only step left before
|
|
18
|
+
// starting the game, regardless of how the room/opponent was found. See
|
|
19
|
+
// examples/full-local/game/game-pwa/src/pages/Download/index.tsx, which this
|
|
20
|
+
// is ported from, generalized from "one machine's own piece progress" to
|
|
21
|
+
// also list which machines in the room are connected.
|
|
22
|
+
export function DownloadPage() {
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
const identity = useIdentity();
|
|
25
|
+
const matchAgent = useMatchAgent();
|
|
26
|
+
const { session, clearSession } = useDownloadSession();
|
|
27
|
+
|
|
28
|
+
const [machines, setMachines] = useState<Array<MachineRow>>([]);
|
|
29
|
+
const [downloadResult, setDownloadResult] = useState<RosterLockV1SyncDLResult | null>(null);
|
|
30
|
+
const [roomState, setRoomState] = useState<string | null>(null);
|
|
31
|
+
const [pieces, setPieces] = useState<Record<string, PieceProgress>>({});
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [starting, setStarting] = useState(false);
|
|
34
|
+
const startedDownload = useRef(false);
|
|
35
|
+
const startedGame = useRef(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (session) return;
|
|
39
|
+
navigate("/match-making");
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (startedDownload.current) return;
|
|
45
|
+
if (!session) return;
|
|
46
|
+
startedDownload.current = true;
|
|
47
|
+
|
|
48
|
+
const request: RosterLockV1SyncDLRequestUserToClient = {
|
|
49
|
+
version: 1,
|
|
50
|
+
relay: session.relay,
|
|
51
|
+
machine: { machineId: identity.machineId, keys: identity.keys },
|
|
52
|
+
rosterConfig: session.rosterConfig,
|
|
53
|
+
playerSelections: session.playerSelections,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
Promise.all([
|
|
57
|
+
getMachines({ machine: request.machine, relay: request.relay })
|
|
58
|
+
.catch((e) => { throw new Error(`getMachines (relay ${request.relay.url}) failed: ${(e as Error).message}`); }),
|
|
59
|
+
syncDownloadOverWebSocket(
|
|
60
|
+
request,
|
|
61
|
+
matchAgent.settings.authCode,
|
|
62
|
+
{
|
|
63
|
+
onState: (state) => setRoomState(state),
|
|
64
|
+
onDownloadProgress: (update: RosterLockDownloadUpdate) => {
|
|
65
|
+
if (update.type === ROSTERLOCK_DOWNLOAD_STATE.downloadFullFailure) {
|
|
66
|
+
setError(update.error);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (update.type === ROSTERLOCK_DOWNLOAD_STATE.downloadAllComplete) return;
|
|
70
|
+
const key = `${update.pieceType}:${update.pieceVersions.logic}:${update.pieceVersions.media}`;
|
|
71
|
+
setPieces((prev) => {
|
|
72
|
+
const existing = prev[key];
|
|
73
|
+
const progress = update.type === ROSTERLOCK_DOWNLOAD_STATE.downloadProgress
|
|
74
|
+
? update.progress
|
|
75
|
+
: update.type === ROSTERLOCK_DOWNLOAD_STATE.downloadFinished ? 100 : (existing?.progress ?? 0);
|
|
76
|
+
return {
|
|
77
|
+
...prev,
|
|
78
|
+
[key]: {
|
|
79
|
+
pieceType: update.pieceType,
|
|
80
|
+
status: update.type,
|
|
81
|
+
progress,
|
|
82
|
+
error: update.type === ROSTERLOCK_DOWNLOAD_STATE.downloadFailure ? update.error : undefined,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
matchAgent.settings.url,
|
|
89
|
+
).catch((e) => { throw new Error(`syncDownloadOverWebSocket (match-agent ${matchAgent.settings.url}) failed: ${(e as Error).message}`); }),
|
|
90
|
+
])
|
|
91
|
+
.then(([users, result]) => {
|
|
92
|
+
setMachines(users.map((u) => ({
|
|
93
|
+
machineId: u.machineId, publicKey: u.publicKey, displayName: u.displayName,
|
|
94
|
+
playerCount: u.playerCount, connected: u.connected,
|
|
95
|
+
})));
|
|
96
|
+
setDownloadResult(result);
|
|
97
|
+
})
|
|
98
|
+
.catch((e) => setError((e as Error).message));
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
}, [session]);
|
|
101
|
+
|
|
102
|
+
const pieceEntries = Object.entries(pieces);
|
|
103
|
+
const overall = pieceEntries.length
|
|
104
|
+
? pieceEntries.reduce((sum, [, p]) => sum + p.progress, 0) / pieceEntries.length
|
|
105
|
+
: 0;
|
|
106
|
+
const downloadComplete = pieceEntries.length > 0 && pieceEntries.every(([, p]) => p.progress >= 100);
|
|
107
|
+
|
|
108
|
+
// Starts automatically as soon as the download finishes - no separate
|
|
109
|
+
// "Start Match" click needed. Guarded so it only ever attempts once, same
|
|
110
|
+
// as the download-kickoff effect above.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (startedGame.current) return;
|
|
113
|
+
if (!session || !downloadComplete || !downloadResult) return;
|
|
114
|
+
startedGame.current = true;
|
|
115
|
+
|
|
116
|
+
if (!session.coordinator) {
|
|
117
|
+
setError(`No rendezvous coordinator available for "${session.gameLauncherPlugin}" - the matchmaker didn't provide one`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setStarting(true);
|
|
121
|
+
setError(null);
|
|
122
|
+
|
|
123
|
+
const connectionConfig = session.isHost
|
|
124
|
+
? { type: "direct-tcp" as const, party: "host" as const, port: 7000, coordinator: session.coordinator }
|
|
125
|
+
: { type: "direct-tcp" as const, party: "client" as const, port: 7000, coordinator: session.coordinator };
|
|
126
|
+
|
|
127
|
+
startGameLauncher(matchAgent.settings.url, matchAgent.settings.authCode, session.gameLauncherPlugin, {
|
|
128
|
+
connectionConfig,
|
|
129
|
+
currentMachine: { machineId: identity.machineId, publicKey: identity.keys.publicKey, privateKey: identity.keys.privateKey },
|
|
130
|
+
allMachines: machines,
|
|
131
|
+
selectionResult: downloadResult,
|
|
132
|
+
rosterConfig: session.rosterConfig,
|
|
133
|
+
gameConfig: session.gameConfig,
|
|
134
|
+
relayRoomId: session.relay.roomId,
|
|
135
|
+
})
|
|
136
|
+
.then(() => {
|
|
137
|
+
clearSession();
|
|
138
|
+
navigate("/game");
|
|
139
|
+
})
|
|
140
|
+
.catch((e) => setError((e as Error).message))
|
|
141
|
+
.finally(() => setStarting(false));
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
143
|
+
}, [session, downloadComplete, downloadResult]);
|
|
144
|
+
|
|
145
|
+
if (!session) return null;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="page">
|
|
149
|
+
<h1>Downloading</h1>
|
|
150
|
+
{error && <p className="error">{error}</p>}
|
|
151
|
+
<p>{roomState ?? "Connecting..."}</p>
|
|
152
|
+
|
|
153
|
+
<h2>Players</h2>
|
|
154
|
+
<ul>
|
|
155
|
+
{machines.map((m) => (
|
|
156
|
+
<li key={m.machineId}>{m.displayName} - {m.connected ? "Connected" : "Not connected"} - Selected</li>
|
|
157
|
+
))}
|
|
158
|
+
</ul>
|
|
159
|
+
|
|
160
|
+
<div className="progress-bar"><div className="progress-fill" style={{ width: `${overall}%` }} /></div>
|
|
161
|
+
{pieceEntries.map(([key, p]) => (
|
|
162
|
+
<div className="download-row" key={key}>
|
|
163
|
+
<div>{p.pieceType}: {p.status}{p.error ? ` - ${p.error}` : ""}</div>
|
|
164
|
+
<div className="progress-bar"><div className="progress-fill" style={{ width: `${p.progress}%` }} /></div>
|
|
165
|
+
</div>
|
|
166
|
+
))}
|
|
167
|
+
|
|
168
|
+
{downloadComplete && (
|
|
169
|
+
<div className="start-game">
|
|
170
|
+
<h2>{starting ? "Starting..." : "Ready to start"}</h2>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useMatchAgent } from "../../context/MatchAgentContext";
|
|
3
|
+
import { listGameProcesses, stopGameLauncher, GameProcessSummary } from "../../api/matchAgent";
|
|
4
|
+
|
|
5
|
+
const POLL_INTERVAL_MS = 2000;
|
|
6
|
+
|
|
7
|
+
// Every game process this machine's match-agent has started, regardless of
|
|
8
|
+
// which page launched it (today that's only pages/Download's "Start Match"
|
|
9
|
+
// button) - lets a player see a game is actually running, and close it,
|
|
10
|
+
// without having to go find the real game window.
|
|
11
|
+
export function GameStatusPage() {
|
|
12
|
+
const { settings } = useMatchAgent();
|
|
13
|
+
const [processes, setProcesses] = useState<Array<GameProcessSummary>>([]);
|
|
14
|
+
const [stoppingId, setStoppingId] = useState<string | null>(null);
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
const poll = () => {
|
|
20
|
+
listGameProcesses(settings.url, settings.authCode)
|
|
21
|
+
.then((next) => { if (!cancelled) setProcesses(next); })
|
|
22
|
+
.catch((e) => { if (!cancelled) setError(e.message); });
|
|
23
|
+
};
|
|
24
|
+
poll();
|
|
25
|
+
const interval = setInterval(poll, POLL_INTERVAL_MS);
|
|
26
|
+
return () => { cancelled = true; clearInterval(interval); };
|
|
27
|
+
}, [settings]);
|
|
28
|
+
|
|
29
|
+
const handleClose = async (proc: GameProcessSummary) => {
|
|
30
|
+
setStoppingId(proc.handleId);
|
|
31
|
+
setError(null);
|
|
32
|
+
try {
|
|
33
|
+
await stopGameLauncher(settings.url, settings.authCode, proc.pluginName, proc.handleId);
|
|
34
|
+
const next = await listGameProcesses(settings.url, settings.authCode);
|
|
35
|
+
setProcesses(next);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
setError((e as Error).message);
|
|
38
|
+
} finally {
|
|
39
|
+
setStoppingId(null);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="page">
|
|
45
|
+
<h1>Games</h1>
|
|
46
|
+
{error && <p className="error">{error}</p>}
|
|
47
|
+
<ul className="game-process-list">
|
|
48
|
+
{processes.map((proc) => (
|
|
49
|
+
<li key={proc.handleId} className="game-process-row">
|
|
50
|
+
<span>{proc.pluginName}</span>
|
|
51
|
+
<span>{proc.exited === false ? "Running" : `Exited (code ${proc.exited.code})`}</span>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
disabled={proc.exited !== false || stoppingId === proc.handleId}
|
|
55
|
+
onClick={() => handleClose(proc)}
|
|
56
|
+
>
|
|
57
|
+
{stoppingId === proc.handleId ? "Closing..." : "Close"}
|
|
58
|
+
</button>
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
{processes.length === 0 && !error && <p>No games have been started through this match-agent yet.</p>}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useParams } from "react-router-dom";
|
|
2
|
+
import { GameLauncherSettingsForm } from "../../components/GameLauncherSettingsForm";
|
|
3
|
+
|
|
4
|
+
export function GameLauncherDetailPage() {
|
|
5
|
+
const { pluginName = "" } = useParams();
|
|
6
|
+
return (
|
|
7
|
+
<div className="page">
|
|
8
|
+
<h1>{pluginName}</h1>
|
|
9
|
+
<GameLauncherSettingsForm pluginName={pluginName} />
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { useMatchAgent } from "../../context/MatchAgentContext";
|
|
4
|
+
import { listAvailableGameLaunchers, AvailableGameLauncher } from "../../api/matchAgent";
|
|
5
|
+
|
|
6
|
+
export function GameLauncherListPage() {
|
|
7
|
+
const { settings } = useMatchAgent();
|
|
8
|
+
const [runners, setRunners] = useState<Array<AvailableGameLauncher>>([]);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
listAvailableGameLaunchers(settings.url, settings.authCode)
|
|
13
|
+
.then(setRunners)
|
|
14
|
+
.catch((e) => setError(e.message));
|
|
15
|
+
}, [settings]);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="page">
|
|
19
|
+
<h1>Game Launchers</h1>
|
|
20
|
+
{error && <p className="error">{error}</p>}
|
|
21
|
+
<ul>
|
|
22
|
+
{runners.map((r) => (
|
|
23
|
+
<li key={r.pluginName}>
|
|
24
|
+
<Link to={`/game-launcher/${encodeURIComponent(r.pluginName)}`}>{r.publicInfo.title}</Link>
|
|
25
|
+
{" - "}{r.publicInfo.description}
|
|
26
|
+
</li>
|
|
27
|
+
))}
|
|
28
|
+
</ul>
|
|
29
|
+
{runners.length === 0 && !error && <p>No game-launcher plugins installed on this match-agent yet.</p>}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useNavigate } from "react-router-dom";
|
|
2
|
+
import { useJoinSettings } from "../../context/JoinSettingsContext";
|
|
3
|
+
import { useGamepads } from "../../hooks/useGamepads";
|
|
4
|
+
|
|
5
|
+
// "Join Settings": how many local players this machine brings to a room and
|
|
6
|
+
// which input device each uses (keyboard or a gamepad/USB controller). Only
|
|
7
|
+
// one slot may claim the keyboard - see JoinSettingsContext's addSlot for
|
|
8
|
+
// why. This player count is exposed to whatever matchmaker is loaded via the
|
|
9
|
+
// getIdentity bridge call (see bridge/hostBridge.ts) - it's the matchmaker's
|
|
10
|
+
// own job to push it to its account/auth backend, not this app's.
|
|
11
|
+
export function JoinSettingsPage() {
|
|
12
|
+
const { playerSlots, addSlot, removeSlot, setSlotInput, setSlotLabel } = useJoinSettings();
|
|
13
|
+
const gamepads = useGamepads();
|
|
14
|
+
const navigate = useNavigate();
|
|
15
|
+
|
|
16
|
+
const keyboardTaken = playerSlots.some((s) => s.input.type === "keyboard");
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="page">
|
|
20
|
+
<h1>Join Settings</h1>
|
|
21
|
+
<p>
|
|
22
|
+
Ikemen GO only supports 1v1 today - additional local player slots are saved for future
|
|
23
|
+
team-mode support (2v2, 3v(2+1), etc).
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
{playerSlots.map((slot) => (
|
|
27
|
+
<div key={slot.id} className="player-slot-row">
|
|
28
|
+
<input value={slot.label} onChange={(e) => setSlotLabel(slot.id, e.target.value)} />
|
|
29
|
+
<select
|
|
30
|
+
value={slot.input.type === "keyboard" ? "keyboard" : `gamepad:${slot.input.index}`}
|
|
31
|
+
onChange={(e) => {
|
|
32
|
+
const value = e.target.value;
|
|
33
|
+
if (value === "keyboard") setSlotInput(slot.id, { type: "keyboard" });
|
|
34
|
+
else setSlotInput(slot.id, { type: "gamepad", index: Number(value.split(":")[1]) });
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<option value="keyboard" disabled={keyboardTaken && slot.input.type !== "keyboard"}>
|
|
38
|
+
Keyboard
|
|
39
|
+
</option>
|
|
40
|
+
{gamepads.map((g) => (
|
|
41
|
+
<option key={g.index} value={`gamepad:${g.index}`}>{g.id || `Gamepad ${g.index + 1}`}</option>
|
|
42
|
+
))}
|
|
43
|
+
{gamepads.length === 0 && slot.input.type === "gamepad" && (
|
|
44
|
+
<option value={`gamepad:${slot.input.index}`}>Gamepad {slot.input.index + 1} (not detected)</option>
|
|
45
|
+
)}
|
|
46
|
+
</select>
|
|
47
|
+
<button type="button" onClick={() => removeSlot(slot.id)} disabled={playerSlots.length <= 1}>
|
|
48
|
+
Remove
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
|
|
53
|
+
<button type="button" onClick={addSlot}>Add local player</button>
|
|
54
|
+
|
|
55
|
+
<button type="button" onClick={() => navigate("/match-making")}>Continue</button>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { useMatchAgent } from "../../context/MatchAgentContext";
|
|
4
|
+
import { useIdentity } from "../../context/IdentityContext";
|
|
5
|
+
import { useJoinSettings } from "../../context/JoinSettingsContext";
|
|
6
|
+
import { useDownloadSession } from "../../context/DownloadSessionContext";
|
|
7
|
+
import { useHostBridge } from "../../bridge/hostBridge";
|
|
8
|
+
import { Lightbox } from "../../components/Lightbox";
|
|
9
|
+
import { InstallGameLauncherLightbox } from "../../components/InstallGameLauncherLightbox";
|
|
10
|
+
import { SelectionBoard } from "../../components/Selection";
|
|
11
|
+
|
|
12
|
+
const STORAGE_KEY = "match-agent-client:matchmaker-url";
|
|
13
|
+
const SAVED_STORAGE_KEY = "match-agent-client:saved-matchmaker-urls";
|
|
14
|
+
// No real hosted default exists yet - this points at titled-room/client's
|
|
15
|
+
// local dev server, the only matchmaker this repo currently ships.
|
|
16
|
+
const DEFAULT_URL = "http://localhost:5183";
|
|
17
|
+
|
|
18
|
+
type SavedMatchmaker = { title: string, url: string };
|
|
19
|
+
|
|
20
|
+
function loadSaved(): Array<SavedMatchmaker> {
|
|
21
|
+
try {
|
|
22
|
+
const raw = localStorage.getItem(SAVED_STORAGE_KEY);
|
|
23
|
+
if (!raw) return [];
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function MatchMakingPage() {
|
|
32
|
+
const matchAgent = useMatchAgent();
|
|
33
|
+
const identity = useIdentity();
|
|
34
|
+
const { playerSlots } = useJoinSettings();
|
|
35
|
+
const { setSession } = useDownloadSession();
|
|
36
|
+
const navigate = useNavigate();
|
|
37
|
+
|
|
38
|
+
const [url, setUrl] = useState(() => localStorage.getItem(STORAGE_KEY) || DEFAULT_URL);
|
|
39
|
+
const [title, setTitle] = useState("");
|
|
40
|
+
const [savedUrls, setSavedUrls] = useState<Array<SavedMatchmaker>>(loadSaved);
|
|
41
|
+
const [selectedSaved, setSelectedSaved] = useState("");
|
|
42
|
+
const [connectedUrl, setConnectedUrl] = useState<string | null>(null);
|
|
43
|
+
const [connecting, setConnecting] = useState(false);
|
|
44
|
+
const [iframeLoaded, setIframeLoaded] = useState(false);
|
|
45
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
46
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
47
|
+
|
|
48
|
+
const { connectionError, pendingLightbox, resolveInstallLightbox, resolveSelectionLightbox, cancelSelectionLightbox } =
|
|
49
|
+
useHostBridge({
|
|
50
|
+
iframeRef,
|
|
51
|
+
iframeLoaded,
|
|
52
|
+
matchAgent: matchAgent.settings,
|
|
53
|
+
identityKeys: identity.keys,
|
|
54
|
+
machineId: identity.machineId,
|
|
55
|
+
playerSlots,
|
|
56
|
+
onInitiateRelay: (session) => {
|
|
57
|
+
setSession(session);
|
|
58
|
+
navigate("/download");
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!connectionError) return;
|
|
64
|
+
setConnecting(false);
|
|
65
|
+
setErrorMessage(`Could not connect to the matchmaker at ${connectedUrl}.`);
|
|
66
|
+
}, [connectionError, connectedUrl]);
|
|
67
|
+
|
|
68
|
+
const handleConnect = (targetUrl: string = url) => {
|
|
69
|
+
localStorage.setItem(STORAGE_KEY, targetUrl);
|
|
70
|
+
setIframeLoaded(false);
|
|
71
|
+
setErrorMessage(null);
|
|
72
|
+
setConnectedUrl(targetUrl);
|
|
73
|
+
setConnecting(true);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Reconnect to whatever URL was last used, so returning users land in the
|
|
77
|
+
// matchmaker without an extra click. A manual Connect button remains below
|
|
78
|
+
// in case this first attempt fails (e.g. a stale/unreachable saved URL).
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
handleConnect(url);
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const handleUrlChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
85
|
+
setUrl(e.target.value);
|
|
86
|
+
setSelectedSaved("");
|
|
87
|
+
// The iframe/bridge only ever target `connectedUrl`, so once the user
|
|
88
|
+
// edits the field the in-flight connection is stale - drop it rather
|
|
89
|
+
// than leaving `connecting` true against a URL that's no longer shown.
|
|
90
|
+
setConnecting(false);
|
|
91
|
+
setErrorMessage(null);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleIframeError = () => {
|
|
95
|
+
setConnecting(false);
|
|
96
|
+
setErrorMessage(`Failed to load the matchmaker at ${connectedUrl}.`);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const persistSaved = (next: Array<SavedMatchmaker>) => {
|
|
100
|
+
setSavedUrls(next);
|
|
101
|
+
localStorage.setItem(SAVED_STORAGE_KEY, JSON.stringify(next));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleSaveCurrent = () => {
|
|
105
|
+
const trimmedTitle = title.trim();
|
|
106
|
+
if (!trimmedTitle) return;
|
|
107
|
+
const withoutExisting = savedUrls.filter((s) => s.title !== trimmedTitle);
|
|
108
|
+
persistSaved([...withoutExisting, { title: trimmedTitle, url }]);
|
|
109
|
+
setSelectedSaved(trimmedTitle);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleSelectSaved = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
113
|
+
const nextTitle = e.target.value;
|
|
114
|
+
setSelectedSaved(nextTitle);
|
|
115
|
+
const entry = savedUrls.find((s) => s.title === nextTitle);
|
|
116
|
+
if (!entry) return;
|
|
117
|
+
setTitle(entry.title);
|
|
118
|
+
setUrl(entry.url);
|
|
119
|
+
handleConnect(entry.url);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleDeleteSaved = () => {
|
|
123
|
+
if (!selectedSaved) return;
|
|
124
|
+
persistSaved(savedUrls.filter((s) => s.title !== selectedSaved));
|
|
125
|
+
setSelectedSaved("");
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="page">
|
|
130
|
+
<h1>Match Making</h1>
|
|
131
|
+
<div style={{ display: "flex", flexDirection: "row", gap: "0.5rem" }}>
|
|
132
|
+
<select value={selectedSaved} onChange={handleSelectSaved}>
|
|
133
|
+
<option value="">Saved matchmakers...</option>
|
|
134
|
+
{savedUrls.map((s) => (
|
|
135
|
+
<option key={s.title} value={s.title}>{s.title}</option>
|
|
136
|
+
))}
|
|
137
|
+
</select>
|
|
138
|
+
<button type="button" onClick={handleDeleteSaved} disabled={!selectedSaved}>Delete</button>
|
|
139
|
+
</div>
|
|
140
|
+
<div style={{ display: "flex", flexDirection: "row", gap: "0.5rem" }}>
|
|
141
|
+
<input
|
|
142
|
+
placeholder="Title"
|
|
143
|
+
value={title}
|
|
144
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
145
|
+
/>
|
|
146
|
+
<input
|
|
147
|
+
style={{ flexGrow: 1 }}
|
|
148
|
+
placeholder="Matchmaker URL"
|
|
149
|
+
value={url}
|
|
150
|
+
onChange={handleUrlChange}
|
|
151
|
+
/>
|
|
152
|
+
<button type="button" onClick={handleSaveCurrent} disabled={!title.trim()}>Save</button>
|
|
153
|
+
<button type="button" onClick={() => handleConnect()}>Connect</button>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{errorMessage && <p className="error">{errorMessage}</p>}
|
|
157
|
+
|
|
158
|
+
{connecting && connectedUrl && (
|
|
159
|
+
<div className="matchmaker-frame-wrap">
|
|
160
|
+
<iframe
|
|
161
|
+
ref={iframeRef}
|
|
162
|
+
src={connectedUrl}
|
|
163
|
+
className="matchmaker-frame"
|
|
164
|
+
title="Matchmaker"
|
|
165
|
+
onLoad={() => setIframeLoaded(true)}
|
|
166
|
+
onError={handleIframeError}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{pendingLightbox?.type === "install" && (
|
|
172
|
+
<Lightbox>
|
|
173
|
+
<InstallGameLauncherLightbox pluginName={pendingLightbox.pluginName} onDone={resolveInstallLightbox} />
|
|
174
|
+
</Lightbox>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{pendingLightbox?.type === "selection" && (
|
|
178
|
+
<Lightbox onClose={cancelSelectionLightbox}>
|
|
179
|
+
<SelectionBoard
|
|
180
|
+
rosterConfig={pendingLightbox.rosterConfig}
|
|
181
|
+
playerSlots={playerSlots.slice(0, pendingLightbox.numPlayers)}
|
|
182
|
+
matchAgentUrl={matchAgent.settings.url}
|
|
183
|
+
matchAgentAuth={matchAgent.settings.authCode}
|
|
184
|
+
onConfirm={resolveSelectionLightbox}
|
|
185
|
+
/>
|
|
186
|
+
</Lightbox>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|