@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,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
|
+
}
|