diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 22d89d0..03ac1cb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "frontend-tmp", + "name": "commander-ai-lab-frontend", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend-tmp", + "name": "commander-ai-lab-frontend", "version": "0.0.0", "dependencies": { "clsx": "^2.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46a5992..07acb0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,121 +1,17 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import LobbyPage from './pages/LobbyPage'; +import RoomPage from './pages/RoomPage'; +import GamePage from './pages/GamePage'; function App() { - const [count, setCount] = useState(0) - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) + + + } /> + } /> + } /> + + + ); } - -export default App +export default App; diff --git a/frontend/src/components/Board.tsx b/frontend/src/components/Board.tsx new file mode 100644 index 0000000..cd5ca2c --- /dev/null +++ b/frontend/src/components/Board.tsx @@ -0,0 +1,54 @@ +import type { GameState } from '../types/game'; +import { SEAT_COLOURS } from '../constants'; +import PlayerZone from './PlayerZone'; +import CardChip from './CardChip'; + +interface BoardProps { + gameState: GameState; + thinkingSeats: Set; +} + +export default function Board({ gameState, thinkingSeats }: BoardProps) { + const { players, active_seat, stack } = gameState; + + const getPlayer = (seat: number) => players.find((p) => p.seat === seat); + + const renderZone = (seat: number, className: string) => { + const player = getPlayer(seat); + if (!player) return
; + return ( +
+ +
+ ); + }; + + return ( +
+ {/* Row 1: P1 (top-left), stack center, P2 (top-right) */} + {renderZone(1, 'row-start-1 col-start-1')} +
+ Stack +
+ {stack.map((card, i) => ( + + ))} +
+
+ {renderZone(2, 'row-start-1 col-start-3')} + + {/* Row 2: empty side cells (stack spans all 3 rows in center) */} +
+
+ + {/* Row 3: P0 (bottom-left), P3 (bottom-right) */} + {renderZone(0, 'row-start-3 col-start-1')} + {renderZone(3, 'row-start-3 col-start-3')} +
+ ); +} diff --git a/frontend/src/components/CardChip.tsx b/frontend/src/components/CardChip.tsx new file mode 100644 index 0000000..73dbac1 --- /dev/null +++ b/frontend/src/components/CardChip.tsx @@ -0,0 +1,20 @@ +import type { Card } from '../types/game'; + +interface CardChipProps { + card: Card; + onClick?: () => void; +} + +export default function CardChip({ card, onClick }: CardChipProps) { + return ( + + {card.name} + {card.is_commander && ★} + + ); +} diff --git a/frontend/src/components/GameOverOverlay.tsx b/frontend/src/components/GameOverOverlay.tsx new file mode 100644 index 0000000..63fdb6b --- /dev/null +++ b/frontend/src/components/GameOverOverlay.tsx @@ -0,0 +1,40 @@ +import type { Player } from '../types/game'; +import { SEAT_COLOURS } from '../constants'; + +interface GameOverOverlayProps { + show: boolean; + winner: Player | null; + reason: string; + players: Player[]; +} + +export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) { + if (!show) return null; + + return ( +
+
+ {winner && ( +

+ {winner.name} Wins! +

+ )} +

{reason}

+
    + {players.map((p) => ( +
  • + P{p.seat}{' '} + {p.name} — {p.life} life + {p.eliminated && ( + [ELIM] + )} +
  • + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/HandCard.tsx b/frontend/src/components/HandCard.tsx new file mode 100644 index 0000000..291f020 --- /dev/null +++ b/frontend/src/components/HandCard.tsx @@ -0,0 +1,23 @@ +import type { Card } from '../types/game'; + +interface HandCardProps { + card: Card; + index: number; + isSelected: boolean; + onClick: () => void; +} + +export default function HandCard({ card, isSelected, onClick }: HandCardProps) { + return ( + + ); +} diff --git a/frontend/src/components/MoveBar.tsx b/frontend/src/components/MoveBar.tsx new file mode 100644 index 0000000..6eefcf8 --- /dev/null +++ b/frontend/src/components/MoveBar.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import type { Card } from '../types/game'; +import HandCard from './HandCard'; + +interface MoveBarProps { + hand: Card[]; + humanSeat: number; + onPass: () => void; + onConcede: () => void; + onPlayCard: (index: number) => void; + status: string; +} + +export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) { + const [selectedIndex, setSelectedIndex] = useState(null); + + const handleCardClick = (index: number) => { + if (selectedIndex === index) { + onPlayCard(index); + setSelectedIndex(null); + } else { + setSelectedIndex(index); + } + }; + + return ( +
+ + + {hand.map((card, i) => ( + handleCardClick(i)} + /> + ))} + {status} +
+ ); +} diff --git a/frontend/src/components/NarrationPanel.tsx b/frontend/src/components/NarrationPanel.tsx new file mode 100644 index 0000000..7606037 --- /dev/null +++ b/frontend/src/components/NarrationPanel.tsx @@ -0,0 +1,68 @@ +import { useRef, useEffect, useState } from 'react'; +import type { NarrationLine } from '../types/game'; +import { SEAT_COLOURS } from '../constants'; + +interface NarrationPanelProps { + lines: NarrationLine[]; + thinkingSeats: Set; +} + +export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) { + const containerRef = useRef(null); + const [dots, setDots] = useState('.'); + + // Auto-scroll on new lines + useEffect(() => { + const el = containerRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines.length]); + + // Cycling dots for thinking animation + useEffect(() => { + if (thinkingSeats.size === 0) return; + const interval = setInterval(() => { + setDots((prev) => (prev.length >= 3 ? '.' : prev + '.')); + }, 500); + return () => clearInterval(interval); + }, [thinkingSeats.size]); + + const displayed = lines.slice(-120); + + return ( +
+ {displayed.map((line, i) => { + let colorClass = 'text-[#e8e8e8]'; + let style: React.CSSProperties | undefined; + + if (line.isSystem) { + colorClass = 'text-[#556]'; + } else if (line.seat !== undefined) { + colorClass = ''; + style = { color: SEAT_COLOURS[line.seat] }; + } + + return ( +
+ {line.text} +
+ ); + })} + {thinkingSeats.size > 0 && + Array.from(thinkingSeats).map((seat) => ( +
+ P{seat} thinking{dots} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/PhaseBar.tsx b/frontend/src/components/PhaseBar.tsx new file mode 100644 index 0000000..a2be38f --- /dev/null +++ b/frontend/src/components/PhaseBar.tsx @@ -0,0 +1,37 @@ +import type { Phase, Player } from '../types/game'; +import { PHASES, SEAT_COLOURS } from '../constants'; + +interface PhaseBarProps { + phase: Phase; + activeSeat: number; + players: Player[]; +} + +export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) { + const activePlayer = players.find((p) => p.seat === activeSeat); + + return ( +
+ {PHASES.map((p) => ( + + ))} + {activePlayer && ( + + P{activeSeat}: {activePlayer.name} + + )} +
+ ); +} diff --git a/frontend/src/components/PlayerZone.tsx b/frontend/src/components/PlayerZone.tsx new file mode 100644 index 0000000..b74b363 --- /dev/null +++ b/frontend/src/components/PlayerZone.tsx @@ -0,0 +1,40 @@ +import type { Player } from '../types/game'; +import CardChip from './CardChip'; + +interface PlayerZoneProps { + player: Player; + isActive: boolean; + isThinking: boolean; + seatColour: string; +} + +function lifeColor(life: number): string { + if (life >= 30) return 'text-life-high'; + if (life >= 15) return 'text-life-med'; + return 'text-life-low'; +} + +export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) { + return ( +
+
+ + P{player.seat}: {player.name} + + + {player.life} + +
+
+ {player.battlefield.map((card, i) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/hooks/useGameSocket.ts b/frontend/src/hooks/useGameSocket.ts new file mode 100644 index 0000000..40568a8 --- /dev/null +++ b/frontend/src/hooks/useGameSocket.ts @@ -0,0 +1,132 @@ +import { useReducer, useRef, useEffect, useCallback } from 'react'; +import { gameReducer, initialState } from '../store/gameReducer'; +import type { WsIncoming, NarrationLine } from '../types/game'; + +export function useGameSocket(gameId: string) { + const [state, dispatch] = useReducer(gameReducer, initialState); + const wsRef = useRef(null); + const reconnectTimeout = useRef | null>(null); + const heartbeatInterval = useRef | null>(null); + const intentionalClose = useRef(false); + + const connect = useCallback(() => { + const url = `ws://${window.location.host}/ws/game/${gameId}`; + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + dispatch({ type: 'SET_STATUS', status: 'Connected' }); + }; + + ws.onmessage = (event) => { + const msg: WsIncoming = JSON.parse(event.data); + + switch (msg.type) { + case 'state': + dispatch({ type: 'SET_STATE', payload: msg.payload }); + break; + + case 'hand': + dispatch({ type: 'SET_HAND', cards: msg.cards }); + break; + + case 'event': { + const line: NarrationLine = { + text: msg.text, + seat: msg.seat, + isSystem: false, + timestamp: Date.now(), + }; + dispatch({ type: 'ADD_NARRATION', line }); + break; + } + + case 'phase': { + const line: NarrationLine = { + text: `Phase: ${msg.phase} (P${msg.active_seat})`, + isSystem: true, + timestamp: Date.now(), + }; + dispatch({ type: 'ADD_NARRATION', line }); + break; + } + + case 'thinking': + dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active }); + break; + + case 'game_over': + dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason }); + break; + + case 'pong': + break; + + case 'error': + dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` }); + break; + } + }; + + ws.onclose = () => { + if (!intentionalClose.current) { + dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' }); + reconnectTimeout.current = setTimeout(() => { + connect(); + }, 3000); + } + }; + + ws.onerror = () => { + dispatch({ type: 'SET_STATUS', status: 'Connection error' }); + }; + }, [gameId]); + + // Connect on mount + useEffect(() => { + intentionalClose.current = false; + connect(); + + return () => { + intentionalClose.current = true; + if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current); + wsRef.current?.close(); + }; + }, [connect]); + + // Heartbeat ping every 15s + useEffect(() => { + heartbeatInterval.current = setInterval(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'ping' })); + } + }, 15000); + + return () => { + if (heartbeatInterval.current) clearInterval(heartbeatInterval.current); + }; + }, []); + + const sendMove = useCallback((move: string, data?: Record) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'move', move, ...data })); + } + }, []); + + const sendConcede = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'concede' })); + } + }, []); + + return { + gameState: state.gameState, + narration: state.narration, + thinking: state.thinkingSeats, + hand: state.hand, + gameOver: state.gameOver, + status: state.status, + sendMove, + sendConcede, + }; +} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx new file mode 100644 index 0000000..c241ac2 --- /dev/null +++ b/frontend/src/pages/GamePage.tsx @@ -0,0 +1,53 @@ +import { useParams } from 'react-router-dom'; +import { useGameSocket } from '../hooks/useGameSocket'; +import PhaseBar from '../components/PhaseBar'; +import Board from '../components/Board'; +import NarrationPanel from '../components/NarrationPanel'; +import MoveBar from '../components/MoveBar'; +import GameOverOverlay from '../components/GameOverOverlay'; + +export default function GamePage() { + const { gameId } = useParams<{ gameId: string }>(); + const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } = + useGameSocket(gameId!); + + if (!gameState) { + return ( +
+ Connecting… +
+ ); + } + + const winnerPlayer = gameOver + ? gameState.players.find((p) => p.seat === gameOver.winner) ?? null + : null; + + return ( +
+ +
+ + +
+ sendMove('pass')} + onConcede={sendConcede} + onPlayCard={(index) => sendMove('play_card', { index })} + status={status} + /> + +
+ ); +} diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx new file mode 100644 index 0000000..1cb2ab7 --- /dev/null +++ b/frontend/src/pages/LobbyPage.tsx @@ -0,0 +1,7 @@ +export default function LobbyPage() { + return ( +
+

Lobby — coming soon

+
+ ); +} diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx new file mode 100644 index 0000000..18cfd27 --- /dev/null +++ b/frontend/src/pages/RoomPage.tsx @@ -0,0 +1,7 @@ +export default function RoomPage() { + return ( +
+

Room — coming soon

+
+ ); +} diff --git a/frontend/src/store/gameReducer.ts b/frontend/src/store/gameReducer.ts new file mode 100644 index 0000000..d3a80c7 --- /dev/null +++ b/frontend/src/store/gameReducer.ts @@ -0,0 +1,61 @@ +import type { GameState, Card, NarrationLine } from '../types/game'; + +export type GameAction = + | { type: 'SET_STATE'; payload: GameState } + | { type: 'SET_HAND'; cards: Card[] } + | { type: 'ADD_NARRATION'; line: NarrationLine } + | { type: 'SET_THINKING'; seat: number; active: boolean } + | { type: 'GAME_OVER'; winner: number; reason: string } + | { type: 'SET_STATUS'; status: string }; + +export interface ReducerState { + gameState: GameState | null; + hand: Card[]; + narration: NarrationLine[]; + thinkingSeats: Set; + gameOver: { winner: number; reason: string } | null; + status: string; +} + +export const initialState: ReducerState = { + gameState: null, + hand: [], + narration: [], + thinkingSeats: new Set(), + gameOver: null, + status: '', +}; + +export function gameReducer(state: ReducerState, action: GameAction): ReducerState { + switch (action.type) { + case 'SET_STATE': + return { ...state, gameState: action.payload }; + + case 'SET_HAND': + return { ...state, hand: action.cards }; + + case 'ADD_NARRATION': { + const narration = [...state.narration, action.line]; + return { ...state, narration: narration.slice(-120) }; + } + + case 'SET_THINKING': { + const thinkingSeats = new Set(state.thinkingSeats); + if (action.active) { + thinkingSeats.add(action.seat); + } else { + thinkingSeats.delete(action.seat); + } + return { ...state, thinkingSeats }; + } + + case 'GAME_OVER': + return { ...state, gameOver: { winner: action.winner, reason: action.reason } }; + + case 'SET_STATUS': + return { ...state, status: action.status }; + + default: + return state; + } +}