@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.
Files changed (107) hide show
  1. package/client/dist/assets/index-DeYshswm.js +125 -0
  2. package/client/dist/assets/index-gxAuIfGQ.css +1 -0
  3. package/client/dist/index.html +13 -0
  4. package/client/index.html +12 -0
  5. package/client/node_modules/.bin/terser +17 -0
  6. package/client/node_modules/.bin/tsc +17 -0
  7. package/client/node_modules/.bin/vite +17 -0
  8. package/client/node_modules/.vite/deps/@rjsf_core.js +6609 -0
  9. package/client/node_modules/.vite/deps/@rjsf_core.js.map +7 -0
  10. package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js +13 -0
  11. package/client/node_modules/.vite/deps/@rjsf_validator-ajv8.js.map +7 -0
  12. package/client/node_modules/.vite/deps/@roster-lock_ts-client.js +419 -0
  13. package/client/node_modules/.vite/deps/@roster-lock_ts-client.js.map +7 -0
  14. package/client/node_modules/.vite/deps/@roster-lock_types.js +5 -0
  15. package/client/node_modules/.vite/deps/@roster-lock_types.js.map +7 -0
  16. package/client/node_modules/.vite/deps/@roster-lock_utils.js +5 -0
  17. package/client/node_modules/.vite/deps/@roster-lock_utils.js.map +7 -0
  18. package/client/node_modules/.vite/deps/_metadata.json +97 -0
  19. package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js +16039 -0
  20. package/client/node_modules/.vite/deps/chunk-4D2L7OBZ.js.map +7 -0
  21. package/client/node_modules/.vite/deps/chunk-5KLROPUR.js +292 -0
  22. package/client/node_modules/.vite/deps/chunk-5KLROPUR.js.map +7 -0
  23. package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js +280 -0
  24. package/client/node_modules/.vite/deps/chunk-77TXW5ZT.js.map +7 -0
  25. package/client/node_modules/.vite/deps/chunk-I55GDNGV.js +1004 -0
  26. package/client/node_modules/.vite/deps/chunk-I55GDNGV.js.map +7 -0
  27. package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js +2337 -0
  28. package/client/node_modules/.vite/deps/chunk-IIDO6PKU.js.map +7 -0
  29. package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js +466 -0
  30. package/client/node_modules/.vite/deps/chunk-NE7PXY4V.js.map +7 -0
  31. package/client/node_modules/.vite/deps/chunk-WMTNK64S.js +52 -0
  32. package/client/node_modules/.vite/deps/chunk-WMTNK64S.js.map +7 -0
  33. package/client/node_modules/.vite/deps/package.json +3 -0
  34. package/client/node_modules/.vite/deps/react-dom.js +6 -0
  35. package/client/node_modules/.vite/deps/react-dom.js.map +7 -0
  36. package/client/node_modules/.vite/deps/react-dom_client.js +20217 -0
  37. package/client/node_modules/.vite/deps/react-dom_client.js.map +7 -0
  38. package/client/node_modules/.vite/deps/react-router-dom.js +14702 -0
  39. package/client/node_modules/.vite/deps/react-router-dom.js.map +7 -0
  40. package/client/node_modules/.vite/deps/react.js +5 -0
  41. package/client/node_modules/.vite/deps/react.js.map +7 -0
  42. package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js +278 -0
  43. package/client/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
  44. package/client/node_modules/.vite/deps/react_jsx-runtime.js +6 -0
  45. package/client/node_modules/.vite/deps/react_jsx-runtime.js.map +7 -0
  46. package/client/node_modules/.vite/deps_temp_ab51ebe9/package.json +3 -0
  47. package/client/package.json +30 -0
  48. package/client/src/App.tsx +56 -0
  49. package/client/src/NavBar.tsx +13 -0
  50. package/client/src/api/matchAgent.ts +159 -0
  51. package/client/src/bridge/hostBridge.ts +149 -0
  52. package/client/src/components/GameLauncherSettingsForm.tsx +140 -0
  53. package/client/src/components/InstallGameLauncherLightbox.tsx +31 -0
  54. package/client/src/components/Lightbox.tsx +15 -0
  55. package/client/src/components/RequireConnection.tsx +9 -0
  56. package/client/src/components/Selection/PieceCard.tsx +36 -0
  57. package/client/src/components/Selection/PieceTypeSection.tsx +112 -0
  58. package/client/src/components/Selection/PieceTypeTabs.tsx +31 -0
  59. package/client/src/components/Selection/PlayerSelectionPanel.tsx +40 -0
  60. package/client/src/components/Selection/index.tsx +147 -0
  61. package/client/src/components/Selection/selectionPlan.ts +75 -0
  62. package/client/src/context/DownloadSessionContext.tsx +48 -0
  63. package/client/src/context/IdentityContext.tsx +53 -0
  64. package/client/src/context/JoinSettingsContext.tsx +83 -0
  65. package/client/src/context/MatchAgentContext.tsx +85 -0
  66. package/client/src/hooks/useCursorInput.ts +55 -0
  67. package/client/src/hooks/useGamepads.ts +31 -0
  68. package/client/src/main.tsx +10 -0
  69. package/client/src/pages/Connect/index.tsx +46 -0
  70. package/client/src/pages/Download/index.tsx +175 -0
  71. package/client/src/pages/Game/index.tsx +65 -0
  72. package/client/src/pages/GameLauncher/Detail.tsx +12 -0
  73. package/client/src/pages/GameLauncher/index.tsx +32 -0
  74. package/client/src/pages/JoinSettings/index.tsx +58 -0
  75. package/client/src/pages/MatchMaking/index.tsx +190 -0
  76. package/client/src/styles/global.css +355 -0
  77. package/client/tsconfig.json +17 -0
  78. package/client/tsconfig.tsbuildinfo +1 -0
  79. package/client/vite.config.ts +33 -0
  80. package/dist/config/manage.js.map +1 -1
  81. package/dist/handle-room/version-1/game-launcher.js +181 -0
  82. package/dist/handle-room/version-1/game-launcher.js.map +1 -0
  83. package/dist/handle-room/version-1/index.js +15 -2
  84. package/dist/handle-room/version-1/index.js.map +1 -1
  85. package/dist/handle-room/version-1/piece-sort.js +1 -0
  86. package/dist/handle-room/version-1/piece-sort.js.map +1 -1
  87. package/dist/index.js +7 -4
  88. package/dist/index.js.map +1 -1
  89. package/dist/serve-client.js +88 -0
  90. package/dist/serve-client.js.map +1 -0
  91. package/dist/server.js +1 -1
  92. package/dist/server.js.map +1 -1
  93. package/package.json +12 -6
  94. package/src/config/manage.ts +5 -1
  95. package/src/handle-room/version-1/game-launcher.ts +263 -0
  96. package/src/handle-room/version-1/globals/types.ts +24 -0
  97. package/src/handle-room/version-1/index.ts +23 -3
  98. package/src/handle-room/version-1/piece-sort.ts +1 -1
  99. package/src/index.ts +25 -5
  100. package/src/serve-client.ts +100 -0
  101. package/src/server.ts +1 -1
  102. package/tests/integration/game-launcher-list.test.ts +77 -0
  103. package/tests/integration/game-launcher-settings.test.ts +138 -0
  104. package/tests/integration/game-launcher-start.test.ts +81 -0
  105. package/tests/integration/helpers/fakePluginRuntime.ts +26 -1
  106. package/tests/integration/helpers/server.ts +8 -2
  107. 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
+ }