https://checkmate.social
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

time controls

jcalabro 0b754936 ffac93bc

+1308 -210
+10 -5
client/src/App.tsx
··· 23 23 export default function App() { 24 24 const auth = useAuth(); 25 25 const game = useGame(); 26 - const registrationDone = useRef(false); 26 + // Stores the last DID that was successfully registered, not a boolean, 27 + // so that if a different user logs in without a page reload the new user 28 + // gets registered too. 29 + const registrationDone = useRef<string | null>(null); 27 30 // Track which ended game the user has dismissed so we route back to lobby. 28 31 // Lives here (not in GameScreen) because routing decisions belong in App. 29 32 const [dismissedGameId, setDismissedGameId] = useState<bigint | null>(null); ··· 34 37 !auth.did || 35 38 !auth.handle || 36 39 !game.connected || 37 - registrationDone.current 40 + registrationDone.current === auth.did 38 41 ) { 39 42 return; 40 43 } 41 44 42 - registrationDone.current = true; 45 + registrationDone.current = auth.did; 43 46 game.registerPlayer({ 44 47 did: auth.did, 45 48 handle: auth.handle, ··· 48 51 }); 49 52 }, [auth.did, auth.handle, auth.displayName, auth.avatarUrl, game.connected, game.registerPlayer]); 50 53 51 - // Publish completed games to atproto 52 - useGamePublisher(); 54 + // Publish completed games to atproto. 55 + // Pass identityHex and players from our already-subscribed useGame call 56 + // so useGamePublisher doesn't create a second redundant subscription. 57 + useGamePublisher({ identityHex: game.identityHex, players: game.players }); 53 58 54 59 // Not logged in 55 60 if (!auth.did) {
+149 -1
client/src/__tests__/pgn.test.ts
··· 3 3 */ 4 4 5 5 import { describe, it, expect } from 'vitest'; 6 - import { buildPgn, gameResultToPgn } from '../lib/pgn'; 6 + import { buildPgn, gameResultToPgn, gameTerminationToPgn } from '../lib/pgn'; 7 7 8 8 describe('gameResultToPgn', () => { 9 9 it('maps white win', () => { ··· 23 23 24 24 it('maps active game', () => { 25 25 expect(gameResultToPgn('active', '')).toBe('*'); 26 + }); 27 + 28 + it('maps timeout win for white', () => { 29 + expect(gameResultToPgn('timeout', 'white')).toBe('1-0'); 30 + }); 31 + 32 + it('maps timeout win for black', () => { 33 + expect(gameResultToPgn('timeout', 'black')).toBe('0-1'); 34 + }); 35 + }); 36 + 37 + describe('gameTerminationToPgn', () => { 38 + it('returns "normal" for checkmate/stalemate/draw', () => { 39 + expect(gameTerminationToPgn('checkmate')).toBe('normal'); 40 + expect(gameTerminationToPgn('stalemate')).toBe('normal'); 41 + expect(gameTerminationToPgn('draw')).toBe('normal'); 42 + }); 43 + 44 + it('returns "normal" for resignation — resignation is a deliberate conclusion, not abandonment', () => { 45 + expect(gameTerminationToPgn('resigned')).toBe('normal'); 46 + }); 47 + 48 + it('returns "time forfeit" for timeout', () => { 49 + expect(gameTerminationToPgn('timeout')).toBe('time forfeit'); 50 + }); 51 + 52 + it('returns undefined for unknown/active', () => { 53 + expect(gameTerminationToPgn('active')).toBeUndefined(); 54 + expect(gameTerminationToPgn('abandoned')).toBeUndefined(); 26 55 }); 27 56 }); 28 57 ··· 137 166 expect(pgn).toContain('[White "user\\\\name"]'); 138 167 }); 139 168 169 + it('includes TimeControl tag for timed games', () => { 170 + const pgn = buildPgn({ 171 + whiteHandle: 'w', 172 + blackHandle: 'b', 173 + result: '1-0', 174 + date: new Date('2026-04-15'), 175 + moves: [{ san: 'e4' }], 176 + timeControlSecs: 300, 177 + }); 178 + 179 + expect(pgn).toContain('[TimeControl "300"]'); 180 + }); 181 + 182 + it('omits TimeControl tag when timeControlSecs is 0 or absent', () => { 183 + const pgn = buildPgn({ 184 + whiteHandle: 'w', 185 + blackHandle: 'b', 186 + result: '*', 187 + date: new Date('2026-04-15'), 188 + moves: [], 189 + timeControlSecs: 0, 190 + }); 191 + 192 + expect(pgn).not.toContain('[TimeControl'); 193 + }); 194 + 195 + it('includes Termination tag when provided', () => { 196 + const pgn = buildPgn({ 197 + whiteHandle: 'w', 198 + blackHandle: 'b', 199 + result: '0-1', 200 + date: new Date('2026-04-15'), 201 + moves: [{ san: 'e4' }], 202 + timeControlSecs: 60, 203 + termination: 'time forfeit', 204 + }); 205 + 206 + expect(pgn).toContain('[TimeControl "60"]'); 207 + expect(pgn).toContain('[Termination "time forfeit"]'); 208 + }); 209 + 210 + it('TimeControl and Termination tags appear after the Seven Tag Roster', () => { 211 + const pgn = buildPgn({ 212 + whiteHandle: 'w', 213 + blackHandle: 'b', 214 + result: '1-0', 215 + date: new Date('2026-04-15'), 216 + moves: [], 217 + timeControlSecs: 180, 218 + termination: 'normal', 219 + }); 220 + 221 + const lines = pgn.split('\n').filter((l) => l.startsWith('[')); 222 + const resultIdx = lines.findIndex((l) => l.startsWith('[Result')); 223 + const tcIdx = lines.findIndex((l) => l.startsWith('[TimeControl')); 224 + const termIdx = lines.findIndex((l) => l.startsWith('[Termination')); 225 + 226 + expect(tcIdx).toBeGreaterThan(resultIdx); 227 + expect(termIdx).toBeGreaterThan(resultIdx); 228 + }); 229 + 140 230 it('escapes combined backslash and quote', () => { 141 231 const pgn = buildPgn({ 142 232 whiteHandle: 'a\\"b', ··· 148 238 149 239 // \\" should become \\\\" (backslash escaped, then quote escaped) 150 240 expect(pgn).toContain('[White "a\\\\\\"b"]'); 241 + }); 242 + 243 + it('strips control characters from player handles (PGN tag injection prevention)', () => { 244 + // A newline in a handle could inject a fake PGN tag 245 + const pgn = buildPgn({ 246 + whiteHandle: 'alice\n[Black "mallory"]', 247 + blackHandle: 'bob', 248 + result: '1-0', 249 + date: new Date('2026-04-15'), 250 + moves: [], 251 + }); 252 + 253 + // The injected tag should not appear as a separate tag line 254 + expect(pgn).not.toContain('[Black "mallory"]'); 255 + // The White tag must still be a single line 256 + const whiteTag = pgn.split('\n').find((l) => l.startsWith('[White')); 257 + expect(whiteTag).toBeDefined(); 258 + expect(whiteTag!.endsWith('"]')).toBe(true); 259 + }); 260 + 261 + it('strips tab and carriage return from handles', () => { 262 + const pgn = buildPgn({ 263 + whiteHandle: 'alice\tbob\r', 264 + blackHandle: 'carol', 265 + result: '*', 266 + date: new Date('2026-04-15'), 267 + moves: [], 268 + }); 269 + 270 + const whiteTag = pgn.split('\n').find((l) => l.startsWith('[White')); 271 + expect(whiteTag).toBeDefined(); 272 + // Tab and CR stripped — result is just "alicebob" 273 + expect(whiteTag).toBe('[White "alicebob"]'); 274 + }); 275 + 276 + it('wraps movetext lines at 80 characters', () => { 277 + // Build a long game where the naive single-line movetext would exceed 80 chars. 278 + // Scholar's mate is only 7 half-moves, so construct a long move list manually. 279 + const longMoves = Array.from({ length: 40 }, (_, i) => ({ 280 + san: i % 2 === 0 ? 'Nf3' : 'Nc6', 281 + })); 282 + 283 + const pgn = buildPgn({ 284 + whiteHandle: 'w', 285 + blackHandle: 'b', 286 + result: '1/2-1/2', 287 + date: new Date('2026-04-15'), 288 + moves: longMoves, 289 + }); 290 + 291 + // Find the movetext section (after the blank line following tags) 292 + const sections = pgn.split('\n\n'); 293 + expect(sections.length).toBe(2); 294 + const movetext = sections[1]; 295 + 296 + for (const line of movetext.split('\n').filter(Boolean)) { 297 + expect(line.length).toBeLessThanOrEqual(80); 298 + } 151 299 }); 152 300 });
+419 -45
client/src/__tests__/server-logic.test.ts
··· 88 88 }; 89 89 } 90 90 91 + /** 92 + * Simulates the server's resign reducer logic. 93 + * callerIsWhite: true if the caller is the white player, false for black. 94 + */ 95 + function simulateResign( 96 + gameState: { status: string; winner: string }, 97 + callerIsWhite: boolean 98 + ): { status: string; winner: string } { 99 + if (gameState.status !== 'active') { 100 + throw new Error('Game is not active'); 101 + } 102 + 103 + // The opponent of the resigning player wins 104 + const winner = callerIsWhite ? 'black' : 'white'; 105 + 106 + return { 107 + status: 'resigned', 108 + winner, 109 + }; 110 + } 111 + 91 112 describe('Server reducer: makeMove logic', () => { 92 113 it('rejects moves on a non-active game', () => { 93 114 const game = { ··· 241 262 expect(() => simulateMove(game, { from: 'e7', to: 'e8' }, true)).toThrow('Illegal move'); 242 263 }); 243 264 244 - it('handles resignation state transitions', () => { 245 - // Simulating resign reducer logic 246 - const game = { 247 - fen: STARTING_FEN, 248 - status: 'active', 249 - turn: 'w', 250 - winner: '', 251 - moveCount: 0, 252 - }; 253 - 254 - // If white resigns: 255 - const afterResign = { 256 - ...game, 257 - status: 'resigned', 258 - winner: 'black', // opponent wins 259 - }; 260 - expect(afterResign.status).toBe('resigned'); 261 - expect(afterResign.winner).toBe('black'); 262 - 263 - // Cannot make a move after resignation 264 - expect(() => 265 - simulateMove(afterResign, { from: 'e2', to: 'e4' }, true) 266 - ).toThrow('Game is not active'); 267 - }); 268 - 269 265 it('plays a full Scholar\'s Mate sequence', () => { 270 266 let game = { 271 267 fen: STARTING_FEN, ··· 295 291 }); 296 292 }); 297 293 294 + describe('Server reducer: resign logic', () => { 295 + it('white resigning gives the win to black', () => { 296 + const game = { status: 'active', winner: '' }; 297 + const result = simulateResign(game, /* callerIsWhite */ true); 298 + expect(result.status).toBe('resigned'); 299 + expect(result.winner).toBe('black'); 300 + }); 301 + 302 + it('black resigning gives the win to white', () => { 303 + const game = { status: 'active', winner: '' }; 304 + const result = simulateResign(game, /* callerIsWhite */ false); 305 + expect(result.status).toBe('resigned'); 306 + expect(result.winner).toBe('white'); 307 + }); 308 + 309 + it('cannot resign a game that is not active', () => { 310 + const game = { status: 'checkmate', winner: 'black' }; 311 + expect(() => simulateResign(game, true)).toThrow('Game is not active'); 312 + expect(() => simulateResign(game, false)).toThrow('Game is not active'); 313 + }); 314 + 315 + it('cannot make moves after resignation', () => { 316 + const game = { 317 + fen: STARTING_FEN, 318 + status: 'resigned', 319 + turn: 'w', 320 + winner: 'black', 321 + moveCount: 0, 322 + }; 323 + expect(() => 324 + simulateMove(game, { from: 'e2', to: 'e4' }, true) 325 + ).toThrow('Game is not active'); 326 + }); 327 + }); 328 + 298 329 describe('Server reducer: input validation', () => { 299 - it('validates DID format', () => { 300 - // Mirror of server's registerPlayer validation 301 - const validDids = ['did:plc:abc123', 'did:web:example.com']; 302 - const invalidDids = ['', 'notadid', 'di:plc:abc']; 330 + // Mirrors the validation in server/src/index.ts registerPlayer reducer. 331 + function simulateRegisterPlayer(params: { 332 + did: string; 333 + handle: string; 334 + displayName: string; 335 + avatarUrl: string; 336 + }): void { 337 + const { did, handle, displayName, avatarUrl } = params; 303 338 304 - for (const did of validDids) { 305 - expect(did.startsWith('did:')).toBe(true); 339 + if (!did || !did.startsWith('did:')) { 340 + throw new Error('Invalid DID format'); 306 341 } 307 - for (const did of invalidDids) { 308 - expect(!did || !did.startsWith('did:')).toBe(true); 342 + if (did.length > 2048) throw new Error('DID too long'); 343 + if (handle.length > 253) throw new Error('Handle too long'); 344 + if (displayName.length > 640) throw new Error('Display name too long'); 345 + if (avatarUrl.length > 2048) throw new Error('Avatar URL too long'); 346 + 347 + const hasControlChars = (s: string) => /[\x00-\x1f\x7f]/.test(s); 348 + if (hasControlChars(handle)) throw new Error('Handle contains invalid characters'); 349 + if (hasControlChars(displayName)) throw new Error('Display name contains invalid characters'); 350 + if (hasControlChars(avatarUrl)) throw new Error('Avatar URL contains invalid characters'); 351 + } 352 + 353 + it('accepts valid registration data', () => { 354 + expect(() => 355 + simulateRegisterPlayer({ 356 + did: 'did:plc:abc123', 357 + handle: 'alice.bsky.social', 358 + displayName: 'Alice', 359 + avatarUrl: 'https://cdn.bsky.app/avatar.jpg', 360 + }) 361 + ).not.toThrow(); 362 + }); 363 + 364 + it('rejects invalid DID formats', () => { 365 + for (const did of ['', 'notadid', 'di:plc:abc', 'http://example.com']) { 366 + expect(() => 367 + simulateRegisterPlayer({ did, handle: 'h', displayName: 'd', avatarUrl: '' }) 368 + ).toThrow('Invalid DID format'); 309 369 } 310 370 }); 311 371 312 - it('enforces input length limits', () => { 313 - // Mirror of server's registerPlayer validation 314 - const MAX_DID_LEN = 2048; 315 - const MAX_HANDLE_LEN = 253; 316 - const MAX_DISPLAY_NAME_LEN = 640; 317 - const MAX_AVATAR_URL_LEN = 2048; 372 + it('accepts valid DID formats', () => { 373 + for (const did of ['did:plc:abc123', 'did:web:example.com']) { 374 + expect(() => 375 + simulateRegisterPlayer({ did, handle: 'h', displayName: 'd', avatarUrl: '' }) 376 + ).not.toThrow(); 377 + } 378 + }); 318 379 319 - expect('did:plc:abc'.length).toBeLessThanOrEqual(MAX_DID_LEN); 320 - expect('a'.repeat(2049).length).toBeGreaterThan(MAX_DID_LEN); 321 - expect('handle.bsky.social'.length).toBeLessThanOrEqual(MAX_HANDLE_LEN); 322 - expect('a'.repeat(254).length).toBeGreaterThan(MAX_HANDLE_LEN); 323 - expect('Display Name'.length).toBeLessThanOrEqual(MAX_DISPLAY_NAME_LEN); 324 - expect('https://example.com/avatar.jpg'.length).toBeLessThanOrEqual(MAX_AVATAR_URL_LEN); 380 + it('enforces DID length limit', () => { 381 + expect(() => 382 + simulateRegisterPlayer({ 383 + did: 'did:' + 'a'.repeat(2048), 384 + handle: 'h', 385 + displayName: 'd', 386 + avatarUrl: '', 387 + }) 388 + ).toThrow('DID too long'); 389 + }); 390 + 391 + it('enforces handle length limit', () => { 392 + expect(() => 393 + simulateRegisterPlayer({ 394 + did: 'did:plc:abc', 395 + handle: 'a'.repeat(254), 396 + displayName: 'd', 397 + avatarUrl: '', 398 + }) 399 + ).toThrow('Handle too long'); 400 + }); 401 + 402 + it('enforces display name length limit', () => { 403 + expect(() => 404 + simulateRegisterPlayer({ 405 + did: 'did:plc:abc', 406 + handle: 'h', 407 + displayName: 'a'.repeat(641), 408 + avatarUrl: '', 409 + }) 410 + ).toThrow('Display name too long'); 411 + }); 412 + 413 + it('rejects control characters in handle (PGN injection prevention)', () => { 414 + expect(() => 415 + simulateRegisterPlayer({ 416 + did: 'did:plc:abc', 417 + handle: 'alice\nbob', 418 + displayName: 'd', 419 + avatarUrl: '', 420 + }) 421 + ).toThrow('Handle contains invalid characters'); 422 + }); 423 + 424 + it('rejects control characters in display name', () => { 425 + expect(() => 426 + simulateRegisterPlayer({ 427 + did: 'did:plc:abc', 428 + handle: 'h', 429 + displayName: 'Alice\tBob', 430 + avatarUrl: '', 431 + }) 432 + ).toThrow('Display name contains invalid characters'); 433 + }); 434 + 435 + it('rejects control characters in avatar URL', () => { 436 + expect(() => 437 + simulateRegisterPlayer({ 438 + did: 'did:plc:abc', 439 + handle: 'h', 440 + displayName: 'd', 441 + avatarUrl: 'https://example.com/\x00evil', 442 + }) 443 + ).toThrow('Avatar URL contains invalid characters'); 325 444 }); 326 445 }); 327 446 ··· 347 466 expect(game.moveCount).toBe(2); 348 467 }); 349 468 }); 469 + 470 + // --------------------------------------------------------------------------- 471 + // Clock / time control logic (mirrors server's claimTimeoutVictory reducer) 472 + // --------------------------------------------------------------------------- 473 + 474 + /** 475 + * Simulates claimTimeoutVictory reducer logic. 476 + * Returns the updated game or throws as the reducer would. 477 + */ 478 + function simulateClaimTimeout( 479 + gameState: { 480 + status: string; 481 + turn: string; 482 + winner: string; 483 + timeControlSecs: number; 484 + whiteTimeMs: number; // remaining ms for white as of lastMoveAtMs 485 + blackTimeMs: number; // remaining ms for black as of lastMoveAtMs 486 + lastMoveAtMs: number; // epoch ms when last move was committed 487 + }, 488 + claimantIsWhite: boolean, 489 + nowMs: number // the "current" time for the claim 490 + ): { status: string; winner: string } { 491 + if (gameState.status !== 'active') throw new Error('Game is not active'); 492 + if (gameState.timeControlSecs === 0) throw new Error('No time control'); 493 + 494 + const clockOwnerIsWhite = gameState.turn === 'w'; 495 + if (clockOwnerIsWhite === claimantIsWhite) { 496 + throw new Error('Cannot claim your own timeout'); 497 + } 498 + 499 + const elapsedMs = nowMs - gameState.lastMoveAtMs; 500 + const opponentTimeMs = clockOwnerIsWhite ? gameState.whiteTimeMs : gameState.blackTimeMs; 501 + 502 + if (elapsedMs < opponentTimeMs) { 503 + throw new Error('Opponent still has time remaining'); 504 + } 505 + 506 + return { 507 + status: 'timeout', 508 + winner: claimantIsWhite ? 'white' : 'black', 509 + }; 510 + } 511 + 512 + /** 513 + * Simulates clock deduction logic from makeMove. 514 + */ 515 + function simulateClockDeduction( 516 + gameState: { turn: string; whiteTimeMs: number; blackTimeMs: number; lastMoveAtMs: number; timeControlSecs: number }, 517 + nowMs: number 518 + ): { whiteTimeMs: number; blackTimeMs: number } { 519 + if (gameState.timeControlSecs === 0) { 520 + return { whiteTimeMs: gameState.whiteTimeMs, blackTimeMs: gameState.blackTimeMs }; 521 + } 522 + 523 + const elapsedMs = nowMs - gameState.lastMoveAtMs; 524 + let { whiteTimeMs, blackTimeMs } = gameState; 525 + 526 + if (gameState.turn === 'w') { 527 + whiteTimeMs = Math.max(0, whiteTimeMs - elapsedMs); 528 + } else { 529 + blackTimeMs = Math.max(0, blackTimeMs - elapsedMs); 530 + } 531 + 532 + return { whiteTimeMs, blackTimeMs }; 533 + } 534 + 535 + describe('Server reducer: clock deduction', () => { 536 + it('deducts elapsed time from the moving player on each move', () => { 537 + const game = { 538 + turn: 'w', 539 + whiteTimeMs: 60_000, 540 + blackTimeMs: 60_000, 541 + lastMoveAtMs: 1000, 542 + timeControlSecs: 60, 543 + }; 544 + 545 + // White takes 5 seconds to move 546 + const result = simulateClockDeduction(game, game.lastMoveAtMs + 5_000); 547 + expect(result.whiteTimeMs).toBe(55_000); 548 + expect(result.blackTimeMs).toBe(60_000); // black untouched 549 + }); 550 + 551 + it('deducts from black on black\'s turn', () => { 552 + const game = { 553 + turn: 'b', 554 + whiteTimeMs: 55_000, 555 + blackTimeMs: 60_000, 556 + lastMoveAtMs: 2000, 557 + timeControlSecs: 60, 558 + }; 559 + 560 + const result = simulateClockDeduction(game, game.lastMoveAtMs + 10_000); 561 + expect(result.blackTimeMs).toBe(50_000); 562 + expect(result.whiteTimeMs).toBe(55_000); 563 + }); 564 + 565 + it('floors at 0 — never goes negative', () => { 566 + const game = { 567 + turn: 'w', 568 + whiteTimeMs: 3_000, 569 + blackTimeMs: 60_000, 570 + lastMoveAtMs: 0, 571 + timeControlSecs: 60, 572 + }; 573 + 574 + // White has 3 s left but took 10 s 575 + const result = simulateClockDeduction(game, 10_000); 576 + expect(result.whiteTimeMs).toBe(0); 577 + }); 578 + 579 + it('is a no-op for untimed games', () => { 580 + const game = { 581 + turn: 'w', 582 + whiteTimeMs: 0, 583 + blackTimeMs: 0, 584 + lastMoveAtMs: 0, 585 + timeControlSecs: 0, 586 + }; 587 + 588 + const result = simulateClockDeduction(game, 99_999); 589 + expect(result.whiteTimeMs).toBe(0); 590 + expect(result.blackTimeMs).toBe(0); 591 + }); 592 + }); 593 + 594 + describe('Server reducer: claimTimeoutVictory', () => { 595 + it('grants white a win when black\'s clock expires', () => { 596 + const game = { 597 + status: 'active', 598 + turn: 'b', 599 + winner: '', 600 + timeControlSecs: 60, 601 + whiteTimeMs: 30_000, 602 + blackTimeMs: 5_000, 603 + lastMoveAtMs: 1000, 604 + }; 605 + 606 + // 6 seconds later — black only had 5 s 607 + const result = simulateClaimTimeout(game, true /* claimant = white */, game.lastMoveAtMs + 6_000); 608 + expect(result.status).toBe('timeout'); 609 + expect(result.winner).toBe('white'); 610 + }); 611 + 612 + it('grants black a win when white\'s clock expires', () => { 613 + const game = { 614 + status: 'active', 615 + turn: 'w', 616 + winner: '', 617 + timeControlSecs: 60, 618 + whiteTimeMs: 2_000, 619 + blackTimeMs: 30_000, 620 + lastMoveAtMs: 0, 621 + }; 622 + 623 + // 3 seconds later — white only had 2 s 624 + const result = simulateClaimTimeout(game, false /* claimant = black */, 3_000); 625 + expect(result.status).toBe('timeout'); 626 + expect(result.winner).toBe('black'); 627 + }); 628 + 629 + it('accepts claim at exact expiry (elapsedMs === opponentTimeMs)', () => { 630 + const game = { 631 + status: 'active', 632 + turn: 'b', 633 + winner: '', 634 + timeControlSecs: 60, 635 + whiteTimeMs: 30_000, 636 + blackTimeMs: 5_000, 637 + lastMoveAtMs: 0, 638 + }; 639 + 640 + // Exactly 5 000 ms elapsed — black's clock is exactly 0 641 + const result = simulateClaimTimeout(game, true, 5_000); 642 + expect(result.status).toBe('timeout'); 643 + expect(result.winner).toBe('white'); 644 + }); 645 + 646 + it('rejects claim when opponent still has time', () => { 647 + const game = { 648 + status: 'active', 649 + turn: 'b', 650 + winner: '', 651 + timeControlSecs: 60, 652 + whiteTimeMs: 30_000, 653 + blackTimeMs: 30_000, 654 + lastMoveAtMs: 0, 655 + }; 656 + 657 + // Only 5 s elapsed — black has 30 s remaining 658 + expect(() => 659 + simulateClaimTimeout(game, true /* white claims */, 5_000) 660 + ).toThrow('Opponent still has time remaining'); 661 + }); 662 + 663 + it('rejects claim against your own turn', () => { 664 + const game = { 665 + status: 'active', 666 + turn: 'w', // white's clock is running 667 + winner: '', 668 + timeControlSecs: 60, 669 + whiteTimeMs: 1_000, 670 + blackTimeMs: 30_000, 671 + lastMoveAtMs: 0, 672 + }; 673 + 674 + // White tries to claim their own timeout — server should reject 675 + expect(() => 676 + simulateClaimTimeout(game, true /* claimant = white, but it's white's turn */, 5_000) 677 + ).toThrow('Cannot claim your own timeout'); 678 + }); 679 + 680 + it('rejects claim on a non-active game', () => { 681 + const game = { 682 + status: 'checkmate', 683 + turn: 'w', 684 + winner: 'black', 685 + timeControlSecs: 60, 686 + whiteTimeMs: 0, 687 + blackTimeMs: 30_000, 688 + lastMoveAtMs: 0, 689 + }; 690 + 691 + expect(() => simulateClaimTimeout(game, false, 99_000)).toThrow('Game is not active'); 692 + }); 693 + 694 + it('rejects claim on an untimed game', () => { 695 + const game = { 696 + status: 'active', 697 + turn: 'b', 698 + winner: '', 699 + timeControlSecs: 0, 700 + whiteTimeMs: 0, 701 + blackTimeMs: 0, 702 + lastMoveAtMs: 0, 703 + }; 704 + 705 + expect(() => simulateClaimTimeout(game, true, 99_000)).toThrow('No time control'); 706 + }); 707 + }); 708 + 709 + describe('Server reducer: joinQueue time control validation', () => { 710 + it('accepts valid time controls', () => { 711 + const VALID = new Set([60, 180, 300, 600]); 712 + for (const secs of [60, 180, 300, 600]) { 713 + expect(VALID.has(secs)).toBe(true); 714 + } 715 + }); 716 + 717 + it('rejects invalid time controls', () => { 718 + const VALID = new Set([60, 180, 300, 600]); 719 + for (const secs of [0, 30, 120, 900, -1]) { 720 + expect(VALID.has(secs)).toBe(false); 721 + } 722 + }); 723 + });
+95
client/src/components/game/ClockDisplay.tsx
··· 1 + /** 2 + * ClockDisplay — shows the remaining time for one player. 3 + * 4 + * SpacetimeDB stores the remaining time as of the last move commit 5 + * (`baseTimeMs` + `lastMoveAtMicros`). For the player whose clock is 6 + * currently running we interpolate locally at 100 ms intervals so the 7 + * display ticks smoothly without a round-trip per frame. For the player 8 + * whose clock is paused we just display the stored value directly. 9 + */ 10 + 11 + import { useEffect, useState } from 'react'; 12 + 13 + /** Format milliseconds as M:SS (e.g. "3:07", "0:09"). 14 + * 15 + * Uses Math.floor so that 3200ms shows "0:03", not "0:04" — consistent 16 + * with standard chess clock display (show complete seconds remaining). 17 + */ 18 + export function formatClockMs(ms: number): string { 19 + if (ms <= 0) return '0:00'; 20 + const totalSeconds = Math.floor(ms / 1000); 21 + const minutes = Math.floor(totalSeconds / 60); 22 + const seconds = totalSeconds % 60; 23 + return `${minutes}:${seconds.toString().padStart(2, '0')}`; 24 + } 25 + 26 + interface ClockDisplayProps { 27 + /** Remaining time in ms as stored by SpacetimeDB (accurate as of lastMoveAtMicros) */ 28 + baseTimeMs: bigint; 29 + /** Timestamp of the last move commit, in microseconds since Unix epoch */ 30 + lastMoveAtMicros: bigint; 31 + /** Whether this player's clock is currently counting down */ 32 + isRunning: boolean; 33 + /** Whether the game is still in progress (clocks freeze on game end) */ 34 + isActive: boolean; 35 + } 36 + 37 + function computeDisplayMs( 38 + baseTimeMs: bigint, 39 + lastMoveAtMicros: bigint, 40 + isRunning: boolean, 41 + isActive: boolean 42 + ): number { 43 + if (!isRunning || !isActive) return Math.max(0, Number(baseTimeMs)); 44 + const lastMoveAtMs = Number(lastMoveAtMicros / 1000n); 45 + const elapsed = Date.now() - lastMoveAtMs; 46 + return Math.max(0, Number(baseTimeMs) - elapsed); 47 + } 48 + 49 + export function ClockDisplay({ 50 + baseTimeMs, 51 + lastMoveAtMicros, 52 + isRunning, 53 + isActive, 54 + }: ClockDisplayProps) { 55 + const [displayMs, setDisplayMs] = useState(() => 56 + computeDisplayMs(baseTimeMs, lastMoveAtMicros, isRunning, isActive) 57 + ); 58 + 59 + useEffect(() => { 60 + // Sync immediately whenever the SpacetimeDB values change 61 + setDisplayMs(computeDisplayMs(baseTimeMs, lastMoveAtMicros, isRunning, isActive)); 62 + 63 + if (!isRunning || !isActive) return; 64 + 65 + // Tick at 100 ms intervals while the clock is running 66 + const id = setInterval(() => { 67 + setDisplayMs(computeDisplayMs(baseTimeMs, lastMoveAtMicros, isRunning, isActive)); 68 + }, 100); 69 + 70 + return () => clearInterval(id); 71 + }, [baseTimeMs, lastMoveAtMicros, isRunning, isActive]); 72 + 73 + const isLow = displayMs < 30_000; // < 30 s — yellow warning 74 + const isCritical = displayMs < 10_000; // < 10 s — red pulse 75 + 76 + return ( 77 + <div 78 + className={[ 79 + 'font-mono font-bold tabular-nums text-2xl px-3 py-0.5 rounded transition-colors', 80 + !isActive 81 + ? 'text-neutral-600' 82 + : isCritical 83 + ? 'text-red-400 animate-pulse' 84 + : isLow 85 + ? 'text-yellow-400' 86 + : isRunning 87 + ? 'text-white' 88 + : 'text-neutral-400', 89 + ].join(' ')} 90 + aria-label={`${isRunning ? 'Running' : 'Paused'}: ${formatClockMs(displayMs)}`} 91 + > 92 + {formatClockMs(displayMs)} 93 + </div> 94 + ); 95 + }
+98 -13
client/src/components/game/GameScreen.tsx
··· 2 2 * GameScreen — the main game view. 3 3 * 4 4 * Layout: 5 - * - Opponent player bar (top) 5 + * - Opponent player bar (top) with clock 6 6 * - Chess board (center) 7 - * - Your player bar (bottom) 7 + * - Your player bar (bottom) with clock 8 8 * - Move list sidebar (right, collapses below on mobile) 9 9 * - Game status overlay (when game ends) 10 - * - Resign button 10 + * - Resign button (multiplayer only) 11 11 * 12 12 * In solo mode (both sides are the same player), the board always shows 13 - * white's perspective and both sides are always movable. 13 + * white's perspective and both sides are always movable. Solo games are 14 + * always untimed. 15 + * 16 + * For timed games: each player's remaining time is displayed in their bar. 17 + * When the opponent's clock reaches zero, this client calls claimTimeoutVictory 18 + * to end the game. The server independently validates the elapsed time. 14 19 */ 15 20 16 - import { useCallback, useMemo, useState } from 'react'; 21 + import { useCallback, useEffect, useRef, useMemo, useState } from 'react'; 17 22 import { useAuth } from '../../hooks/useAuth'; 18 23 import { useGame } from '../../hooks/useGame'; 19 24 import { ChessBoard } from './ChessBoard'; 25 + import { ClockDisplay } from './ClockDisplay'; 20 26 import { MoveList } from './MoveList'; 21 27 import { PlayerBar } from './PlayerBar'; 22 28 import { GameStatus } from './GameStatus'; ··· 28 34 29 35 export function GameScreen({ onDismiss }: GameScreenProps) { 30 36 const { displayName, avatarUrl, handle } = useAuth(); 31 - const { activeGame, moves, players, makeMove, resignGame } = useGame(); 37 + const { activeGame, moves, players, makeMove, resignGame, claimTimeoutVictory } = useGame(); 32 38 const [showResignConfirm, setShowResignConfirm] = useState(false); 33 39 40 + // Tracks the game ID for which we have already sent a claimTimeoutVictory 41 + // call, so we don't spam the server while waiting for confirmation. 42 + const claimSentForGameRef = useRef<bigint | null>(null); 43 + 34 44 // Shouldn't happen — App only renders GameScreen when activeGame exists 35 45 if (!activeGame) return null; 36 46 37 - const { isSolo } = activeGame; 47 + const { isSolo, timeControlSecs } = activeGame; 48 + const isTimed = timeControlSecs > 0; 38 49 39 50 // In solo mode, orient as white and always allow moves. 40 51 // In multiplayer, orient to the player's assigned color. ··· 90 101 setShowResignConfirm(false); 91 102 }, [resignGame, activeGame.id]); 92 103 93 - const handleBackToLobby = useCallback(() => { 94 - onDismiss(); 95 - }, [onDismiss]); 104 + // --------------------------------------------------------------------------- 105 + // Timeout claiming 106 + // 107 + // When the clock currently running (the active-turn player's clock) reaches 108 + // zero, the OTHER player wins and should claim the timeout. We set a 109 + // setTimeout to fire exactly when the opponent's remaining time expires and 110 + // call claimTimeoutVictory then. The server validates independently. 111 + // 112 + // We gate the call on claimSentForGameRef so that if the effect re-runs 113 + // before the server confirms (which changes game.status), we don't spam 114 + // duplicate reducer calls. 115 + // 116 + // Solo games are always untimed, so this effect is a no-op for them. 117 + // --------------------------------------------------------------------------- 118 + useEffect(() => { 119 + if (!activeGame || activeGame.status !== 'active') return; 120 + if (isSolo || !isTimed) return; 121 + 122 + // The clock running belongs to the player whose turn it is 123 + const runningClockIsWhite = activeGame.turn === 'w'; 124 + const runningTimeMs = runningClockIsWhite ? activeGame.whiteTimeMs : activeGame.blackTimeMs; 125 + 126 + // Only the player NOT currently on the clock claims the timeout 127 + // (i.e., the one whose opponent's time just ran out) 128 + const weAreTheClaimant = activeGame.isWhite !== runningClockIsWhite; 129 + 130 + const lastMoveAtMs = Number(activeGame.lastMoveAtMicros / 1000n); 131 + const elapsed = Date.now() - lastMoveAtMs; 132 + const remaining = Math.max(0, Number(runningTimeMs) - elapsed); 133 + 134 + if (remaining === 0 && weAreTheClaimant) { 135 + // Already expired when this effect ran — claim immediately (once per game) 136 + if (claimSentForGameRef.current !== activeGame.id) { 137 + claimSentForGameRef.current = activeGame.id; 138 + claimTimeoutVictory({ gameId: activeGame.id }); 139 + } 140 + return; 141 + } 142 + 143 + if (!weAreTheClaimant) return; // Not our job this turn 144 + 145 + const timerId = setTimeout(() => { 146 + if (claimSentForGameRef.current !== activeGame.id) { 147 + claimSentForGameRef.current = activeGame.id; 148 + claimTimeoutVictory({ gameId: activeGame.id }); 149 + } 150 + }, remaining); 151 + 152 + return () => clearTimeout(timerId); 153 + }, [ 154 + activeGame, 155 + isSolo, 156 + isTimed, 157 + claimTimeoutVictory, 158 + ]); 159 + 160 + // Build clock nodes for the player bars (only for timed games) 161 + const topClock = isTimed ? ( 162 + <ClockDisplay 163 + baseTimeMs={topColor === 'white' ? activeGame.whiteTimeMs : activeGame.blackTimeMs} 164 + lastMoveAtMicros={activeGame.lastMoveAtMicros} 165 + isRunning={topIsActive} 166 + isActive={activeGame.status === 'active'} 167 + /> 168 + ) : undefined; 169 + 170 + const bottomClock = isTimed ? ( 171 + <ClockDisplay 172 + baseTimeMs={bottomColor === 'white' ? activeGame.whiteTimeMs : activeGame.blackTimeMs} 173 + lastMoveAtMicros={activeGame.lastMoveAtMicros} 174 + isRunning={bottomIsActive} 175 + isActive={activeGame.status === 'active'} 176 + /> 177 + ) : undefined; 96 178 97 179 return ( 98 180 <div className="flex flex-1 flex-col lg:flex-row items-center justify-center gap-4 p-4"> ··· 105 187 avatarUrl={isSolo ? undefined : opponentAvatar} 106 188 isActive={topIsActive} 107 189 color={topColor} 190 + clock={topClock} 108 191 /> 109 192 110 193 {/* Board */} ··· 123 206 status={activeGame.status} 124 207 winner={activeGame.winner} 125 208 isWhite={activeGame.isWhite} 126 - onBackToLobby={handleBackToLobby} 209 + isSolo={isSolo} 210 + onBackToLobby={onDismiss} 127 211 /> 128 212 </div> 129 213 ··· 134 218 avatarUrl={isSolo ? undefined : (avatarUrl ?? undefined)} 135 219 isActive={bottomIsActive} 136 220 color={bottomColor} 221 + clock={bottomClock} 137 222 /> 138 223 139 - {/* Resign button */} 140 - {activeGame.status === 'active' && ( 224 + {/* Resign button — multiplayer only; resigning against yourself makes no sense */} 225 + {activeGame.status === 'active' && !isSolo && ( 141 226 <div className="flex justify-center mt-2"> 142 227 {showResignConfirm ? ( 143 228 <div className="flex items-center gap-2">
+64 -34
client/src/components/game/GameStatus.tsx
··· 1 1 /** 2 2 * GameStatus — displays the game outcome when it ends. 3 3 * 4 - * Shows a modal overlay for checkmate, stalemate, draw, or resignation. 4 + * Shows a modal overlay for checkmate, stalemate, draw, resignation, or timeout. 5 + * In solo mode, win/loss framing is replaced with a neutral result message. 5 6 */ 6 7 7 8 import type { GameStatus as GameStatusType } from '../../hooks/useGame'; ··· 10 11 status: GameStatusType; 11 12 winner: string; 12 13 isWhite: boolean; 14 + isSolo: boolean; 13 15 onBackToLobby: () => void; 14 16 } 15 17 16 - export function GameStatus({ status, winner, isWhite, onBackToLobby }: GameStatusProps) { 18 + export function GameStatus({ status, winner, isWhite, isSolo, onBackToLobby }: GameStatusProps) { 17 19 if (status === 'active') return null; 18 20 19 21 const myColor = isWhite ? 'white' : 'black'; 20 - const iWon = winner === myColor; 22 + const iWon = !isSolo && winner === myColor; 21 23 const isDraw = winner === 'draw'; 22 24 23 25 let title: string; 24 26 let subtitle: string; 27 + let emoji: string; 25 28 26 - switch (status) { 27 - case 'checkmate': 28 - title = iWon ? 'You win!' : 'Checkmate'; 29 - subtitle = iWon 30 - ? 'Checkmate — well played!' 31 - : 'Your king has been checkmated.'; 32 - break; 33 - case 'stalemate': 34 - title = 'Stalemate'; 35 - subtitle = 'No legal moves — the game is a draw.'; 36 - break; 37 - case 'draw': 38 - title = 'Draw'; 39 - subtitle = 'The game ended in a draw.'; 40 - break; 41 - case 'resigned': 42 - title = iWon ? 'Opponent resigned' : 'You resigned'; 43 - subtitle = iWon 44 - ? 'Your opponent has resigned. You win!' 45 - : 'You have resigned the game.'; 46 - break; 47 - case 'abandoned': 48 - title = 'Game abandoned'; 49 - subtitle = 'Your opponent disconnected.'; 50 - break; 51 - default: 52 - title = 'Game over'; 53 - subtitle = ''; 29 + if (isSolo) { 30 + // No winner/loser framing for solo games — just show the result. 31 + emoji = isDraw ? '🤝' : '♟'; 32 + switch (status) { 33 + case 'checkmate': 34 + title = 'Checkmate'; 35 + subtitle = `${winner === 'white' ? 'White' : 'Black'} wins.`; 36 + break; 37 + case 'stalemate': 38 + title = 'Stalemate'; 39 + subtitle = 'No legal moves — the game is a draw.'; 40 + break; 41 + case 'draw': 42 + title = 'Draw'; 43 + subtitle = 'The game ended in a draw.'; 44 + break; 45 + default: 46 + title = 'Game over'; 47 + subtitle = ''; 48 + } 49 + } else { 50 + emoji = iWon ? '🏆' : isDraw ? '🤝' : status === 'timeout' ? '⏰' : '💀'; 51 + switch (status) { 52 + case 'checkmate': 53 + title = iWon ? 'You win!' : 'Checkmate'; 54 + subtitle = iWon 55 + ? 'Checkmate — well played!' 56 + : 'Your king has been checkmated.'; 57 + break; 58 + case 'stalemate': 59 + title = 'Stalemate'; 60 + subtitle = 'No legal moves — the game is a draw.'; 61 + break; 62 + case 'draw': 63 + title = 'Draw'; 64 + subtitle = 'The game ended in a draw.'; 65 + break; 66 + case 'resigned': 67 + title = iWon ? 'Opponent resigned' : 'You resigned'; 68 + subtitle = iWon 69 + ? 'Your opponent has resigned. You win!' 70 + : 'You have resigned the game.'; 71 + break; 72 + case 'timeout': 73 + title = iWon ? 'You win on time!' : 'Time\'s up'; 74 + subtitle = iWon 75 + ? 'Your opponent ran out of time.' 76 + : 'You ran out of time.'; 77 + break; 78 + case 'abandoned': 79 + title = 'Game abandoned'; 80 + subtitle = 'Your opponent disconnected.'; 81 + break; 82 + default: 83 + title = 'Game over'; 84 + subtitle = ''; 85 + } 54 86 } 55 87 56 88 return ( 57 89 <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 backdrop-blur-sm"> 58 90 <div className="mx-4 w-full max-w-xs rounded-xl bg-neutral-900 border border-neutral-700 p-6 text-center shadow-2xl"> 59 - <div className="text-4xl mb-3"> 60 - {iWon ? '🏆' : isDraw ? '🤝' : '💀'} 61 - </div> 91 + <div className="text-4xl mb-3">{emoji}</div> 62 92 <h2 className="text-xl font-bold text-white">{title}</h2> 63 93 <p className="mt-1 text-sm text-neutral-400">{subtitle}</p> 64 94 <button
+10 -1
client/src/components/game/PlayerBar.tsx
··· 1 1 /** 2 2 * PlayerBar — displays player info above/below the chess board. 3 3 * 4 - * Shows avatar, display name, handle, and a turn indicator. 4 + * Shows avatar, display name, handle, a turn indicator, and (for timed 5 + * games) the player's remaining clock time. 5 6 */ 6 7 8 + import type { ReactNode } from 'react'; 9 + 7 10 interface PlayerBarProps { 8 11 displayName: string; 9 12 handle?: string; 10 13 avatarUrl?: string; 11 14 isActive: boolean; // Whether it's this player's turn 12 15 color: 'white' | 'black'; 16 + /** Optional clock node rendered on the right side of the bar */ 17 + clock?: ReactNode; 13 18 } 14 19 15 20 export function PlayerBar({ ··· 18 23 avatarUrl, 19 24 isActive, 20 25 color, 26 + clock, 21 27 }: PlayerBarProps) { 22 28 return ( 23 29 <div ··· 54 60 <div className="truncate text-xs text-neutral-500">@{handle}</div> 55 61 )} 56 62 </div> 63 + 64 + {/* Clock (timed games) */} 65 + {clock} 57 66 58 67 {/* Turn indicator */} 59 68 {isActive && (
+55 -1
client/src/components/game/__tests__/GameStatus.test.tsx
··· 6 6 import { render, screen, fireEvent } from '@testing-library/react'; 7 7 import { GameStatus } from '../GameStatus'; 8 8 9 - describe('GameStatus', () => { 9 + describe('GameStatus — multiplayer', () => { 10 10 it('returns null for active games', () => { 11 11 const { container } = render( 12 12 <GameStatus 13 13 status="active" 14 14 winner="" 15 15 isWhite={true} 16 + isSolo={false} 16 17 onBackToLobby={vi.fn()} 17 18 /> 18 19 ); ··· 25 26 status="checkmate" 26 27 winner="white" 27 28 isWhite={true} 29 + isSolo={false} 28 30 onBackToLobby={vi.fn()} 29 31 /> 30 32 ); ··· 38 40 status="checkmate" 39 41 winner="white" 40 42 isWhite={false} 43 + isSolo={false} 41 44 onBackToLobby={vi.fn()} 42 45 /> 43 46 ); ··· 51 54 status="stalemate" 52 55 winner="draw" 53 56 isWhite={true} 57 + isSolo={false} 54 58 onBackToLobby={vi.fn()} 55 59 /> 56 60 ); ··· 63 67 status="draw" 64 68 winner="draw" 65 69 isWhite={true} 70 + isSolo={false} 66 71 onBackToLobby={vi.fn()} 67 72 /> 68 73 ); ··· 75 80 status="resigned" 76 81 winner="white" 77 82 isWhite={true} 83 + isSolo={false} 78 84 onBackToLobby={vi.fn()} 79 85 /> 80 86 ); ··· 87 93 status="resigned" 88 94 winner="white" 89 95 isWhite={false} 96 + isSolo={false} 90 97 onBackToLobby={vi.fn()} 91 98 /> 92 99 ); ··· 100 107 status="checkmate" 101 108 winner="white" 102 109 isWhite={true} 110 + isSolo={false} 103 111 onBackToLobby={onBack} 104 112 /> 105 113 ); ··· 107 115 expect(onBack).toHaveBeenCalledOnce(); 108 116 }); 109 117 }); 118 + 119 + describe('GameStatus — solo mode', () => { 120 + it('shows neutral checkmate message with color winner, not you win/lose', () => { 121 + render( 122 + <GameStatus 123 + status="checkmate" 124 + winner="white" 125 + isWhite={true} 126 + isSolo={true} 127 + onBackToLobby={vi.fn()} 128 + /> 129 + ); 130 + expect(screen.getByText('Checkmate')).toBeInTheDocument(); 131 + expect(screen.getByText(/White wins/i)).toBeInTheDocument(); 132 + // Should NOT show "You win!" since it's a solo game 133 + expect(screen.queryByText('You win!')).toBeNull(); 134 + }); 135 + 136 + it('shows neutral checkmate message when black wins in solo', () => { 137 + render( 138 + <GameStatus 139 + status="checkmate" 140 + winner="black" 141 + isWhite={true} 142 + isSolo={true} 143 + onBackToLobby={vi.fn()} 144 + /> 145 + ); 146 + expect(screen.getByText('Checkmate')).toBeInTheDocument(); 147 + expect(screen.getByText(/Black wins/i)).toBeInTheDocument(); 148 + expect(screen.queryByText('Checkmate — well played!')).toBeNull(); 149 + }); 150 + 151 + it('shows stalemate in solo without win/lose framing', () => { 152 + render( 153 + <GameStatus 154 + status="stalemate" 155 + winner="draw" 156 + isWhite={true} 157 + isSolo={true} 158 + onBackToLobby={vi.fn()} 159 + /> 160 + ); 161 + expect(screen.getByText('Stalemate')).toBeInTheDocument(); 162 + }); 163 + });
+38 -16
client/src/components/lobby/LobbyScreen.tsx
··· 1 1 /** 2 2 * LobbyScreen — matchmaking interface. 3 3 * 4 - * Shows a "Find Game" button for real matchmaking and a "Play vs Self" 5 - * button for solo play. While searching, displays an animated indicator 6 - * with a cancel option. Auto-transitions to GameScreen when a match is 7 - * found (handled by the parent App routing via activeGame state). 4 + * Shows four time-control buttons (1', 3', 5', 10') for real matchmaking, 5 + * each with its own queue pool, and a "Play vs Self" button for untimed solo 6 + * play. While searching, displays an animated indicator with the selected 7 + * time control and a cancel option. 8 + * 9 + * Auto-transitions to GameScreen when a match is found (handled by the 10 + * parent App routing via activeGame state). 8 11 */ 9 12 10 13 import { useGame } from '../../hooks/useGame'; 11 14 15 + /** Available time controls in seconds per side */ 16 + const TIME_CONTROLS = [ 17 + { secs: 60, label: '1 min', sublabel: 'Bullet' }, 18 + { secs: 180, label: '3 min', sublabel: 'Blitz' }, 19 + { secs: 300, label: '5 min', sublabel: 'Blitz' }, 20 + { secs: 600, label: '10 min', sublabel: 'Rapid' }, 21 + ] as const; 22 + 23 + function formatTimeControl(secs: number): string { 24 + return `${secs / 60} min`; 25 + } 26 + 12 27 export function LobbyScreen() { 13 - const { connected, isQueued, joinQueue, leaveQueue, createSoloGame } = useGame(); 28 + const { connected, isQueued, queuedTimeControlSecs, joinQueue, leaveQueue, createSoloGame } = useGame(); 14 29 15 - if (isQueued) { 30 + if (isQueued && queuedTimeControlSecs !== null) { 16 31 return ( 17 32 <div className="flex flex-1 flex-col items-center justify-center gap-8"> 18 33 {/* Animated searching indicator */} ··· 26 41 Searching for opponent... 27 42 </p> 28 43 <p className="mt-1 text-sm text-neutral-400"> 29 - Waiting for another player to join 44 + {formatTimeControl(queuedTimeControlSecs)} per side 30 45 </p> 31 46 </div> 32 47 ··· 46 61 <div className="text-6xl mb-4">♟</div> 47 62 <h2 className="text-2xl font-bold text-white">Ready to play?</h2> 48 63 <p className="mt-2 text-neutral-400"> 49 - Find an opponent or play both sides yourself 64 + Choose a time control to find an opponent 50 65 </p> 51 66 </div> 52 67 53 - <div className="flex flex-col gap-3"> 54 - <button 55 - onClick={joinQueue} 56 - disabled={!connected} 57 - 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" 58 - > 59 - Find Game 60 - </button> 68 + {/* Time control grid */} 69 + <div className="flex flex-col gap-3 w-full max-w-xs"> 70 + <div className="grid grid-cols-2 gap-3"> 71 + {TIME_CONTROLS.map(({ secs, label, sublabel }) => ( 72 + <button 73 + key={secs} 74 + onClick={() => joinQueue({ timeControlSecs: secs })} 75 + disabled={!connected} 76 + 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" 77 + > 78 + <span className="text-lg">{label}</span> 79 + <span className="text-xs text-violet-300">{sublabel}</span> 80 + </button> 81 + ))} 82 + </div> 61 83 62 84 <button 63 85 onClick={createSoloGame}
+30 -7
client/src/hooks/useGame.ts
··· 16 16 | 'stalemate' 17 17 | 'draw' 18 18 | 'resigned' 19 - | 'abandoned'; 19 + | 'abandoned' 20 + | 'timeout'; 20 21 21 22 export interface GameState { 22 23 /** SpacetimeDB connection state */ ··· 26 27 27 28 /** Whether the player is currently in the matchmaking queue */ 28 29 isQueued: boolean; 30 + /** Time control (secs) the player is currently queued for, or null if not queued */ 31 + queuedTimeControlSecs: number | null; 29 32 30 33 /** The active game, if any */ 31 34 activeGame: { ··· 38 41 isWhite: boolean; 39 42 isSolo: boolean; 40 43 opponentIdentityHex: string; 44 + /** 0 = untimed (solo); 60/180/300/600 = seconds per side */ 45 + timeControlSecs: number; 46 + /** Remaining ms for white as of lastMoveAtMicros */ 47 + whiteTimeMs: bigint; 48 + /** Remaining ms for black as of lastMoveAtMicros */ 49 + blackTimeMs: bigint; 50 + /** When the last move was committed (microseconds since Unix epoch) — used 51 + * by the clock display to interpolate the currently-running player's time */ 52 + lastMoveAtMicros: bigint; 41 53 } | null; 42 54 43 55 /** Moves for the active game, sorted by move number */ ··· 61 73 >; 62 74 63 75 /** Reducer calls */ 64 - joinQueue: () => void; 76 + joinQueue: (params: { timeControlSecs: number }) => void; 65 77 leaveQueue: () => void; 66 78 createSoloGame: () => void; 67 79 makeMove: (params: { gameId: bigint; from: string; to: string; promotion: string }) => void; 68 80 resignGame: (params: { gameId: bigint }) => void; 81 + claimTimeoutVictory: (params: { gameId: bigint }) => void; 69 82 registerPlayer: (params: { 70 83 did: string; 71 84 handle: string; ··· 92 105 const resignReducer = useReducer(reducers.resign); 93 106 const registerPlayerReducer = useReducer(reducers.registerPlayer); 94 107 const createSoloGameReducer = useReducer(reducers.createSoloGame); 108 + const claimTimeoutVictoryReducer = useReducer(reducers.claimTimeoutVictory); 95 109 96 110 // Derive state from the raw table data 97 - const isQueued = useMemo(() => { 98 - if (!identityHex) return false; 99 - return queueEntries.some((e) => e.identity.toHexString() === identityHex); 111 + const queuedTimeControlSecs = useMemo(() => { 112 + if (!identityHex) return null; 113 + const entry = queueEntries.find((e) => e.identity.toHexString() === identityHex); 114 + return entry ? entry.timeControlSecs : null; 100 115 }, [queueEntries, identityHex]); 116 + 117 + const isQueued = queuedTimeControlSecs !== null; 101 118 102 119 const activeGame = useMemo(() => { 103 120 if (!identityHex) return null; 104 121 105 122 // Find the most recent game this player is in. Prefer 'active' games, 106 123 // but also return recently-ended games so the GameStatus overlay can 107 - // render (checkmate/resign/draw screen). Without this, ending a game 108 - // would instantly route back to the lobby with no outcome shown. 124 + // render (checkmate/resign/draw/timeout screen). Without this, ending a 125 + // game would instantly route back to the lobby with no outcome shown. 109 126 let best: (typeof allGames)[number] | null = null; 110 127 for (const g of allGames) { 111 128 const isPlayer = ··· 142 159 isWhite, 143 160 isSolo, 144 161 opponentIdentityHex, 162 + timeControlSecs: best.timeControlSecs, 163 + whiteTimeMs: best.whiteTimeMs, 164 + blackTimeMs: best.blackTimeMs, 165 + lastMoveAtMicros: best.lastMoveAt.microsSinceUnixEpoch, 145 166 }; 146 167 }, [allGames, identityHex]); 147 168 ··· 176 197 connected, 177 198 identityHex, 178 199 isQueued, 200 + queuedTimeControlSecs, 179 201 activeGame, 180 202 moves, 181 203 players, ··· 184 206 createSoloGame: createSoloGameReducer, 185 207 makeMove: makeMoveReducer, 186 208 resignGame: resignReducer, 209 + claimTimeoutVictory: claimTimeoutVictoryReducer, 187 210 registerPlayer: registerPlayerReducer, 188 211 }; 189 212 }
+79 -16
client/src/hooks/useGamePublisher.ts
··· 5 5 * terminal state. When detected, builds a PGN and publishes a 6 6 * `social.checkmate.game` record to the player's PDS. 7 7 * 8 - * Publishes at most once per game by tracking published game IDs. 9 - * Solo games (playing yourself) are skipped. 8 + * Publishes at most once per game — IDs are persisted to localStorage so 9 + * games are not re-published across page refreshes. Solo games are skipped. 10 + * 11 + * Accepts identityHex and players as props (passed from App via useGame) 12 + * so this hook does not call useGame() itself, avoiding a duplicate 13 + * subscription to the same SpacetimeDB tables. 10 14 */ 11 15 12 16 import { useEffect, useRef } from 'react'; 13 17 import { useTable } from 'spacetimedb/react'; 14 18 import { tables } from '../module_bindings'; 15 19 import { useAuth } from './useAuth'; 16 - import { useGame } from './useGame'; 17 - import { buildPgn, gameResultToPgn } from '../lib/pgn'; 20 + import { buildPgn, gameResultToPgn, gameTerminationToPgn } from '../lib/pgn'; 18 21 import { publishGameRecord } from '../lib/atproto-publish'; 19 22 20 - const TERMINAL_STATUSES = new Set(['checkmate', 'stalemate', 'draw', 'resigned']); 23 + const TERMINAL_STATUSES = new Set(['checkmate', 'stalemate', 'draw', 'resigned', 'timeout']); 24 + 25 + // --------------------------------------------------------------------------- 26 + // localStorage helpers — persist published game IDs across page refreshes 27 + // --------------------------------------------------------------------------- 28 + 29 + function storageKey(identityHex: string): string { 30 + return `checkmate:published:${identityHex}`; 31 + } 32 + 33 + function loadPublishedIds(identityHex: string): Set<string> { 34 + try { 35 + const raw = localStorage.getItem(storageKey(identityHex)); 36 + if (!raw) return new Set(); 37 + const parsed = JSON.parse(raw); 38 + if (Array.isArray(parsed)) return new Set(parsed as string[]); 39 + return new Set(); 40 + } catch { 41 + return new Set(); 42 + } 43 + } 44 + 45 + function savePublishedId(identityHex: string, gameIdStr: string): void { 46 + try { 47 + const existing = loadPublishedIds(identityHex); 48 + existing.add(gameIdStr); 49 + localStorage.setItem(storageKey(identityHex), JSON.stringify([...existing])); 50 + } catch { 51 + // localStorage may be unavailable (private browsing quota); skip silently. 52 + } 53 + } 54 + 55 + // --------------------------------------------------------------------------- 56 + 57 + interface GamePublisherProps { 58 + identityHex: string | null; 59 + players: Map<string, { did: string; handle: string; displayName: string; avatarUrl: string }>; 60 + } 21 61 22 - export function useGamePublisher() { 62 + export function useGamePublisher({ identityHex, players }: GamePublisherProps) { 23 63 const { session, did } = useAuth(); 24 - const { identityHex, players } = useGame(); 25 64 const [allGames] = useTable(tables.game); 26 65 const [allMoves] = useTable(tables.gameMove); 66 + 67 + // In-memory set to avoid redundant localStorage reads on every render. 68 + // Seeded from localStorage when identityHex is first known. 27 69 const publishedGameIds = useRef(new Set<string>()); 70 + const seededForIdentity = useRef<string | null>(null); 28 71 29 72 useEffect(() => { 30 73 if (!session || !did || !identityHex) return; 31 74 75 + // Seed from localStorage once per identity 76 + if (seededForIdentity.current !== identityHex) { 77 + publishedGameIds.current = loadPublishedIds(identityHex); 78 + seededForIdentity.current = identityHex; 79 + } 80 + 32 81 for (const game of allGames) { 33 82 if (!TERMINAL_STATUSES.has(game.status)) continue; 34 83 ··· 45 94 // Solo games — skip publishing 46 95 if (whiteHex === blackHex) { 47 96 publishedGameIds.current.add(gameIdStr); 97 + savePublishedId(identityHex, gameIdStr); 48 98 continue; 49 99 } 50 100 ··· 59 109 // we'd skip this game permanently. 60 110 if (gameMoves.length === 0) continue; 61 111 62 - // Resolve player info — wait for both players to be loaded so we 112 + // Resolve player info — wait for BOTH players to be loaded so we 63 113 // don't publish with placeholder data. 64 114 const whitePlayer = players.get(whiteHex); 65 115 const blackPlayer = players.get(blackHex); 66 - const opponentHex = isWhite ? blackHex : whiteHex; 67 - const opponentPlayer = players.get(opponentHex); 116 + if (!whitePlayer || !blackPlayer) continue; // retry next cycle 68 117 69 - if (!opponentPlayer) continue; // Player data still loading — retry next cycle 70 - 71 - // Mark as published only after we've confirmed all data is available. 118 + // Mark as published (in memory + localStorage) before the async call 119 + // so concurrent renders don't double-publish. Remove on failure to 120 + // allow retry. 72 121 publishedGameIds.current.add(gameIdStr); 122 + savePublishedId(identityHex, gameIdStr); 73 123 74 124 const result = gameResultToPgn(game.status, game.winner); 125 + // gameResultToPgn returns '*' only for active games; we filtered those above. 126 + if (result === '*') continue; 127 + 128 + const termination = game.timeControlSecs > 0 129 + ? gameTerminationToPgn(game.status) 130 + : undefined; 75 131 76 132 const pgn = buildPgn({ 77 - whiteHandle: whitePlayer?.handle ?? 'Unknown', 78 - blackHandle: blackPlayer?.handle ?? 'Unknown', 133 + whiteHandle: whitePlayer.handle, 134 + blackHandle: blackPlayer.handle, 79 135 result, 80 136 date: new Date(Number(game.createdAt.microsSinceUnixEpoch / 1000n)), 81 137 moves: gameMoves, 138 + timeControlSecs: game.timeControlSecs > 0 ? game.timeControlSecs : undefined, 139 + termination, 82 140 }); 141 + 142 + const opponentDid = isWhite ? blackPlayer.did : whitePlayer.did; 83 143 84 144 publishGameRecord({ 85 145 session, 86 146 pgn, 87 147 result, 88 148 color: isWhite ? 'white' : 'black', 89 - opponentDid: opponentPlayer.did, 149 + opponentDid, 90 150 }) 91 151 .then(({ uri }) => { 92 152 console.log(`[publish] Game ${gameIdStr} published to atproto: ${uri}`); ··· 95 155 console.error(`[publish] Failed to publish game ${gameIdStr}:`, err); 96 156 // Allow retry on next render cycle 97 157 publishedGameIds.current.delete(gameIdStr); 158 + // Note: we don't remove from localStorage — a partial publish 159 + // (where the record was created but the response was lost) is 160 + // preferable to a guaranteed duplicate. 98 161 }); 99 162 } 100 163 }, [allGames, allMoves, session, did, identityHex, players]);
+2 -1
client/src/lib/atproto-publish.ts
··· 22 22 export interface PublishGameParams { 23 23 session: OAuthSession; 24 24 pgn: string; 25 - result: '1-0' | '0-1' | '1/2-1/2' | '*'; 25 + // Only terminal results — '*' (in-progress) should never be published. 26 + result: '1-0' | '0-1' | '1/2-1/2'; 26 27 color: 'white' | 'black'; 27 28 opponentDid: string; 28 29 }
+68 -7
client/src/lib/pgn.ts
··· 14 14 result: '1-0' | '0-1' | '1/2-1/2' | '*'; 15 15 date: Date; 16 16 moves: Array<{ san: string }>; 17 + /** Seconds per side (0 or undefined = no time control). Adds [TimeControl] tag. */ 18 + timeControlSecs?: number; 19 + /** 20 + * PGN Termination value, included as a supplemental tag when provided. 21 + * Examples: "normal", "time forfeit", "abandoned" 22 + */ 23 + termination?: string; 17 24 } 18 25 19 26 /** ··· 31 38 } 32 39 33 40 /** 41 + * Derive the PGN Termination tag value from a game status. 42 + * Returns undefined for untimed games or when there's no meaningful value. 43 + */ 44 + export function gameTerminationToPgn(status: string): string | undefined { 45 + switch (status) { 46 + case 'checkmate': 47 + case 'stalemate': 48 + case 'draw': 49 + return 'normal'; 50 + case 'resigned': 51 + // Resignation is a normal, deliberate conclusion — not an abandonment. 52 + // 'abandoned' in PGN means a game that was left unfinished by both players. 53 + return 'normal'; 54 + case 'timeout': 55 + return 'time forfeit'; 56 + default: 57 + return undefined; 58 + } 59 + } 60 + 61 + /** 34 62 * Escape a string for use inside a PGN tag value. 35 63 * PGN spec says tag values are delimited by double quotes and 36 - * backslashes/quotes within must be escaped. 64 + * backslashes/quotes within must be escaped. Control characters 65 + * (newlines, tabs, etc.) would break single-line tag syntax and 66 + * could be used for PGN injection, so we strip them first. 37 67 */ 38 68 function escapePgnTagValue(value: string): string { 39 - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 69 + // Strip ASCII control characters (0x00–0x1f and 0x7f) 70 + const sanitized = value.replace(/[\x00-\x1f\x7f]/g, ''); 71 + return sanitized.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 40 72 } 41 73 42 74 /** 43 75 * Build a complete PGN string from game data. 44 76 */ 45 77 export function buildPgn(data: PgnGameData): string { 46 - const { whiteHandle, blackHandle, result, date, moves } = data; 78 + const { whiteHandle, blackHandle, result, date, moves, timeControlSecs, termination } = data; 47 79 48 80 // Format date as YYYY.MM.DD per PGN spec (UTC to avoid timezone drift) 49 81 const year = date.getUTCFullYear(); ··· 54 86 // Seven Tag Roster (STR) — the required PGN headers. 55 87 // Player names are escaped to prevent PGN header injection via 56 88 // crafted Bluesky display names. 57 - const headers = [ 89 + const strTags = [ 58 90 `[Event "Checkmate Online"]`, 59 91 `[Site "checkmate.social"]`, 60 92 `[Date "${dateStr}"]`, ··· 62 94 `[White "${escapePgnTagValue(whiteHandle)}"]`, 63 95 `[Black "${escapePgnTagValue(blackHandle)}"]`, 64 96 `[Result "${result}"]`, 65 - ].join('\n'); 97 + ]; 98 + 99 + // Supplemental tags — added after STR when present 100 + const supplementalTags: string[] = []; 101 + 102 + if (timeControlSecs && timeControlSecs > 0) { 103 + // PGN TimeControl format: just the seconds per side (no increment) 104 + supplementalTags.push(`[TimeControl "${timeControlSecs}"]`); 105 + } 106 + 107 + if (termination) { 108 + supplementalTags.push(`[Termination "${escapePgnTagValue(termination)}"]`); 109 + } 110 + 111 + const allTags = [...strTags, ...supplementalTags]; 66 112 67 113 // Build movetext: "1. e4 e5 2. Nf3 Nc6 ..." 114 + // PGN spec requires lines ≤ 80 characters — wrap at word boundaries. 68 115 const parts: string[] = []; 69 116 for (let i = 0; i < moves.length; i++) { 70 117 if (i % 2 === 0) { ··· 73 120 parts.push(moves[i].san); 74 121 } 75 122 parts.push(result); 76 - const movetext = parts.join(' '); 77 123 78 - return `${headers}\n\n${movetext}\n`; 124 + const movetextLines: string[] = []; 125 + let currentLine = ''; 126 + for (const token of parts) { 127 + if (currentLine.length === 0) { 128 + currentLine = token; 129 + } else if (currentLine.length + 1 + token.length <= 80) { 130 + currentLine += ' ' + token; 131 + } else { 132 + movetextLines.push(currentLine); 133 + currentLine = token; 134 + } 135 + } 136 + if (currentLine) movetextLines.push(currentLine); 137 + const movetext = movetextLines.join('\n'); 138 + 139 + return `${allTags.join('\n')}\n\n${movetext}\n`; 79 140 }
+191 -63
server/src/index.ts
··· 17 17 // Starting position FEN 18 18 const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; 19 19 20 + // Valid time controls in seconds per side (0 = untimed) 21 + const VALID_TIME_CONTROLS = new Set([60, 180, 300, 600]); 22 + 20 23 // --------------------------------------------------------------------------- 21 24 // Schema: Tables 22 25 // --------------------------------------------------------------------------- ··· 38 41 } 39 42 ), 40 43 41 - // Matchmaking queue — players waiting for an opponent 44 + // Matchmaking queue — players waiting for an opponent, partitioned by time control 42 45 matchQueue: table( 43 46 { name: 'match_queue', public: true }, 44 47 { 45 48 identity: t.identity().primaryKey(), 46 49 joinedAt: t.timestamp(), 50 + // 60 | 180 | 300 | 600 — players only match within the same pool 51 + timeControlSecs: t.u32().default(0), 47 52 } 48 53 ), 49 54 ··· 62 67 whiteIdentity: t.identity(), 63 68 blackIdentity: t.identity(), 64 69 fen: t.string(), 65 - // "active" | "checkmate" | "stalemate" | "draw" | "resigned" | "abandoned" 70 + // "active" | "checkmate" | "stalemate" | "draw" | "resigned" | "abandoned" | "timeout" 66 71 status: t.string(), 67 72 turn: t.string(), // "w" | "b" 68 73 winner: t.string().default(''), // "" | "white" | "black" | "draw" 69 74 moveCount: t.u32().default(0), 70 75 lastMoveAt: t.timestamp(), 71 76 createdAt: t.timestamp(), 77 + // Time control — 0 means untimed (solo or legacy games) 78 + timeControlSecs: t.u32().default(0), 79 + // Remaining time for each player in milliseconds, as of lastMoveAt. 80 + // The currently-active player's clock counts down from this value. 81 + whiteTimeMs: t.u64().default(0n), 82 + blackTimeMs: t.u64().default(0n), 72 83 } 73 84 ), 74 85 ··· 152 163 if (avatarUrl.length > 2048) { 153 164 throw new SenderError('Avatar URL too long'); 154 165 } 166 + // Reject control characters in string fields. They have no valid use in 167 + // display names or URLs, and could be exploited for PGN tag injection 168 + // (newlines) or other downstream parsing attacks. 169 + const hasControlChars = (s: string) => /[\x00-\x1f\x7f]/.test(s); 170 + if (hasControlChars(handle)) { 171 + throw new SenderError('Handle contains invalid characters'); 172 + } 173 + if (hasControlChars(displayName)) { 174 + throw new SenderError('Display name contains invalid characters'); 175 + } 176 + if (hasControlChars(avatarUrl)) { 177 + throw new SenderError('Avatar URL contains invalid characters'); 178 + } 155 179 156 180 const existing = ctx.db.player.identity.find(ctx.sender); 157 181 if (existing) { ··· 188 212 // --------------------------------------------------------------------------- 189 213 190 214 /** 191 - * Join the matchmaking queue. If another player is already waiting, immediately 192 - * create a new game and remove both players from the queue. 215 + * Join the matchmaking queue for a given time control. Players are only 216 + * matched against others seeking the same time control (separate pools for 217 + * 1', 3', 5', 10'). If another player is already waiting in the same pool, 218 + * a game is created immediately. 193 219 */ 194 - export const joinQueue = spacetimedb.reducer((ctx) => { 195 - const identity = ctx.sender; 220 + export const joinQueue = spacetimedb.reducer( 221 + { timeControlSecs: t.u32() }, 222 + (ctx, { timeControlSecs }) => { 223 + const identity = ctx.sender; 196 224 197 - // Must be a registered player 198 - const player = ctx.db.player.identity.find(identity); 199 - if (!player) { 200 - throw new SenderError('Must register before joining queue'); 201 - } 225 + // Validate time control 226 + if (!VALID_TIME_CONTROLS.has(timeControlSecs)) { 227 + throw new SenderError('Invalid time control — must be 60, 180, 300, or 600'); 228 + } 202 229 203 - // Cannot join if already in queue 204 - const alreadyQueued = ctx.db.matchQueue.identity.find(identity); 205 - if (alreadyQueued) { 206 - throw new SenderError('Already in matchmaking queue'); 207 - } 230 + // Must be a registered player 231 + const player = ctx.db.player.identity.find(identity); 232 + if (!player) { 233 + throw new SenderError('Must register before joining queue'); 234 + } 235 + 236 + // Cannot join if already in queue 237 + const alreadyQueued = ctx.db.matchQueue.identity.find(identity); 238 + if (alreadyQueued) { 239 + throw new SenderError('Already in matchmaking queue'); 240 + } 208 241 209 - // Cannot join if already in an active game 210 - for (const game of ctx.db.game.game_white.filter(identity)) { 211 - if (game.status === 'active') { 212 - throw new SenderError('Already in an active game'); 242 + // Cannot join if already in an active game 243 + for (const game of ctx.db.game.game_white.filter(identity)) { 244 + if (game.status === 'active') { 245 + throw new SenderError('Already in an active game'); 246 + } 213 247 } 214 - } 215 - for (const game of ctx.db.game.game_black.filter(identity)) { 216 - if (game.status === 'active') { 217 - throw new SenderError('Already in an active game'); 248 + for (const game of ctx.db.game.game_black.filter(identity)) { 249 + if (game.status === 'active') { 250 + throw new SenderError('Already in an active game'); 251 + } 218 252 } 219 - } 220 253 221 - // Check if someone is already waiting 222 - let opponent: { identity: typeof identity; joinedAt: typeof ctx.timestamp } | undefined; 223 - for (const entry of ctx.db.matchQueue.iter()) { 224 - if (entry.identity.toHexString() !== identity.toHexString()) { 225 - opponent = entry; 226 - break; 254 + // Check if someone is already waiting in the same time-control pool 255 + let opponent: { identity: typeof identity; joinedAt: typeof ctx.timestamp; timeControlSecs: number } | undefined; 256 + for (const entry of ctx.db.matchQueue.iter()) { 257 + if ( 258 + entry.identity.toHexString() !== identity.toHexString() && 259 + entry.timeControlSecs === timeControlSecs 260 + ) { 261 + opponent = entry; 262 + break; 263 + } 227 264 } 228 - } 229 265 230 - if (opponent) { 231 - // Match found! Remove opponent from queue and create a game. 232 - ctx.db.matchQueue.identity.delete(opponent.identity); 266 + if (opponent) { 267 + // Match found! Remove opponent from queue and create a timed game. 268 + ctx.db.matchQueue.identity.delete(opponent.identity); 233 269 234 - // Assign colors: the player who waited longer gets white (slight advantage 235 - // as a reward for patience). 236 - const whiteIdentity = opponent.identity; 237 - const blackIdentity = identity; 270 + // Assign colors: the player who waited longer gets white (slight advantage 271 + // as a reward for patience). 272 + const whiteIdentity = opponent.identity; 273 + const blackIdentity = identity; 238 274 239 - ctx.db.game.insert({ 240 - id: 0n, // auto-increment 241 - whiteIdentity, 242 - blackIdentity, 243 - fen: STARTING_FEN, 244 - status: 'active', 245 - turn: 'w', 246 - winner: '', 247 - moveCount: 0, 248 - lastMoveAt: ctx.timestamp, 249 - createdAt: ctx.timestamp, 250 - }); 275 + const initialTimeMs = BigInt(timeControlSecs) * 1000n; 251 276 252 - console.info( 253 - `Game created: ${whiteIdentity.toHexString()} (white) vs ${blackIdentity.toHexString()} (black)` 254 - ); 255 - } else { 256 - // No opponent available — add to queue 257 - ctx.db.matchQueue.insert({ 258 - identity, 259 - joinedAt: ctx.timestamp, 260 - }); 261 - console.info(`Player queued: ${identity.toHexString()}`); 277 + ctx.db.game.insert({ 278 + id: 0n, // auto-increment 279 + whiteIdentity, 280 + blackIdentity, 281 + fen: STARTING_FEN, 282 + status: 'active', 283 + turn: 'w', 284 + winner: '', 285 + moveCount: 0, 286 + lastMoveAt: ctx.timestamp, 287 + createdAt: ctx.timestamp, 288 + timeControlSecs, 289 + whiteTimeMs: initialTimeMs, 290 + blackTimeMs: initialTimeMs, 291 + }); 292 + 293 + console.info( 294 + `Game created: ${whiteIdentity.toHexString()} (white) vs ${blackIdentity.toHexString()} (black) [${timeControlSecs}s]` 295 + ); 296 + } else { 297 + // No opponent available — add to queue 298 + ctx.db.matchQueue.insert({ 299 + identity, 300 + joinedAt: ctx.timestamp, 301 + timeControlSecs, 302 + }); 303 + console.info(`Player queued: ${identity.toHexString()} [${timeControlSecs}s]`); 304 + } 262 305 } 263 - }); 306 + ); 264 307 265 308 /** 266 309 * Create a solo game where the caller plays both sides. All moves still go 267 310 * through SpacetimeDB — same validation, same persistence. Useful for local 268 - * testing and casual practice. 311 + * testing and casual practice. Solo games are always untimed. 269 312 */ 270 313 export const createSoloGame = spacetimedb.reducer((ctx) => { 271 314 const identity = ctx.sender; ··· 304 347 moveCount: 0, 305 348 lastMoveAt: ctx.timestamp, 306 349 createdAt: ctx.timestamp, 350 + timeControlSecs: 0, 351 + whiteTimeMs: 0n, 352 + blackTimeMs: 0n, 307 353 }); 308 354 309 355 console.info(`Solo game created for ${identity.toHexString()}`); ··· 327 373 /** 328 374 * Make a chess move. This is the core reducer — it validates the move using 329 375 * chess.js and updates all game state atomically. 376 + * 377 + * For timed games, deducts the elapsed time since the last move from the 378 + * moving player's clock. 330 379 */ 331 380 export const makeMove = spacetimedb.reducer( 332 381 { ··· 357 406 throw new SenderError('Not your turn'); 358 407 } 359 408 409 + // Deduct elapsed time from the moving player's clock (timed games only). 410 + // We do this before the move so that if they ran out of time the move is 411 + // still recorded but the clock floors at 0 — timeout must still be claimed 412 + // via claimTimeoutVictory (to avoid ambiguity about whose move "ended" the game). 413 + let whiteTimeMs = game.whiteTimeMs; 414 + let blackTimeMs = game.blackTimeMs; 415 + 416 + if (game.timeControlSecs > 0) { 417 + const elapsedMs = 418 + (ctx.timestamp.microsSinceUnixEpoch - game.lastMoveAt.microsSinceUnixEpoch) / 1000n; 419 + 420 + if (game.turn === 'w') { 421 + whiteTimeMs = whiteTimeMs > elapsedMs ? whiteTimeMs - elapsedMs : 0n; 422 + } else { 423 + blackTimeMs = blackTimeMs > elapsedMs ? blackTimeMs - elapsedMs : 0n; 424 + } 425 + } 426 + 360 427 // Validate the move with chess.js 361 428 const chess = new Chess(game.fen); 362 429 ··· 402 469 winner, 403 470 moveCount: newMoveCount, 404 471 lastMoveAt: ctx.timestamp, 472 + whiteTimeMs, 473 + blackTimeMs, 405 474 }); 406 475 407 476 // Record the move ··· 420 489 if (status !== 'active') { 421 490 console.info(`Game ${gameId} ended: ${status}, winner: ${winner}`); 422 491 } 492 + } 493 + ); 494 + 495 + /** 496 + * Claim a timeout victory. Called by the winning player when their opponent's 497 + * clock reaches zero. The server independently verifies the elapsed time to 498 + * prevent false claims. 499 + */ 500 + export const claimTimeoutVictory = spacetimedb.reducer( 501 + { gameId: t.u64() }, 502 + (ctx, { gameId }) => { 503 + const game = ctx.db.game.id.find(gameId); 504 + if (!game) { 505 + throw new SenderError('Game not found'); 506 + } 507 + 508 + if (game.status !== 'active') { 509 + throw new SenderError('Game is not active'); 510 + } 511 + 512 + if (game.timeControlSecs === 0) { 513 + throw new SenderError('Game has no time control'); 514 + } 515 + 516 + const isWhite = game.whiteIdentity.toHexString() === ctx.sender.toHexString(); 517 + const isBlack = game.blackIdentity.toHexString() === ctx.sender.toHexString(); 518 + 519 + if (!isWhite && !isBlack) { 520 + throw new SenderError('You are not a player in this game'); 521 + } 522 + 523 + // The clock currently running belongs to the player whose turn it is. 524 + // You can only claim a timeout when it is the OPPONENT'S turn (their clock 525 + // is running and they've run out of time). 526 + const clockOwnerIsWhite = game.turn === 'w'; 527 + if (clockOwnerIsWhite === isWhite) { 528 + throw new SenderError('Cannot claim your own timeout — wait for your opponent to claim theirs'); 529 + } 530 + 531 + // Verify the opponent's remaining time has actually been exhausted. 532 + const elapsedMs = 533 + (ctx.timestamp.microsSinceUnixEpoch - game.lastMoveAt.microsSinceUnixEpoch) / 1000n; 534 + 535 + const opponentTimeMs = clockOwnerIsWhite ? game.whiteTimeMs : game.blackTimeMs; 536 + 537 + if (elapsedMs < opponentTimeMs) { 538 + throw new SenderError('Opponent still has time remaining'); 539 + } 540 + 541 + const winner = isWhite ? 'white' : 'black'; 542 + 543 + ctx.db.game.id.update({ 544 + ...game, 545 + status: 'timeout', 546 + winner, 547 + lastMoveAt: ctx.timestamp, 548 + }); 549 + 550 + console.info(`Game ${gameId}: timeout, ${winner} wins`); 423 551 } 424 552 ); 425 553