···2323export default function App() {
2424 const auth = useAuth();
2525 const game = useGame();
2626- const registrationDone = useRef(false);
2626+ // Stores the last DID that was successfully registered, not a boolean,
2727+ // so that if a different user logs in without a page reload the new user
2828+ // gets registered too.
2929+ const registrationDone = useRef<string | null>(null);
2730 // Track which ended game the user has dismissed so we route back to lobby.
2831 // Lives here (not in GameScreen) because routing decisions belong in App.
2932 const [dismissedGameId, setDismissedGameId] = useState<bigint | null>(null);
···3437 !auth.did ||
3538 !auth.handle ||
3639 !game.connected ||
3737- registrationDone.current
4040+ registrationDone.current === auth.did
3841 ) {
3942 return;
4043 }
41444242- registrationDone.current = true;
4545+ registrationDone.current = auth.did;
4346 game.registerPlayer({
4447 did: auth.did,
4548 handle: auth.handle,
···4851 });
4952 }, [auth.did, auth.handle, auth.displayName, auth.avatarUrl, game.connected, game.registerPlayer]);
50535151- // Publish completed games to atproto
5252- useGamePublisher();
5454+ // Publish completed games to atproto.
5555+ // Pass identityHex and players from our already-subscribed useGame call
5656+ // so useGamePublisher doesn't create a second redundant subscription.
5757+ useGamePublisher({ identityHex: game.identityHex, players: game.players });
53585459 // Not logged in
5560 if (!auth.did) {
+149-1
client/src/__tests__/pgn.test.ts
···33 */
4455import { describe, it, expect } from 'vitest';
66-import { buildPgn, gameResultToPgn } from '../lib/pgn';
66+import { buildPgn, gameResultToPgn, gameTerminationToPgn } from '../lib/pgn';
7788describe('gameResultToPgn', () => {
99 it('maps white win', () => {
···23232424 it('maps active game', () => {
2525 expect(gameResultToPgn('active', '')).toBe('*');
2626+ });
2727+2828+ it('maps timeout win for white', () => {
2929+ expect(gameResultToPgn('timeout', 'white')).toBe('1-0');
3030+ });
3131+3232+ it('maps timeout win for black', () => {
3333+ expect(gameResultToPgn('timeout', 'black')).toBe('0-1');
3434+ });
3535+});
3636+3737+describe('gameTerminationToPgn', () => {
3838+ it('returns "normal" for checkmate/stalemate/draw', () => {
3939+ expect(gameTerminationToPgn('checkmate')).toBe('normal');
4040+ expect(gameTerminationToPgn('stalemate')).toBe('normal');
4141+ expect(gameTerminationToPgn('draw')).toBe('normal');
4242+ });
4343+4444+ it('returns "normal" for resignation — resignation is a deliberate conclusion, not abandonment', () => {
4545+ expect(gameTerminationToPgn('resigned')).toBe('normal');
4646+ });
4747+4848+ it('returns "time forfeit" for timeout', () => {
4949+ expect(gameTerminationToPgn('timeout')).toBe('time forfeit');
5050+ });
5151+5252+ it('returns undefined for unknown/active', () => {
5353+ expect(gameTerminationToPgn('active')).toBeUndefined();
5454+ expect(gameTerminationToPgn('abandoned')).toBeUndefined();
2655 });
2756});
2857···137166 expect(pgn).toContain('[White "user\\\\name"]');
138167 });
139168169169+ it('includes TimeControl tag for timed games', () => {
170170+ const pgn = buildPgn({
171171+ whiteHandle: 'w',
172172+ blackHandle: 'b',
173173+ result: '1-0',
174174+ date: new Date('2026-04-15'),
175175+ moves: [{ san: 'e4' }],
176176+ timeControlSecs: 300,
177177+ });
178178+179179+ expect(pgn).toContain('[TimeControl "300"]');
180180+ });
181181+182182+ it('omits TimeControl tag when timeControlSecs is 0 or absent', () => {
183183+ const pgn = buildPgn({
184184+ whiteHandle: 'w',
185185+ blackHandle: 'b',
186186+ result: '*',
187187+ date: new Date('2026-04-15'),
188188+ moves: [],
189189+ timeControlSecs: 0,
190190+ });
191191+192192+ expect(pgn).not.toContain('[TimeControl');
193193+ });
194194+195195+ it('includes Termination tag when provided', () => {
196196+ const pgn = buildPgn({
197197+ whiteHandle: 'w',
198198+ blackHandle: 'b',
199199+ result: '0-1',
200200+ date: new Date('2026-04-15'),
201201+ moves: [{ san: 'e4' }],
202202+ timeControlSecs: 60,
203203+ termination: 'time forfeit',
204204+ });
205205+206206+ expect(pgn).toContain('[TimeControl "60"]');
207207+ expect(pgn).toContain('[Termination "time forfeit"]');
208208+ });
209209+210210+ it('TimeControl and Termination tags appear after the Seven Tag Roster', () => {
211211+ const pgn = buildPgn({
212212+ whiteHandle: 'w',
213213+ blackHandle: 'b',
214214+ result: '1-0',
215215+ date: new Date('2026-04-15'),
216216+ moves: [],
217217+ timeControlSecs: 180,
218218+ termination: 'normal',
219219+ });
220220+221221+ const lines = pgn.split('\n').filter((l) => l.startsWith('['));
222222+ const resultIdx = lines.findIndex((l) => l.startsWith('[Result'));
223223+ const tcIdx = lines.findIndex((l) => l.startsWith('[TimeControl'));
224224+ const termIdx = lines.findIndex((l) => l.startsWith('[Termination'));
225225+226226+ expect(tcIdx).toBeGreaterThan(resultIdx);
227227+ expect(termIdx).toBeGreaterThan(resultIdx);
228228+ });
229229+140230 it('escapes combined backslash and quote', () => {
141231 const pgn = buildPgn({
142232 whiteHandle: 'a\\"b',
···148238149239 // \\" should become \\\\" (backslash escaped, then quote escaped)
150240 expect(pgn).toContain('[White "a\\\\\\"b"]');
241241+ });
242242+243243+ it('strips control characters from player handles (PGN tag injection prevention)', () => {
244244+ // A newline in a handle could inject a fake PGN tag
245245+ const pgn = buildPgn({
246246+ whiteHandle: 'alice\n[Black "mallory"]',
247247+ blackHandle: 'bob',
248248+ result: '1-0',
249249+ date: new Date('2026-04-15'),
250250+ moves: [],
251251+ });
252252+253253+ // The injected tag should not appear as a separate tag line
254254+ expect(pgn).not.toContain('[Black "mallory"]');
255255+ // The White tag must still be a single line
256256+ const whiteTag = pgn.split('\n').find((l) => l.startsWith('[White'));
257257+ expect(whiteTag).toBeDefined();
258258+ expect(whiteTag!.endsWith('"]')).toBe(true);
259259+ });
260260+261261+ it('strips tab and carriage return from handles', () => {
262262+ const pgn = buildPgn({
263263+ whiteHandle: 'alice\tbob\r',
264264+ blackHandle: 'carol',
265265+ result: '*',
266266+ date: new Date('2026-04-15'),
267267+ moves: [],
268268+ });
269269+270270+ const whiteTag = pgn.split('\n').find((l) => l.startsWith('[White'));
271271+ expect(whiteTag).toBeDefined();
272272+ // Tab and CR stripped — result is just "alicebob"
273273+ expect(whiteTag).toBe('[White "alicebob"]');
274274+ });
275275+276276+ it('wraps movetext lines at 80 characters', () => {
277277+ // Build a long game where the naive single-line movetext would exceed 80 chars.
278278+ // Scholar's mate is only 7 half-moves, so construct a long move list manually.
279279+ const longMoves = Array.from({ length: 40 }, (_, i) => ({
280280+ san: i % 2 === 0 ? 'Nf3' : 'Nc6',
281281+ }));
282282+283283+ const pgn = buildPgn({
284284+ whiteHandle: 'w',
285285+ blackHandle: 'b',
286286+ result: '1/2-1/2',
287287+ date: new Date('2026-04-15'),
288288+ moves: longMoves,
289289+ });
290290+291291+ // Find the movetext section (after the blank line following tags)
292292+ const sections = pgn.split('\n\n');
293293+ expect(sections.length).toBe(2);
294294+ const movetext = sections[1];
295295+296296+ for (const line of movetext.split('\n').filter(Boolean)) {
297297+ expect(line.length).toBeLessThanOrEqual(80);
298298+ }
151299 });
152300});
+419-45
client/src/__tests__/server-logic.test.ts
···8888 };
8989}
90909191+/**
9292+ * Simulates the server's resign reducer logic.
9393+ * callerIsWhite: true if the caller is the white player, false for black.
9494+ */
9595+function simulateResign(
9696+ gameState: { status: string; winner: string },
9797+ callerIsWhite: boolean
9898+): { status: string; winner: string } {
9999+ if (gameState.status !== 'active') {
100100+ throw new Error('Game is not active');
101101+ }
102102+103103+ // The opponent of the resigning player wins
104104+ const winner = callerIsWhite ? 'black' : 'white';
105105+106106+ return {
107107+ status: 'resigned',
108108+ winner,
109109+ };
110110+}
111111+91112describe('Server reducer: makeMove logic', () => {
92113 it('rejects moves on a non-active game', () => {
93114 const game = {
···241262 expect(() => simulateMove(game, { from: 'e7', to: 'e8' }, true)).toThrow('Illegal move');
242263 });
243264244244- it('handles resignation state transitions', () => {
245245- // Simulating resign reducer logic
246246- const game = {
247247- fen: STARTING_FEN,
248248- status: 'active',
249249- turn: 'w',
250250- winner: '',
251251- moveCount: 0,
252252- };
253253-254254- // If white resigns:
255255- const afterResign = {
256256- ...game,
257257- status: 'resigned',
258258- winner: 'black', // opponent wins
259259- };
260260- expect(afterResign.status).toBe('resigned');
261261- expect(afterResign.winner).toBe('black');
262262-263263- // Cannot make a move after resignation
264264- expect(() =>
265265- simulateMove(afterResign, { from: 'e2', to: 'e4' }, true)
266266- ).toThrow('Game is not active');
267267- });
268268-269265 it('plays a full Scholar\'s Mate sequence', () => {
270266 let game = {
271267 fen: STARTING_FEN,
···295291 });
296292});
297293294294+describe('Server reducer: resign logic', () => {
295295+ it('white resigning gives the win to black', () => {
296296+ const game = { status: 'active', winner: '' };
297297+ const result = simulateResign(game, /* callerIsWhite */ true);
298298+ expect(result.status).toBe('resigned');
299299+ expect(result.winner).toBe('black');
300300+ });
301301+302302+ it('black resigning gives the win to white', () => {
303303+ const game = { status: 'active', winner: '' };
304304+ const result = simulateResign(game, /* callerIsWhite */ false);
305305+ expect(result.status).toBe('resigned');
306306+ expect(result.winner).toBe('white');
307307+ });
308308+309309+ it('cannot resign a game that is not active', () => {
310310+ const game = { status: 'checkmate', winner: 'black' };
311311+ expect(() => simulateResign(game, true)).toThrow('Game is not active');
312312+ expect(() => simulateResign(game, false)).toThrow('Game is not active');
313313+ });
314314+315315+ it('cannot make moves after resignation', () => {
316316+ const game = {
317317+ fen: STARTING_FEN,
318318+ status: 'resigned',
319319+ turn: 'w',
320320+ winner: 'black',
321321+ moveCount: 0,
322322+ };
323323+ expect(() =>
324324+ simulateMove(game, { from: 'e2', to: 'e4' }, true)
325325+ ).toThrow('Game is not active');
326326+ });
327327+});
328328+298329describe('Server reducer: input validation', () => {
299299- it('validates DID format', () => {
300300- // Mirror of server's registerPlayer validation
301301- const validDids = ['did:plc:abc123', 'did:web:example.com'];
302302- const invalidDids = ['', 'notadid', 'di:plc:abc'];
330330+ // Mirrors the validation in server/src/index.ts registerPlayer reducer.
331331+ function simulateRegisterPlayer(params: {
332332+ did: string;
333333+ handle: string;
334334+ displayName: string;
335335+ avatarUrl: string;
336336+ }): void {
337337+ const { did, handle, displayName, avatarUrl } = params;
303338304304- for (const did of validDids) {
305305- expect(did.startsWith('did:')).toBe(true);
339339+ if (!did || !did.startsWith('did:')) {
340340+ throw new Error('Invalid DID format');
306341 }
307307- for (const did of invalidDids) {
308308- expect(!did || !did.startsWith('did:')).toBe(true);
342342+ if (did.length > 2048) throw new Error('DID too long');
343343+ if (handle.length > 253) throw new Error('Handle too long');
344344+ if (displayName.length > 640) throw new Error('Display name too long');
345345+ if (avatarUrl.length > 2048) throw new Error('Avatar URL too long');
346346+347347+ const hasControlChars = (s: string) => /[\x00-\x1f\x7f]/.test(s);
348348+ if (hasControlChars(handle)) throw new Error('Handle contains invalid characters');
349349+ if (hasControlChars(displayName)) throw new Error('Display name contains invalid characters');
350350+ if (hasControlChars(avatarUrl)) throw new Error('Avatar URL contains invalid characters');
351351+ }
352352+353353+ it('accepts valid registration data', () => {
354354+ expect(() =>
355355+ simulateRegisterPlayer({
356356+ did: 'did:plc:abc123',
357357+ handle: 'alice.bsky.social',
358358+ displayName: 'Alice',
359359+ avatarUrl: 'https://cdn.bsky.app/avatar.jpg',
360360+ })
361361+ ).not.toThrow();
362362+ });
363363+364364+ it('rejects invalid DID formats', () => {
365365+ for (const did of ['', 'notadid', 'di:plc:abc', 'http://example.com']) {
366366+ expect(() =>
367367+ simulateRegisterPlayer({ did, handle: 'h', displayName: 'd', avatarUrl: '' })
368368+ ).toThrow('Invalid DID format');
309369 }
310370 });
311371312312- it('enforces input length limits', () => {
313313- // Mirror of server's registerPlayer validation
314314- const MAX_DID_LEN = 2048;
315315- const MAX_HANDLE_LEN = 253;
316316- const MAX_DISPLAY_NAME_LEN = 640;
317317- const MAX_AVATAR_URL_LEN = 2048;
372372+ it('accepts valid DID formats', () => {
373373+ for (const did of ['did:plc:abc123', 'did:web:example.com']) {
374374+ expect(() =>
375375+ simulateRegisterPlayer({ did, handle: 'h', displayName: 'd', avatarUrl: '' })
376376+ ).not.toThrow();
377377+ }
378378+ });
318379319319- expect('did:plc:abc'.length).toBeLessThanOrEqual(MAX_DID_LEN);
320320- expect('a'.repeat(2049).length).toBeGreaterThan(MAX_DID_LEN);
321321- expect('handle.bsky.social'.length).toBeLessThanOrEqual(MAX_HANDLE_LEN);
322322- expect('a'.repeat(254).length).toBeGreaterThan(MAX_HANDLE_LEN);
323323- expect('Display Name'.length).toBeLessThanOrEqual(MAX_DISPLAY_NAME_LEN);
324324- expect('https://example.com/avatar.jpg'.length).toBeLessThanOrEqual(MAX_AVATAR_URL_LEN);
380380+ it('enforces DID length limit', () => {
381381+ expect(() =>
382382+ simulateRegisterPlayer({
383383+ did: 'did:' + 'a'.repeat(2048),
384384+ handle: 'h',
385385+ displayName: 'd',
386386+ avatarUrl: '',
387387+ })
388388+ ).toThrow('DID too long');
389389+ });
390390+391391+ it('enforces handle length limit', () => {
392392+ expect(() =>
393393+ simulateRegisterPlayer({
394394+ did: 'did:plc:abc',
395395+ handle: 'a'.repeat(254),
396396+ displayName: 'd',
397397+ avatarUrl: '',
398398+ })
399399+ ).toThrow('Handle too long');
400400+ });
401401+402402+ it('enforces display name length limit', () => {
403403+ expect(() =>
404404+ simulateRegisterPlayer({
405405+ did: 'did:plc:abc',
406406+ handle: 'h',
407407+ displayName: 'a'.repeat(641),
408408+ avatarUrl: '',
409409+ })
410410+ ).toThrow('Display name too long');
411411+ });
412412+413413+ it('rejects control characters in handle (PGN injection prevention)', () => {
414414+ expect(() =>
415415+ simulateRegisterPlayer({
416416+ did: 'did:plc:abc',
417417+ handle: 'alice\nbob',
418418+ displayName: 'd',
419419+ avatarUrl: '',
420420+ })
421421+ ).toThrow('Handle contains invalid characters');
422422+ });
423423+424424+ it('rejects control characters in display name', () => {
425425+ expect(() =>
426426+ simulateRegisterPlayer({
427427+ did: 'did:plc:abc',
428428+ handle: 'h',
429429+ displayName: 'Alice\tBob',
430430+ avatarUrl: '',
431431+ })
432432+ ).toThrow('Display name contains invalid characters');
433433+ });
434434+435435+ it('rejects control characters in avatar URL', () => {
436436+ expect(() =>
437437+ simulateRegisterPlayer({
438438+ did: 'did:plc:abc',
439439+ handle: 'h',
440440+ displayName: 'd',
441441+ avatarUrl: 'https://example.com/\x00evil',
442442+ })
443443+ ).toThrow('Avatar URL contains invalid characters');
325444 });
326445});
327446···347466 expect(game.moveCount).toBe(2);
348467 });
349468});
469469+470470+// ---------------------------------------------------------------------------
471471+// Clock / time control logic (mirrors server's claimTimeoutVictory reducer)
472472+// ---------------------------------------------------------------------------
473473+474474+/**
475475+ * Simulates claimTimeoutVictory reducer logic.
476476+ * Returns the updated game or throws as the reducer would.
477477+ */
478478+function simulateClaimTimeout(
479479+ gameState: {
480480+ status: string;
481481+ turn: string;
482482+ winner: string;
483483+ timeControlSecs: number;
484484+ whiteTimeMs: number; // remaining ms for white as of lastMoveAtMs
485485+ blackTimeMs: number; // remaining ms for black as of lastMoveAtMs
486486+ lastMoveAtMs: number; // epoch ms when last move was committed
487487+ },
488488+ claimantIsWhite: boolean,
489489+ nowMs: number // the "current" time for the claim
490490+): { status: string; winner: string } {
491491+ if (gameState.status !== 'active') throw new Error('Game is not active');
492492+ if (gameState.timeControlSecs === 0) throw new Error('No time control');
493493+494494+ const clockOwnerIsWhite = gameState.turn === 'w';
495495+ if (clockOwnerIsWhite === claimantIsWhite) {
496496+ throw new Error('Cannot claim your own timeout');
497497+ }
498498+499499+ const elapsedMs = nowMs - gameState.lastMoveAtMs;
500500+ const opponentTimeMs = clockOwnerIsWhite ? gameState.whiteTimeMs : gameState.blackTimeMs;
501501+502502+ if (elapsedMs < opponentTimeMs) {
503503+ throw new Error('Opponent still has time remaining');
504504+ }
505505+506506+ return {
507507+ status: 'timeout',
508508+ winner: claimantIsWhite ? 'white' : 'black',
509509+ };
510510+}
511511+512512+/**
513513+ * Simulates clock deduction logic from makeMove.
514514+ */
515515+function simulateClockDeduction(
516516+ gameState: { turn: string; whiteTimeMs: number; blackTimeMs: number; lastMoveAtMs: number; timeControlSecs: number },
517517+ nowMs: number
518518+): { whiteTimeMs: number; blackTimeMs: number } {
519519+ if (gameState.timeControlSecs === 0) {
520520+ return { whiteTimeMs: gameState.whiteTimeMs, blackTimeMs: gameState.blackTimeMs };
521521+ }
522522+523523+ const elapsedMs = nowMs - gameState.lastMoveAtMs;
524524+ let { whiteTimeMs, blackTimeMs } = gameState;
525525+526526+ if (gameState.turn === 'w') {
527527+ whiteTimeMs = Math.max(0, whiteTimeMs - elapsedMs);
528528+ } else {
529529+ blackTimeMs = Math.max(0, blackTimeMs - elapsedMs);
530530+ }
531531+532532+ return { whiteTimeMs, blackTimeMs };
533533+}
534534+535535+describe('Server reducer: clock deduction', () => {
536536+ it('deducts elapsed time from the moving player on each move', () => {
537537+ const game = {
538538+ turn: 'w',
539539+ whiteTimeMs: 60_000,
540540+ blackTimeMs: 60_000,
541541+ lastMoveAtMs: 1000,
542542+ timeControlSecs: 60,
543543+ };
544544+545545+ // White takes 5 seconds to move
546546+ const result = simulateClockDeduction(game, game.lastMoveAtMs + 5_000);
547547+ expect(result.whiteTimeMs).toBe(55_000);
548548+ expect(result.blackTimeMs).toBe(60_000); // black untouched
549549+ });
550550+551551+ it('deducts from black on black\'s turn', () => {
552552+ const game = {
553553+ turn: 'b',
554554+ whiteTimeMs: 55_000,
555555+ blackTimeMs: 60_000,
556556+ lastMoveAtMs: 2000,
557557+ timeControlSecs: 60,
558558+ };
559559+560560+ const result = simulateClockDeduction(game, game.lastMoveAtMs + 10_000);
561561+ expect(result.blackTimeMs).toBe(50_000);
562562+ expect(result.whiteTimeMs).toBe(55_000);
563563+ });
564564+565565+ it('floors at 0 — never goes negative', () => {
566566+ const game = {
567567+ turn: 'w',
568568+ whiteTimeMs: 3_000,
569569+ blackTimeMs: 60_000,
570570+ lastMoveAtMs: 0,
571571+ timeControlSecs: 60,
572572+ };
573573+574574+ // White has 3 s left but took 10 s
575575+ const result = simulateClockDeduction(game, 10_000);
576576+ expect(result.whiteTimeMs).toBe(0);
577577+ });
578578+579579+ it('is a no-op for untimed games', () => {
580580+ const game = {
581581+ turn: 'w',
582582+ whiteTimeMs: 0,
583583+ blackTimeMs: 0,
584584+ lastMoveAtMs: 0,
585585+ timeControlSecs: 0,
586586+ };
587587+588588+ const result = simulateClockDeduction(game, 99_999);
589589+ expect(result.whiteTimeMs).toBe(0);
590590+ expect(result.blackTimeMs).toBe(0);
591591+ });
592592+});
593593+594594+describe('Server reducer: claimTimeoutVictory', () => {
595595+ it('grants white a win when black\'s clock expires', () => {
596596+ const game = {
597597+ status: 'active',
598598+ turn: 'b',
599599+ winner: '',
600600+ timeControlSecs: 60,
601601+ whiteTimeMs: 30_000,
602602+ blackTimeMs: 5_000,
603603+ lastMoveAtMs: 1000,
604604+ };
605605+606606+ // 6 seconds later — black only had 5 s
607607+ const result = simulateClaimTimeout(game, true /* claimant = white */, game.lastMoveAtMs + 6_000);
608608+ expect(result.status).toBe('timeout');
609609+ expect(result.winner).toBe('white');
610610+ });
611611+612612+ it('grants black a win when white\'s clock expires', () => {
613613+ const game = {
614614+ status: 'active',
615615+ turn: 'w',
616616+ winner: '',
617617+ timeControlSecs: 60,
618618+ whiteTimeMs: 2_000,
619619+ blackTimeMs: 30_000,
620620+ lastMoveAtMs: 0,
621621+ };
622622+623623+ // 3 seconds later — white only had 2 s
624624+ const result = simulateClaimTimeout(game, false /* claimant = black */, 3_000);
625625+ expect(result.status).toBe('timeout');
626626+ expect(result.winner).toBe('black');
627627+ });
628628+629629+ it('accepts claim at exact expiry (elapsedMs === opponentTimeMs)', () => {
630630+ const game = {
631631+ status: 'active',
632632+ turn: 'b',
633633+ winner: '',
634634+ timeControlSecs: 60,
635635+ whiteTimeMs: 30_000,
636636+ blackTimeMs: 5_000,
637637+ lastMoveAtMs: 0,
638638+ };
639639+640640+ // Exactly 5 000 ms elapsed — black's clock is exactly 0
641641+ const result = simulateClaimTimeout(game, true, 5_000);
642642+ expect(result.status).toBe('timeout');
643643+ expect(result.winner).toBe('white');
644644+ });
645645+646646+ it('rejects claim when opponent still has time', () => {
647647+ const game = {
648648+ status: 'active',
649649+ turn: 'b',
650650+ winner: '',
651651+ timeControlSecs: 60,
652652+ whiteTimeMs: 30_000,
653653+ blackTimeMs: 30_000,
654654+ lastMoveAtMs: 0,
655655+ };
656656+657657+ // Only 5 s elapsed — black has 30 s remaining
658658+ expect(() =>
659659+ simulateClaimTimeout(game, true /* white claims */, 5_000)
660660+ ).toThrow('Opponent still has time remaining');
661661+ });
662662+663663+ it('rejects claim against your own turn', () => {
664664+ const game = {
665665+ status: 'active',
666666+ turn: 'w', // white's clock is running
667667+ winner: '',
668668+ timeControlSecs: 60,
669669+ whiteTimeMs: 1_000,
670670+ blackTimeMs: 30_000,
671671+ lastMoveAtMs: 0,
672672+ };
673673+674674+ // White tries to claim their own timeout — server should reject
675675+ expect(() =>
676676+ simulateClaimTimeout(game, true /* claimant = white, but it's white's turn */, 5_000)
677677+ ).toThrow('Cannot claim your own timeout');
678678+ });
679679+680680+ it('rejects claim on a non-active game', () => {
681681+ const game = {
682682+ status: 'checkmate',
683683+ turn: 'w',
684684+ winner: 'black',
685685+ timeControlSecs: 60,
686686+ whiteTimeMs: 0,
687687+ blackTimeMs: 30_000,
688688+ lastMoveAtMs: 0,
689689+ };
690690+691691+ expect(() => simulateClaimTimeout(game, false, 99_000)).toThrow('Game is not active');
692692+ });
693693+694694+ it('rejects claim on an untimed game', () => {
695695+ const game = {
696696+ status: 'active',
697697+ turn: 'b',
698698+ winner: '',
699699+ timeControlSecs: 0,
700700+ whiteTimeMs: 0,
701701+ blackTimeMs: 0,
702702+ lastMoveAtMs: 0,
703703+ };
704704+705705+ expect(() => simulateClaimTimeout(game, true, 99_000)).toThrow('No time control');
706706+ });
707707+});
708708+709709+describe('Server reducer: joinQueue time control validation', () => {
710710+ it('accepts valid time controls', () => {
711711+ const VALID = new Set([60, 180, 300, 600]);
712712+ for (const secs of [60, 180, 300, 600]) {
713713+ expect(VALID.has(secs)).toBe(true);
714714+ }
715715+ });
716716+717717+ it('rejects invalid time controls', () => {
718718+ const VALID = new Set([60, 180, 300, 600]);
719719+ for (const secs of [0, 30, 120, 900, -1]) {
720720+ expect(VALID.has(secs)).toBe(false);
721721+ }
722722+ });
723723+});
+95
client/src/components/game/ClockDisplay.tsx
···11+/**
22+ * ClockDisplay — shows the remaining time for one player.
33+ *
44+ * SpacetimeDB stores the remaining time as of the last move commit
55+ * (`baseTimeMs` + `lastMoveAtMicros`). For the player whose clock is
66+ * currently running we interpolate locally at 100 ms intervals so the
77+ * display ticks smoothly without a round-trip per frame. For the player
88+ * whose clock is paused we just display the stored value directly.
99+ */
1010+1111+import { useEffect, useState } from 'react';
1212+1313+/** Format milliseconds as M:SS (e.g. "3:07", "0:09").
1414+ *
1515+ * Uses Math.floor so that 3200ms shows "0:03", not "0:04" — consistent
1616+ * with standard chess clock display (show complete seconds remaining).
1717+ */
1818+export function formatClockMs(ms: number): string {
1919+ if (ms <= 0) return '0:00';
2020+ const totalSeconds = Math.floor(ms / 1000);
2121+ const minutes = Math.floor(totalSeconds / 60);
2222+ const seconds = totalSeconds % 60;
2323+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
2424+}
2525+2626+interface ClockDisplayProps {
2727+ /** Remaining time in ms as stored by SpacetimeDB (accurate as of lastMoveAtMicros) */
2828+ baseTimeMs: bigint;
2929+ /** Timestamp of the last move commit, in microseconds since Unix epoch */
3030+ lastMoveAtMicros: bigint;
3131+ /** Whether this player's clock is currently counting down */
3232+ isRunning: boolean;
3333+ /** Whether the game is still in progress (clocks freeze on game end) */
3434+ isActive: boolean;
3535+}
3636+3737+function computeDisplayMs(
3838+ baseTimeMs: bigint,
3939+ lastMoveAtMicros: bigint,
4040+ isRunning: boolean,
4141+ isActive: boolean
4242+): number {
4343+ if (!isRunning || !isActive) return Math.max(0, Number(baseTimeMs));
4444+ const lastMoveAtMs = Number(lastMoveAtMicros / 1000n);
4545+ const elapsed = Date.now() - lastMoveAtMs;
4646+ return Math.max(0, Number(baseTimeMs) - elapsed);
4747+}
4848+4949+export function ClockDisplay({
5050+ baseTimeMs,
5151+ lastMoveAtMicros,
5252+ isRunning,
5353+ isActive,
5454+}: ClockDisplayProps) {
5555+ const [displayMs, setDisplayMs] = useState(() =>
5656+ computeDisplayMs(baseTimeMs, lastMoveAtMicros, isRunning, isActive)
5757+ );
5858+5959+ useEffect(() => {
6060+ // Sync immediately whenever the SpacetimeDB values change
6161+ setDisplayMs(computeDisplayMs(baseTimeMs, lastMoveAtMicros, isRunning, isActive));
6262+6363+ if (!isRunning || !isActive) return;
6464+6565+ // Tick at 100 ms intervals while the clock is running
6666+ const id = setInterval(() => {
6767+ setDisplayMs(computeDisplayMs(baseTimeMs, lastMoveAtMicros, isRunning, isActive));
6868+ }, 100);
6969+7070+ return () => clearInterval(id);
7171+ }, [baseTimeMs, lastMoveAtMicros, isRunning, isActive]);
7272+7373+ const isLow = displayMs < 30_000; // < 30 s — yellow warning
7474+ const isCritical = displayMs < 10_000; // < 10 s — red pulse
7575+7676+ return (
7777+ <div
7878+ className={[
7979+ 'font-mono font-bold tabular-nums text-2xl px-3 py-0.5 rounded transition-colors',
8080+ !isActive
8181+ ? 'text-neutral-600'
8282+ : isCritical
8383+ ? 'text-red-400 animate-pulse'
8484+ : isLow
8585+ ? 'text-yellow-400'
8686+ : isRunning
8787+ ? 'text-white'
8888+ : 'text-neutral-400',
8989+ ].join(' ')}
9090+ aria-label={`${isRunning ? 'Running' : 'Paused'}: ${formatClockMs(displayMs)}`}
9191+ >
9292+ {formatClockMs(displayMs)}
9393+ </div>
9494+ );
9595+}
+98-13
client/src/components/game/GameScreen.tsx
···22 * GameScreen — the main game view.
33 *
44 * Layout:
55- * - Opponent player bar (top)
55+ * - Opponent player bar (top) with clock
66 * - Chess board (center)
77- * - Your player bar (bottom)
77+ * - Your player bar (bottom) with clock
88 * - Move list sidebar (right, collapses below on mobile)
99 * - Game status overlay (when game ends)
1010- * - Resign button
1010+ * - Resign button (multiplayer only)
1111 *
1212 * In solo mode (both sides are the same player), the board always shows
1313- * white's perspective and both sides are always movable.
1313+ * white's perspective and both sides are always movable. Solo games are
1414+ * always untimed.
1515+ *
1616+ * For timed games: each player's remaining time is displayed in their bar.
1717+ * When the opponent's clock reaches zero, this client calls claimTimeoutVictory
1818+ * to end the game. The server independently validates the elapsed time.
1419 */
15201616-import { useCallback, useMemo, useState } from 'react';
2121+import { useCallback, useEffect, useRef, useMemo, useState } from 'react';
1722import { useAuth } from '../../hooks/useAuth';
1823import { useGame } from '../../hooks/useGame';
1924import { ChessBoard } from './ChessBoard';
2525+import { ClockDisplay } from './ClockDisplay';
2026import { MoveList } from './MoveList';
2127import { PlayerBar } from './PlayerBar';
2228import { GameStatus } from './GameStatus';
···28342935export function GameScreen({ onDismiss }: GameScreenProps) {
3036 const { displayName, avatarUrl, handle } = useAuth();
3131- const { activeGame, moves, players, makeMove, resignGame } = useGame();
3737+ const { activeGame, moves, players, makeMove, resignGame, claimTimeoutVictory } = useGame();
3238 const [showResignConfirm, setShowResignConfirm] = useState(false);
33394040+ // Tracks the game ID for which we have already sent a claimTimeoutVictory
4141+ // call, so we don't spam the server while waiting for confirmation.
4242+ const claimSentForGameRef = useRef<bigint | null>(null);
4343+3444 // Shouldn't happen — App only renders GameScreen when activeGame exists
3545 if (!activeGame) return null;
36463737- const { isSolo } = activeGame;
4747+ const { isSolo, timeControlSecs } = activeGame;
4848+ const isTimed = timeControlSecs > 0;
38493950 // In solo mode, orient as white and always allow moves.
4051 // In multiplayer, orient to the player's assigned color.
···90101 setShowResignConfirm(false);
91102 }, [resignGame, activeGame.id]);
921039393- const handleBackToLobby = useCallback(() => {
9494- onDismiss();
9595- }, [onDismiss]);
104104+ // ---------------------------------------------------------------------------
105105+ // Timeout claiming
106106+ //
107107+ // When the clock currently running (the active-turn player's clock) reaches
108108+ // zero, the OTHER player wins and should claim the timeout. We set a
109109+ // setTimeout to fire exactly when the opponent's remaining time expires and
110110+ // call claimTimeoutVictory then. The server validates independently.
111111+ //
112112+ // We gate the call on claimSentForGameRef so that if the effect re-runs
113113+ // before the server confirms (which changes game.status), we don't spam
114114+ // duplicate reducer calls.
115115+ //
116116+ // Solo games are always untimed, so this effect is a no-op for them.
117117+ // ---------------------------------------------------------------------------
118118+ useEffect(() => {
119119+ if (!activeGame || activeGame.status !== 'active') return;
120120+ if (isSolo || !isTimed) return;
121121+122122+ // The clock running belongs to the player whose turn it is
123123+ const runningClockIsWhite = activeGame.turn === 'w';
124124+ const runningTimeMs = runningClockIsWhite ? activeGame.whiteTimeMs : activeGame.blackTimeMs;
125125+126126+ // Only the player NOT currently on the clock claims the timeout
127127+ // (i.e., the one whose opponent's time just ran out)
128128+ const weAreTheClaimant = activeGame.isWhite !== runningClockIsWhite;
129129+130130+ const lastMoveAtMs = Number(activeGame.lastMoveAtMicros / 1000n);
131131+ const elapsed = Date.now() - lastMoveAtMs;
132132+ const remaining = Math.max(0, Number(runningTimeMs) - elapsed);
133133+134134+ if (remaining === 0 && weAreTheClaimant) {
135135+ // Already expired when this effect ran — claim immediately (once per game)
136136+ if (claimSentForGameRef.current !== activeGame.id) {
137137+ claimSentForGameRef.current = activeGame.id;
138138+ claimTimeoutVictory({ gameId: activeGame.id });
139139+ }
140140+ return;
141141+ }
142142+143143+ if (!weAreTheClaimant) return; // Not our job this turn
144144+145145+ const timerId = setTimeout(() => {
146146+ if (claimSentForGameRef.current !== activeGame.id) {
147147+ claimSentForGameRef.current = activeGame.id;
148148+ claimTimeoutVictory({ gameId: activeGame.id });
149149+ }
150150+ }, remaining);
151151+152152+ return () => clearTimeout(timerId);
153153+ }, [
154154+ activeGame,
155155+ isSolo,
156156+ isTimed,
157157+ claimTimeoutVictory,
158158+ ]);
159159+160160+ // Build clock nodes for the player bars (only for timed games)
161161+ const topClock = isTimed ? (
162162+ <ClockDisplay
163163+ baseTimeMs={topColor === 'white' ? activeGame.whiteTimeMs : activeGame.blackTimeMs}
164164+ lastMoveAtMicros={activeGame.lastMoveAtMicros}
165165+ isRunning={topIsActive}
166166+ isActive={activeGame.status === 'active'}
167167+ />
168168+ ) : undefined;
169169+170170+ const bottomClock = isTimed ? (
171171+ <ClockDisplay
172172+ baseTimeMs={bottomColor === 'white' ? activeGame.whiteTimeMs : activeGame.blackTimeMs}
173173+ lastMoveAtMicros={activeGame.lastMoveAtMicros}
174174+ isRunning={bottomIsActive}
175175+ isActive={activeGame.status === 'active'}
176176+ />
177177+ ) : undefined;
9617897179 return (
98180 <div className="flex flex-1 flex-col lg:flex-row items-center justify-center gap-4 p-4">
···105187 avatarUrl={isSolo ? undefined : opponentAvatar}
106188 isActive={topIsActive}
107189 color={topColor}
190190+ clock={topClock}
108191 />
109192110193 {/* Board */}
···123206 status={activeGame.status}
124207 winner={activeGame.winner}
125208 isWhite={activeGame.isWhite}
126126- onBackToLobby={handleBackToLobby}
209209+ isSolo={isSolo}
210210+ onBackToLobby={onDismiss}
127211 />
128212 </div>
129213···134218 avatarUrl={isSolo ? undefined : (avatarUrl ?? undefined)}
135219 isActive={bottomIsActive}
136220 color={bottomColor}
221221+ clock={bottomClock}
137222 />
138223139139- {/* Resign button */}
140140- {activeGame.status === 'active' && (
224224+ {/* Resign button — multiplayer only; resigning against yourself makes no sense */}
225225+ {activeGame.status === 'active' && !isSolo && (
141226 <div className="flex justify-center mt-2">
142227 {showResignConfirm ? (
143228 <div className="flex items-center gap-2">
+64-34
client/src/components/game/GameStatus.tsx
···11/**
22 * GameStatus — displays the game outcome when it ends.
33 *
44- * Shows a modal overlay for checkmate, stalemate, draw, or resignation.
44+ * Shows a modal overlay for checkmate, stalemate, draw, resignation, or timeout.
55+ * In solo mode, win/loss framing is replaced with a neutral result message.
56 */
6778import type { GameStatus as GameStatusType } from '../../hooks/useGame';
···1011 status: GameStatusType;
1112 winner: string;
1213 isWhite: boolean;
1414+ isSolo: boolean;
1315 onBackToLobby: () => void;
1416}
15171616-export function GameStatus({ status, winner, isWhite, onBackToLobby }: GameStatusProps) {
1818+export function GameStatus({ status, winner, isWhite, isSolo, onBackToLobby }: GameStatusProps) {
1719 if (status === 'active') return null;
18201921 const myColor = isWhite ? 'white' : 'black';
2020- const iWon = winner === myColor;
2222+ const iWon = !isSolo && winner === myColor;
2123 const isDraw = winner === 'draw';
22242325 let title: string;
2426 let subtitle: string;
2727+ let emoji: string;
25282626- switch (status) {
2727- case 'checkmate':
2828- title = iWon ? 'You win!' : 'Checkmate';
2929- subtitle = iWon
3030- ? 'Checkmate — well played!'
3131- : 'Your king has been checkmated.';
3232- break;
3333- case 'stalemate':
3434- title = 'Stalemate';
3535- subtitle = 'No legal moves — the game is a draw.';
3636- break;
3737- case 'draw':
3838- title = 'Draw';
3939- subtitle = 'The game ended in a draw.';
4040- break;
4141- case 'resigned':
4242- title = iWon ? 'Opponent resigned' : 'You resigned';
4343- subtitle = iWon
4444- ? 'Your opponent has resigned. You win!'
4545- : 'You have resigned the game.';
4646- break;
4747- case 'abandoned':
4848- title = 'Game abandoned';
4949- subtitle = 'Your opponent disconnected.';
5050- break;
5151- default:
5252- title = 'Game over';
5353- subtitle = '';
2929+ if (isSolo) {
3030+ // No winner/loser framing for solo games — just show the result.
3131+ emoji = isDraw ? '🤝' : '♟';
3232+ switch (status) {
3333+ case 'checkmate':
3434+ title = 'Checkmate';
3535+ subtitle = `${winner === 'white' ? 'White' : 'Black'} wins.`;
3636+ break;
3737+ case 'stalemate':
3838+ title = 'Stalemate';
3939+ subtitle = 'No legal moves — the game is a draw.';
4040+ break;
4141+ case 'draw':
4242+ title = 'Draw';
4343+ subtitle = 'The game ended in a draw.';
4444+ break;
4545+ default:
4646+ title = 'Game over';
4747+ subtitle = '';
4848+ }
4949+ } else {
5050+ emoji = iWon ? '🏆' : isDraw ? '🤝' : status === 'timeout' ? '⏰' : '💀';
5151+ switch (status) {
5252+ case 'checkmate':
5353+ title = iWon ? 'You win!' : 'Checkmate';
5454+ subtitle = iWon
5555+ ? 'Checkmate — well played!'
5656+ : 'Your king has been checkmated.';
5757+ break;
5858+ case 'stalemate':
5959+ title = 'Stalemate';
6060+ subtitle = 'No legal moves — the game is a draw.';
6161+ break;
6262+ case 'draw':
6363+ title = 'Draw';
6464+ subtitle = 'The game ended in a draw.';
6565+ break;
6666+ case 'resigned':
6767+ title = iWon ? 'Opponent resigned' : 'You resigned';
6868+ subtitle = iWon
6969+ ? 'Your opponent has resigned. You win!'
7070+ : 'You have resigned the game.';
7171+ break;
7272+ case 'timeout':
7373+ title = iWon ? 'You win on time!' : 'Time\'s up';
7474+ subtitle = iWon
7575+ ? 'Your opponent ran out of time.'
7676+ : 'You ran out of time.';
7777+ break;
7878+ case 'abandoned':
7979+ title = 'Game abandoned';
8080+ subtitle = 'Your opponent disconnected.';
8181+ break;
8282+ default:
8383+ title = 'Game over';
8484+ subtitle = '';
8585+ }
5486 }
55875688 return (
5789 <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 backdrop-blur-sm">
5890 <div className="mx-4 w-full max-w-xs rounded-xl bg-neutral-900 border border-neutral-700 p-6 text-center shadow-2xl">
5959- <div className="text-4xl mb-3">
6060- {iWon ? '🏆' : isDraw ? '🤝' : '💀'}
6161- </div>
9191+ <div className="text-4xl mb-3">{emoji}</div>
6292 <h2 className="text-xl font-bold text-white">{title}</h2>
6393 <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>
6494 <button
+10-1
client/src/components/game/PlayerBar.tsx
···11/**
22 * PlayerBar — displays player info above/below the chess board.
33 *
44- * Shows avatar, display name, handle, and a turn indicator.
44+ * Shows avatar, display name, handle, a turn indicator, and (for timed
55+ * games) the player's remaining clock time.
56 */
6788+import type { ReactNode } from 'react';
99+710interface PlayerBarProps {
811 displayName: string;
912 handle?: string;
1013 avatarUrl?: string;
1114 isActive: boolean; // Whether it's this player's turn
1215 color: 'white' | 'black';
1616+ /** Optional clock node rendered on the right side of the bar */
1717+ clock?: ReactNode;
1318}
14191520export function PlayerBar({
···1823 avatarUrl,
1924 isActive,
2025 color,
2626+ clock,
2127}: PlayerBarProps) {
2228 return (
2329 <div
···5460 <div className="truncate text-xs text-neutral-500">@{handle}</div>
5561 )}
5662 </div>
6363+6464+ {/* Clock (timed games) */}
6565+ {clock}
57665867 {/* Turn indicator */}
5968 {isActive && (
···11/**
22 * LobbyScreen — matchmaking interface.
33 *
44- * Shows a "Find Game" button for real matchmaking and a "Play vs Self"
55- * button for solo play. While searching, displays an animated indicator
66- * with a cancel option. Auto-transitions to GameScreen when a match is
77- * found (handled by the parent App routing via activeGame state).
44+ * Shows four time-control buttons (1', 3', 5', 10') for real matchmaking,
55+ * each with its own queue pool, and a "Play vs Self" button for untimed solo
66+ * play. While searching, displays an animated indicator with the selected
77+ * time control and a cancel option.
88+ *
99+ * Auto-transitions to GameScreen when a match is found (handled by the
1010+ * parent App routing via activeGame state).
811 */
9121013import { useGame } from '../../hooks/useGame';
11141515+/** Available time controls in seconds per side */
1616+const TIME_CONTROLS = [
1717+ { secs: 60, label: '1 min', sublabel: 'Bullet' },
1818+ { secs: 180, label: '3 min', sublabel: 'Blitz' },
1919+ { secs: 300, label: '5 min', sublabel: 'Blitz' },
2020+ { secs: 600, label: '10 min', sublabel: 'Rapid' },
2121+] as const;
2222+2323+function formatTimeControl(secs: number): string {
2424+ return `${secs / 60} min`;
2525+}
2626+1227export function LobbyScreen() {
1313- const { connected, isQueued, joinQueue, leaveQueue, createSoloGame } = useGame();
2828+ const { connected, isQueued, queuedTimeControlSecs, joinQueue, leaveQueue, createSoloGame } = useGame();
14291515- if (isQueued) {
3030+ if (isQueued && queuedTimeControlSecs !== null) {
1631 return (
1732 <div className="flex flex-1 flex-col items-center justify-center gap-8">
1833 {/* Animated searching indicator */}
···2641 Searching for opponent...
2742 </p>
2843 <p className="mt-1 text-sm text-neutral-400">
2929- Waiting for another player to join
4444+ {formatTimeControl(queuedTimeControlSecs)} per side
3045 </p>
3146 </div>
3247···4661 <div className="text-6xl mb-4">♟</div>
4762 <h2 className="text-2xl font-bold text-white">Ready to play?</h2>
4863 <p className="mt-2 text-neutral-400">
4949- Find an opponent or play both sides yourself
6464+ Choose a time control to find an opponent
5065 </p>
5166 </div>
52675353- <div className="flex flex-col gap-3">
5454- <button
5555- onClick={joinQueue}
5656- disabled={!connected}
5757- className="rounded-lg bg-violet-600 px-8 py-3 text-lg font-medium text-white transition-colors hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50"
5858- >
5959- Find Game
6060- </button>
6868+ {/* Time control grid */}
6969+ <div className="flex flex-col gap-3 w-full max-w-xs">
7070+ <div className="grid grid-cols-2 gap-3">
7171+ {TIME_CONTROLS.map(({ secs, label, sublabel }) => (
7272+ <button
7373+ key={secs}
7474+ onClick={() => joinQueue({ timeControlSecs: secs })}
7575+ disabled={!connected}
7676+ className="flex flex-col items-center rounded-lg bg-violet-600 px-4 py-3 font-medium text-white transition-colors hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50"
7777+ >
7878+ <span className="text-lg">{label}</span>
7979+ <span className="text-xs text-violet-300">{sublabel}</span>
8080+ </button>
8181+ ))}
8282+ </div>
61836284 <button
6385 onClick={createSoloGame}
+30-7
client/src/hooks/useGame.ts
···1616 | 'stalemate'
1717 | 'draw'
1818 | 'resigned'
1919- | 'abandoned';
1919+ | 'abandoned'
2020+ | 'timeout';
20212122export interface GameState {
2223 /** SpacetimeDB connection state */
···26272728 /** Whether the player is currently in the matchmaking queue */
2829 isQueued: boolean;
3030+ /** Time control (secs) the player is currently queued for, or null if not queued */
3131+ queuedTimeControlSecs: number | null;
29323033 /** The active game, if any */
3134 activeGame: {
···3841 isWhite: boolean;
3942 isSolo: boolean;
4043 opponentIdentityHex: string;
4444+ /** 0 = untimed (solo); 60/180/300/600 = seconds per side */
4545+ timeControlSecs: number;
4646+ /** Remaining ms for white as of lastMoveAtMicros */
4747+ whiteTimeMs: bigint;
4848+ /** Remaining ms for black as of lastMoveAtMicros */
4949+ blackTimeMs: bigint;
5050+ /** When the last move was committed (microseconds since Unix epoch) — used
5151+ * by the clock display to interpolate the currently-running player's time */
5252+ lastMoveAtMicros: bigint;
4153 } | null;
42544355 /** Moves for the active game, sorted by move number */
···6173 >;
62746375 /** Reducer calls */
6464- joinQueue: () => void;
7676+ joinQueue: (params: { timeControlSecs: number }) => void;
6577 leaveQueue: () => void;
6678 createSoloGame: () => void;
6779 makeMove: (params: { gameId: bigint; from: string; to: string; promotion: string }) => void;
6880 resignGame: (params: { gameId: bigint }) => void;
8181+ claimTimeoutVictory: (params: { gameId: bigint }) => void;
6982 registerPlayer: (params: {
7083 did: string;
7184 handle: string;
···92105 const resignReducer = useReducer(reducers.resign);
93106 const registerPlayerReducer = useReducer(reducers.registerPlayer);
94107 const createSoloGameReducer = useReducer(reducers.createSoloGame);
108108+ const claimTimeoutVictoryReducer = useReducer(reducers.claimTimeoutVictory);
9510996110 // Derive state from the raw table data
9797- const isQueued = useMemo(() => {
9898- if (!identityHex) return false;
9999- return queueEntries.some((e) => e.identity.toHexString() === identityHex);
111111+ const queuedTimeControlSecs = useMemo(() => {
112112+ if (!identityHex) return null;
113113+ const entry = queueEntries.find((e) => e.identity.toHexString() === identityHex);
114114+ return entry ? entry.timeControlSecs : null;
100115 }, [queueEntries, identityHex]);
116116+117117+ const isQueued = queuedTimeControlSecs !== null;
101118102119 const activeGame = useMemo(() => {
103120 if (!identityHex) return null;
104121105122 // Find the most recent game this player is in. Prefer 'active' games,
106123 // but also return recently-ended games so the GameStatus overlay can
107107- // render (checkmate/resign/draw screen). Without this, ending a game
108108- // would instantly route back to the lobby with no outcome shown.
124124+ // render (checkmate/resign/draw/timeout screen). Without this, ending a
125125+ // game would instantly route back to the lobby with no outcome shown.
109126 let best: (typeof allGames)[number] | null = null;
110127 for (const g of allGames) {
111128 const isPlayer =
···142159 isWhite,
143160 isSolo,
144161 opponentIdentityHex,
162162+ timeControlSecs: best.timeControlSecs,
163163+ whiteTimeMs: best.whiteTimeMs,
164164+ blackTimeMs: best.blackTimeMs,
165165+ lastMoveAtMicros: best.lastMoveAt.microsSinceUnixEpoch,
145166 };
146167 }, [allGames, identityHex]);
147168···176197 connected,
177198 identityHex,
178199 isQueued,
200200+ queuedTimeControlSecs,
179201 activeGame,
180202 moves,
181203 players,
···184206 createSoloGame: createSoloGameReducer,
185207 makeMove: makeMoveReducer,
186208 resignGame: resignReducer,
209209+ claimTimeoutVictory: claimTimeoutVictoryReducer,
187210 registerPlayer: registerPlayerReducer,
188211 };
189212}
+79-16
client/src/hooks/useGamePublisher.ts
···55 * terminal state. When detected, builds a PGN and publishes a
66 * `social.checkmate.game` record to the player's PDS.
77 *
88- * Publishes at most once per game by tracking published game IDs.
99- * Solo games (playing yourself) are skipped.
88+ * Publishes at most once per game — IDs are persisted to localStorage so
99+ * games are not re-published across page refreshes. Solo games are skipped.
1010+ *
1111+ * Accepts identityHex and players as props (passed from App via useGame)
1212+ * so this hook does not call useGame() itself, avoiding a duplicate
1313+ * subscription to the same SpacetimeDB tables.
1014 */
11151216import { useEffect, useRef } from 'react';
1317import { useTable } from 'spacetimedb/react';
1418import { tables } from '../module_bindings';
1519import { useAuth } from './useAuth';
1616-import { useGame } from './useGame';
1717-import { buildPgn, gameResultToPgn } from '../lib/pgn';
2020+import { buildPgn, gameResultToPgn, gameTerminationToPgn } from '../lib/pgn';
1821import { publishGameRecord } from '../lib/atproto-publish';
19222020-const TERMINAL_STATUSES = new Set(['checkmate', 'stalemate', 'draw', 'resigned']);
2323+const TERMINAL_STATUSES = new Set(['checkmate', 'stalemate', 'draw', 'resigned', 'timeout']);
2424+2525+// ---------------------------------------------------------------------------
2626+// localStorage helpers — persist published game IDs across page refreshes
2727+// ---------------------------------------------------------------------------
2828+2929+function storageKey(identityHex: string): string {
3030+ return `checkmate:published:${identityHex}`;
3131+}
3232+3333+function loadPublishedIds(identityHex: string): Set<string> {
3434+ try {
3535+ const raw = localStorage.getItem(storageKey(identityHex));
3636+ if (!raw) return new Set();
3737+ const parsed = JSON.parse(raw);
3838+ if (Array.isArray(parsed)) return new Set(parsed as string[]);
3939+ return new Set();
4040+ } catch {
4141+ return new Set();
4242+ }
4343+}
4444+4545+function savePublishedId(identityHex: string, gameIdStr: string): void {
4646+ try {
4747+ const existing = loadPublishedIds(identityHex);
4848+ existing.add(gameIdStr);
4949+ localStorage.setItem(storageKey(identityHex), JSON.stringify([...existing]));
5050+ } catch {
5151+ // localStorage may be unavailable (private browsing quota); skip silently.
5252+ }
5353+}
5454+5555+// ---------------------------------------------------------------------------
5656+5757+interface GamePublisherProps {
5858+ identityHex: string | null;
5959+ players: Map<string, { did: string; handle: string; displayName: string; avatarUrl: string }>;
6060+}
21612222-export function useGamePublisher() {
6262+export function useGamePublisher({ identityHex, players }: GamePublisherProps) {
2363 const { session, did } = useAuth();
2424- const { identityHex, players } = useGame();
2564 const [allGames] = useTable(tables.game);
2665 const [allMoves] = useTable(tables.gameMove);
6666+6767+ // In-memory set to avoid redundant localStorage reads on every render.
6868+ // Seeded from localStorage when identityHex is first known.
2769 const publishedGameIds = useRef(new Set<string>());
7070+ const seededForIdentity = useRef<string | null>(null);
28712972 useEffect(() => {
3073 if (!session || !did || !identityHex) return;
31747575+ // Seed from localStorage once per identity
7676+ if (seededForIdentity.current !== identityHex) {
7777+ publishedGameIds.current = loadPublishedIds(identityHex);
7878+ seededForIdentity.current = identityHex;
7979+ }
8080+3281 for (const game of allGames) {
3382 if (!TERMINAL_STATUSES.has(game.status)) continue;
3483···4594 // Solo games — skip publishing
4695 if (whiteHex === blackHex) {
4796 publishedGameIds.current.add(gameIdStr);
9797+ savePublishedId(identityHex, gameIdStr);
4898 continue;
4999 }
50100···59109 // we'd skip this game permanently.
60110 if (gameMoves.length === 0) continue;
611116262- // Resolve player info — wait for both players to be loaded so we
112112+ // Resolve player info — wait for BOTH players to be loaded so we
63113 // don't publish with placeholder data.
64114 const whitePlayer = players.get(whiteHex);
65115 const blackPlayer = players.get(blackHex);
6666- const opponentHex = isWhite ? blackHex : whiteHex;
6767- const opponentPlayer = players.get(opponentHex);
116116+ if (!whitePlayer || !blackPlayer) continue; // retry next cycle
681176969- if (!opponentPlayer) continue; // Player data still loading — retry next cycle
7070-7171- // Mark as published only after we've confirmed all data is available.
118118+ // Mark as published (in memory + localStorage) before the async call
119119+ // so concurrent renders don't double-publish. Remove on failure to
120120+ // allow retry.
72121 publishedGameIds.current.add(gameIdStr);
122122+ savePublishedId(identityHex, gameIdStr);
7312374124 const result = gameResultToPgn(game.status, game.winner);
125125+ // gameResultToPgn returns '*' only for active games; we filtered those above.
126126+ if (result === '*') continue;
127127+128128+ const termination = game.timeControlSecs > 0
129129+ ? gameTerminationToPgn(game.status)
130130+ : undefined;
7513176132 const pgn = buildPgn({
7777- whiteHandle: whitePlayer?.handle ?? 'Unknown',
7878- blackHandle: blackPlayer?.handle ?? 'Unknown',
133133+ whiteHandle: whitePlayer.handle,
134134+ blackHandle: blackPlayer.handle,
79135 result,
80136 date: new Date(Number(game.createdAt.microsSinceUnixEpoch / 1000n)),
81137 moves: gameMoves,
138138+ timeControlSecs: game.timeControlSecs > 0 ? game.timeControlSecs : undefined,
139139+ termination,
82140 });
141141+142142+ const opponentDid = isWhite ? blackPlayer.did : whitePlayer.did;
8314384144 publishGameRecord({
85145 session,
86146 pgn,
87147 result,
88148 color: isWhite ? 'white' : 'black',
8989- opponentDid: opponentPlayer.did,
149149+ opponentDid,
90150 })
91151 .then(({ uri }) => {
92152 console.log(`[publish] Game ${gameIdStr} published to atproto: ${uri}`);
···95155 console.error(`[publish] Failed to publish game ${gameIdStr}:`, err);
96156 // Allow retry on next render cycle
97157 publishedGameIds.current.delete(gameIdStr);
158158+ // Note: we don't remove from localStorage — a partial publish
159159+ // (where the record was created but the response was lost) is
160160+ // preferable to a guaranteed duplicate.
98161 });
99162 }
100163 }, [allGames, allMoves, session, did, identityHex, players]);
···1414 result: '1-0' | '0-1' | '1/2-1/2' | '*';
1515 date: Date;
1616 moves: Array<{ san: string }>;
1717+ /** Seconds per side (0 or undefined = no time control). Adds [TimeControl] tag. */
1818+ timeControlSecs?: number;
1919+ /**
2020+ * PGN Termination value, included as a supplemental tag when provided.
2121+ * Examples: "normal", "time forfeit", "abandoned"
2222+ */
2323+ termination?: string;
1724}
18251926/**
···3138}
32393340/**
4141+ * Derive the PGN Termination tag value from a game status.
4242+ * Returns undefined for untimed games or when there's no meaningful value.
4343+ */
4444+export function gameTerminationToPgn(status: string): string | undefined {
4545+ switch (status) {
4646+ case 'checkmate':
4747+ case 'stalemate':
4848+ case 'draw':
4949+ return 'normal';
5050+ case 'resigned':
5151+ // Resignation is a normal, deliberate conclusion — not an abandonment.
5252+ // 'abandoned' in PGN means a game that was left unfinished by both players.
5353+ return 'normal';
5454+ case 'timeout':
5555+ return 'time forfeit';
5656+ default:
5757+ return undefined;
5858+ }
5959+}
6060+6161+/**
3462 * Escape a string for use inside a PGN tag value.
3563 * PGN spec says tag values are delimited by double quotes and
3636- * backslashes/quotes within must be escaped.
6464+ * backslashes/quotes within must be escaped. Control characters
6565+ * (newlines, tabs, etc.) would break single-line tag syntax and
6666+ * could be used for PGN injection, so we strip them first.
3767 */
3868function escapePgnTagValue(value: string): string {
3939- return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
6969+ // Strip ASCII control characters (0x00–0x1f and 0x7f)
7070+ const sanitized = value.replace(/[\x00-\x1f\x7f]/g, '');
7171+ return sanitized.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
4072}
41734274/**
4375 * Build a complete PGN string from game data.
4476 */
4577export function buildPgn(data: PgnGameData): string {
4646- const { whiteHandle, blackHandle, result, date, moves } = data;
7878+ const { whiteHandle, blackHandle, result, date, moves, timeControlSecs, termination } = data;
47794880 // Format date as YYYY.MM.DD per PGN spec (UTC to avoid timezone drift)
4981 const year = date.getUTCFullYear();
···5486 // Seven Tag Roster (STR) — the required PGN headers.
5587 // Player names are escaped to prevent PGN header injection via
5688 // crafted Bluesky display names.
5757- const headers = [
8989+ const strTags = [
5890 `[Event "Checkmate Online"]`,
5991 `[Site "checkmate.social"]`,
6092 `[Date "${dateStr}"]`,
···6294 `[White "${escapePgnTagValue(whiteHandle)}"]`,
6395 `[Black "${escapePgnTagValue(blackHandle)}"]`,
6496 `[Result "${result}"]`,
6565- ].join('\n');
9797+ ];
9898+9999+ // Supplemental tags — added after STR when present
100100+ const supplementalTags: string[] = [];
101101+102102+ if (timeControlSecs && timeControlSecs > 0) {
103103+ // PGN TimeControl format: just the seconds per side (no increment)
104104+ supplementalTags.push(`[TimeControl "${timeControlSecs}"]`);
105105+ }
106106+107107+ if (termination) {
108108+ supplementalTags.push(`[Termination "${escapePgnTagValue(termination)}"]`);
109109+ }
110110+111111+ const allTags = [...strTags, ...supplementalTags];
6611267113 // Build movetext: "1. e4 e5 2. Nf3 Nc6 ..."
114114+ // PGN spec requires lines ≤ 80 characters — wrap at word boundaries.
68115 const parts: string[] = [];
69116 for (let i = 0; i < moves.length; i++) {
70117 if (i % 2 === 0) {
···73120 parts.push(moves[i].san);
74121 }
75122 parts.push(result);
7676- const movetext = parts.join(' ');
771237878- return `${headers}\n\n${movetext}\n`;
124124+ const movetextLines: string[] = [];
125125+ let currentLine = '';
126126+ for (const token of parts) {
127127+ if (currentLine.length === 0) {
128128+ currentLine = token;
129129+ } else if (currentLine.length + 1 + token.length <= 80) {
130130+ currentLine += ' ' + token;
131131+ } else {
132132+ movetextLines.push(currentLine);
133133+ currentLine = token;
134134+ }
135135+ }
136136+ if (currentLine) movetextLines.push(currentLine);
137137+ const movetext = movetextLines.join('\n');
138138+139139+ return `${allTags.join('\n')}\n\n${movetext}\n`;
79140}
+191-63
server/src/index.ts
···1717// Starting position FEN
1818const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
19192020+// Valid time controls in seconds per side (0 = untimed)
2121+const VALID_TIME_CONTROLS = new Set([60, 180, 300, 600]);
2222+2023// ---------------------------------------------------------------------------
2124// Schema: Tables
2225// ---------------------------------------------------------------------------
···3841 }
3942 ),
40434141- // Matchmaking queue — players waiting for an opponent
4444+ // Matchmaking queue — players waiting for an opponent, partitioned by time control
4245 matchQueue: table(
4346 { name: 'match_queue', public: true },
4447 {
4548 identity: t.identity().primaryKey(),
4649 joinedAt: t.timestamp(),
5050+ // 60 | 180 | 300 | 600 — players only match within the same pool
5151+ timeControlSecs: t.u32().default(0),
4752 }
4853 ),
4954···6267 whiteIdentity: t.identity(),
6368 blackIdentity: t.identity(),
6469 fen: t.string(),
6565- // "active" | "checkmate" | "stalemate" | "draw" | "resigned" | "abandoned"
7070+ // "active" | "checkmate" | "stalemate" | "draw" | "resigned" | "abandoned" | "timeout"
6671 status: t.string(),
6772 turn: t.string(), // "w" | "b"
6873 winner: t.string().default(''), // "" | "white" | "black" | "draw"
6974 moveCount: t.u32().default(0),
7075 lastMoveAt: t.timestamp(),
7176 createdAt: t.timestamp(),
7777+ // Time control — 0 means untimed (solo or legacy games)
7878+ timeControlSecs: t.u32().default(0),
7979+ // Remaining time for each player in milliseconds, as of lastMoveAt.
8080+ // The currently-active player's clock counts down from this value.
8181+ whiteTimeMs: t.u64().default(0n),
8282+ blackTimeMs: t.u64().default(0n),
7283 }
7384 ),
7485···152163 if (avatarUrl.length > 2048) {
153164 throw new SenderError('Avatar URL too long');
154165 }
166166+ // Reject control characters in string fields. They have no valid use in
167167+ // display names or URLs, and could be exploited for PGN tag injection
168168+ // (newlines) or other downstream parsing attacks.
169169+ const hasControlChars = (s: string) => /[\x00-\x1f\x7f]/.test(s);
170170+ if (hasControlChars(handle)) {
171171+ throw new SenderError('Handle contains invalid characters');
172172+ }
173173+ if (hasControlChars(displayName)) {
174174+ throw new SenderError('Display name contains invalid characters');
175175+ }
176176+ if (hasControlChars(avatarUrl)) {
177177+ throw new SenderError('Avatar URL contains invalid characters');
178178+ }
155179156180 const existing = ctx.db.player.identity.find(ctx.sender);
157181 if (existing) {
···188212// ---------------------------------------------------------------------------
189213190214/**
191191- * Join the matchmaking queue. If another player is already waiting, immediately
192192- * create a new game and remove both players from the queue.
215215+ * Join the matchmaking queue for a given time control. Players are only
216216+ * matched against others seeking the same time control (separate pools for
217217+ * 1', 3', 5', 10'). If another player is already waiting in the same pool,
218218+ * a game is created immediately.
193219 */
194194-export const joinQueue = spacetimedb.reducer((ctx) => {
195195- const identity = ctx.sender;
220220+export const joinQueue = spacetimedb.reducer(
221221+ { timeControlSecs: t.u32() },
222222+ (ctx, { timeControlSecs }) => {
223223+ const identity = ctx.sender;
196224197197- // Must be a registered player
198198- const player = ctx.db.player.identity.find(identity);
199199- if (!player) {
200200- throw new SenderError('Must register before joining queue');
201201- }
225225+ // Validate time control
226226+ if (!VALID_TIME_CONTROLS.has(timeControlSecs)) {
227227+ throw new SenderError('Invalid time control — must be 60, 180, 300, or 600');
228228+ }
202229203203- // Cannot join if already in queue
204204- const alreadyQueued = ctx.db.matchQueue.identity.find(identity);
205205- if (alreadyQueued) {
206206- throw new SenderError('Already in matchmaking queue');
207207- }
230230+ // Must be a registered player
231231+ const player = ctx.db.player.identity.find(identity);
232232+ if (!player) {
233233+ throw new SenderError('Must register before joining queue');
234234+ }
235235+236236+ // Cannot join if already in queue
237237+ const alreadyQueued = ctx.db.matchQueue.identity.find(identity);
238238+ if (alreadyQueued) {
239239+ throw new SenderError('Already in matchmaking queue');
240240+ }
208241209209- // Cannot join if already in an active game
210210- for (const game of ctx.db.game.game_white.filter(identity)) {
211211- if (game.status === 'active') {
212212- throw new SenderError('Already in an active game');
242242+ // Cannot join if already in an active game
243243+ for (const game of ctx.db.game.game_white.filter(identity)) {
244244+ if (game.status === 'active') {
245245+ throw new SenderError('Already in an active game');
246246+ }
213247 }
214214- }
215215- for (const game of ctx.db.game.game_black.filter(identity)) {
216216- if (game.status === 'active') {
217217- throw new SenderError('Already in an active game');
248248+ for (const game of ctx.db.game.game_black.filter(identity)) {
249249+ if (game.status === 'active') {
250250+ throw new SenderError('Already in an active game');
251251+ }
218252 }
219219- }
220253221221- // Check if someone is already waiting
222222- let opponent: { identity: typeof identity; joinedAt: typeof ctx.timestamp } | undefined;
223223- for (const entry of ctx.db.matchQueue.iter()) {
224224- if (entry.identity.toHexString() !== identity.toHexString()) {
225225- opponent = entry;
226226- break;
254254+ // Check if someone is already waiting in the same time-control pool
255255+ let opponent: { identity: typeof identity; joinedAt: typeof ctx.timestamp; timeControlSecs: number } | undefined;
256256+ for (const entry of ctx.db.matchQueue.iter()) {
257257+ if (
258258+ entry.identity.toHexString() !== identity.toHexString() &&
259259+ entry.timeControlSecs === timeControlSecs
260260+ ) {
261261+ opponent = entry;
262262+ break;
263263+ }
227264 }
228228- }
229265230230- if (opponent) {
231231- // Match found! Remove opponent from queue and create a game.
232232- ctx.db.matchQueue.identity.delete(opponent.identity);
266266+ if (opponent) {
267267+ // Match found! Remove opponent from queue and create a timed game.
268268+ ctx.db.matchQueue.identity.delete(opponent.identity);
233269234234- // Assign colors: the player who waited longer gets white (slight advantage
235235- // as a reward for patience).
236236- const whiteIdentity = opponent.identity;
237237- const blackIdentity = identity;
270270+ // Assign colors: the player who waited longer gets white (slight advantage
271271+ // as a reward for patience).
272272+ const whiteIdentity = opponent.identity;
273273+ const blackIdentity = identity;
238274239239- ctx.db.game.insert({
240240- id: 0n, // auto-increment
241241- whiteIdentity,
242242- blackIdentity,
243243- fen: STARTING_FEN,
244244- status: 'active',
245245- turn: 'w',
246246- winner: '',
247247- moveCount: 0,
248248- lastMoveAt: ctx.timestamp,
249249- createdAt: ctx.timestamp,
250250- });
275275+ const initialTimeMs = BigInt(timeControlSecs) * 1000n;
251276252252- console.info(
253253- `Game created: ${whiteIdentity.toHexString()} (white) vs ${blackIdentity.toHexString()} (black)`
254254- );
255255- } else {
256256- // No opponent available — add to queue
257257- ctx.db.matchQueue.insert({
258258- identity,
259259- joinedAt: ctx.timestamp,
260260- });
261261- console.info(`Player queued: ${identity.toHexString()}`);
277277+ ctx.db.game.insert({
278278+ id: 0n, // auto-increment
279279+ whiteIdentity,
280280+ blackIdentity,
281281+ fen: STARTING_FEN,
282282+ status: 'active',
283283+ turn: 'w',
284284+ winner: '',
285285+ moveCount: 0,
286286+ lastMoveAt: ctx.timestamp,
287287+ createdAt: ctx.timestamp,
288288+ timeControlSecs,
289289+ whiteTimeMs: initialTimeMs,
290290+ blackTimeMs: initialTimeMs,
291291+ });
292292+293293+ console.info(
294294+ `Game created: ${whiteIdentity.toHexString()} (white) vs ${blackIdentity.toHexString()} (black) [${timeControlSecs}s]`
295295+ );
296296+ } else {
297297+ // No opponent available — add to queue
298298+ ctx.db.matchQueue.insert({
299299+ identity,
300300+ joinedAt: ctx.timestamp,
301301+ timeControlSecs,
302302+ });
303303+ console.info(`Player queued: ${identity.toHexString()} [${timeControlSecs}s]`);
304304+ }
262305 }
263263-});
306306+);
264307265308/**
266309 * Create a solo game where the caller plays both sides. All moves still go
267310 * through SpacetimeDB — same validation, same persistence. Useful for local
268268- * testing and casual practice.
311311+ * testing and casual practice. Solo games are always untimed.
269312 */
270313export const createSoloGame = spacetimedb.reducer((ctx) => {
271314 const identity = ctx.sender;
···304347 moveCount: 0,
305348 lastMoveAt: ctx.timestamp,
306349 createdAt: ctx.timestamp,
350350+ timeControlSecs: 0,
351351+ whiteTimeMs: 0n,
352352+ blackTimeMs: 0n,
307353 });
308354309355 console.info(`Solo game created for ${identity.toHexString()}`);
···327373/**
328374 * Make a chess move. This is the core reducer — it validates the move using
329375 * chess.js and updates all game state atomically.
376376+ *
377377+ * For timed games, deducts the elapsed time since the last move from the
378378+ * moving player's clock.
330379 */
331380export const makeMove = spacetimedb.reducer(
332381 {
···357406 throw new SenderError('Not your turn');
358407 }
359408409409+ // Deduct elapsed time from the moving player's clock (timed games only).
410410+ // We do this before the move so that if they ran out of time the move is
411411+ // still recorded but the clock floors at 0 — timeout must still be claimed
412412+ // via claimTimeoutVictory (to avoid ambiguity about whose move "ended" the game).
413413+ let whiteTimeMs = game.whiteTimeMs;
414414+ let blackTimeMs = game.blackTimeMs;
415415+416416+ if (game.timeControlSecs > 0) {
417417+ const elapsedMs =
418418+ (ctx.timestamp.microsSinceUnixEpoch - game.lastMoveAt.microsSinceUnixEpoch) / 1000n;
419419+420420+ if (game.turn === 'w') {
421421+ whiteTimeMs = whiteTimeMs > elapsedMs ? whiteTimeMs - elapsedMs : 0n;
422422+ } else {
423423+ blackTimeMs = blackTimeMs > elapsedMs ? blackTimeMs - elapsedMs : 0n;
424424+ }
425425+ }
426426+360427 // Validate the move with chess.js
361428 const chess = new Chess(game.fen);
362429···402469 winner,
403470 moveCount: newMoveCount,
404471 lastMoveAt: ctx.timestamp,
472472+ whiteTimeMs,
473473+ blackTimeMs,
405474 });
406475407476 // Record the move
···420489 if (status !== 'active') {
421490 console.info(`Game ${gameId} ended: ${status}, winner: ${winner}`);
422491 }
492492+ }
493493+);
494494+495495+/**
496496+ * Claim a timeout victory. Called by the winning player when their opponent's
497497+ * clock reaches zero. The server independently verifies the elapsed time to
498498+ * prevent false claims.
499499+ */
500500+export const claimTimeoutVictory = spacetimedb.reducer(
501501+ { gameId: t.u64() },
502502+ (ctx, { gameId }) => {
503503+ const game = ctx.db.game.id.find(gameId);
504504+ if (!game) {
505505+ throw new SenderError('Game not found');
506506+ }
507507+508508+ if (game.status !== 'active') {
509509+ throw new SenderError('Game is not active');
510510+ }
511511+512512+ if (game.timeControlSecs === 0) {
513513+ throw new SenderError('Game has no time control');
514514+ }
515515+516516+ const isWhite = game.whiteIdentity.toHexString() === ctx.sender.toHexString();
517517+ const isBlack = game.blackIdentity.toHexString() === ctx.sender.toHexString();
518518+519519+ if (!isWhite && !isBlack) {
520520+ throw new SenderError('You are not a player in this game');
521521+ }
522522+523523+ // The clock currently running belongs to the player whose turn it is.
524524+ // You can only claim a timeout when it is the OPPONENT'S turn (their clock
525525+ // is running and they've run out of time).
526526+ const clockOwnerIsWhite = game.turn === 'w';
527527+ if (clockOwnerIsWhite === isWhite) {
528528+ throw new SenderError('Cannot claim your own timeout — wait for your opponent to claim theirs');
529529+ }
530530+531531+ // Verify the opponent's remaining time has actually been exhausted.
532532+ const elapsedMs =
533533+ (ctx.timestamp.microsSinceUnixEpoch - game.lastMoveAt.microsSinceUnixEpoch) / 1000n;
534534+535535+ const opponentTimeMs = clockOwnerIsWhite ? game.whiteTimeMs : game.blackTimeMs;
536536+537537+ if (elapsedMs < opponentTimeMs) {
538538+ throw new SenderError('Opponent still has time remaining');
539539+ }
540540+541541+ const winner = isWhite ? 'white' : 'black';
542542+543543+ ctx.db.game.id.update({
544544+ ...game,
545545+ status: 'timeout',
546546+ winner,
547547+ lastMoveAt: ctx.timestamp,
548548+ });
549549+550550+ console.info(`Game ${gameId}: timeout, ${winner} wins`);
423551 }
424552);
425553