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:
claude (blind_chess)
2026-04-28 11:20:18 -04:00
parent 9a5ad55f30
commit a6de43edc1
53 changed files with 11970 additions and 5 deletions
+105
View File
@@ -0,0 +1,105 @@
import Fastify from 'fastify';
import websocketPlugin from '@fastify/websocket';
import staticPlugin from '@fastify/static';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
activeGameCount,
chooseSide,
createGame,
pruneFinished,
} from './games.js';
import { attachSocket } from './ws.js';
import { createGameSchema } from './validation.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT ?? '3000', 10);
const HOST = process.env.HOST ?? '0.0.0.0';
const STATIC_DIR = process.env.STATIC_DIR ?? path.resolve(__dirname, '../../client/dist');
const PUBLIC_BASE = process.env.PUBLIC_BASE ?? '';
const startedAt = Date.now();
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? 'info',
transport: process.env.NODE_ENV === 'production' ? undefined : {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'HH:MM:ss' },
},
},
trustProxy: true,
});
await fastify.register(websocketPlugin);
fastify.get('/api/health', async () => ({
ok: true,
activeGames: activeGameCount(),
uptime: Math.floor((Date.now() - startedAt) / 1000),
}));
fastify.post('/api/games', async (req, reply) => {
const parsed = createGameSchema.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'malformed', detail: parsed.error.issues };
}
const { mode, side, highlightingEnabled } = parsed.data;
const creatorSide = chooseSide(side);
const { game, creatorToken } = createGame({ mode, creatorSide, highlightingEnabled });
const publicBase = PUBLIC_BASE
|| (req.headers.host ? `${req.protocol}://${req.headers.host}` : '');
const joinUrl = `${publicBase}/g/${game.id}`;
return { gameId: game.id, creatorToken, creatorColor: creatorSide, joinUrl };
});
fastify.get('/ws', { websocket: true }, (socket) => {
// fastify-websocket v11 passes the raw ws socket directly.
const raw = (socket as unknown as { socket?: unknown }).socket ?? socket;
attachSocket(raw as never);
});
// Static client assets — serve dist/ if present, gracefully degrade if not.
import('node:fs').then((fs) => {
if (fs.existsSync(STATIC_DIR)) {
fastify.register(staticPlugin, {
root: STATIC_DIR,
prefix: '/',
decorateReply: true,
});
// SPA fallback: serve index.html for /g/<id> etc.
fastify.setNotFoundHandler((req, reply) => {
const accept = String(req.headers.accept ?? '');
if (accept.includes('text/html')) {
return (reply as unknown as { sendFile: (n: string) => unknown }).sendFile('index.html');
}
reply.code(404).send({ error: 'not_found' });
});
} else {
fastify.log.warn({ STATIC_DIR }, 'static client dist not found; serving API only');
}
});
// Janitor: prune finished games every 5 min.
const janitor = setInterval(() => {
const removed = pruneFinished();
if (removed > 0) fastify.log.info({ removed }, 'pruned finished games');
}, 5 * 60 * 1000);
janitor.unref();
const ready = fastify.listen({ port: PORT, host: HOST });
ready.then(() => {
fastify.log.info(`blind_chess listening on ${HOST}:${PORT}`);
}).catch((err) => {
fastify.log.error(err);
process.exit(1);
});
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.on(sig, () => {
fastify.log.info({ sig }, 'shutting down');
fastify.close().then(() => process.exit(0));
});
}