diff --git a/connect4-ui/.eslintrc.json b/connect4-ui/.eslintrc.json new file mode 100644 index 0000000..6b10a5b --- /dev/null +++ b/connect4-ui/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ] +} diff --git a/connect4-ui/AGENTS.md b/connect4-ui/AGENTS.md new file mode 100644 index 0000000..13c65c6 --- /dev/null +++ b/connect4-ui/AGENTS.md @@ -0,0 +1,4 @@ +# AGENTS + +- Use the TypeScript/TSX lint checker for validation instead of `npm run build`. +- Prefer `npm run lint` after TS/TSX changes unless the user explicitly asks for a different verification step. diff --git a/connect4-ui/app/play/page.tsx b/connect4-ui/app/play/page.tsx index 6516403..cd7fbbd 100644 --- a/connect4-ui/app/play/page.tsx +++ b/connect4-ui/app/play/page.tsx @@ -260,7 +260,17 @@ export default function PlayPage() { Connected as {username}

- +
+ {gamePhase === "connected" && ( + + )} + +
{status === "reconnecting" && ( @@ -270,72 +280,38 @@ export default function PlayPage() { )} -
-
-
-

- Match -

- - {tournamentMode && ( -
- 🏆 - Tournament mode active -
- )} - - {gamePhase === "connected" && ( - - )} - - {gamePhase === "ready" && ( -
- ⏳ Waiting for opponent... -
- )} - - {(gamePhase === "playing" || gamePhase === "game-over") && - myColor && ( -
-
- - You are {myColorLabel} - -
- - {gamePhase === "playing" && ( -
- {isMyTurn - ? "⬆ Your turn - click a column" - : "⏳ Waiting for opponent..."} -
- )} -
- )} - - {gamePhase === "game-over" && gameResult && !tournamentMode && ( - - )} -
+ {tournamentMode && ( +
+ 🏆 + Tournament mode active
+ )} -
+ {(gamePhase === "playing" || gamePhase === "game-over") && myColor && ( +
+
+ + You are {myColorLabel} + +
+ + {gamePhase === "playing" && ( +
+ {isMyTurn + ? "⬆ Your turn - click a column" + : "⏳ Waiting for opponent..."} +
+ )} +
+ )} + +
{gamePhase === "idle" ? (
Connect from the connection page to start. @@ -349,9 +325,9 @@ export default function PlayPage() {

Click the{" "} - Ready to Play + Ready Up {" "} - button in the Match panel to enter the queue. + button beside your status to enter the queue.

) : gamePhase === "ready" ? ( @@ -405,7 +381,6 @@ export default function PlayPage() { /> )} -
); diff --git a/connect4-ui/app/spectate/page.tsx b/connect4-ui/app/spectate/page.tsx index 77af5b7..4d9fd89 100644 --- a/connect4-ui/app/spectate/page.tsx +++ b/connect4-ui/app/spectate/page.tsx @@ -106,6 +106,13 @@ export default function SpectatePage() { }); }, []); + const resetLiveGames = useCallback(() => { + const next = new Map(); + liveGamesRef.current = next; + setLiveGames(next); + setSelectedGame(null); + }, []); + useEffect(() => { if (status === "disconnected" && shouldRedirectToConnect) { clearRedirectFlag(); @@ -156,6 +163,7 @@ export default function SpectatePage() { break; case "TOURNAMENT_SCORES": + resetLiveGames(); setScores(msg.scores); break; @@ -199,10 +207,15 @@ export default function SpectatePage() { } case "GAME_MATCH_START": + updateGame(msg.matchId, { + player1: msg.player1, + player2: msg.player2, + }); addLog( `Match started: #${msg.matchId} ${msg.player1} vs ${msg.player2}`, ); send(cmd.gameList()); + send(cmd.gameWatch(msg.matchId)); send(cmd.playerList()); if (tournamentType === "KnockoutBracket" && !knockoutRawData) { send(cmd.getData("TOURNAMENT_DATA")); @@ -271,6 +284,9 @@ export default function SpectatePage() { updateGame(msg.matchId, { result: { kind: "win", winner: msg.winner }, }); + if (tournamentType === "KnockoutBracket" && !knockoutRawData) { + setSelectedGame((prev) => (prev === msg.matchId ? null : prev)); + } setTimeout(() => { send(cmd.gameList()); send(cmd.playerList()); @@ -284,6 +300,9 @@ export default function SpectatePage() { break; } updateGame(msg.matchId, { result: { kind: "draw" } }); + if (tournamentType === "KnockoutBracket" && !knockoutRawData) { + setSelectedGame((prev) => (prev === msg.matchId ? null : prev)); + } break; case "GAME_TERMINATED": @@ -292,6 +311,9 @@ export default function SpectatePage() { break; } updateGame(msg.matchId, { result: { kind: "terminated" } }); + if (tournamentType === "KnockoutBracket" && !knockoutRawData) { + setSelectedGame((prev) => (prev === msg.matchId ? null : prev)); + } send(cmd.gameList()); break; @@ -311,6 +333,7 @@ export default function SpectatePage() { send(cmd.getData("TOURNAMENT_DATA")); } } else if (msg.key === "TOURNAMENT_DATA" && msg.value) { + resetLiveGames(); setKnockoutRawData(msg.value); } break; @@ -325,7 +348,15 @@ export default function SpectatePage() { }); return unsubscribe; - }, [addLog, knockoutRawData, send, subscribe, tournamentType, updateGame]); + }, [ + addLog, + knockoutRawData, + resetLiveGames, + send, + subscribe, + tournamentType, + updateGame, + ]); useEffect(() => { if (status !== "connected" || role !== "observer") return; @@ -357,6 +388,49 @@ export default function SpectatePage() { () => buildKnockoutBracket(knockoutRounds, liveGames, tournamentWinner), [knockoutRounds, liveGames, tournamentWinner], ); + const seedingMatches = useMemo(() => { + const seen = new Set(); + const matches: Array<{ + id: number; + player1: string; + player2: string; + currentTurnColor: 1 | 2 | null; + resultKind: KnockoutMatchView["resultKind"]; + status: "scheduled" | "live" | "completed"; + }> = []; + + for (const game of gameList) { + seen.add(game.id); + const live = liveGames.get(game.id); + const resultKind = live?.result?.kind ?? null; + if (resultKind) continue; + matches.push({ + id: game.id, + player1: game.player1, + player2: game.player2, + currentTurnColor: resultKind ? null : (live?.currentTurnColor ?? null), + resultKind, + status: resultKind ? "completed" : live ? "live" : "scheduled", + }); + } + + for (const game of Array.from(liveGames.values()).sort( + (a, b) => b.id - a.id, + )) { + if (seen.has(game.id) || !game.player1 || !game.player2 || game.result) + continue; + matches.push({ + id: game.id, + player1: game.player1, + player2: game.player2, + currentTurnColor: game.result ? null : game.currentTurnColor, + resultKind: game.result?.kind ?? null, + status: game.result ? "completed" : "live", + }); + } + + return matches; + }, [gameList, liveGames]); return (
@@ -366,20 +440,12 @@ export default function SpectatePage() {
)} - {status === "connected" && ( -
-
{tournamentActive ? "🏆" : "⏳"}
+ {status === "connected" && tournamentActive && ( +
+
🏆
- {tournamentActive - ? `Tournament Active - ${tournamentType ?? "Unknown Type"}` - : "No Active Tournament"} + {`Tournament Active - ${tournamentType ?? "Unknown Type"}`}
{gameList.length} match{gameList.length !== 1 ? "es" : ""} running @@ -470,9 +536,31 @@ export default function SpectatePage() { {showKnockoutBracket ? ( knockoutBracket.length === 0 ? ( -
- Knockout seeding is still in progress. The bracket will appear - once tournament data is available. +
+
+ Knockout seeding is still in progress. The bracket will + appear once tournament data is available. +
+ {seedingMatches.length > 0 && ( +
+ {seedingMatches.map((match) => { + return ( + + ); + })} +
+ )}
) : (
@@ -489,74 +577,18 @@ export default function SpectatePage() {
{round.matches.map((match) => { - const isSelected = - match.matchId !== null && - selectedGame === match.matchId; - const canSelect = match.matchId !== null; - const borderClass = - match.status === "completed" - ? "border-green-700/80" - : "border-gray-700"; - - const Tag = canSelect ? "button" : "div"; - return ( - - setSelectedGame( - isSelected ? null : match.matchId, - ), - type: "button" as const, - } - : {})} - className={`w-full rounded-xl border bg-gray-950/70 p-3 text-left transition-colors ${borderClass} ${ - canSelect - ? isSelected - ? "ring-1 ring-blue-400" - : "hover:border-blue-700/80" - : "" - }`} - > -
- - -
- - {match.matchId !== null - ? `Match #${match.matchId}` - : "Awaiting match"} - - - {match.status === "completed" - ? match.resultKind === "draw" - ? "Tie" - : "Complete" - : match.status === "live" - ? "Live" - : "Pending"} - -
-
-
+ matchId={match.matchId} + player1={match.player1} + player2={match.player2} + currentTurnColor={match.currentTurnColor} + status={match.status} + resultKind={match.resultKind} + selectedGame={selectedGame} + onSelect={setSelectedGame} + /> ); })}
@@ -570,54 +602,38 @@ export default function SpectatePage() { No active matches

) : ( -
+
{gameList.map((g) => { const live = liveGames.get(g.id); + const resultKind = live?.result?.kind ?? null; + const status = resultKind + ? "completed" + : live + ? "live" + : "scheduled"; return ( - + status={status} + resultKind={resultKind} + selectedGame={selectedGame} + onSelect={setSelectedGame} + className="w-full sm:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]" + /> ); })}
)}
-
- {!selectedGameData ? ( -
- 🎯 -

- {gameList.length > 0 - ? "Click a match above to see the board" - : "Active matches will appear here"} -

-
- ) : ( + {selectedGameData && ( +
<> {selectedGameData.result && (
- )} -
+
+ )}
@@ -692,6 +708,87 @@ function BracketPlayerRow({ ); } +function MatchSelectorCard({ + matchId, + player1, + player2, + currentTurnColor, + status, + resultKind, + selectedGame, + onSelect, + className = "w-full", +}: { + matchId: number | null; + player1: string | null; + player2: string | null; + currentTurnColor: 1 | 2 | null; + status: "scheduled" | "live" | "completed"; + resultKind: KnockoutMatchView["resultKind"]; + selectedGame: number | null; + onSelect: (matchId: number | null) => void; + className?: string; +}) { + const isSelected = matchId !== null && selectedGame === matchId; + const canSelect = matchId !== null; + const borderClass = + status === "completed" ? "border-green-700/80" : "border-gray-700"; + const Tag = canSelect ? "button" : "div"; + + return ( + onSelect(isSelected ? null : matchId), + type: "button" as const, + } + : {})} + className={`${className} rounded-xl border bg-gray-950/70 p-3 text-left transition-colors ${borderClass} ${ + canSelect + ? isSelected + ? "ring-1 ring-blue-400" + : "hover:border-blue-700/80" + : "" + }`} + > +
+ + +
+ + {matchId !== null ? `Match #${matchId}` : "Awaiting match"} + + + {status === "completed" + ? resultKind === "draw" + ? "Tie" + : "Complete" + : status === "live" + ? "Live" + : "Pending"} + +
+
+
+ ); +} + function isKnockoutDataMessage(raw: string) { return !raw.includes(":") && (raw.includes(",") || raw.includes("|")); } diff --git a/connect4-ui/components/Nav.tsx b/connect4-ui/components/Nav.tsx index 26ad938..9256910 100644 --- a/connect4-ui/components/Nav.tsx +++ b/connect4-ui/components/Nav.tsx @@ -9,7 +9,8 @@ import { cmd } from "@/lib/protocol"; export default function Nav() { const pathname = usePathname(); const router = useRouter(); - const { status, role, username, send, becomePlayer, disconnect } = useConnection(); + const { status, role, username, send, becomePlayer, disconnect } = + useConnection(); const [showPlayerModal, setShowPlayerModal] = useState(false); const [nextUsername, setNextUsername] = useState(username); const isConnectionPage = pathname === "/"; @@ -47,21 +48,18 @@ export default function Nav() {
{!isConnectionPage && ( <> - - + {role !== "player" && ( + + )}