@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,149 @@
1
+ import { RefObject, useEffect, useRef, useState } from "react";
2
+ import { MessageBridge, waitForBridgeEvent } from "@roster-lock/utils";
3
+ import {
4
+ MATCHMAKER_BRIDGE_PATHS,
5
+ InstallGameLauncherPluginRequest, InstallGameLauncherPluginResponse,
6
+ GetInstalledGameLauncherPluginsResponse,
7
+ GetIdentityResponse,
8
+ RequestSelectionRequest, RequestSelectionResponse,
9
+ UpdateGameLauncherSettingsRequest, UpdateGameLauncherSettingsResponse,
10
+ InitiateRelayEvent,
11
+ } from "@roster-lock/types";
12
+ import { UserKeyPair } from "@roster-lock/ts-client";
13
+ import { listAvailableGameLaunchers } from "../api/matchAgent";
14
+ import { PlayerSlot } from "../context/JoinSettingsContext";
15
+ import { DownloadSession } from "../context/DownloadSessionContext";
16
+
17
+ export type PendingLightbox = (
18
+ | { type: "install", pluginName: string, resolve: () => void }
19
+ | { type: "selection", rosterConfig: RequestSelectionRequest["rosterLockConfig"], numPlayers: number, resolve: (v: RequestSelectionResponse) => void, reject: (e: Error) => void }
20
+ );
21
+
22
+ // Host-side half of the matchmaker bridge protocol (see
23
+ // core/types/src/v1/runtime/matchmaker-bridge) - one instance per mounted
24
+ // <iframe>, built once the guest signals "ready" (mirrors the "wait for the
25
+ // other side's bridge to come up" handshake @roster-lock/ts-client's
26
+ // sync-dl.ws.ts uses over WebSocket, just over postMessage here).
27
+ export function useHostBridge(args: {
28
+ iframeRef: RefObject<HTMLIFrameElement | null>,
29
+ iframeLoaded: boolean,
30
+ matchAgent: { url: string, authCode: string },
31
+ identityKeys: UserKeyPair,
32
+ machineId: string,
33
+ playerSlots: Array<PlayerSlot>,
34
+ onInitiateRelay: (session: DownloadSession) => void,
35
+ }) {
36
+ const { iframeRef, iframeLoaded, matchAgent, identityKeys, machineId, playerSlots, onInitiateRelay } = args;
37
+
38
+ const [pendingLightbox, setPendingLightbox] = useState<PendingLightbox | null>(null);
39
+ const [connected, setConnected] = useState(false);
40
+ const [connectionError, setConnectionError] = useState(false);
41
+ const bridgeRef = useRef<MessageBridge | null>(null);
42
+ const pendingGameConfigRef = useRef<unknown>(undefined);
43
+ const pendingSelectionRef = useRef<Record<number, unknown> | null>(null);
44
+
45
+ // Stable across renders via refs, so the bridge's onRequest handlers
46
+ // always see current props without needing to be torn down/rebuilt.
47
+ const latest = useRef(args);
48
+ latest.current = args;
49
+
50
+ useEffect(() => {
51
+ if (!iframeLoaded) return;
52
+ setConnected(false);
53
+ setConnectionError(false);
54
+
55
+ const bridge = new MessageBridge((message) => {
56
+ iframeRef.current?.contentWindow?.postMessage(message, "*");
57
+ });
58
+ bridgeRef.current = bridge;
59
+
60
+ const onMessage = (event: MessageEvent) => {
61
+ if (event.source !== iframeRef.current?.contentWindow) return;
62
+ bridge.handleMessage(event.data).catch(() => {});
63
+ };
64
+ window.addEventListener("message", onMessage);
65
+
66
+ bridge.onRequest(MATCHMAKER_BRIDGE_PATHS.installGameLauncherPlugin, ({ pluginName }: InstallGameLauncherPluginRequest) => {
67
+ return new Promise<InstallGameLauncherPluginResponse>((resolve) => {
68
+ setPendingLightbox({ type: "install", pluginName, resolve: () => resolve({}) });
69
+ });
70
+ });
71
+
72
+ bridge.onRequest(MATCHMAKER_BRIDGE_PATHS.getInstalledGameLauncherPlugins, async (): Promise<GetInstalledGameLauncherPluginsResponse> => {
73
+ const runners = await listAvailableGameLaunchers(latest.current.matchAgent.url, latest.current.matchAgent.authCode);
74
+ return runners.map((r) => ({ id: r.pluginName, version: r.version, gameConfigSchema: r.gameConfigSchema }));
75
+ });
76
+
77
+ bridge.onRequest(MATCHMAKER_BRIDGE_PATHS.getIdentity, (): GetIdentityResponse => ({
78
+ publicKey: latest.current.identityKeys.publicKey,
79
+ machineId: latest.current.machineId,
80
+ playerCount: latest.current.playerSlots.length,
81
+ }));
82
+
83
+ bridge.onRequest(
84
+ MATCHMAKER_BRIDGE_PATHS.requestSelection,
85
+ ({ rosterLockConfig, numPlayers }: RequestSelectionRequest) => {
86
+ return new Promise<RequestSelectionResponse>((resolve, reject) => {
87
+ setPendingLightbox({
88
+ type: "selection",
89
+ rosterConfig: rosterLockConfig,
90
+ numPlayers,
91
+ resolve: (selection) => {
92
+ pendingSelectionRef.current = selection;
93
+ resolve(selection);
94
+ },
95
+ reject: (e) => reject(e),
96
+ });
97
+ });
98
+ }
99
+ );
100
+
101
+ bridge.onRequest(
102
+ MATCHMAKER_BRIDGE_PATHS.updateGameLauncherSettings,
103
+ ({ gameConfig }: UpdateGameLauncherSettingsRequest): UpdateGameLauncherSettingsResponse => {
104
+ pendingGameConfigRef.current = gameConfig;
105
+ return {};
106
+ }
107
+ );
108
+
109
+ bridge.onEvent(MATCHMAKER_BRIDGE_PATHS.initiateRelay, (payload: InitiateRelayEvent) => {
110
+ if (!pendingSelectionRef.current) return;
111
+ latest.current.onInitiateRelay({
112
+ relay: payload.relay,
113
+ rosterConfig: payload.rosterConfig,
114
+ gameLauncherPlugin: payload.gameLauncherPlugin,
115
+ gameConfig: pendingGameConfigRef.current,
116
+ playerSelections: pendingSelectionRef.current as DownloadSession["playerSelections"],
117
+ isHost: payload.isHost,
118
+ coordinator: payload.coordinator,
119
+ });
120
+ });
121
+
122
+ waitForBridgeEvent(bridge, MATCHMAKER_BRIDGE_PATHS.ready, 15_000)
123
+ .then(() => setConnected(true))
124
+ .catch(() => setConnectionError(true));
125
+
126
+ return () => {
127
+ window.removeEventListener("message", onMessage);
128
+ bridgeRef.current = null;
129
+ };
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
131
+ }, [iframeLoaded]);
132
+
133
+ const resolveInstallLightbox = () => {
134
+ if (pendingLightbox?.type === "install") pendingLightbox.resolve();
135
+ setPendingLightbox(null);
136
+ };
137
+
138
+ const resolveSelectionLightbox = (selection: RequestSelectionResponse) => {
139
+ if (pendingLightbox?.type === "selection") pendingLightbox.resolve(selection);
140
+ setPendingLightbox(null);
141
+ };
142
+
143
+ const cancelSelectionLightbox = () => {
144
+ if (pendingLightbox?.type === "selection") pendingLightbox.reject(new Error("cancelled"));
145
+ setPendingLightbox(null);
146
+ };
147
+
148
+ return { connected, connectionError, pendingLightbox, resolveInstallLightbox, resolveSelectionLightbox, cancelSelectionLightbox };
149
+ }
@@ -0,0 +1,140 @@
1
+ import { useEffect, useState } from "react";
2
+ import Form from "@rjsf/core";
3
+ import validator from "@rjsf/validator-ajv8";
4
+ import type { RJSFSchema } from "@rjsf/utils";
5
+ import { useMatchAgent } from "../context/MatchAgentContext";
6
+ import {
7
+ getGameLauncherSettings, setGameLauncherSettings, getGameLauncherVersion, updateGameLauncherBinary,
8
+ validateGameLauncherBinaryLocation, listAvailableGameLaunchers,
9
+ } from "../api/matchAgent";
10
+
11
+ // A schema with no declared properties renders nothing useful in rjsf - most
12
+ // plugins (e.g. ikemen-go today) have nothing beyond binaryLocation, so this
13
+ // is what most of the time skips rendering the form at all rather than
14
+ // showing an empty box.
15
+ function hasProperties(schema: unknown): schema is RJSFSchema {
16
+ return !!schema && typeof schema === "object" && !!(schema as RJSFSchema).properties
17
+ && Object.keys((schema as RJSFSchema).properties!).length > 0;
18
+ }
19
+
20
+ // binaryLocation/version/update form for one game-launcher plugin - shared by
21
+ // pages/GameLauncher/Detail.tsx (the standalone settings route) and
22
+ // components/InstallGameLauncherLightbox.tsx (the same form, right after a
23
+ // fresh install, inside the lightbox the host opens for the
24
+ // installGameLauncherPlugin bridge call).
25
+ export function GameLauncherSettingsForm({ pluginName }: { pluginName: string }) {
26
+ const { settings } = useMatchAgent();
27
+ const [binaryLocation, setBinaryLocation] = useState("");
28
+ const [version, setVersion] = useState<{ local: { title: string }, supported: { title: string } } | null>(null);
29
+ // Set right after load/save, not just on-demand - so a bad folder is
30
+ // flagged before the user ever hits "Check version" or "Download / update"
31
+ // and gets a raw spawn/ENOENT-flavored error instead (see
32
+ // docs/v2/binary-location.md's Validation section). Left `undefined` while
33
+ // there's no binaryLocation to check at all, as opposed to `null` (never
34
+ // checked yet) or a real result.
35
+ const [validation, setValidation] = useState<{ valid: true } | { valid: false, message: string } | null>(null);
36
+ // The plugin's own per-machine settings beyond binaryLocation - schema
37
+ // comes from AvailableGameLauncher.localConfigSchema (listAvailableGameLaunchers),
38
+ // not from getGameLauncherSettings, since that route only ever returns the
39
+ // stored *value* (GameLauncherLocalSettings), never the plugin's schema.
40
+ const [localConfigSchema, setLocalConfigSchema] = useState<unknown>(null);
41
+ const [localConfig, setLocalConfig] = useState<unknown>(undefined);
42
+ const [busy, setBusy] = useState(false);
43
+ const [error, setError] = useState<string | null>(null);
44
+
45
+ const validate = async (location: string) => {
46
+ if(!location){
47
+ setValidation(null);
48
+ return;
49
+ }
50
+ try {
51
+ setValidation(await validateGameLauncherBinaryLocation(settings.url, settings.authCode, pluginName));
52
+ } catch (e) {
53
+ setValidation({ valid: false, message: (e as Error).message });
54
+ }
55
+ };
56
+
57
+ const load = () => {
58
+ setError(null);
59
+ Promise.all([
60
+ getGameLauncherSettings(settings.url, settings.authCode, pluginName),
61
+ listAvailableGameLaunchers(settings.url, settings.authCode),
62
+ ])
63
+ .then(([s, available]) => {
64
+ setBinaryLocation(s.binaryLocation ?? "");
65
+ setLocalConfig(s.localConfig);
66
+ setLocalConfigSchema(available.find((a) => a.pluginName === pluginName)?.localConfigSchema ?? null);
67
+ return validate(s.binaryLocation ?? "");
68
+ })
69
+ .catch((e) => setError(e.message));
70
+ };
71
+
72
+ useEffect(load, [pluginName, settings]);
73
+
74
+ const save = async () => {
75
+ setBusy(true);
76
+ setError(null);
77
+ try {
78
+ await setGameLauncherSettings(settings.url, settings.authCode, pluginName, { binaryLocation, localConfig });
79
+ await validate(binaryLocation);
80
+ } catch (e) {
81
+ setError((e as Error).message);
82
+ } finally {
83
+ setBusy(false);
84
+ }
85
+ };
86
+
87
+ const checkVersion = async () => {
88
+ setBusy(true);
89
+ setError(null);
90
+ try {
91
+ setVersion(await getGameLauncherVersion(settings.url, settings.authCode, pluginName));
92
+ } catch (e) {
93
+ setError((e as Error).message);
94
+ } finally {
95
+ setBusy(false);
96
+ }
97
+ };
98
+
99
+ const download = async () => {
100
+ setBusy(true);
101
+ setError(null);
102
+ try {
103
+ await updateGameLauncherBinary(settings.url, settings.authCode, pluginName);
104
+ await checkVersion();
105
+ } catch (e) {
106
+ setError((e as Error).message);
107
+ } finally {
108
+ setBusy(false);
109
+ }
110
+ };
111
+
112
+ return (
113
+ <div className="game-launcher-settings-form">
114
+ <label>
115
+ Binary location
116
+ <input value={binaryLocation} onChange={(e) => setBinaryLocation(e.target.value)} />
117
+ </label>
118
+ {hasProperties(localConfigSchema) && (
119
+ // Own submit button suppressed - localConfig just feeds into this
120
+ // component's own state via onChange, and goes out through the
121
+ // Save button below alongside binaryLocation, as one settings write.
122
+ <Form
123
+ schema={localConfigSchema}
124
+ formData={localConfig}
125
+ validator={validator}
126
+ onChange={(e) => setLocalConfig(e.formData)}
127
+ uiSchema={{ "ui:submitButtonOptions": { norender: true } }}
128
+ />
129
+ )}
130
+ <button type="button" disabled={busy} onClick={save}>Save</button>
131
+ <button type="button" disabled={busy || !binaryLocation} onClick={checkVersion}>Check version</button>
132
+ <button type="button" disabled={busy || !binaryLocation} onClick={download}>Download / update</button>
133
+ {validation && !validation.valid && <p className="error">{validation.message}</p>}
134
+ {version && (
135
+ <p>Local: {version.local.title} - Supported: {version.supported.title}</p>
136
+ )}
137
+ {error && <p className="error">{error}</p>}
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useMatchAgent } from "../context/MatchAgentContext";
3
+ import { installGameLauncherPlugin } from "../api/matchAgent";
4
+ import { GameLauncherSettingsForm } from "./GameLauncherSettingsForm";
5
+
6
+ // Content for the Lightbox opened by the installGameLauncherPlugin bridge
7
+ // request: installs the plugin package, then lets the user finish local
8
+ // config (binaryLocation) with the same form the standalone
9
+ // /game-launcher/:pluginName route uses, before resolving the bridge request.
10
+ export function InstallGameLauncherLightbox({ pluginName, onDone }: { pluginName: string, onDone: () => void }) {
11
+ const { settings } = useMatchAgent();
12
+ const [installing, setInstalling] = useState(true);
13
+ const [error, setError] = useState<string | null>(null);
14
+
15
+ useEffect(() => {
16
+ installGameLauncherPlugin(settings.url, settings.authCode, pluginName)
17
+ .catch((e) => setError((e as Error).message))
18
+ .finally(() => setInstalling(false));
19
+ // eslint-disable-next-line react-hooks/exhaustive-deps
20
+ }, [pluginName]);
21
+
22
+ return (
23
+ <div className="page">
24
+ <h1>Install {pluginName}</h1>
25
+ {installing && <p>Installing...</p>}
26
+ {error && <p className="error">{error}</p>}
27
+ {!installing && <GameLauncherSettingsForm pluginName={pluginName} />}
28
+ <button type="button" onClick={onDone}>Done</button>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,15 @@
1
+ import { ReactNode } from "react";
2
+
3
+ // Generic overlay used by pages/MatchMaking to host whichever bridge request
4
+ // is currently pending (installGameLauncherPlugin or requestSelection) -
5
+ // rendered by the host shell, on top of the matchmaker <iframe>, never
6
+ // inside it.
7
+ export function Lightbox({ children, onClose }: { children: ReactNode, onClose?: () => void }) {
8
+ return (
9
+ <div className="lightbox-backdrop" onClick={onClose}>
10
+ <div className="lightbox-box" onClick={(e) => e.stopPropagation()}>
11
+ {children}
12
+ </div>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,9 @@
1
+ import { ReactNode } from "react";
2
+ import { Navigate } from "react-router-dom";
3
+ import { useMatchAgent } from "../context/MatchAgentContext";
4
+
5
+ export function RequireConnection({ children }: { children: ReactNode }) {
6
+ const { connected } = useMatchAgent();
7
+ if (!connected) return <Navigate to="/connect" replace />;
8
+ return <>{children}</>;
9
+ }
@@ -0,0 +1,36 @@
1
+ import { RosterLockPiece } from "@roster-lock/types";
2
+
3
+ export function PieceCard({ piece, selected, orderNumber, downloaded, cursored, disabled, onClick }: {
4
+ piece: RosterLockPiece,
5
+ selected: boolean,
6
+ orderNumber: number | undefined,
7
+ downloaded: boolean | undefined,
8
+ cursored: boolean,
9
+ disabled: boolean,
10
+ onClick: () => void,
11
+ }) {
12
+ return (
13
+ <button
14
+ type="button"
15
+ className="piece-card"
16
+ onClick={onClick}
17
+ disabled={disabled && !selected}
18
+ data-selected={selected}
19
+ data-cursored={cursored}
20
+ title={piece.humanInfo.author ? `by ${piece.humanInfo.author}` : undefined}
21
+ >
22
+ <div className="piece-card-image">
23
+ {orderNumber !== undefined && <span className="piece-card-order-badge">{orderNumber}</span>}
24
+ {piece.humanInfo.image ? (
25
+ <img src={piece.humanInfo.image} alt={piece.humanInfo.name} />
26
+ ) : (
27
+ <span className="piece-card-placeholder">{piece.humanInfo.name.slice(0, 1).toUpperCase()}</span>
28
+ )}
29
+ </div>
30
+ <div className="piece-card-name">{piece.humanInfo.name}</div>
31
+ <div className="piece-card-status">
32
+ {downloaded === undefined ? "" : downloaded ? "Downloaded" : "Not downloaded"}
33
+ </div>
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,112 @@
1
+ import { useEffect, useState } from "react";
2
+ import { RosterLockV1Config } from "@roster-lock/types";
3
+ import { PieceTypePlan } from "./selectionPlan";
4
+ import { PieceCard } from "./PieceCard";
5
+ import { InputSource } from "../../context/JoinSettingsContext";
6
+ import { useCursorInput } from "../../hooks/useCursorInput";
7
+ import { listDownloadedPiecesFromConfig } from "@roster-lock/ts-client";
8
+
9
+ export function PieceTypeSection({
10
+ rosterConfig, pieceType, plan, picks, onTogglePick, onReorderPick, inputSource, matchAgentUrl, matchAgentAuth,
11
+ }: {
12
+ rosterConfig: RosterLockV1Config,
13
+ pieceType: string,
14
+ plan: Extract<PieceTypePlan, { kind: "pickable" }>,
15
+ picks: Array<string>,
16
+ onTogglePick: (pieceId: string) => void,
17
+ onReorderPick: (fromIndex: number, toIndex: number) => void,
18
+ inputSource: InputSource,
19
+ matchAgentUrl: string,
20
+ matchAgentAuth: string,
21
+ }) {
22
+ const pieces = (rosterConfig.rosters[pieceType] ?? []).filter((p) => !plan.banList.includes(p.id));
23
+ const [downloaded, setDownloaded] = useState<Record<string, boolean>>({});
24
+ const [cursorIndex, setCursorIndex] = useState(0);
25
+
26
+ useEffect(() => {
27
+ let cancelled = false;
28
+ listDownloadedPiecesFromConfig(
29
+ { version: 1, rosterConfig, pieceType }, { page: 0, limit: 500 }, matchAgentAuth, matchAgentUrl
30
+ ).then((rows) => {
31
+ if (cancelled) return;
32
+ const byLogic = new Set(rows.map((r) => r.version.logic));
33
+ const next: Record<string, boolean> = {};
34
+ for (const piece of pieces) next[piece.id] = byLogic.has(piece.version.logic);
35
+ setDownloaded(next);
36
+ }).catch(() => {});
37
+ return () => { cancelled = true; };
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [pieceType]);
40
+
41
+ const atMax = picks.length >= plan.max;
42
+ const move = (delta: number) => setCursorIndex((i) => (i + delta + pieces.length) % Math.max(pieces.length, 1));
43
+ const confirm = () => {
44
+ const piece = pieces[cursorIndex];
45
+ if (piece) onTogglePick(piece.id);
46
+ };
47
+ useCursorInput(inputSource, pieces.length > 0, move, confirm);
48
+
49
+ const countLabel = plan.min === plan.max
50
+ ? `pick exactly ${plan.min}`
51
+ : plan.max === Number.POSITIVE_INFINITY
52
+ ? `pick ${plan.min}-any`
53
+ : `pick ${plan.min}-${plan.max}`;
54
+
55
+ const pieceById = new Map(pieces.map((piece) => [piece.id, piece]));
56
+
57
+ return (
58
+ <div className="piece-type-section">
59
+ <div className="piece-type-header">
60
+ <span className="piece-type-count">{countLabel} ({picks.length} picked)</span>
61
+ </div>
62
+ <div className="card-grid">
63
+ {pieces.map((piece, index) => {
64
+ const orderIndex = picks.indexOf(piece.id);
65
+ return (
66
+ <PieceCard
67
+ key={piece.id}
68
+ piece={piece}
69
+ selected={orderIndex !== -1}
70
+ orderNumber={orderIndex === -1 ? undefined : orderIndex + 1}
71
+ downloaded={downloaded[piece.id]}
72
+ cursored={index === cursorIndex}
73
+ disabled={atMax}
74
+ onClick={() => onTogglePick(piece.id)}
75
+ />
76
+ );
77
+ })}
78
+ </div>
79
+ {picks.length > 0 && (
80
+ <ol className="pick-order-list">
81
+ {picks.map((pieceId, index) => (
82
+ <li key={pieceId} className="pick-order-item">
83
+ <span className="pick-order-index">{index + 1}</span>
84
+ <span className="pick-order-name">{pieceById.get(pieceId)?.humanInfo.name ?? pieceId}</span>
85
+ <button
86
+ type="button"
87
+ className="pick-order-move"
88
+ disabled={index === 0}
89
+ onClick={() => onReorderPick(index, index - 1)}
90
+ aria-label="Move earlier"
91
+ >
92
+
93
+ </button>
94
+ <button
95
+ type="button"
96
+ className="pick-order-move"
97
+ disabled={index === picks.length - 1}
98
+ onClick={() => onReorderPick(index, index + 1)}
99
+ aria-label="Move later"
100
+ >
101
+
102
+ </button>
103
+ <button type="button" className="pick-order-remove" onClick={() => onTogglePick(pieceId)}>
104
+ Remove
105
+ </button>
106
+ </li>
107
+ ))}
108
+ </ol>
109
+ )}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,31 @@
1
+ // Piece-type tabs are shared across every player slot - switching a tab
2
+ // swaps which piece type's grid is mounted for all slots at once, rather
3
+ // than each slot owning its own tab state. The dot reflects whether every
4
+ // slot's picks for that piece type currently satisfy its min/max, so
5
+ // players can see at a glance which tabs still need attention before
6
+ // Confirm unlocks (see SelectionBoard.allValid).
7
+ export function PieceTypeTabs({ types, activeType, readyByType, onSelect }: {
8
+ types: Array<string>,
9
+ activeType: string,
10
+ readyByType: Record<string, boolean>,
11
+ onSelect: (pieceType: string) => void,
12
+ }) {
13
+ if (types.length === 0) return null;
14
+
15
+ return (
16
+ <nav className="piece-type-tabs">
17
+ {types.map((pieceType) => (
18
+ <button
19
+ key={pieceType}
20
+ type="button"
21
+ className="piece-type-tab"
22
+ data-active={pieceType === activeType}
23
+ onClick={() => onSelect(pieceType)}
24
+ >
25
+ <span className="piece-type-tab-dot" data-ready={readyByType[pieceType] ?? false} aria-hidden="true" />
26
+ {pieceType}
27
+ </button>
28
+ ))}
29
+ </nav>
30
+ );
31
+ }
@@ -0,0 +1,40 @@
1
+ import { RosterLockV1Config } from "@roster-lock/types";
2
+ import { PlayerSlot } from "../../context/JoinSettingsContext";
3
+ import { PieceTypePlan } from "./selectionPlan";
4
+ import { PieceTypeSection } from "./PieceTypeSection";
5
+
6
+ export function PlayerSelectionPanel({
7
+ rosterConfig, slot, pieceType, plan, picks, onTogglePick, onReorderPick, matchAgentUrl, matchAgentAuth,
8
+ }: {
9
+ rosterConfig: RosterLockV1Config,
10
+ slot: PlayerSlot,
11
+ pieceType: string,
12
+ plan: Extract<PieceTypePlan, { kind: "pickable" }>,
13
+ picks: Array<string>,
14
+ onTogglePick: (pieceId: string) => void,
15
+ onReorderPick: (fromIndex: number, toIndex: number) => void,
16
+ matchAgentUrl: string,
17
+ matchAgentAuth: string,
18
+ }) {
19
+ return (
20
+ <div className="player-selection-panel">
21
+ <div className="player-selection-header">
22
+ {slot.label}
23
+ <span className="player-selection-input">
24
+ {slot.input.type === "keyboard" ? "Keyboard" : `Gamepad ${slot.input.index + 1}`}
25
+ </span>
26
+ </div>
27
+ <PieceTypeSection
28
+ rosterConfig={rosterConfig}
29
+ pieceType={pieceType}
30
+ plan={plan}
31
+ picks={picks}
32
+ onTogglePick={onTogglePick}
33
+ onReorderPick={onReorderPick}
34
+ inputSource={slot.input}
35
+ matchAgentUrl={matchAgentUrl}
36
+ matchAgentAuth={matchAgentAuth}
37
+ />
38
+ </div>
39
+ );
40
+ }