feat: add lobby UI, build integration, and test suite (Phases 9, 11, 12)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d489f84825
commit
0b623ec39e
20 files changed
+2203
-9
No files matched your search
@@ -33,6 +33,26 @@ jobs:
|
||||
- name: Run tests
|
||||
run: pytest -v --tb=short tests/ || true
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
run: npx vitest run
|
||||
|
||||
java-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -16,7 +16,7 @@ UVICORN ?= uvicorn
|
||||
PORT ?= 8000
|
||||
SRC = src/commander_ai_lab
|
||||
|
||||
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build
|
||||
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest tests/ -q
|
||||
@@ -69,3 +69,6 @@ frontend-dev:
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm run build
|
||||
|
||||
test-frontend:
|
||||
cd frontend && npx vitest run
|
||||
@@ -354,6 +354,25 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
The UI is a React + TypeScript SPA in the `frontend/` directory.
|
||||
|
||||
### Dev server (with hot reload)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
|
||||
|
||||
### Production build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
FastAPI serves the built assets from `frontend/dist/`.
|
||||
|
||||
## Known Limitations
|
||||
- Winner's life total not captured (Forge quiet mode only reports losers)
|
||||
- Win condition classification is heuristic-based on Forge loss reason strings
|
||||
|
||||
Generated
+1155
-1
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
@@ -17,6 +19,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -26,10 +31,12 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import CardChip from '../components/CardChip';
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
function makeCard(overrides: Partial<Card> = {}): Card {
|
||||
return {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: 'Tap: Add CC.',
|
||||
cmc: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CardChip', () => {
|
||||
it('applies opacity class when tapped', () => {
|
||||
render(<CardChip card={makeCard({ tapped: true })} />);
|
||||
const chip = screen.getByText(/Sol Ring/);
|
||||
expect(chip.closest('span')).toHaveClass('opacity-55');
|
||||
});
|
||||
|
||||
it('shows star for commander cards', () => {
|
||||
render(<CardChip card={makeCard({ is_commander: true })} />);
|
||||
expect(screen.getByText('★')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hover border class', () => {
|
||||
render(<CardChip card={makeCard()} />);
|
||||
const chip = screen.getByText(/Sol Ring/).closest('span');
|
||||
expect(chip).toHaveClass('hover:border-[#ffdc32]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import GamePage from '../pages/GamePage';
|
||||
import type { GameState, Card } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'test-game',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 38, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 6,
|
||||
},
|
||||
{
|
||||
seat: 2, name: 'Charlie', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 3, name: 'Diana', life: 35, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 5,
|
||||
},
|
||||
],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockHand: Card[] = [];
|
||||
const mockSendMove = vi.fn();
|
||||
const mockSendConcede = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useGameSocket', () => ({
|
||||
useGameSocket: () => ({
|
||||
gameState: mockGameState,
|
||||
narration: [],
|
||||
thinking: new Set<number>(),
|
||||
hand: mockHand,
|
||||
gameOver: null,
|
||||
status: 'Connected',
|
||||
sendMove: mockSendMove,
|
||||
sendConcede: mockSendConcede,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GamePage integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all 4 PlayerZone components', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// P0: Alice appears in both PhaseBar and PlayerZone, so use getAllByText
|
||||
expect(screen.getAllByText(/P0: Alice/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P1: Bob/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P2: Charlie/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P3: Diana/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders PhaseBar with current phase', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// main1 should be highlighted
|
||||
const main1Buttons = screen.getAllByText('main1');
|
||||
expect(main1Buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
|
||||
describe('MoveBar', () => {
|
||||
it('calls onPass when pass button is clicked', async () => {
|
||||
const onPass = vi.fn();
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={onPass}
|
||||
onConcede={vi.fn()}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Pass Priority'));
|
||||
expect(onPass).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows confirm dialog on concede', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to concede?');
|
||||
expect(onConcede).toHaveBeenCalledOnce();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not concede if confirm is cancelled', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(onConcede).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
|
||||
describe('NarrationPanel', () => {
|
||||
it('renders only 120 lines when given more', () => {
|
||||
const lines: NarrationLine[] = Array.from({ length: 150 }, (_, i) => ({
|
||||
text: `Line ${i}`,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
render(<NarrationPanel lines={lines} thinkingSeats={new Set()} />);
|
||||
|
||||
// Should not render the first 30 lines (0-29)
|
||||
expect(screen.queryByText('Line 0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Line 29')).not.toBeInTheDocument();
|
||||
|
||||
// Should render lines 30-149
|
||||
expect(screen.getByText('Line 30')).toBeInTheDocument();
|
||||
expect(screen.getByText('Line 149')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a scroll container ref', () => {
|
||||
const lines: NarrationLine[] = [
|
||||
{ text: 'Hello world', isSystem: false, timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<NarrationPanel lines={lines} thinkingSeats={new Set()} />,
|
||||
);
|
||||
|
||||
// The container div has overflow-y-auto
|
||||
const panel = container.firstChild as HTMLElement;
|
||||
expect(panel).toHaveClass('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
const players: Player[] = [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
];
|
||||
|
||||
describe('PhaseBar', () => {
|
||||
it('highlights the active phase tab', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const main1 = screen.getByText('main1');
|
||||
expect(main1).toHaveClass('bg-accent-blue');
|
||||
});
|
||||
|
||||
it('does not highlight inactive phase tabs', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const untap = screen.getByText('untap');
|
||||
expect(untap).not.toHaveClass('bg-accent-blue');
|
||||
expect(untap).toHaveClass('bg-[#252a42]');
|
||||
});
|
||||
|
||||
it('shows active player name', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
expect(screen.getByText(/P0: Alice/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PlayerZone from '../components/PlayerZone';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
function makePlayer(overrides: Partial<Player> = {}): Player {
|
||||
return {
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayerZone life colors', () => {
|
||||
it('uses life-high color for life >= 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 35 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('35');
|
||||
expect(life).toHaveClass('text-life-high');
|
||||
});
|
||||
|
||||
it('uses life-med color for life >= 15 and < 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 20 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('20');
|
||||
expect(life).toHaveClass('text-life-med');
|
||||
});
|
||||
|
||||
it('uses life-low color for life < 15', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 10 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('10');
|
||||
expect(life).toHaveClass('text-life-low');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'g1',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockCard: Card = {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: '',
|
||||
cmc: 1,
|
||||
};
|
||||
|
||||
const mockLine: NarrationLine = {
|
||||
text: 'Test narration',
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
describe('gameReducer', () => {
|
||||
it('SET_STATE sets the game state', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATE', payload: mockGameState });
|
||||
expect(result.gameState).toEqual(mockGameState);
|
||||
});
|
||||
|
||||
it('SET_HAND sets the hand cards', () => {
|
||||
const cards = [mockCard];
|
||||
const result = gameReducer(initialState, { type: 'SET_HAND', cards });
|
||||
expect(result.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('ADD_NARRATION appends a line', () => {
|
||||
const result = gameReducer(initialState, { type: 'ADD_NARRATION', line: mockLine });
|
||||
expect(result.narration).toHaveLength(1);
|
||||
expect(result.narration[0].text).toBe('Test narration');
|
||||
});
|
||||
|
||||
it('ADD_NARRATION caps at 120 lines', () => {
|
||||
let state = initialState;
|
||||
for (let i = 0; i < 130; i++) {
|
||||
state = gameReducer(state, {
|
||||
type: 'ADD_NARRATION',
|
||||
line: { text: `line ${i}`, isSystem: false, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
expect(state.narration).toHaveLength(120);
|
||||
expect(state.narration[0].text).toBe('line 10');
|
||||
});
|
||||
|
||||
it('SET_THINKING adds and removes seats', () => {
|
||||
let state = gameReducer(initialState, { type: 'SET_THINKING', seat: 2, active: true });
|
||||
expect(state.thinkingSeats.has(2)).toBe(true);
|
||||
|
||||
state = gameReducer(state, { type: 'SET_THINKING', seat: 2, active: false });
|
||||
expect(state.thinkingSeats.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('GAME_OVER sets winner and reason', () => {
|
||||
const result = gameReducer(initialState, { type: 'GAME_OVER', winner: 1, reason: 'elimination' });
|
||||
expect(result.gameOver).toEqual({ winner: 1, reason: 'elimination' });
|
||||
});
|
||||
|
||||
it('SET_STATUS sets status text', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATUS', status: 'Connected' });
|
||||
expect(result.status).toBe('Connected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
url: string;
|
||||
readyState = 1; // OPEN
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: string[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
// Simulate connection open
|
||||
setTimeout(() => this.onopen?.(new Event('open')), 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGameSocket', () => {
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches state on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
const mockState = {
|
||||
game_id: 'game-1', turn: 1, current_phase: 'main1', active_seat: 0,
|
||||
players: [], stack: [], game_over: false, winner: null,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'state', payload: mockState });
|
||||
});
|
||||
|
||||
expect(result.current.gameState).toEqual(mockState);
|
||||
});
|
||||
|
||||
it('dispatches hand cards on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
const cards = [{ name: 'Sol Ring', type: 'Artifact', tapped: false, is_commander: false, oracle: '', cmc: 1 }];
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'hand', cards });
|
||||
});
|
||||
|
||||
expect(result.current.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('sends heartbeat ping every 15s', () => {
|
||||
renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
expect(ws.sent).toContain(JSON.stringify({ type: 'ping' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface Seat {
|
||||
seat: number;
|
||||
name: string;
|
||||
ready: boolean;
|
||||
isAi: boolean;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
host: string;
|
||||
seats: (Seat | null)[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'PLAYER' | 'AI' | 'SYSTEM';
|
||||
name: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type LobbyAction =
|
||||
| { type: 'SET_ROOM'; payload: RoomState }
|
||||
| { type: 'ADD_CHAT'; message: ChatMessage }
|
||||
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
|
||||
| { type: 'LAUNCH'; gameId: string };
|
||||
|
||||
interface LobbyState {
|
||||
roomState: RoomState | null;
|
||||
chat: ChatMessage[];
|
||||
launchedGameId: string | null;
|
||||
}
|
||||
|
||||
const initialState: LobbyState = {
|
||||
roomState: null,
|
||||
chat: [],
|
||||
launchedGameId: null,
|
||||
};
|
||||
|
||||
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
|
||||
switch (action.type) {
|
||||
case 'SET_ROOM':
|
||||
return { ...state, roomState: action.payload };
|
||||
case 'ADD_CHAT':
|
||||
return { ...state, chat: [...state.chat, action.message].slice(-50) };
|
||||
case 'SET_CHAT_HISTORY':
|
||||
return { ...state, chat: action.messages.slice(-50) };
|
||||
case 'LAUNCH':
|
||||
return { ...state, launchedGameId: action.gameId };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLobbySock(roomId: string, playerName: string) {
|
||||
const [state, dispatch] = useReducer(lobbyReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'lobby_state':
|
||||
dispatch({ type: 'SET_ROOM', payload: msg.payload });
|
||||
break;
|
||||
case 'chat':
|
||||
dispatch({
|
||||
type: 'ADD_CHAT',
|
||||
message: {
|
||||
role: msg.role ?? 'PLAYER',
|
||||
name: msg.name ?? '',
|
||||
text: msg.text,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'chat_history':
|
||||
dispatch({
|
||||
type: 'SET_CHAT_HISTORY',
|
||||
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
|
||||
role: m.role ?? 'SYSTEM',
|
||||
name: m.name ?? '',
|
||||
text: m.text,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
});
|
||||
break;
|
||||
case 'launch':
|
||||
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
reconnectTimeout.current = setTimeout(() => connect(), 3000);
|
||||
}
|
||||
};
|
||||
}, [roomId, playerName]);
|
||||
|
||||
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 sendReady = useCallback((seat: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendChat = useCallback((text: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendLaunch = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'launch' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
roomState: state.roomState,
|
||||
chat: state.chat,
|
||||
launchedGameId: state.launchedGameId,
|
||||
sendReady,
|
||||
sendChat,
|
||||
sendLaunch,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,141 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface RoomSummary {
|
||||
roomId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
}
|
||||
|
||||
export default function LobbyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Create room form state
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [hostName, setHostName] = useState('');
|
||||
const [aiSlots, setAiSlots] = useState(0);
|
||||
const [password, setPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/lobby/rooms')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setRooms(data))
|
||||
.catch(() => setRooms([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/lobby/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: roomName,
|
||||
host: hostName,
|
||||
ai_slots: aiSlots,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
|
||||
} catch {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<h1 className="text-2xl">Lobby — coming soon</h1>
|
||||
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
|
||||
{/* Room List */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
|
||||
{loading ? (
|
||||
<p className="text-text-muted text-sm">Loading...</p>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rooms.map((room) => (
|
||||
<li key={room.roomId}>
|
||||
<button
|
||||
onClick={() => navigate(`/room/${room.roomId}`)}
|
||||
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
|
||||
>
|
||||
<span className="font-medium">{room.name}</span>
|
||||
<span className="text-text-muted text-sm ml-2">
|
||||
Host: {room.host} · {room.playerCount}/4 players
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Room Form */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hostName}
|
||||
onChange={(e) => setHostName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">AI Players</label>
|
||||
<select
|
||||
value={aiSlots}
|
||||
onChange={(e) => setAiSlots(Number(e.target.value))}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value={0}>0 AI players</option>
|
||||
<option value={1}>1 AI player</option>
|
||||
<option value={2}>2 AI players</option>
|
||||
<option value={3}>3 AI players</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,220 @@
|
||||
export default function RoomPage() {
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useLobbySock } from '../hooks/useLobbySock';
|
||||
import type { ChatMessage } from '../hooks/useLobbySock';
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
PLAYER: 'text-accent-blue',
|
||||
AI: 'text-accent-purple',
|
||||
SYSTEM: 'text-text-muted',
|
||||
};
|
||||
|
||||
function ChatBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<h1 className="text-2xl">Room — coming soon</h1>
|
||||
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
|
||||
[{role}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isSpectator = searchParams.get('spectate') === '1';
|
||||
const nameFromUrl = searchParams.get('name') ?? '';
|
||||
const [playerName, setPlayerName] = useState(nameFromUrl);
|
||||
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
|
||||
useLobbySock(roomId!, nameConfirmed ? playerName : '');
|
||||
|
||||
// Redirect when game launches
|
||||
useEffect(() => {
|
||||
if (launchedGameId) {
|
||||
navigate(`/game/${launchedGameId}`);
|
||||
}
|
||||
}, [launchedGameId, navigate]);
|
||||
|
||||
// Auto-scroll chat
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chat.length]);
|
||||
|
||||
// Determine own seat
|
||||
const mySeat = roomState?.seats.findIndex(
|
||||
(s) => s && !s.isAi && s.name === playerName,
|
||||
) ?? -1;
|
||||
const isHost = mySeat === 0;
|
||||
|
||||
if (!nameConfirmed) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
|
||||
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
|
||||
}}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => playerName.trim() && setNameConfirmed(true)}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!roomState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting to room...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendChat = () => {
|
||||
if (chatInput.trim()) {
|
||||
sendChat(chatInput.trim());
|
||||
setChatInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg-base text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
|
||||
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
|
||||
</div>
|
||||
{isSpectator && (
|
||||
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
|
||||
SPECTATING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Seat Grid */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 max-w-lg">
|
||||
{[0, 1, 2, 3].map((seatIdx) => {
|
||||
const seat = roomState.seats[seatIdx];
|
||||
return (
|
||||
<div
|
||||
key={seatIdx}
|
||||
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
|
||||
>
|
||||
{seat ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">
|
||||
{seat.isAi ? '🤖 AI' : seat.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
{!seat.isAi && (
|
||||
<span
|
||||
className={`text-xs mt-1 font-bold ${
|
||||
seat.ready ? 'text-life-high' : 'text-life-med'
|
||||
}`}
|
||||
>
|
||||
{seat.ready ? 'READY' : 'Not Ready'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-muted text-sm">Waiting...</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isSpectator && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
{mySeat >= 0 && (
|
||||
<button
|
||||
onClick={() => sendReady(mySeat)}
|
||||
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Toggle Ready
|
||||
</button>
|
||||
)}
|
||||
{isHost && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.gameId) {
|
||||
navigate(`/game/${data.gameId}`);
|
||||
}
|
||||
} catch {
|
||||
// launch via WS fallback
|
||||
sendLaunch();
|
||||
}
|
||||
}}
|
||||
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
|
||||
>
|
||||
Launch Game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
|
||||
Chat
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{chat.map((msg: ChatMessage, i: number) => (
|
||||
<div key={i} className="text-[11px] leading-[1.4] break-words">
|
||||
<ChatBadge role={msg.role} />
|
||||
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
|
||||
<span className="text-text-body">{msg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
{!isSpectator && (
|
||||
<div className="border-t border-border-sep p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSendChat();
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -5,6 +5,8 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// Dev-only proxy — in production FastAPI serves the built assets
|
||||
// and handles /api and /ws routes directly.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- DEPRECATED: This file is replaced by the React frontend in frontend/. Kept as fallback. -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
Reference in new issue
Block a user