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
|
- name: Run tests
|
||||||
run: pytest -v --tb=short tests/ || true
|
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:
|
java-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ UVICORN ?= uvicorn
|
|||||||
PORT ?= 8000
|
PORT ?= 8000
|
||||||
SRC = src/commander_ai_lab
|
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:
|
test:
|
||||||
$(PYTHON) -m pytest tests/ -q
|
$(PYTHON) -m pytest tests/ -q
|
||||||
@@ -69,3 +69,6 @@ frontend-dev:
|
|||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
cd frontend && npm run 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
|
## Known Limitations
|
||||||
- Winner's life total not captured (Forge quiet mode only reports losers)
|
- Winner's life total not captured (Forge quiet mode only reports losers)
|
||||||
- Win condition classification is heuristic-based on Forge loss reason strings
|
- 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",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -17,6 +19,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@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/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -26,10 +31,12 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
|
"jsdom": "^29.0.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"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() {
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||||
<h1 className="text-2xl">Lobby — coming soon</h1>
|
<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>
|
</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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
|
||||||
<h1 className="text-2xl">Room — coming soon</h1>
|
[{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
@@ -5,6 +5,8 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
// Dev-only proxy — in production FastAPI serves the built assets
|
||||||
|
// and handles /api and /ws routes directly.
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
Reference in new issue
Block a user