feat: implement and deploy blind_chess MVP
- pnpm workspace: shared/server/client packages - Server: Fastify+ws, chess.js, FSM (touch-move + hierarchy), per-player view filter, zod validation, rate limiting, grace-window disconnect handling - Client: Svelte 5 + Vite, click-to-move board, moderator panel, promotion/draw dialogs - Shared: protocol types, ModeratorText enum, geometricMoves helper (provably zero opponent-info leak) - 43 tests pass (21 shared, 22 server incl. 4 real-WS integration) - Deploy: CT 690 on node-241 (192.168.0.245), systemd-managed, Caddy block for chess.sethpc.xyz - Live at https://chess.sethpc.xyz Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0c0d10" />
|
||||
<title>blind chess</title>
|
||||
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><text y='52' font-size='52'>♚</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@blind-chess/client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blind-chess/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import Landing from './lib/Landing.svelte';
|
||||
import Game from './lib/Game.svelte';
|
||||
|
||||
let route: { name: 'landing' } | { name: 'game'; id: string } = $state({ name: 'landing' });
|
||||
|
||||
function parseHash() {
|
||||
// Support both pathname-based (joinUrl: /g/<id>) and hash-based (#/g/<id>) routes.
|
||||
const hash = location.hash || '';
|
||||
const hashMatch = hash.match(/^#\/g\/([a-z0-9]{8})$/);
|
||||
if (hashMatch) {
|
||||
route = { name: 'game', id: hashMatch[1]! };
|
||||
return;
|
||||
}
|
||||
const pathMatch = location.pathname.match(/^\/g\/([a-z0-9]{8})\/?$/);
|
||||
if (pathMatch) {
|
||||
route = { name: 'game', id: pathMatch[1]! };
|
||||
return;
|
||||
}
|
||||
route = { name: 'landing' };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
parseHash();
|
||||
const handler = () => parseHash();
|
||||
window.addEventListener('hashchange', handler);
|
||||
return () => window.removeEventListener('hashchange', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if route.name === 'landing'}
|
||||
<Landing />
|
||||
{:else}
|
||||
<Game gameId={route.id} />
|
||||
{/if}
|
||||
@@ -0,0 +1,64 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0c0d10;
|
||||
--panel: #15171c;
|
||||
--panel-2: #1d2027;
|
||||
--border: #2a2e38;
|
||||
--text: #e8e8ea;
|
||||
--text-dim: #8d92a0;
|
||||
--accent: #d35400;
|
||||
--accent-dim: #8a3a09;
|
||||
--light: #d8c8a8;
|
||||
--dark: #6b4f3a;
|
||||
--highlight: rgba(74, 222, 128, 0.55);
|
||||
--highlight-cap: rgba(248, 113, 113, 0.65);
|
||||
--armed: rgba(211, 84, 0, 0.55);
|
||||
--touched: rgba(211, 84, 0, 0.85);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); }
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #252932; border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover { background: #e2671f; border-color: #e2671f; }
|
||||
|
||||
input, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.muted { color: var(--text-dim); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared';
|
||||
import { pieceGlyph } from './pieces.js';
|
||||
|
||||
interface Props {
|
||||
pieces: Partial<Record<Square, Piece>>;
|
||||
you: Color;
|
||||
toMove: Color;
|
||||
mode: 'blind' | 'vanilla';
|
||||
highlightingEnabled: boolean;
|
||||
armedSquare: Square | null; // local visual arm (pre-commit)
|
||||
touchedSquare: Square | null; // server-authoritative touch
|
||||
onArm: (sq: Square | null) => void;
|
||||
onCommit: (from: Square, to: Square) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
pieces, you, toMove, mode, highlightingEnabled,
|
||||
armedSquare, touchedSquare, onArm, onCommit,
|
||||
}: Props = $props();
|
||||
|
||||
const ranksDisplay = $derived(you === 'w' ? [...RANKS].reverse() : [...RANKS]);
|
||||
const filesDisplay = $derived(you === 'w' ? [...FILES] : [...FILES].reverse());
|
||||
|
||||
const ownSquares = $derived(
|
||||
new Set<Square>(
|
||||
Object.entries(pieces)
|
||||
.filter(([_, p]) => p?.color === you)
|
||||
.map(([sq]) => sq as Square),
|
||||
),
|
||||
);
|
||||
|
||||
// Highlight set: only when toggle is ON, only for the active piece (touched
|
||||
// wins over armed). Geometric in blind mode (no opponent input). In vanilla
|
||||
// mode we'd ideally use chess.js legal moves, but for simplicity we use the
|
||||
// same geometric set with the understanding that the client is non-authoritative.
|
||||
const highlightFrom = $derived(touchedSquare ?? armedSquare);
|
||||
const highlights = $derived.by(() => {
|
||||
if (!highlightingEnabled || !highlightFrom) return new Set<Square>();
|
||||
const piece = pieces[highlightFrom];
|
||||
if (!piece) return new Set<Square>();
|
||||
const moves = geometricMoves(piece, highlightFrom, ownSquares);
|
||||
return new Set(moves);
|
||||
});
|
||||
|
||||
function squareColor(sq: Square): 'light' | 'dark' {
|
||||
const f = sq.charCodeAt(0) - 'a'.charCodeAt(0);
|
||||
const r = parseInt(sq[1]!, 10) - 1;
|
||||
return (f + r) % 2 === 0 ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function onSquareClick(sq: Square) {
|
||||
const piece = pieces[sq];
|
||||
|
||||
// If a piece is touched (server-authoritative), only commits to that square's destinations are valid.
|
||||
if (touchedSquare) {
|
||||
if (sq === touchedSquare) return; // tap own piece is no-op
|
||||
onCommit(touchedSquare, sq);
|
||||
return;
|
||||
}
|
||||
|
||||
// If a piece is armed locally (no commit yet), clicking another own piece reassigns,
|
||||
// clicking elsewhere commits the armed piece to that destination.
|
||||
if (armedSquare) {
|
||||
if (piece?.color === you) {
|
||||
onArm(sq); // reassign
|
||||
return;
|
||||
}
|
||||
onCommit(armedSquare, sq); // commit to destination
|
||||
return;
|
||||
}
|
||||
|
||||
// No arm yet. Tap an own piece to arm it.
|
||||
if (piece?.color === you) {
|
||||
onArm(sq);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="board" class:flipped={you === 'b'}>
|
||||
{#each ranksDisplay as r (r)}
|
||||
{#each filesDisplay as f (f)}
|
||||
{@const sq = `${f}${r}` as Square}
|
||||
{@const piece = pieces[sq]}
|
||||
{@const sc = squareColor(sq)}
|
||||
{@const isArmed = sq === armedSquare}
|
||||
{@const isTouched = sq === touchedSquare}
|
||||
{@const isHighlight = highlights.has(sq)}
|
||||
{@const isHighlightCap = isHighlight && piece && piece.color !== you}
|
||||
<button
|
||||
type="button"
|
||||
class="sq sq-{sc}"
|
||||
class:armed={isArmed}
|
||||
class:touched={isTouched}
|
||||
class:hl={isHighlight && !isHighlightCap}
|
||||
class:hl-cap={isHighlightCap}
|
||||
onclick={() => onSquareClick(sq)}
|
||||
aria-label={sq}
|
||||
>
|
||||
{#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]}
|
||||
<span class="coord coord-rank">{r}</span>
|
||||
{/if}
|
||||
{#if r === (you === 'w' ? '1' : '8')}
|
||||
<span class="coord coord-file">{f}</span>
|
||||
{/if}
|
||||
{#if piece}
|
||||
<span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span>
|
||||
{/if}
|
||||
{#if isHighlight && !piece}
|
||||
<span class="dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
grid-template-rows: repeat(8, 1fr);
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: min(85vh, 92vw);
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sq {
|
||||
position: relative;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(28px, 5.5vw, 56px);
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sq-light { background: var(--light); }
|
||||
.sq-dark { background: var(--dark); }
|
||||
.sq:hover { filter: brightness(1.1); }
|
||||
|
||||
.piece {
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.piece-w { color: #fafafa; }
|
||||
.piece-b { color: #1a1a1a; }
|
||||
|
||||
.armed { box-shadow: inset 0 0 0 4px var(--armed); }
|
||||
.touched { box-shadow: inset 0 0 0 4px var(--touched); }
|
||||
.hl::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(circle, var(--highlight) 18%, transparent 22%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hl-cap { box-shadow: inset 0 0 0 4px var(--highlight-cap); }
|
||||
|
||||
.dot { display: none; } /* using ::before above */
|
||||
|
||||
.coord {
|
||||
position: absolute;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
.coord-rank { left: 3px; top: 3px; }
|
||||
.coord-file { right: 3px; bottom: 1px; }
|
||||
.sq-dark .coord { color: rgba(255,255,255,0.5); }
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { game } from './stores/game.js';
|
||||
import Board from './Board.svelte';
|
||||
import ModeratorPanel from './ModeratorPanel.svelte';
|
||||
import PromotionDialog from './PromotionDialog.svelte';
|
||||
import type { PromotionType, Square } from '@blind-chess/shared';
|
||||
|
||||
interface Props { gameId: string; }
|
||||
let { gameId }: Props = $props();
|
||||
|
||||
let armedSquare: Square | null = $state(null);
|
||||
let pendingPromotion: { from: Square; to: Square } | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
game.connect(gameId);
|
||||
return () => game.disconnect();
|
||||
});
|
||||
|
||||
// Once the server commits, our local arm clears (visual state slaved to server).
|
||||
$effect(() => {
|
||||
if (game.state.touchedPiece) armedSquare = null;
|
||||
if (game.state.gameStatus === 'finished') armedSquare = null;
|
||||
});
|
||||
|
||||
function onArm(sq: Square | null) {
|
||||
armedSquare = sq;
|
||||
}
|
||||
|
||||
function onCommit(from: Square, to: Square) {
|
||||
const piece = game.state.view?.pieces[from];
|
||||
if (!piece) return;
|
||||
// Promotion check (white pawn to rank 8, black pawn to rank 1).
|
||||
if (piece.type === 'p') {
|
||||
const rank = to[1];
|
||||
if ((piece.color === 'w' && rank === '8') || (piece.color === 'b' && rank === '1')) {
|
||||
pendingPromotion = { from, to };
|
||||
return;
|
||||
}
|
||||
}
|
||||
game.commit(from, to);
|
||||
}
|
||||
|
||||
function choosePromotion(p: PromotionType) {
|
||||
if (!pendingPromotion) return;
|
||||
game.commit(pendingPromotion.from, pendingPromotion.to, p);
|
||||
pendingPromotion = null;
|
||||
}
|
||||
|
||||
const joinUrl = $derived(`${location.origin}/#/g/${gameId}`);
|
||||
|
||||
let copied = $state(false);
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(joinUrl);
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 1500);
|
||||
}
|
||||
|
||||
const turnLabel = $derived.by(() => {
|
||||
if (game.state.gameStatus === 'finished') {
|
||||
const reason = game.state.endReason;
|
||||
const winner = game.state.winner;
|
||||
if (reason === 'checkmate') return `Checkmate — ${winner === 'w' ? 'White' : 'Black'} wins`;
|
||||
if (reason === 'resign') return `${winner === 'w' ? 'White' : 'Black'} wins by resignation`;
|
||||
if (reason === 'abandoned') return winner ? `${winner === 'w' ? 'White' : 'Black'} wins by abandonment` : 'Game abandoned';
|
||||
if (reason) return `Draw — ${reason}`;
|
||||
return 'Game over';
|
||||
}
|
||||
if (game.state.gameStatus === 'waiting') return 'Waiting for opponent…';
|
||||
if (!game.state.you) return '…';
|
||||
if (game.state.view?.toMove === game.state.you) return 'Your turn';
|
||||
return 'Opponent thinking';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="game-layout" class:waiting={game.state.gameStatus === 'waiting'}>
|
||||
|
||||
<div class="topbar">
|
||||
<a href="#/" class="back">← New game</a>
|
||||
<span class="status">{turnLabel}</span>
|
||||
<span class="mode-badge">
|
||||
{game.state.mode ?? '…'}
|
||||
{#if game.state.you}
|
||||
· You: {game.state.you === 'w' ? 'White' : 'Black'}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if game.state.gameStatus === 'waiting'}
|
||||
<div class="waiting-card">
|
||||
<h2>Share this link to start the game</h2>
|
||||
<p class="muted">The first person to open it claims the open color.</p>
|
||||
<div class="link-row">
|
||||
<input class="link" type="text" readonly value={joinUrl} onclick={(e) => (e.currentTarget as HTMLInputElement).select()} />
|
||||
<button class="primary" onclick={copyLink}>{copied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if game.state.view && game.state.you}
|
||||
<div class="board-area">
|
||||
<Board
|
||||
pieces={game.state.view.pieces}
|
||||
you={game.state.you}
|
||||
toMove={game.state.view.toMove}
|
||||
mode={game.state.mode ?? 'blind'}
|
||||
highlightingEnabled={game.state.highlightingEnabled}
|
||||
armedSquare={armedSquare}
|
||||
touchedSquare={game.state.touchedPiece}
|
||||
{onArm}
|
||||
{onCommit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside class="side">
|
||||
<ModeratorPanel announcements={game.state.announcements} you={game.state.you} />
|
||||
|
||||
<div class="actions">
|
||||
{#if game.state.gameStatus === 'active'}
|
||||
<button onclick={() => game.offerDraw()} disabled={!!game.state.drawOffer}>Offer draw</button>
|
||||
<button onclick={() => { if (confirm('Resign?')) game.resign(); }}>Resign</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if game.state.drawOffer && game.state.drawOffer.from !== game.state.you}
|
||||
<div class="banner">
|
||||
Opponent offers a draw.
|
||||
<div class="row">
|
||||
<button class="primary" onclick={() => game.respondDraw(true)}>Accept</button>
|
||||
<button onclick={() => game.respondDraw(false)}>Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if game.state.drawOffer && game.state.drawOffer.from === game.state.you}
|
||||
<div class="banner muted">Draw offer pending…</div>
|
||||
{/if}
|
||||
|
||||
{#if game.state.lastError && Date.now() - game.state.lastError.at < 4000}
|
||||
<div class="banner err">⚠ {game.state.lastError.code}: {game.state.lastError.message}</div>
|
||||
{/if}
|
||||
|
||||
{#if !game.state.opponentConnected && game.state.gameStatus === 'active'}
|
||||
<div class="banner muted">Opponent disconnected — 5 minute grace window.</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pendingPromotion && game.state.you}
|
||||
<PromotionDialog
|
||||
color={game.state.you}
|
||||
onChoose={choosePromotion}
|
||||
onCancel={() => pendingPromotion = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.game-layout {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.game-layout {
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'board side';
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.topbar { grid-area: top; }
|
||||
.board-area { grid-area: board; }
|
||||
.side { grid-area: side; }
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.back { color: var(--text-dim); }
|
||||
.status { flex: 1; text-align: center; font-weight: 600; }
|
||||
.mode-badge {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.board-area { display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 280px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.actions button { flex: 1; }
|
||||
|
||||
.banner {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.banner.err { border-color: #f87171; color: #f87171; }
|
||||
.banner .row { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.banner .row button { flex: 1; }
|
||||
|
||||
.waiting-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 520px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
.waiting-card h2 { margin: 0 0 8px; font-size: 18px; }
|
||||
.link-row { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.link { flex: 1; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { Mode, Color, CreateGameResponse } from '@blind-chess/shared';
|
||||
|
||||
let mode: Mode = $state('blind');
|
||||
let side: Color | 'random' = $state('random');
|
||||
let highlightingEnabled = $state(false);
|
||||
let creating = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ mode, side, highlightingEnabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: CreateGameResponse & { creatorColor: Color } = await res.json();
|
||||
// store creator token before navigating
|
||||
localStorage.setItem(`bc:${json.gameId}`, json.creatorToken);
|
||||
location.hash = `#/g/${json.gameId}`;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="hero">
|
||||
<h1>blind <span class="accent">chess</span></h1>
|
||||
<p class="tagline">A two-player chess variant where each player sees only their own pieces. The server is the moderator.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Create a game</h2>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">Mode</span>
|
||||
<div class="opts">
|
||||
<label class="opt" class:active={mode === 'blind'}>
|
||||
<input type="radio" bind:group={mode} value="blind" />
|
||||
<span class="opt-title">Blind</span>
|
||||
<span class="opt-sub">Each player sees only their own pieces.</span>
|
||||
</label>
|
||||
<label class="opt" class:active={mode === 'vanilla'}>
|
||||
<input type="radio" bind:group={mode} value="vanilla" />
|
||||
<span class="opt-title">Vanilla</span>
|
||||
<span class="opt-sub">Normal chess. Both players see everything.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl">You play as</span>
|
||||
<div class="row">
|
||||
<label><input type="radio" bind:group={side} value="w" /> White</label>
|
||||
<label><input type="radio" bind:group={side} value="b" /> Black</label>
|
||||
<label><input type="radio" bind:group={side} value="random" /> Random</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={highlightingEnabled} />
|
||||
<span>Highlight reachable squares</span>
|
||||
{#if mode === 'blind'}
|
||||
<span class="hint muted">(geometric only — no opponent info)</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primary big" disabled={creating} onclick={create}>
|
||||
{creating ? 'Creating…' : 'Create game'}
|
||||
</button>
|
||||
|
||||
{#if error}
|
||||
<p class="error">Error: {error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="muted">
|
||||
<span class="mono">git.sethpc.xyz/Seth/blind_chess</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 80px;
|
||||
min-height: 100%;
|
||||
}
|
||||
.hero { text-align: center; margin-bottom: 32px; }
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.accent { color: var(--accent); }
|
||||
.tagline {
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 22px;
|
||||
}
|
||||
h2 { font-size: 18px; margin: 0 0 16px; }
|
||||
|
||||
.field { margin-bottom: 20px; }
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.opts { display: grid; gap: 8px; }
|
||||
.opt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border 0.15s, background 0.15s;
|
||||
}
|
||||
.opt:hover { border-color: var(--accent-dim); }
|
||||
.opt.active { border-color: var(--accent); background: rgba(211,84,0,0.07); }
|
||||
.opt input { display: none; }
|
||||
.opt-title { font-weight: 600; }
|
||||
.opt-sub { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
|
||||
|
||||
.row { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.row label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
|
||||
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; flex-wrap: wrap; }
|
||||
.hint { font-size: 13px; }
|
||||
|
||||
button.big {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error { color: #f87171; margin-top: 12px; }
|
||||
footer { text-align: center; margin-top: 24px; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { Announcement, Color } from '@blind-chess/shared';
|
||||
import { moderatorText } from './moderator-strings.js';
|
||||
|
||||
interface Props {
|
||||
announcements: Announcement[];
|
||||
you: Color;
|
||||
}
|
||||
let { announcements, you }: Props = $props();
|
||||
|
||||
// Show only entries this viewer is allowed to see.
|
||||
const visible = $derived(
|
||||
announcements.filter((a) => a.audience === 'both' || a.audience === you),
|
||||
);
|
||||
|
||||
let scrollEl: HTMLDivElement | null = $state(null);
|
||||
$effect(() => {
|
||||
void visible.length;
|
||||
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<header>Moderator</header>
|
||||
<div class="log" bind:this={scrollEl}>
|
||||
{#each visible as a, i (i)}
|
||||
<div class="entry" class:err={a.text === 'illegal_move' || a.text === 'no_such_piece' || a.text === 'no_legal_moves' || a.text === 'wont_help'}>
|
||||
<span class="ply">{a.ply > 0 ? `#${a.ply}` : ''}</span>
|
||||
<span class="text">{moderatorText(a.text, a.payload)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty muted">The moderator is silent.</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
header {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.log {
|
||||
padding: 8px 14px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.entry {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px dashed rgba(255,255,255,0.05);
|
||||
}
|
||||
.entry:last-child { border-bottom: none; }
|
||||
.entry.err .text { color: #f87171; }
|
||||
.ply {
|
||||
color: var(--text-dim);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
}
|
||||
.empty { padding: 6px 0; font-style: italic; }
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Color, PromotionType } from '@blind-chess/shared';
|
||||
|
||||
interface Props {
|
||||
color: Color;
|
||||
onChoose: (p: PromotionType) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
let { color, onChoose, onCancel }: Props = $props();
|
||||
|
||||
const glyphs: Record<PromotionType, { w: string; b: string; name: string }> = {
|
||||
q: { w: '♕', b: '♛', name: 'Queen' },
|
||||
r: { w: '♖', b: '♜', name: 'Rook' },
|
||||
b: { w: '♗', b: '♝', name: 'Bishop' },
|
||||
n: { w: '♘', b: '♞', name: 'Knight' },
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overlay" role="dialog" aria-modal="true" aria-label="Choose promotion">
|
||||
<div class="card">
|
||||
<h3>Promote pawn</h3>
|
||||
<div class="row">
|
||||
{#each Object.entries(glyphs) as [k, g] (k)}
|
||||
<button class="choice" onclick={() => onChoose(k as PromotionType)}>
|
||||
<span class="glyph piece-{color}">{color === 'w' ? g.w : g.b}</span>
|
||||
<span class="lbl">{g.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="cancel" onclick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
max-width: 360px;
|
||||
width: 92vw;
|
||||
}
|
||||
h3 { margin: 0 0 14px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 4px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.glyph { font-size: 36px; line-height: 1; }
|
||||
.piece-w { color: #fafafa; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
|
||||
.piece-b { color: #1a1a1a; text-shadow: 0 1px 1px rgba(255,255,255,0.3); background: var(--light); border-radius: 4px; padding: 2px 6px; }
|
||||
.lbl { font-size: 12px; margin-top: 4px; color: var(--text-dim); }
|
||||
.cancel { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ModeratorText } from '@blind-chess/shared';
|
||||
|
||||
const MAP: Record<ModeratorText, string> = {
|
||||
no_such_piece: 'That piece no longer exists.',
|
||||
no_legal_moves: 'That piece has no legal moves.',
|
||||
wont_help: 'Moving that piece will not help you.',
|
||||
illegal_move: 'Illegal move.',
|
||||
white_moved: 'White has moved.',
|
||||
black_moved: 'Black has moved.',
|
||||
white_moved_captured: 'White has moved and captured.',
|
||||
black_moved_captured: 'Black has moved and captured.',
|
||||
white_moved_captured_ep: 'White has captured en passant.',
|
||||
black_moved_captured_ep: 'Black has captured en passant.',
|
||||
white_castled_kingside: 'White has castled kingside.',
|
||||
white_castled_queenside: 'White has castled queenside.',
|
||||
black_castled_kingside: 'Black has castled kingside.',
|
||||
black_castled_queenside: 'Black has castled queenside.',
|
||||
white_in_check: 'White is in check.',
|
||||
black_in_check: 'Black is in check.',
|
||||
white_promoted: 'White has promoted.',
|
||||
black_promoted: 'Black has promoted.',
|
||||
white_checkmate: 'Checkmate. White wins.',
|
||||
black_checkmate: 'Checkmate. Black wins.',
|
||||
stalemate: 'Stalemate. The game is a draw.',
|
||||
draw_insufficient: 'Draw — insufficient material.',
|
||||
draw_fifty: 'Draw — fifty-move rule.',
|
||||
draw_threefold: 'Draw — threefold repetition.',
|
||||
white_resigned: 'White has resigned.',
|
||||
black_resigned: 'Black has resigned.',
|
||||
draw_agreed: 'The game is a draw by agreement.',
|
||||
game_abandoned: 'The game has been abandoned.',
|
||||
};
|
||||
|
||||
export function moderatorText(
|
||||
text: ModeratorText,
|
||||
payload?: { promotedTo?: 'q' | 'r' | 'b' | 'n' | 'p' | 'k' },
|
||||
): string {
|
||||
const base = MAP[text];
|
||||
if ((text === 'white_promoted' || text === 'black_promoted') && payload?.promotedTo) {
|
||||
const piece = { q: 'queen', r: 'rook', b: 'bishop', n: 'knight', p: 'pawn', k: 'king' }[payload.promotedTo];
|
||||
return base.replace('promoted.', `promoted to a ${piece}.`);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Piece } from '@blind-chess/shared';
|
||||
|
||||
export function pieceGlyph(piece: Piece): string {
|
||||
const map: Record<string, string> = {
|
||||
wk: '♔', wq: '♕', wr: '♖', wb: '♗', wn: '♘', wp: '♙',
|
||||
bk: '♚', bq: '♛', br: '♜', bb: '♝', bn: '♞', bp: '♟',
|
||||
};
|
||||
return map[`${piece.color}${piece.type}`] ?? '?';
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
Announcement,
|
||||
BoardView,
|
||||
ClientMessage,
|
||||
Color,
|
||||
ErrorCode,
|
||||
GameStatus,
|
||||
Mode,
|
||||
PromotionType,
|
||||
ServerMessage,
|
||||
Square,
|
||||
EndReason,
|
||||
} from '@blind-chess/shared';
|
||||
|
||||
interface GameStateValue {
|
||||
ws: WebSocket | null;
|
||||
connected: boolean;
|
||||
gameId: string | null;
|
||||
you: Color | null;
|
||||
view: BoardView | null;
|
||||
announcements: Announcement[];
|
||||
gameStatus: GameStatus;
|
||||
mode: Mode | null;
|
||||
highlightingEnabled: boolean;
|
||||
touchedPiece: Square | null;
|
||||
drawOffer: { from: Color } | null;
|
||||
endReason: EndReason | null;
|
||||
winner: Color | null;
|
||||
opponentConnected: boolean;
|
||||
lastError: { code: ErrorCode; message: string; at: number } | null;
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
const state = $state<GameStateValue>({
|
||||
ws: null,
|
||||
connected: false,
|
||||
gameId: null,
|
||||
you: null,
|
||||
view: null,
|
||||
announcements: [],
|
||||
gameStatus: 'waiting',
|
||||
mode: null,
|
||||
highlightingEnabled: false,
|
||||
touchedPiece: null,
|
||||
drawOffer: null,
|
||||
endReason: null,
|
||||
winner: null,
|
||||
opponentConnected: false,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
function tokenKey(gameId: string) { return `bc:${gameId}`; }
|
||||
|
||||
function connect(gameId: string, joinAs?: Color | 'auto') {
|
||||
if (state.ws) state.ws.close();
|
||||
state.gameId = gameId;
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${location.host}/ws?game=${gameId}`;
|
||||
const ws = new WebSocket(url);
|
||||
state.ws = ws;
|
||||
ws.onopen = () => {
|
||||
state.connected = true;
|
||||
const token = localStorage.getItem(tokenKey(gameId)) ?? undefined;
|
||||
const hello: ClientMessage = token
|
||||
? { type: 'hello', gameId, token }
|
||||
: { type: 'hello', gameId, joinAs: joinAs ?? 'auto' };
|
||||
ws.send(JSON.stringify(hello));
|
||||
};
|
||||
ws.onmessage = (ev) => onServerMessage(JSON.parse(ev.data) as ServerMessage);
|
||||
ws.onclose = () => {
|
||||
state.connected = false;
|
||||
// attempt reconnect after 2s if game is still active
|
||||
if (state.gameStatus === 'active') {
|
||||
setTimeout(() => { if (state.gameId === gameId) connect(gameId); }, 2000);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => { state.connected = false; };
|
||||
}
|
||||
|
||||
function onServerMessage(m: ServerMessage) {
|
||||
switch (m.type) {
|
||||
case 'joined':
|
||||
if (m.you === 'spectator-rejected') {
|
||||
state.lastError = { code: 'spectators_disabled', message: 'both slots filled', at: Date.now() };
|
||||
return;
|
||||
}
|
||||
state.you = m.you;
|
||||
state.view = m.view;
|
||||
state.announcements = m.announcements;
|
||||
state.gameStatus = m.gameStatus;
|
||||
state.mode = m.mode;
|
||||
state.highlightingEnabled = m.highlightingEnabled;
|
||||
state.opponentConnected = m.opponentConnected;
|
||||
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
|
||||
break;
|
||||
case 'update':
|
||||
state.view = m.view;
|
||||
state.gameStatus = m.gameStatus;
|
||||
state.touchedPiece = m.touchedPiece ?? null;
|
||||
state.drawOffer = m.drawOffer ?? null;
|
||||
state.endReason = m.endReason ?? null;
|
||||
state.winner = m.winner ?? null;
|
||||
if (m.newAnnouncements.length) {
|
||||
state.announcements = [...state.announcements, ...m.newAnnouncements];
|
||||
}
|
||||
break;
|
||||
case 'peer-status':
|
||||
if (state.you && m.color !== state.you) {
|
||||
state.opponentConnected = m.connected;
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
state.lastError = { code: m.code, message: m.message, at: Date.now() };
|
||||
break;
|
||||
case 'ping':
|
||||
send({ type: 'pong' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function send(m: ClientMessage) {
|
||||
if (state.ws?.readyState === WebSocket.OPEN) {
|
||||
state.ws.send(JSON.stringify(m));
|
||||
}
|
||||
}
|
||||
|
||||
function commit(from: Square, to?: Square, promotion?: PromotionType) {
|
||||
send({ type: 'commit', from, to, promotion });
|
||||
}
|
||||
|
||||
function resign() { send({ type: 'resign' }); }
|
||||
function offerDraw() { send({ type: 'offer-draw' }); }
|
||||
function respondDraw(accept: boolean) { send({ type: 'respond-draw', accept }); }
|
||||
|
||||
function disconnect() {
|
||||
state.ws?.close();
|
||||
state.ws = null;
|
||||
state.connected = false;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
connect,
|
||||
disconnect,
|
||||
commit,
|
||||
resign,
|
||||
offerDraw,
|
||||
respondDraw,
|
||||
};
|
||||
}
|
||||
|
||||
export const game = makeStore();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import './app.css';
|
||||
|
||||
const app = mount(App, { target: document.getElementById('app')! });
|
||||
export default app;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["svelte", "vite/client"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/ws': { target: 'ws://localhost:3000', ws: true, rewriteWsOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user