Add microservices, web UI, and replay tooling
Some checks failed
ci / tests (push) Has been cancelled
Some checks failed
ci / tests (push) Has been cancelled
This commit is contained in:
15
web/index.html
Normal file
15
web/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Catan Arena</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
web/package.json
Normal file
20
web/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "catan-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
807
web/src/App.jsx
Normal file
807
web/src/App.jsx
Normal file
@@ -0,0 +1,807 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Routes, Route, Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { apiFetch, clearToken, getToken, getUsername, setToken, wsUrl } from './api.js';
|
||||
|
||||
const resourceColors = {
|
||||
brick: '#c46a44',
|
||||
lumber: '#4a6d4a',
|
||||
wool: '#8cc071',
|
||||
grain: '#e2c065',
|
||||
ore: '#7a7c83',
|
||||
desert: '#d8c18f',
|
||||
};
|
||||
|
||||
function downloadJson(data, filename) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function Header({ user }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="header">
|
||||
<div className="logo">CATAN ARENA</div>
|
||||
<div className="nav">
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/">Lobby</Link>
|
||||
<Link to="/analytics">Analytics</Link>
|
||||
<Link to="/replays">Replays</Link>
|
||||
</>
|
||||
)}
|
||||
<span className="small">{user || 'Guest'}</span>
|
||||
{user && (
|
||||
<button
|
||||
className="button ghost"
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
navigate('/login');
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage({ onAuth }) {
|
||||
const [login, setLogin] = useState({ username: '', password: '' });
|
||||
const [register, setRegister] = useState({ username: '', password: '' });
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function submitLogin() {
|
||||
const data = await apiFetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(login),
|
||||
});
|
||||
setToken(data.token, data.username);
|
||||
onAuth(data.username);
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
async function submitRegister() {
|
||||
const data = await apiFetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(register),
|
||||
});
|
||||
setToken(data.token, data.username);
|
||||
onAuth(data.username);
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="grid two">
|
||||
<div className="card">
|
||||
<div className="section-title">Login</div>
|
||||
<div className="panel-stack">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={login.password}
|
||||
onChange={(e) => setLogin({ ...login, password: e.target.value })}
|
||||
/>
|
||||
<button className="button" onClick={submitLogin}>Enter Arena</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="section-title">Create Account</div>
|
||||
<div className="panel-stack">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Username"
|
||||
value={register.username}
|
||||
onChange={(e) => setRegister({ ...register, username: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={register.password}
|
||||
onChange={(e) => setRegister({ ...register, password: e.target.value })}
|
||||
/>
|
||||
<button className="button secondary" onClick={submitRegister}>Register</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LobbyPage() {
|
||||
const [games, setGames] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [create, setCreate] = useState({ name: '', max_players: 4 });
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function load() {
|
||||
const [lobby, modelList, statsData] = await Promise.all([
|
||||
apiFetch('/api/lobby'),
|
||||
apiFetch('/api/models'),
|
||||
apiFetch('/api/stats'),
|
||||
]);
|
||||
setGames(lobby.games || []);
|
||||
setModels(modelList.models || []);
|
||||
setStats(statsData);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(console.error);
|
||||
}, []);
|
||||
|
||||
async function createGame() {
|
||||
await apiFetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(create),
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function joinGame(id) {
|
||||
await apiFetch(`/api/games/${id}/join`, { method: 'POST' });
|
||||
navigate(`/game/${id}`);
|
||||
}
|
||||
|
||||
async function watchGame(id) {
|
||||
navigate(`/game/${id}`);
|
||||
}
|
||||
|
||||
async function addAi(id) {
|
||||
const type = window.prompt('AI type: random or model?', 'random');
|
||||
if (!type) return;
|
||||
const payload = { ai_type: type };
|
||||
if (type === 'model') {
|
||||
payload.model_name = window.prompt(`Model name: ${models.join(', ')}`);
|
||||
}
|
||||
await apiFetch(`/api/games/${id}/add-ai`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function startGame(id) {
|
||||
await apiFetch(`/api/games/${id}/start`, { method: 'POST' });
|
||||
navigate(`/game/${id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="grid two">
|
||||
<div className="card">
|
||||
<div className="section-title">Lobby</div>
|
||||
<div className="panel-stack">
|
||||
{games.length === 0 && <div className="small">No active games.</div>}
|
||||
{games.map((game) => (
|
||||
<div className="card" key={game.id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong>{game.name}</strong>
|
||||
<span className="badge">{game.status}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{game.players.map((slot) => (
|
||||
<span className="player-tag" key={slot.slot_id}>
|
||||
{slot.name || 'Open'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<button className="button" onClick={() => joinGame(game.id)}>Join</button>
|
||||
<button className="button ghost" onClick={() => watchGame(game.id)}>Watch</button>
|
||||
<button className="button secondary" onClick={() => addAi(game.id)}>Add AI</button>
|
||||
<button className="button" onClick={() => startGame(game.id)}>Start</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-stack">
|
||||
<div className="card">
|
||||
<div className="section-title">Create Game</div>
|
||||
<div className="panel-stack">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Game name"
|
||||
value={create.name}
|
||||
onChange={(e) => setCreate({ ...create, name: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={create.max_players}
|
||||
onChange={(e) => setCreate({ ...create, max_players: parseInt(e.target.value, 10) })}
|
||||
>
|
||||
<option value={2}>2 players</option>
|
||||
<option value={3}>3 players</option>
|
||||
<option value={4}>4 players</option>
|
||||
</select>
|
||||
<button className="button" onClick={createGame}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="section-title">Your Stats</div>
|
||||
{stats && (
|
||||
<div className="panel-stack">
|
||||
<div>Total games: {stats.total_games}</div>
|
||||
<div>Finished games: {stats.finished_games}</div>
|
||||
<div>Your games: {stats.user_games}</div>
|
||||
<div>Your wins: {stats.user_wins}</div>
|
||||
<div>Avg turns: {Number(stats.avg_turns || 0).toFixed(1)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="section-title">AI Models</div>
|
||||
<div className="panel-stack">
|
||||
{models.map((model) => (
|
||||
<span className="player-tag" key={model}>{model}</span>
|
||||
))}
|
||||
{models.length === 0 && <div className="small">No models found.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cubeToPixel(coord, scale) {
|
||||
const [x, , z] = coord;
|
||||
const sqrt3 = Math.sqrt(3);
|
||||
return {
|
||||
x: scale * (sqrt3 * x + (sqrt3 / 2) * z),
|
||||
y: scale * (1.5 * z),
|
||||
};
|
||||
}
|
||||
|
||||
function hexCorners(cx, cy, size) {
|
||||
const points = [];
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const angle = (Math.PI / 180) * (60 * i - 30);
|
||||
points.push([cx + size * Math.cos(angle), cy + size * Math.sin(angle)]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function Board({ board, game }) {
|
||||
if (!board || !game) return null;
|
||||
const size = 48;
|
||||
const cornerScale = size / 3;
|
||||
const hexes = Object.values(board.hexes || {});
|
||||
const corners = board.corners || {};
|
||||
const edges = Object.values(board.edges || {});
|
||||
|
||||
const hexPositions = hexes.map((hex) => ({ ...hex, pos: cubeToPixel(hex.coord, size) }));
|
||||
const cornerPositions = {};
|
||||
Object.entries(corners).forEach(([id, corner]) => {
|
||||
cornerPositions[id] = { ...corner, pos: cubeToPixel(corner.coord, cornerScale) };
|
||||
});
|
||||
|
||||
const allPositions = [
|
||||
...hexPositions.map((h) => h.pos),
|
||||
...Object.values(cornerPositions).map((c) => c.pos),
|
||||
];
|
||||
const minX = Math.min(...allPositions.map((p) => p.x));
|
||||
const maxX = Math.max(...allPositions.map((p) => p.x));
|
||||
const minY = Math.min(...allPositions.map((p) => p.y));
|
||||
const maxY = Math.max(...allPositions.map((p) => p.y));
|
||||
const padding = 80;
|
||||
const viewBox = `${minX - padding} ${minY - padding} ${maxX - minX + padding * 2} ${maxY - minY + padding * 2}`;
|
||||
|
||||
const playerColors = {};
|
||||
Object.entries(game.players || {}).forEach(([name, pdata]) => {
|
||||
playerColors[name] = pdata.color || '#333';
|
||||
});
|
||||
|
||||
const roadLines = edges
|
||||
.filter((edge) => edge.owner)
|
||||
.map((edge) => {
|
||||
const a = cornerPositions[edge.a];
|
||||
const b = cornerPositions[edge.b];
|
||||
if (!a || !b) return '';
|
||||
const color = playerColors[edge.owner] || '#222';
|
||||
return `<line x1="${a.pos.x}" y1="${a.pos.y}" x2="${b.pos.x}" y2="${b.pos.y}" stroke="${color}" stroke-width="6" stroke-linecap="round" />`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const cornerNodes = Object.entries(cornerPositions)
|
||||
.filter(([, corner]) => corner.owner)
|
||||
.map(([, corner]) => {
|
||||
const radius = corner.building === 'city' ? 10 : 7;
|
||||
const color = playerColors[corner.owner] || '#222';
|
||||
return `<circle cx="${corner.pos.x}" cy="${corner.pos.y}" r="${radius}" fill="${color}" stroke="#fff" stroke-width="2" />`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const hexShapes = hexPositions
|
||||
.map((hex) => {
|
||||
const points = hexCorners(hex.pos.x, hex.pos.y, size - 4)
|
||||
.map((pt) => pt.join(','))
|
||||
.join(' ');
|
||||
const fill = resourceColors[hex.resource] || '#e0c89a';
|
||||
const number = hex.number || '';
|
||||
const robber = hex.robber ? "<circle r='8' fill='rgba(0,0,0,0.4)' />" : '';
|
||||
return `
|
||||
<g>
|
||||
<polygon points="${points}" fill="${fill}" stroke="rgba(0,0,0,0.2)" stroke-width="2" />
|
||||
<text x="${hex.pos.x}" y="${hex.pos.y}" text-anchor="middle" font-size="16" fill="#3a2a1a" font-weight="600">${number}</text>
|
||||
<g transform="translate(${hex.pos.x}, ${hex.pos.y + 20})">${robber}</g>
|
||||
</g>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return (
|
||||
<svg className="board-svg" viewBox={viewBox} dangerouslySetInnerHTML={{ __html: `${hexShapes}${roadLines}${cornerNodes}` }} />
|
||||
);
|
||||
}
|
||||
|
||||
function ActionForm({ action, onSubmit }) {
|
||||
const [payload, setPayload] = useState(action.payload || {});
|
||||
|
||||
useEffect(() => {
|
||||
setPayload(action.payload || {});
|
||||
}, [action]);
|
||||
|
||||
if (action.type === 'move_robber' || action.type === 'play_knight') {
|
||||
const options = action.payload.options || [];
|
||||
const selected = payload.hex ?? (options[0]?.hex ?? 0);
|
||||
const victims = (options.find((opt) => opt.hex === selected) || {}).victims || [];
|
||||
return (
|
||||
<div className="panel-stack">
|
||||
<select
|
||||
className="input"
|
||||
value={selected}
|
||||
onChange={(e) => setPayload({ ...payload, hex: parseInt(e.target.value, 10) })}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.hex} value={opt.hex}>Hex {opt.hex}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={payload.victim || ''}
|
||||
onChange={(e) => setPayload({ ...payload, victim: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">No victim</option>
|
||||
{victims.map((victim) => (
|
||||
<option key={victim} value={victim}>{victim}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="button" onClick={() => onSubmit(payload)}>Execute</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'play_road_building') {
|
||||
const edges = action.payload.edges || [];
|
||||
return (
|
||||
<div className="panel-stack">
|
||||
<div className="small">Select up to two edges</div>
|
||||
<select
|
||||
className="input"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const values = Array.from(e.target.selectedOptions).map((opt) => parseInt(opt.value, 10));
|
||||
setPayload({ edges: values.slice(0, 2) });
|
||||
}}
|
||||
>
|
||||
{edges.map((edge) => (
|
||||
<option key={edge} value={edge}>Edge {edge}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="button" onClick={() => onSubmit(payload)}>Execute</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'play_year_of_plenty') {
|
||||
const bank = action.payload.bank || {};
|
||||
const resources = Object.keys(bank);
|
||||
return (
|
||||
<div className="panel-stack">
|
||||
<select className="input" onChange={(e) => setPayload({ ...payload, res1: e.target.value })}>
|
||||
{resources.map((res) => (
|
||||
<option key={res} value={res}>{res}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="input" onChange={(e) => setPayload({ ...payload, res2: e.target.value })}>
|
||||
{resources.map((res) => (
|
||||
<option key={res} value={res}>{res}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => onSubmit({ resources: [payload.res1 || resources[0], payload.res2 || resources[0]] })}
|
||||
>
|
||||
Execute
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'play_monopoly') {
|
||||
const resources = action.payload.resources || ['brick', 'lumber', 'wool', 'grain', 'ore'];
|
||||
return (
|
||||
<div className="panel-stack">
|
||||
<select className="input" onChange={(e) => setPayload({ resource: e.target.value })}>
|
||||
{resources.map((res) => (
|
||||
<option key={res} value={res}>{res}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="button" onClick={() => onSubmit({ resource: payload.resource || resources[0] })}>Execute</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === 'discard') {
|
||||
const resources = action.payload.resources || {};
|
||||
return (
|
||||
<div className="panel-stack">
|
||||
{Object.entries(resources).map(([res, count]) => (
|
||||
<div key={res} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="small">{res} ({count})</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max={count}
|
||||
value={payload.cards?.[res] || 0}
|
||||
onChange={(e) => {
|
||||
setPayload({
|
||||
...payload,
|
||||
cards: { ...payload.cards, [res]: parseInt(e.target.value, 10) || 0 },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button className="button" onClick={() => onSubmit({ player: getUsername(), cards: payload.cards || {} })}>Discard</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <button className="button" onClick={() => onSubmit(action.payload)}>Execute</button>;
|
||||
}
|
||||
|
||||
function GamePage() {
|
||||
const { gameId } = useParams();
|
||||
const [state, setState] = useState(null);
|
||||
const [legalActions, setLegalActions] = useState([]);
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [trade, setTrade] = useState({ to_player: '', offer: '', request: '' });
|
||||
|
||||
useEffect(() => {
|
||||
let socket;
|
||||
async function load() {
|
||||
const data = await apiFetch(`/api/games/${gameId}`);
|
||||
setState(data);
|
||||
setLegalActions(data.legal_actions || []);
|
||||
socket = new WebSocket(wsUrl(`/ws/games/${gameId}`));
|
||||
socket.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'state') {
|
||||
setState(msg.data);
|
||||
setLegalActions(msg.data.legal_actions || []);
|
||||
setSelectedIdx(0);
|
||||
}
|
||||
};
|
||||
}
|
||||
load().catch(console.error);
|
||||
return () => socket && socket.close();
|
||||
}, [gameId]);
|
||||
|
||||
const currentAction = legalActions[selectedIdx];
|
||||
|
||||
async function submitAction(payload) {
|
||||
await apiFetch(`/api/games/${gameId}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: currentAction.type, payload }),
|
||||
});
|
||||
}
|
||||
|
||||
async function submitTrade() {
|
||||
const offer = trade.offer.split(',').reduce((acc, item) => {
|
||||
const [res, amt] = item.split(':').map((v) => v.trim());
|
||||
if (res && amt) acc[res] = parseInt(amt, 10) || 0;
|
||||
return acc;
|
||||
}, {});
|
||||
const request = trade.request.split(',').reduce((acc, item) => {
|
||||
const [res, amt] = item.split(':').map((v) => v.trim());
|
||||
if (res && amt) acc[res] = parseInt(amt, 10) || 0;
|
||||
return acc;
|
||||
}, {});
|
||||
await apiFetch(`/api/games/${gameId}/trade/offer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
from_player: getUsername(),
|
||||
to_player: trade.to_player || null,
|
||||
offer,
|
||||
request,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function respondTrade(tradeId, accept) {
|
||||
await apiFetch(`/api/games/${gameId}/trade/${tradeId}/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player: getUsername(), accept }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return <div className="container">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="grid three">
|
||||
<div className="panel-stack">
|
||||
<div className="card">
|
||||
<div className="section-title">Players</div>
|
||||
<div className="panel-stack">
|
||||
{state.game && Object.entries(state.game.players).map(([name, pdata]) => (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="player-tag" style={{ background: pdata.color || '#333', color: 'white' }}>{name}</span>
|
||||
<span className="small">VP {pdata.victory_points}</span>
|
||||
<span className="small">{pdata.resources?.hidden !== undefined ? `Hidden ${pdata.resources.hidden}` : JSON.stringify(pdata.resources)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="section-title">Actions</div>
|
||||
{legalActions.length === 0 && <div className="small">Waiting for your turn…</div>}
|
||||
{legalActions.length > 0 && (
|
||||
<div className="panel-stack">
|
||||
<select
|
||||
className="input"
|
||||
value={selectedIdx}
|
||||
onChange={(e) => setSelectedIdx(parseInt(e.target.value, 10))}
|
||||
>
|
||||
{legalActions.map((action, idx) => (
|
||||
<option key={`${action.type}-${idx}`} value={idx}>{action.type}</option>
|
||||
))}
|
||||
</select>
|
||||
<ActionForm action={currentAction} onSubmit={submitAction} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="section-title">Trades</div>
|
||||
<div className="panel-stack">
|
||||
{state.pending_trades?.length === 0 && <div className="small">No offers.</div>}
|
||||
{state.pending_trades?.map((offer) => (
|
||||
<div key={offer.id} className="panel-stack">
|
||||
<div className="small">{offer.from_player} → {offer.to_player || 'any'} | {JSON.stringify(offer.offer)} for {JSON.stringify(offer.request)}</div>
|
||||
{offer.to_player === getUsername() || offer.to_player === null ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="button" onClick={() => respondTrade(offer.id, true)}>Accept</button>
|
||||
<button className="button ghost" onClick={() => respondTrade(offer.id, false)}>Decline</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="panel-stack">
|
||||
<input className="input" placeholder="Target player (optional)" value={trade.to_player} onChange={(e) => setTrade({ ...trade, to_player: e.target.value })} />
|
||||
<input className="input" placeholder="Offer (e.g. brick:1,lumber:1)" value={trade.offer} onChange={(e) => setTrade({ ...trade, offer: e.target.value })} />
|
||||
<input className="input" placeholder="Request (e.g. grain:1)" value={trade.request} onChange={(e) => setTrade({ ...trade, request: e.target.value })} />
|
||||
<button className="button secondary" onClick={submitTrade}>Propose Trade</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="board-shell">
|
||||
<Board board={state.board} game={state.game} />
|
||||
</div>
|
||||
<div className="panel-stack">
|
||||
<div className="card">
|
||||
<div className="section-title">Status</div>
|
||||
<div className="panel-stack">
|
||||
<div>Phase: {state.game?.phase || '-'}</div>
|
||||
<div>Current: {state.game?.current_player || '-'}</div>
|
||||
<div>Last roll: {state.game?.last_roll || '-'}</div>
|
||||
<div>Winner: {state.game?.winner || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="section-title">AI Debug</div>
|
||||
<div className="small">
|
||||
{state.history?.slice().reverse().find((entry) => entry.meta && Object.keys(entry.meta).length > 0)
|
||||
? JSON.stringify(state.history.slice().reverse().find((entry) => entry.meta && Object.keys(entry.meta).length > 0).meta)
|
||||
: 'No debug data.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="section-title">History</div>
|
||||
<div className="history">
|
||||
{(state.history || []).slice().reverse().map((entry) => (
|
||||
<div key={entry.idx} className="small">{entry.actor}: {entry.action.type}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [stats, setStats] = useState(null);
|
||||
useEffect(() => {
|
||||
apiFetch('/api/stats').then(setStats).catch(console.error);
|
||||
}, []);
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<div className="section-title">Global Stats</div>
|
||||
{stats && (
|
||||
<div className="panel-stack">
|
||||
<div>Total games: {stats.total_games}</div>
|
||||
<div>Finished: {stats.finished_games}</div>
|
||||
<div>Average turns: {Number(stats.avg_turns || 0).toFixed(1)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplaysPage() {
|
||||
const [replays, setReplays] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
apiFetch('/api/replays').then((data) => setReplays(data.replays || [])).catch(console.error);
|
||||
}, []);
|
||||
|
||||
async function exportReplay(id) {
|
||||
const data = await apiFetch(`/api/replays/${id}/export`);
|
||||
downloadJson(data, `catan-replay-${id}.json`);
|
||||
}
|
||||
|
||||
async function importReplay(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const payload = JSON.parse(text);
|
||||
await apiFetch('/api/replays/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
event.target.value = '';
|
||||
const data = await apiFetch('/api/replays');
|
||||
setReplays(data.replays || []);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="card">
|
||||
<div className="section-title">Replays</div>
|
||||
<div className="panel-stack">
|
||||
<label className="button secondary" htmlFor="replay-import">
|
||||
Import Replay
|
||||
</label>
|
||||
<input
|
||||
id="replay-import"
|
||||
type="file"
|
||||
accept="application/json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={importReplay}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel-stack">
|
||||
{replays.map((replay) => (
|
||||
<div key={replay.id} className="card">
|
||||
<div>{replay.players.join(', ')} | Winner: {replay.winner || '-'}</div>
|
||||
<div className="small">Actions: {replay.total_actions}</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="button" onClick={() => navigate(`/replays/${replay.id}`)}>View</button>
|
||||
<button className="button ghost" onClick={() => exportReplay(replay.id)}>Export</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{replays.length === 0 && <div className="small">No replays yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayViewer() {
|
||||
const { replayId } = useParams();
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [state, setState] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`/api/replays/${replayId}`).then(setDetail).catch(console.error);
|
||||
}, [replayId]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`/api/replays/${replayId}/state?step=${step}`).then(setState).catch(console.error);
|
||||
}, [replayId, step]);
|
||||
|
||||
if (!detail) {
|
||||
return <div className="container">Loading replay…</div>;
|
||||
}
|
||||
|
||||
async function exportReplay() {
|
||||
const data = await apiFetch(`/api/replays/${replayId}/export`);
|
||||
downloadJson(data, `catan-replay-${replayId}.json`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="grid two">
|
||||
<div className="card">
|
||||
<div className="section-title">Replay {detail.id}</div>
|
||||
<div className="panel-stack">
|
||||
<div>Players: {detail.players.join(', ')}</div>
|
||||
<div>Winner: {detail.winner || '-'}</div>
|
||||
<div>Actions: {detail.total_actions}</div>
|
||||
<button className="button secondary" onClick={exportReplay}>Export JSON</button>
|
||||
<input
|
||||
className="input"
|
||||
type="range"
|
||||
min="0"
|
||||
max={detail.total_actions}
|
||||
value={step}
|
||||
onChange={(e) => setStep(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<div className="small">Step {step}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="board-shell">
|
||||
<Board board={state?.board} game={state?.game} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(getUsername());
|
||||
const hasToken = getToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasToken) {
|
||||
setUser(null);
|
||||
}
|
||||
}, [hasToken]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Header user={user} />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage onAuth={setUser} />} />
|
||||
<Route path="/" element={user ? <LobbyPage /> : <LoginPage onAuth={setUser} />} />
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/replays" element={<ReplaysPage />} />
|
||||
<Route path="/replays/:replayId" element={<ReplayViewer />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
web/src/api.js
Normal file
37
web/src/api.js
Normal file
@@ -0,0 +1,37 @@
|
||||
export function getToken() {
|
||||
return localStorage.getItem('catan_token');
|
||||
}
|
||||
|
||||
export function setToken(token, username) {
|
||||
localStorage.setItem('catan_token', token);
|
||||
localStorage.setItem('catan_user', username);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('catan_token');
|
||||
localStorage.removeItem('catan_user');
|
||||
}
|
||||
|
||||
export function getUsername() {
|
||||
return localStorage.getItem('catan_user');
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const headers = options.headers || {};
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(path, { ...options, headers });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function wsUrl(path) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const token = getToken();
|
||||
return `${proto}://${window.location.host}${path}?token=${token}`;
|
||||
}
|
||||
13
web/src/main.jsx
Normal file
13
web/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
180
web/src/styles.css
Normal file
180
web/src/styles.css
Normal file
@@ -0,0 +1,180 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #1e1b16;
|
||||
--sand: #f4ecd8;
|
||||
--clay: #d99560;
|
||||
--ocean: #3a6d7a;
|
||||
--forest: #3c5a3c;
|
||||
--sun: #f2c46c;
|
||||
--stone: #6c6c6c;
|
||||
--wheat: #d6b35f;
|
||||
--panel: #fff8e9;
|
||||
--accent: #b1552f;
|
||||
--shadow: rgba(30, 27, 22, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
background: radial-gradient(circle at top, #fff6e6 0%, #efe0c2 45%, #e5d0a7 100%);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 28px;
|
||||
background: linear-gradient(120deg, rgba(255, 245, 215, 0.95), rgba(255, 231, 186, 0.95));
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
border-bottom: 1px solid rgba(30, 27, 22, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: "Cinzel", serif;
|
||||
font-size: 24px;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
padding: 18px 22px;
|
||||
box-shadow: 0 18px 40px var(--shadow);
|
||||
border: 1px solid rgba(30, 27, 22, 0.08);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: "Cinzel", serif;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(30, 27, 22, 0.2);
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: var(--ocean);
|
||||
}
|
||||
|
||||
.button.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(30, 27, 22, 0.2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.player-tag {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.panel-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.board-shell {
|
||||
background: radial-gradient(circle at center, #f7efd9 0%, #eddcb8 60%, #e0c89a 100%);
|
||||
border-radius: 24px;
|
||||
padding: 12px;
|
||||
box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.board-svg {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
}
|
||||
|
||||
.history {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.board-svg {
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
16
web/vite.config.js
Normal file
16
web/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user