@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,147 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { RosterLockV1Config, UserSelection } from "@roster-lock/types";
|
|
3
|
+
import { PlayerSlot } from "../../context/JoinSettingsContext";
|
|
4
|
+
import { PlayerSelectionPanel } from "./PlayerSelectionPanel";
|
|
5
|
+
import { PieceTypeTabs } from "./PieceTypeTabs";
|
|
6
|
+
import { planForPieceType, buildUserSelection, PieceTypePlan } from "./selectionPlan";
|
|
7
|
+
|
|
8
|
+
type PickablePlan = Extract<PieceTypePlan, { kind: "pickable" }>;
|
|
9
|
+
|
|
10
|
+
// Each declared local player slot (see JoinSettingsContext) gets its own
|
|
11
|
+
// panel, but only for the one piece type the shared tab bar has active -
|
|
12
|
+
// this keeps the board from stacking every piece type for every slot into
|
|
13
|
+
// one long scroll. Picks persist per slot/piece-type while a tab is
|
|
14
|
+
// inactive, so switching tabs never loses work. Used inline by
|
|
15
|
+
// pages/MatchMaking/Rooms/[roomId] once a room is ready to select in.
|
|
16
|
+
export function SelectionBoard({
|
|
17
|
+
rosterConfig, playerSlots, matchAgentUrl, matchAgentAuth, onConfirm,
|
|
18
|
+
}: {
|
|
19
|
+
rosterConfig: RosterLockV1Config,
|
|
20
|
+
playerSlots: Array<PlayerSlot>,
|
|
21
|
+
matchAgentUrl: string,
|
|
22
|
+
matchAgentAuth: string,
|
|
23
|
+
onConfirm: (playerSelections: Record<number, UserSelection>) => void,
|
|
24
|
+
}) {
|
|
25
|
+
const [picksBySlot, setPicksBySlot] = useState<Record<string, Record<string, Array<string>>>>({});
|
|
26
|
+
|
|
27
|
+
const pieceTypePlans = useMemo(
|
|
28
|
+
() => Object.keys(rosterConfig.engine.pieceDefinitions).map((t) => [t, planForPieceType(rosterConfig, t)] as const),
|
|
29
|
+
[rosterConfig]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const pickableTypes = useMemo(
|
|
33
|
+
() => pieceTypePlans.filter((entry): entry is [string, PickablePlan] => entry[1].kind === "pickable"),
|
|
34
|
+
[pieceTypePlans]
|
|
35
|
+
);
|
|
36
|
+
const infoTypes = useMemo(
|
|
37
|
+
() => pieceTypePlans.filter(
|
|
38
|
+
(entry): entry is [string, Exclude<PieceTypePlan, PickablePlan>] => entry[1].kind !== "pickable"
|
|
39
|
+
),
|
|
40
|
+
[pieceTypePlans]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const [activeType, setActiveType] = useState(() => pickableTypes[0]?.[0] ?? "");
|
|
44
|
+
|
|
45
|
+
const readyByType = useMemo(() => {
|
|
46
|
+
const result: Record<string, boolean> = {};
|
|
47
|
+
for (const [pieceType, plan] of pickableTypes) {
|
|
48
|
+
result[pieceType] = playerSlots.every((slot) => {
|
|
49
|
+
const count = (picksBySlot[slot.id]?.[pieceType] ?? []).length;
|
|
50
|
+
return count >= plan.min && count <= plan.max;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}, [pickableTypes, picksBySlot, playerSlots]);
|
|
55
|
+
|
|
56
|
+
const togglePick = (slotId: string, pieceType: string, pieceId: string) => {
|
|
57
|
+
setPicksBySlot((prev) => {
|
|
58
|
+
const slotPicks = prev[slotId] ?? {};
|
|
59
|
+
const current = slotPicks[pieceType] ?? [];
|
|
60
|
+
const plan = pickableTypes.find(([t]) => t === pieceType)?.[1];
|
|
61
|
+
let next: Array<string>;
|
|
62
|
+
if (current.includes(pieceId)) {
|
|
63
|
+
next = current.filter((id) => id !== pieceId);
|
|
64
|
+
} else if (plan && current.length >= plan.max) {
|
|
65
|
+
next = current;
|
|
66
|
+
} else {
|
|
67
|
+
next = [...current, pieceId];
|
|
68
|
+
}
|
|
69
|
+
return { ...prev, [slotId]: { ...slotPicks, [pieceType]: next } };
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const reorderPick = (slotId: string, pieceType: string, fromIndex: number, toIndex: number) => {
|
|
74
|
+
setPicksBySlot((prev) => {
|
|
75
|
+
const slotPicks = prev[slotId] ?? {};
|
|
76
|
+
const current = slotPicks[pieceType] ?? [];
|
|
77
|
+
if (toIndex < 0 || toIndex >= current.length) return prev;
|
|
78
|
+
const next = [...current];
|
|
79
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
80
|
+
next.splice(toIndex, 0, moved);
|
|
81
|
+
return { ...prev, [slotId]: { ...slotPicks, [pieceType]: next } };
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const allValid = pickableTypes.every(([pieceType]) => readyByType[pieceType]);
|
|
86
|
+
const pendingTypes = pickableTypes.filter(([pieceType]) => !readyByType[pieceType]).map(([pieceType]) => pieceType);
|
|
87
|
+
const activePlan = pickableTypes.find(([t]) => t === activeType)?.[1];
|
|
88
|
+
|
|
89
|
+
const handleConfirm = () => {
|
|
90
|
+
const result: Record<number, UserSelection> = {};
|
|
91
|
+
playerSlots.forEach((slot, index) => {
|
|
92
|
+
result[index] = buildUserSelection(rosterConfig, picksBySlot[slot.id] ?? {});
|
|
93
|
+
});
|
|
94
|
+
onConfirm(result);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="selection-board">
|
|
99
|
+
{infoTypes.length > 0 && (
|
|
100
|
+
<div className="selection-board-info">
|
|
101
|
+
{infoTypes.map(([pieceType, plan]) => (
|
|
102
|
+
<span key={pieceType} className="piece-type-info-chip">
|
|
103
|
+
{pieceType}: {plan.kind === "auto"
|
|
104
|
+
? (plan.reason === "mandatory" ? "included automatically" : plan.reason)
|
|
105
|
+
: `${plan.configType} (not user-selectable)`}
|
|
106
|
+
</span>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
<PieceTypeTabs
|
|
112
|
+
types={pickableTypes.map(([pieceType]) => pieceType)}
|
|
113
|
+
activeType={activeType}
|
|
114
|
+
readyByType={readyByType}
|
|
115
|
+
onSelect={setActiveType}
|
|
116
|
+
/>
|
|
117
|
+
|
|
118
|
+
{activePlan && (
|
|
119
|
+
<div className="selection-board-panels">
|
|
120
|
+
{playerSlots.map((slot) => (
|
|
121
|
+
<PlayerSelectionPanel
|
|
122
|
+
key={slot.id}
|
|
123
|
+
rosterConfig={rosterConfig}
|
|
124
|
+
slot={slot}
|
|
125
|
+
pieceType={activeType}
|
|
126
|
+
plan={activePlan}
|
|
127
|
+
picks={picksBySlot[slot.id]?.[activeType] ?? []}
|
|
128
|
+
onTogglePick={(pieceId) => togglePick(slot.id, activeType, pieceId)}
|
|
129
|
+
onReorderPick={(fromIndex, toIndex) => reorderPick(slot.id, activeType, fromIndex, toIndex)}
|
|
130
|
+
matchAgentUrl={matchAgentUrl}
|
|
131
|
+
matchAgentAuth={matchAgentAuth}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<div className="selection-board-footer">
|
|
138
|
+
{!allValid && (
|
|
139
|
+
<span className="selection-board-hint">Still needs: {pendingTypes.join(", ")}</span>
|
|
140
|
+
)}
|
|
141
|
+
<button type="button" disabled={!allValid} onClick={handleConfirm}>
|
|
142
|
+
Confirm Selection
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { RosterLockV1Config, SelectedPiece, UserSelection } from "@roster-lock/types";
|
|
2
|
+
|
|
3
|
+
// Ported from examples/full-local/game/game-pwa/src/game/selection.ts
|
|
4
|
+
// (itself ported from game-headless/src/steps/0-select.ts's resolvePiece) -
|
|
5
|
+
// this app needs the same plan/resolve/build logic, just run once per local
|
|
6
|
+
// player slot instead of once for the whole machine.
|
|
7
|
+
export function resolvePiece(
|
|
8
|
+
rosterConfig: RosterLockV1Config, pieceType: string, pieceId: string
|
|
9
|
+
): SelectedPiece {
|
|
10
|
+
const definition = rosterConfig.engine.pieceDefinitions[pieceType];
|
|
11
|
+
if (!definition) throw new Error(`Missing piece definition for ${pieceType}`);
|
|
12
|
+
const available = rosterConfig.rosters[pieceType];
|
|
13
|
+
if (!available) throw new Error(`Missing roster for ${pieceType}`);
|
|
14
|
+
const item = available.find((p) => p.id === pieceId);
|
|
15
|
+
if (!item) throw new Error(`Missing piece ${pieceId} in roster for ${pieceType}`);
|
|
16
|
+
|
|
17
|
+
const selection: SelectedPiece = { id: item.id, required: {} };
|
|
18
|
+
for (const requirePieceType of definition.requires) {
|
|
19
|
+
const requireDef = item.requiredPieces[requirePieceType];
|
|
20
|
+
if (!requireDef) {
|
|
21
|
+
throw new Error(`Piece ${item.id} is missing required piece type ${requirePieceType}`);
|
|
22
|
+
}
|
|
23
|
+
selection.required[requirePieceType] = {
|
|
24
|
+
mandatory: requireDef.expected.map((expectedId) => resolvePiece(rosterConfig, requirePieceType, expectedId)),
|
|
25
|
+
selectable: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return selection;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type PieceTypePlan = (
|
|
32
|
+
| { kind: "auto"; reason: "mandatory" | "on-demand" | "unselectable" }
|
|
33
|
+
| { kind: "unsupported"; configType: "preselected" | "game-controlled" }
|
|
34
|
+
| { kind: "pickable"; min: number; max: number; banList: Array<string> }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export function planForPieceType(rosterConfig: RosterLockV1Config, pieceType: string): PieceTypePlan {
|
|
38
|
+
const definition = rosterConfig.engine.pieceDefinitions[pieceType];
|
|
39
|
+
if (definition.selectionStrategy === "mandatory") return { kind: "auto", reason: "mandatory" };
|
|
40
|
+
if (definition.selectionStrategy === "on demand") return { kind: "auto", reason: "on-demand" };
|
|
41
|
+
|
|
42
|
+
const selectionConfig = rosterConfig.selection.piece[pieceType];
|
|
43
|
+
if (!selectionConfig) throw new Error(`Missing selection config for ${pieceType}`);
|
|
44
|
+
if (selectionConfig.type === "preselected") return { kind: "unsupported", configType: "preselected" };
|
|
45
|
+
if (selectionConfig.type === "game-controlled") return { kind: "unsupported", configType: "game-controlled" };
|
|
46
|
+
if (selectionConfig.type === "unselectable") return { kind: "auto", reason: "unselectable" };
|
|
47
|
+
|
|
48
|
+
const validation = selectionConfig.validation;
|
|
49
|
+
const count = validation?.count ?? 1;
|
|
50
|
+
let min: number;
|
|
51
|
+
let max: number;
|
|
52
|
+
if (count === "*") {
|
|
53
|
+
min = 1;
|
|
54
|
+
max = Number.POSITIVE_INFINITY;
|
|
55
|
+
} else if (Array.isArray(count)) {
|
|
56
|
+
min = count[0];
|
|
57
|
+
max = count[1] === "*" ? Number.POSITIVE_INFINITY : count[1];
|
|
58
|
+
} else {
|
|
59
|
+
min = count;
|
|
60
|
+
max = count;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { kind: "pickable", min, max, banList: validation?.banList ?? [] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildUserSelection(
|
|
67
|
+
rosterConfig: RosterLockV1Config,
|
|
68
|
+
picks: Record<string, Array<string>>,
|
|
69
|
+
): UserSelection {
|
|
70
|
+
const selection: UserSelection = {};
|
|
71
|
+
for (const [pieceType, pieceIds] of Object.entries(picks)) {
|
|
72
|
+
selection[pieceType] = pieceIds.map((id) => resolvePiece(rosterConfig, pieceType, id));
|
|
73
|
+
}
|
|
74
|
+
return selection;
|
|
75
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
|
2
|
+
import { RosterLockV1Config, UserSelection } from "@roster-lock/types";
|
|
3
|
+
|
|
4
|
+
// What /download needs to run the sync-dl protocol, handed off by
|
|
5
|
+
// bridge/hostBridge.ts's initiateRelay handler once whatever matchmaker is
|
|
6
|
+
// loaded in /match-making's <iframe> hands off a relay room. Kept as its own
|
|
7
|
+
// context (not folded into the bridge hook's state) so /download stays
|
|
8
|
+
// reusable regardless of which matchmaker produced the session.
|
|
9
|
+
export type DownloadSession = {
|
|
10
|
+
relay: { url: string, roomId: string },
|
|
11
|
+
rosterConfig: RosterLockV1Config,
|
|
12
|
+
gameLauncherPlugin: string,
|
|
13
|
+
gameConfig: unknown,
|
|
14
|
+
playerSelections: Record<number, UserSelection>,
|
|
15
|
+
// Which side of a direct-tcp connection this machine is.
|
|
16
|
+
isHost: boolean,
|
|
17
|
+
// A direct-tcp game launcher's rendezvous coordinator address, resolved by
|
|
18
|
+
// the matchmaker the same way it resolves `relay` (see
|
|
19
|
+
// core/types's InitiateRelayEvent and
|
|
20
|
+
// docs/v2/ikemen-go/game-coordinator.md) - null for game launchers that
|
|
21
|
+
// don't use one.
|
|
22
|
+
coordinator: { host: string, port: number } | null,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type DownloadSessionContextValue = {
|
|
26
|
+
session: DownloadSession | null,
|
|
27
|
+
setSession: (session: DownloadSession) => void,
|
|
28
|
+
clearSession: () => void,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DownloadSessionContext = createContext<DownloadSessionContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
export function DownloadSessionProvider({ children }: { children: ReactNode }) {
|
|
34
|
+
const [session, setSessionState] = useState<DownloadSession | null>(null);
|
|
35
|
+
const setSession = useCallback((s: DownloadSession) => setSessionState(s), []);
|
|
36
|
+
const clearSession = useCallback(() => setSessionState(null), []);
|
|
37
|
+
return (
|
|
38
|
+
<DownloadSessionContext.Provider value={{ session, setSession, clearSession }}>
|
|
39
|
+
{children}
|
|
40
|
+
</DownloadSessionContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useDownloadSession(): DownloadSessionContextValue {
|
|
45
|
+
const ctx = useContext(DownloadSessionContext);
|
|
46
|
+
if (!ctx) throw new Error("useDownloadSession must be used within a DownloadSessionProvider");
|
|
47
|
+
return ctx;
|
|
48
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useEffect, useRef, ReactNode } from "react";
|
|
2
|
+
import { generateKeyPair, UserKeyPair } from "@roster-lock/ts-client";
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = "match-agent-client:identity";
|
|
5
|
+
|
|
6
|
+
type StoredIdentity = { machineId: string, keys: UserKeyPair };
|
|
7
|
+
|
|
8
|
+
function loadStored(): StoredIdentity | null {
|
|
9
|
+
try {
|
|
10
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
11
|
+
return raw ? JSON.parse(raw) : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type IdentityContextValue = {
|
|
18
|
+
machineId: string,
|
|
19
|
+
keys: UserKeyPair,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const IdentityContext = createContext<IdentityContextValue | null>(null);
|
|
23
|
+
|
|
24
|
+
export function IdentityProvider({ children }: { children: ReactNode }) {
|
|
25
|
+
const [identity, setIdentity] = useState<StoredIdentity | null>(loadStored);
|
|
26
|
+
const generating = useRef(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (identity || generating.current) return;
|
|
30
|
+
generating.current = true;
|
|
31
|
+
generateKeyPair().then((keys) => {
|
|
32
|
+
const next = { machineId: crypto.randomUUID(), keys };
|
|
33
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
34
|
+
setIdentity(next);
|
|
35
|
+
});
|
|
36
|
+
}, [identity]);
|
|
37
|
+
|
|
38
|
+
// Blocks children until the one-time (sub-second) keypair generation
|
|
39
|
+
// completes, rather than threading a loading state through every consumer.
|
|
40
|
+
if (!identity) return null;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<IdentityContext.Provider value={{ machineId: identity.machineId, keys: identity.keys }}>
|
|
44
|
+
{children}
|
|
45
|
+
</IdentityContext.Provider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useIdentity(): IdentityContextValue {
|
|
50
|
+
const ctx = useContext(IdentityContext);
|
|
51
|
+
if (!ctx) throw new Error("useIdentity must be used within an IdentityProvider");
|
|
52
|
+
return ctx;
|
|
53
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type InputSource = (
|
|
4
|
+
| { type: "keyboard" }
|
|
5
|
+
| { type: "gamepad", index: number }
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
export type PlayerSlot = {
|
|
9
|
+
id: string,
|
|
10
|
+
label: string,
|
|
11
|
+
input: InputSource,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = "match-agent-client:join-settings";
|
|
15
|
+
|
|
16
|
+
function defaultSlots(): Array<PlayerSlot> {
|
|
17
|
+
return [{ id: crypto.randomUUID(), label: "Player 1", input: { type: "keyboard" } }];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadSlots(): Array<PlayerSlot> {
|
|
21
|
+
try {
|
|
22
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
23
|
+
if (!raw) return defaultSlots();
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
return Array.isArray(parsed) && parsed.length > 0 ? parsed : defaultSlots();
|
|
26
|
+
} catch {
|
|
27
|
+
return defaultSlots();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type JoinSettingsContextValue = {
|
|
32
|
+
playerSlots: Array<PlayerSlot>,
|
|
33
|
+
addSlot: () => void,
|
|
34
|
+
removeSlot: (id: string) => void,
|
|
35
|
+
setSlotInput: (id: string, input: InputSource) => void,
|
|
36
|
+
setSlotLabel: (id: string, label: string) => void,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const JoinSettingsContext = createContext<JoinSettingsContextValue | null>(null);
|
|
40
|
+
|
|
41
|
+
export function JoinSettingsProvider({ children }: { children: ReactNode }) {
|
|
42
|
+
const [playerSlots, setPlayerSlots] = useState<Array<PlayerSlot>>(loadSlots);
|
|
43
|
+
|
|
44
|
+
const persist = useCallback((next: Array<PlayerSlot>) => {
|
|
45
|
+
setPlayerSlots(next);
|
|
46
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
// Only one slot may use the keyboard at a time - a browser only exposes
|
|
50
|
+
// one keyboard, so there's no way to distinguish which slot a keypress was
|
|
51
|
+
// meant for if more than one claims it. Additional local players need a
|
|
52
|
+
// gamepad (see useGamepads/JoinSettings page).
|
|
53
|
+
const addSlot = useCallback(() => {
|
|
54
|
+
persist([...playerSlots, {
|
|
55
|
+
id: crypto.randomUUID(), label: `Player ${playerSlots.length + 1}`, input: { type: "gamepad", index: 0 },
|
|
56
|
+
}]);
|
|
57
|
+
}, [playerSlots, persist]);
|
|
58
|
+
|
|
59
|
+
const removeSlot = useCallback((id: string) => {
|
|
60
|
+
const next = playerSlots.filter((s) => s.id !== id);
|
|
61
|
+
persist(next.length > 0 ? next : defaultSlots());
|
|
62
|
+
}, [playerSlots, persist]);
|
|
63
|
+
|
|
64
|
+
const setSlotInput = useCallback((id: string, input: InputSource) => {
|
|
65
|
+
persist(playerSlots.map((s) => (s.id === id ? { ...s, input } : s)));
|
|
66
|
+
}, [playerSlots, persist]);
|
|
67
|
+
|
|
68
|
+
const setSlotLabel = useCallback((id: string, label: string) => {
|
|
69
|
+
persist(playerSlots.map((s) => (s.id === id ? { ...s, label } : s)));
|
|
70
|
+
}, [playerSlots, persist]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<JoinSettingsContext.Provider value={{ playerSlots, addSlot, removeSlot, setSlotInput, setSlotLabel }}>
|
|
74
|
+
{children}
|
|
75
|
+
</JoinSettingsContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useJoinSettings(): JoinSettingsContextValue {
|
|
80
|
+
const ctx = useContext(JoinSettingsContext);
|
|
81
|
+
if (!ctx) throw new Error("useJoinSettings must be used within a JoinSettingsProvider");
|
|
82
|
+
return ctx;
|
|
83
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
|
2
|
+
import { validateAuthCode } from "@roster-lock/ts-client";
|
|
3
|
+
|
|
4
|
+
export type MatchAgentSettings = {
|
|
5
|
+
url: string,
|
|
6
|
+
authCode: string,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SETTINGS: MatchAgentSettings = {
|
|
10
|
+
url: "http://localhost:58732",
|
|
11
|
+
authCode: "",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = "match-agent-client:match-agent";
|
|
15
|
+
|
|
16
|
+
function loadSettings(): MatchAgentSettings {
|
|
17
|
+
try {
|
|
18
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
19
|
+
if (!raw) return DEFAULT_SETTINGS;
|
|
20
|
+
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
|
21
|
+
} catch {
|
|
22
|
+
return DEFAULT_SETTINGS;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type MatchAgentContextValue = {
|
|
27
|
+
settings: MatchAgentSettings,
|
|
28
|
+
connected: boolean,
|
|
29
|
+
connecting: boolean,
|
|
30
|
+
error: string | null,
|
|
31
|
+
updateSettings: (settings: MatchAgentSettings) => void,
|
|
32
|
+
connect: (overrideSettings?: MatchAgentSettings) => Promise<boolean>,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const MatchAgentContext = createContext<MatchAgentContextValue | null>(null);
|
|
36
|
+
|
|
37
|
+
export function MatchAgentProvider({ children }: { children: ReactNode }) {
|
|
38
|
+
const [settings, setSettings] = useState<MatchAgentSettings>(loadSettings);
|
|
39
|
+
// Deliberately not persisted, same reasoning as game-pwa's GameSessionContext:
|
|
40
|
+
// a stale/rotated auth code should always be re-checked on load, not assumed.
|
|
41
|
+
const [connected, setConnected] = useState(false);
|
|
42
|
+
const [connecting, setConnecting] = useState(false);
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const updateSettings = useCallback((next: MatchAgentSettings) => {
|
|
46
|
+
setSettings(next);
|
|
47
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
48
|
+
setConnected(false);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
// Takes an optional override rather than always reading `settings` from
|
|
52
|
+
// this closure - a caller that just updated settings via updateSettings()
|
|
53
|
+
// and immediately calls connect() in the same handler (see ConnectPage)
|
|
54
|
+
// would otherwise race the state update and connect with the *previous*
|
|
55
|
+
// settings, since this useCallback only refreshes on the next render.
|
|
56
|
+
const connect = useCallback(async (overrideSettings?: MatchAgentSettings) => {
|
|
57
|
+
const target = overrideSettings ?? settings;
|
|
58
|
+
setConnecting(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
try {
|
|
61
|
+
const ok = Boolean(await validateAuthCode(target.authCode, target.url));
|
|
62
|
+
setConnected(ok);
|
|
63
|
+
if (!ok) setError("Auth code was rejected by match-agent");
|
|
64
|
+
return ok;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
setConnected(false);
|
|
67
|
+
setError((e as Error).message);
|
|
68
|
+
return false;
|
|
69
|
+
} finally {
|
|
70
|
+
setConnecting(false);
|
|
71
|
+
}
|
|
72
|
+
}, [settings]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<MatchAgentContext.Provider value={{ settings, connected, connecting, error, updateSettings, connect }}>
|
|
76
|
+
{children}
|
|
77
|
+
</MatchAgentContext.Provider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useMatchAgent(): MatchAgentContextValue {
|
|
82
|
+
const ctx = useContext(MatchAgentContext);
|
|
83
|
+
if (!ctx) throw new Error("useMatchAgent must be used within a MatchAgentProvider");
|
|
84
|
+
return ctx;
|
|
85
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { InputSource } from "../context/JoinSettingsContext";
|
|
3
|
+
|
|
4
|
+
const AXIS_THRESHOLD = 0.5;
|
|
5
|
+
const DPAD_LEFT = 14;
|
|
6
|
+
const DPAD_RIGHT = 15;
|
|
7
|
+
const CONFIRM_BUTTON = 0;
|
|
8
|
+
|
|
9
|
+
// Drives one player slot's cursor over a grid of `itemCount` cards: arrow
|
|
10
|
+
// keys for a keyboard-assigned slot, d-pad/left-stick + button 0 for a
|
|
11
|
+
// gamepad-assigned one. Edge-detected (fires once per press, not once per
|
|
12
|
+
// frame) so a held direction doesn't spam onMove. Plain mouse clicks on a
|
|
13
|
+
// card work regardless of this hook - it's an addition on top of that, not
|
|
14
|
+
// a replacement for it (see components/Selection).
|
|
15
|
+
export function useCursorInput(
|
|
16
|
+
source: InputSource, enabled: boolean, onMove: (delta: number) => void, onConfirm: () => void
|
|
17
|
+
) {
|
|
18
|
+
const heldRef = useRef<{ left: boolean, right: boolean, confirm: boolean }>({
|
|
19
|
+
left: false, right: false, confirm: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!enabled || source.type !== "keyboard") return;
|
|
24
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
25
|
+
if (e.key === "ArrowLeft") onMove(-1);
|
|
26
|
+
else if (e.key === "ArrowRight") onMove(1);
|
|
27
|
+
else if (e.key === "Enter" || e.key === " ") onConfirm();
|
|
28
|
+
};
|
|
29
|
+
window.addEventListener("keydown", onKeyDown);
|
|
30
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
31
|
+
}, [enabled, source, onMove, onConfirm]);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled || source.type !== "gamepad") return;
|
|
35
|
+
let frame: number;
|
|
36
|
+
const poll = () => {
|
|
37
|
+
const pad = navigator.getGamepads?.()[source.index];
|
|
38
|
+
if (pad) {
|
|
39
|
+
const axis = pad.axes[0] ?? 0;
|
|
40
|
+
const left = axis < -AXIS_THRESHOLD || Boolean(pad.buttons[DPAD_LEFT]?.pressed);
|
|
41
|
+
const right = axis > AXIS_THRESHOLD || Boolean(pad.buttons[DPAD_RIGHT]?.pressed);
|
|
42
|
+
const confirm = Boolean(pad.buttons[CONFIRM_BUTTON]?.pressed);
|
|
43
|
+
|
|
44
|
+
if (left && !heldRef.current.left) onMove(-1);
|
|
45
|
+
if (right && !heldRef.current.right) onMove(1);
|
|
46
|
+
if (confirm && !heldRef.current.confirm) onConfirm();
|
|
47
|
+
|
|
48
|
+
heldRef.current = { left, right, confirm };
|
|
49
|
+
}
|
|
50
|
+
frame = requestAnimationFrame(poll);
|
|
51
|
+
};
|
|
52
|
+
frame = requestAnimationFrame(poll);
|
|
53
|
+
return () => cancelAnimationFrame(frame);
|
|
54
|
+
}, [enabled, source, onMove, onConfirm]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export type ConnectedGamepad = { index: number, id: string };
|
|
4
|
+
|
|
5
|
+
// Polls navigator.getGamepads() rather than relying solely on
|
|
6
|
+
// gamepadconnected/gamepaddisconnected events - Chrome only fires those
|
|
7
|
+
// after a button press on some platforms, and polling is what
|
|
8
|
+
// useCursorInput needs anyway to read stick/button state every frame.
|
|
9
|
+
export function useGamepads(): Array<ConnectedGamepad> {
|
|
10
|
+
const [gamepads, setGamepads] = useState<Array<ConnectedGamepad>>([]);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let frame: number;
|
|
14
|
+
const poll = () => {
|
|
15
|
+
const pads = navigator.getGamepads?.() ?? [];
|
|
16
|
+
const connected: Array<ConnectedGamepad> = [];
|
|
17
|
+
for (const pad of pads) {
|
|
18
|
+
if (pad) connected.push({ index: pad.index, id: pad.id });
|
|
19
|
+
}
|
|
20
|
+
setGamepads((prev) => {
|
|
21
|
+
if (prev.length === connected.length && prev.every((p, i) => p.index === connected[i].index)) return prev;
|
|
22
|
+
return connected;
|
|
23
|
+
});
|
|
24
|
+
frame = requestAnimationFrame(poll);
|
|
25
|
+
};
|
|
26
|
+
frame = requestAnimationFrame(poll);
|
|
27
|
+
return () => cancelAnimationFrame(frame);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
return gamepads;
|
|
31
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { FormEvent, useEffect, useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { useMatchAgent } from "../../context/MatchAgentContext";
|
|
4
|
+
|
|
5
|
+
export function ConnectPage({ autoSubmit }: { autoSubmit?: boolean }) {
|
|
6
|
+
const { settings, connecting, error, updateSettings, connect } = useMatchAgent();
|
|
7
|
+
const [url, setUrl] = useState(settings.url);
|
|
8
|
+
const [authCode, setAuthCode] = useState(settings.authCode);
|
|
9
|
+
|
|
10
|
+
const handleSubmit = async (e: FormEvent) => {
|
|
11
|
+
e.preventDefault();
|
|
12
|
+
const next = { url, authCode };
|
|
13
|
+
updateSettings(next);
|
|
14
|
+
const ok = await connect(next);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
useEffect(()=>{
|
|
18
|
+
if(!autoSubmit) return;
|
|
19
|
+
const next = { url, authCode };
|
|
20
|
+
updateSettings(next);
|
|
21
|
+
connect(next);
|
|
22
|
+
}, [autoSubmit])
|
|
23
|
+
|
|
24
|
+
if(autoSubmit && (connecting || !error)) return null;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="page">
|
|
28
|
+
<h1>Connect to Match Agent</h1>
|
|
29
|
+
<p>Point this app at the match-agent server running on this machine.</p>
|
|
30
|
+
<form onSubmit={handleSubmit}>
|
|
31
|
+
<label>
|
|
32
|
+
Match Agent URL
|
|
33
|
+
<input value={url} onChange={(e) => setUrl(e.target.value)} />
|
|
34
|
+
</label>
|
|
35
|
+
<label>
|
|
36
|
+
Auth Code
|
|
37
|
+
<input type="password" value={authCode} onChange={(e) => setAuthCode(e.target.value)} />
|
|
38
|
+
</label>
|
|
39
|
+
<button type="submit" disabled={connecting}>
|
|
40
|
+
{connecting ? "Connecting..." : "Connect"}
|
|
41
|
+
</button>
|
|
42
|
+
</form>
|
|
43
|
+
{error && <p className="error">{error}</p>}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|