extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.
0
fork

Configure Feed

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

Migrate game status determination from database to Constellation

- Replace database status with client-side derived status based on PDS data
- Game page now computes status from moves, resigns, and scores
- Homepage fetches backlinks from Constellation for moves, passes, resigns
- Fix Constellation API response handling (use 'records' not 'backlinks')
- Add resign detection for both boo.sky.go.resign and boo.sky.go.action
- Remove stale game.status field from homepage logic
- Add debug logging for game status computation
- Reduce verbose logging of full Constellation records

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+1130 -564
+57 -189
package-lock.json
··· 20 20 "@atproto/xrpc-server": "^0.6.0", 21 21 "@resvg/resvg-js": "^2.6.2", 22 22 "@types/better-sqlite3": "^7.6.0", 23 + "actor-typeahead": "^0.1.2", 23 24 "better-sqlite3": "^11.0.0", 25 + "canvas": "^3.2.1", 24 26 "dotenv": "^17.2.3", 25 27 "jgoboard": "github:jokkebk/jgoboard", 26 28 "kysely": "^0.27.0", ··· 197 199 "zod": "^3.23.8" 198 200 } 199 201 }, 200 - "node_modules/@atproto-labs/did-resolver/node_modules/zod": { 201 - "version": "3.25.76", 202 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 203 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 204 - "funding": { 205 - "url": "https://github.com/sponsors/colinhacks" 206 - } 207 - }, 208 202 "node_modules/@atproto-labs/fetch": { 209 203 "version": "0.1.1", 210 204 "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.1.tgz", ··· 228 222 "undici": "^6.14.1" 229 223 } 230 224 }, 231 - "node_modules/@atproto-labs/fetch/node_modules/zod": { 232 - "version": "3.25.76", 233 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 234 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 235 - "optional": true, 236 - "funding": { 237 - "url": "https://github.com/sponsors/colinhacks" 238 - } 239 - }, 240 225 "node_modules/@atproto-labs/handle-resolver": { 241 226 "version": "0.1.3", 242 227 "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.3.tgz", ··· 256 241 "@atproto-labs/fetch-node": "0.1.3", 257 242 "@atproto-labs/handle-resolver": "0.1.3", 258 243 "@atproto/did": "0.1.2" 259 - } 260 - }, 261 - "node_modules/@atproto-labs/handle-resolver/node_modules/zod": { 262 - "version": "3.25.76", 263 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 264 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 265 - "funding": { 266 - "url": "https://github.com/sponsors/colinhacks" 267 244 } 268 245 }, 269 246 "node_modules/@atproto-labs/identity-resolver": { ··· 315 292 "zod": "^3.23.8" 316 293 } 317 294 }, 318 - "node_modules/@atproto/api/node_modules/zod": { 319 - "version": "3.25.76", 320 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 321 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 322 - "funding": { 323 - "url": "https://github.com/sponsors/colinhacks" 324 - } 325 - }, 326 295 "node_modules/@atproto/common": { 327 296 "version": "0.4.7", 328 297 "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.4.7.tgz", ··· 355 324 "tslib": "^2.8.1" 356 325 } 357 326 }, 358 - "node_modules/@atproto/common-web/node_modules/zod": { 359 - "version": "3.25.76", 360 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 361 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 362 - "funding": { 363 - "url": "https://github.com/sponsors/colinhacks" 364 - } 365 - }, 366 327 "node_modules/@atproto/common/node_modules/@atproto/common-web": { 367 328 "version": "0.3.2", 368 329 "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.2.tgz", ··· 372 333 "multiformats": "^9.9.0", 373 334 "uint8arrays": "3.0.0", 374 335 "zod": "^3.23.8" 375 - } 376 - }, 377 - "node_modules/@atproto/common/node_modules/zod": { 378 - "version": "3.25.76", 379 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 380 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 381 - "funding": { 382 - "url": "https://github.com/sponsors/colinhacks" 383 336 } 384 337 }, 385 338 "node_modules/@atproto/crypto": { ··· 403 356 "zod": "^3.23.8" 404 357 } 405 358 }, 406 - "node_modules/@atproto/did/node_modules/zod": { 407 - "version": "3.25.76", 408 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 409 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 410 - "funding": { 411 - "url": "https://github.com/sponsors/colinhacks" 412 - } 413 - }, 414 359 "node_modules/@atproto/jwk": { 415 360 "version": "0.1.1", 416 361 "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", ··· 436 381 "dependencies": { 437 382 "@atproto/jwk": "0.1.1", 438 383 "@atproto/jwk-jose": "0.1.2" 439 - } 440 - }, 441 - "node_modules/@atproto/jwk/node_modules/zod": { 442 - "version": "3.25.76", 443 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 444 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 445 - "funding": { 446 - "url": "https://github.com/sponsors/colinhacks" 447 384 } 448 385 }, 449 386 "node_modules/@atproto/lex-data": { ··· 486 423 "tslib": "^2.8.1" 487 424 } 488 425 }, 489 - "node_modules/@atproto/lexicon/node_modules/zod": { 490 - "version": "3.25.76", 491 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 492 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 493 - "funding": { 494 - "url": "https://github.com/sponsors/colinhacks" 495 - } 496 - }, 497 426 "node_modules/@atproto/oauth-client": { 498 427 "version": "0.2.2", 499 428 "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.2.2.tgz", ··· 538 467 "zod": "^3.23.8" 539 468 } 540 469 }, 541 - "node_modules/@atproto/oauth-client/node_modules/zod": { 542 - "version": "3.25.76", 543 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 544 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 545 - "funding": { 546 - "url": "https://github.com/sponsors/colinhacks" 547 - } 548 - }, 549 470 "node_modules/@atproto/oauth-types": { 550 471 "version": "0.1.5", 551 472 "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.1.5.tgz", ··· 553 474 "dependencies": { 554 475 "@atproto/jwk": "0.1.1", 555 476 "zod": "^3.23.8" 556 - } 557 - }, 558 - "node_modules/@atproto/oauth-types/node_modules/zod": { 559 - "version": "3.25.76", 560 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 561 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 562 - "funding": { 563 - "url": "https://github.com/sponsors/colinhacks" 564 477 } 565 478 }, 566 479 "node_modules/@atproto/syntax": { ··· 594 507 "uint8arrays": "3.0.0", 595 508 "ws": "^8.12.0", 596 509 "zod": "^3.23.8" 597 - } 598 - }, 599 - "node_modules/@atproto/xrpc-server/node_modules/zod": { 600 - "version": "3.25.76", 601 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 602 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 603 - "funding": { 604 - "url": "https://github.com/sponsors/colinhacks" 605 - } 606 - }, 607 - "node_modules/@atproto/xrpc/node_modules/zod": { 608 - "version": "3.25.76", 609 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 610 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 611 - "funding": { 612 - "url": "https://github.com/sponsors/colinhacks" 613 510 } 614 511 }, 615 512 "node_modules/@badrap/valita": { ··· 1863 1760 "node": ">=0.4.0" 1864 1761 } 1865 1762 }, 1763 + "node_modules/actor-typeahead": { 1764 + "version": "0.1.2", 1765 + "resolved": "https://registry.npmjs.org/actor-typeahead/-/actor-typeahead-0.1.2.tgz", 1766 + "integrity": "sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A==" 1767 + }, 1866 1768 "node_modules/aria-query": { 1867 1769 "version": "5.3.2", 1868 1770 "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", ··· 2076 1978 "url": "https://github.com/sponsors/ljharb" 2077 1979 } 2078 1980 }, 1981 + "node_modules/canvas": { 1982 + "version": "3.2.1", 1983 + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", 1984 + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", 1985 + "hasInstallScript": true, 1986 + "dependencies": { 1987 + "node-addon-api": "^7.0.0", 1988 + "prebuild-install": "^7.1.3" 1989 + }, 1990 + "engines": { 1991 + "node": "^18.12.0 || >= 20.9.0" 1992 + } 1993 + }, 2079 1994 "node_modules/cbor-extract": { 2080 1995 "version": "2.2.0", 2081 1996 "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", ··· 3359 3274 "node": ">=10" 3360 3275 } 3361 3276 }, 3277 + "node_modules/node-addon-api": { 3278 + "version": "7.1.1", 3279 + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", 3280 + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" 3281 + }, 3362 3282 "node_modules/node-gyp-build-optional-packages": { 3363 3283 "version": "5.1.1", 3364 3284 "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", ··· 4459 4379 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", 4460 4380 "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", 4461 4381 "dev": true 4382 + }, 4383 + "node_modules/zod": { 4384 + "version": "3.25.76", 4385 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4386 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 4387 + "funding": { 4388 + "url": "https://github.com/sponsors/colinhacks" 4389 + } 4462 4390 } 4463 4391 }, 4464 4392 "dependencies": { ··· 4586 4514 "@atproto-labs/simple-store-memory": "0.1.1", 4587 4515 "@atproto/did": "0.1.2", 4588 4516 "zod": "^3.23.8" 4589 - }, 4590 - "dependencies": { 4591 - "zod": { 4592 - "version": "3.25.76", 4593 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4594 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4595 - } 4596 4517 } 4597 4518 }, 4598 4519 "@atproto-labs/fetch": { ··· 4602 4523 "requires": { 4603 4524 "@atproto-labs/pipe": "0.1.0", 4604 4525 "zod": "^3.23.8" 4605 - }, 4606 - "dependencies": { 4607 - "zod": { 4608 - "version": "3.25.76", 4609 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4610 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 4611 - "optional": true 4612 - } 4613 4526 } 4614 4527 }, 4615 4528 "@atproto-labs/fetch-node": { ··· 4633 4546 "@atproto-labs/simple-store-memory": "0.1.1", 4634 4547 "@atproto/did": "0.1.2", 4635 4548 "zod": "^3.23.8" 4636 - }, 4637 - "dependencies": { 4638 - "zod": { 4639 - "version": "3.25.76", 4640 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4641 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4642 - } 4643 4549 } 4644 4550 }, 4645 4551 "@atproto-labs/handle-resolver-node": { ··· 4701 4607 "multiformats": "^9.9.0", 4702 4608 "tlds": "^1.234.0", 4703 4609 "zod": "^3.23.8" 4704 - }, 4705 - "dependencies": { 4706 - "zod": { 4707 - "version": "3.25.76", 4708 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4709 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4710 - } 4711 4610 } 4712 4611 }, 4713 4612 "@atproto/common": { ··· 4733 4632 "uint8arrays": "3.0.0", 4734 4633 "zod": "^3.23.8" 4735 4634 } 4736 - }, 4737 - "zod": { 4738 - "version": "3.25.76", 4739 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4740 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4741 4635 } 4742 4636 } 4743 4637 }, ··· 4759 4653 "requires": { 4760 4654 "tslib": "^2.8.1" 4761 4655 } 4762 - }, 4763 - "zod": { 4764 - "version": "3.25.76", 4765 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4766 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4767 4656 } 4768 4657 } 4769 4658 }, ··· 4783 4672 "integrity": "sha512-gmY1SyAuqfmsFbIXkUIScfnULqn39FoUNz4oE0fUuMu9in6PEyoxlmD2lAo7Q3KMy3X/hvTn2u5f8W/2KuDg1w==", 4784 4673 "requires": { 4785 4674 "zod": "^3.23.8" 4786 - }, 4787 - "dependencies": { 4788 - "zod": { 4789 - "version": "3.25.76", 4790 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4791 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4792 - } 4793 4675 } 4794 4676 }, 4795 4677 "@atproto/jwk": { ··· 4799 4681 "requires": { 4800 4682 "multiformats": "^9.9.0", 4801 4683 "zod": "^3.23.8" 4802 - }, 4803 - "dependencies": { 4804 - "zod": { 4805 - "version": "3.25.76", 4806 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4807 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4808 - } 4809 4684 } 4810 4685 }, 4811 4686 "@atproto/jwk-jose": { ··· 4865 4740 "requires": { 4866 4741 "tslib": "^2.8.1" 4867 4742 } 4868 - }, 4869 - "zod": { 4870 - "version": "3.25.76", 4871 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4872 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4873 4743 } 4874 4744 } 4875 4745 }, ··· 4900 4770 "@atproto/lexicon": "^0.4.2", 4901 4771 "zod": "^3.23.8" 4902 4772 } 4903 - }, 4904 - "zod": { 4905 - "version": "3.25.76", 4906 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4907 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4908 4773 } 4909 4774 } 4910 4775 }, ··· 4931 4796 "requires": { 4932 4797 "@atproto/jwk": "0.1.1", 4933 4798 "zod": "^3.23.8" 4934 - }, 4935 - "dependencies": { 4936 - "zod": { 4937 - "version": "3.25.76", 4938 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4939 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4940 - } 4941 4799 } 4942 4800 }, 4943 4801 "@atproto/syntax": { ··· 4952 4810 "requires": { 4953 4811 "@atproto/lexicon": "^0.4.10", 4954 4812 "zod": "^3.23.8" 4955 - }, 4956 - "dependencies": { 4957 - "zod": { 4958 - "version": "3.25.76", 4959 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4960 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4961 - } 4962 4813 } 4963 4814 }, 4964 4815 "@atproto/xrpc-server": { ··· 4978 4829 "uint8arrays": "3.0.0", 4979 4830 "ws": "^8.12.0", 4980 4831 "zod": "^3.23.8" 4981 - }, 4982 - "dependencies": { 4983 - "zod": { 4984 - "version": "3.25.76", 4985 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 4986 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 4987 - } 4988 4832 } 4989 4833 }, 4990 4834 "@badrap/valita": { ··· 5632 5476 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 5633 5477 "dev": true 5634 5478 }, 5479 + "actor-typeahead": { 5480 + "version": "0.1.2", 5481 + "resolved": "https://registry.npmjs.org/actor-typeahead/-/actor-typeahead-0.1.2.tgz", 5482 + "integrity": "sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A==" 5483 + }, 5635 5484 "aria-query": { 5636 5485 "version": "5.3.2", 5637 5486 "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", ··· 5778 5627 "get-intrinsic": "^1.3.0" 5779 5628 } 5780 5629 }, 5630 + "canvas": { 5631 + "version": "3.2.1", 5632 + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", 5633 + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", 5634 + "requires": { 5635 + "node-addon-api": "^7.0.0", 5636 + "prebuild-install": "^7.1.3" 5637 + } 5638 + }, 5781 5639 "cbor-extract": { 5782 5640 "version": "2.2.0", 5783 5641 "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", ··· 6570 6428 "semver": "^7.3.5" 6571 6429 } 6572 6430 }, 6431 + "node-addon-api": { 6432 + "version": "7.1.1", 6433 + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", 6434 + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" 6435 + }, 6573 6436 "node-gyp-build-optional-packages": { 6574 6437 "version": "5.1.1", 6575 6438 "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", ··· 7343 7206 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", 7344 7207 "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", 7345 7208 "dev": true 7209 + }, 7210 + "zod": { 7211 + "version": "3.25.76", 7212 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 7213 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 7346 7214 } 7347 7215 } 7348 7216 }
+2
package.json
··· 34 34 "@atproto/xrpc-server": "^0.6.0", 35 35 "@resvg/resvg-js": "^2.6.2", 36 36 "@types/better-sqlite3": "^7.6.0", 37 + "actor-typeahead": "^0.1.2", 37 38 "better-sqlite3": "^11.0.0", 39 + "canvas": "^3.2.1", 38 40 "dotenv": "^17.2.3", 39 41 "jgoboard": "github:jokkebk/jgoboard", 40 42 "kysely": "^0.27.0",
+1
src/app.html
··· 10 10 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" 11 11 rel="stylesheet" 12 12 /> 13 + <script type="module" src="https://unpkg.com/actor-typeahead@latest/actor-typeahead.js"></script> 13 14 %sveltekit.head% 14 15 </head> 15 16 <body data-sveltekit-preload-data="hover">
+57 -31
src/lib/atproto-client.ts
··· 24 24 if (did.startsWith('did:plc:')) { 25 25 const url = `${PLC_DIRECTORY}/${did}`; 26 26 console.log('[fetchDidDocument] Fetching from PLC directory:', url); 27 - const res = await fetch(url); 28 - console.log('[fetchDidDocument] PLC response status:', res.status); 29 - if (res.ok) { 30 - const doc = await res.json(); 31 - console.log('[fetchDidDocument] Successfully fetched PLC document'); 32 - return doc; 27 + 28 + // iOS Safari sometimes blocks plc.directory requests, add timeout 29 + const controller = new AbortController(); 30 + const timeoutId = setTimeout(() => controller.abort(), 5000); 31 + 32 + try { 33 + const res = await fetch(url, { signal: controller.signal }); 34 + clearTimeout(timeoutId); 35 + console.log('[fetchDidDocument] PLC response status:', res.status); 36 + if (res.ok) { 37 + const doc = await res.json(); 38 + console.log('[fetchDidDocument] Successfully fetched PLC document'); 39 + return doc; 40 + } 41 + } catch (fetchErr) { 42 + clearTimeout(timeoutId); 43 + console.warn('[fetchDidDocument] PLC fetch failed (possible iOS Safari blocking):', fetchErr); 44 + throw fetchErr; 33 45 } 34 46 } else if (did.startsWith('did:web:')) { 35 47 const domain = did.slice('did:web:'.length); ··· 58 70 const cached = handleCache.get(did); 59 71 if (cached) return cached; 60 72 73 + // Try PLC directory first 61 74 const doc = await fetchDidDocument(did); 62 75 if (doc?.alsoKnownAs && doc.alsoKnownAs.length > 0) { 63 76 const handleUri = doc.alsoKnownAs[0]; ··· 68 81 } 69 82 } 70 83 84 + // Fallback: Use Bluesky public API (works on iOS Safari when plc.directory is blocked) 85 + try { 86 + console.log('[resolveDidToHandle] Falling back to Bluesky public API for:', did); 87 + const res = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`); 88 + if (res.ok) { 89 + const data = await res.json(); 90 + if (data.handle) { 91 + console.log('[resolveDidToHandle] Resolved via public API:', data.handle); 92 + handleCache.set(did, data.handle); 93 + return data.handle; 94 + } 95 + } 96 + } catch (err) { 97 + console.error('[resolveDidToHandle] Public API fallback failed:', err); 98 + } 99 + 71 100 return did; 72 101 } 73 102 ··· 108 137 creatorDid: string, 109 138 rkey: string 110 139 ): Promise<GameRecord | null> { 140 + console.log('[fetchGameRecord] Called with:', { creatorDid, rkey }); 141 + 142 + if (!creatorDid || !rkey) { 143 + console.error('[fetchGameRecord] Invalid parameters - creatorDid or rkey is missing'); 144 + return null; 145 + } 146 + 111 147 const pds = await resolvePdsHost(creatorDid); 112 - if (!pds) return null; 148 + if (!pds) { 149 + console.error('[fetchGameRecord] Could not resolve PDS for:', creatorDid); 150 + return null; 151 + } 113 152 114 153 try { 115 154 const params = new URLSearchParams({ ··· 117 156 collection: 'boo.sky.go.game', 118 157 rkey, 119 158 }); 120 - const res = await fetch( 121 - `${pds}/xrpc/com.atproto.repo.getRecord?${params}` 122 - ); 159 + const url = `${pds}/xrpc/com.atproto.repo.getRecord?${params}`; 160 + console.log('[fetchGameRecord] Fetching:', url); 161 + const res = await fetch(url); 162 + 123 163 if (res.ok) { 124 164 const data = await res.json(); 165 + console.log('[fetchGameRecord] Success:', data.value); 125 166 return data.value as GameRecord; 167 + } else { 168 + console.error('[fetchGameRecord] HTTP error:', res.status, res.statusText); 169 + const errorBody = await res.text(); 170 + console.error('[fetchGameRecord] Error body:', errorBody); 126 171 } 127 172 } catch (err) { 128 - console.error('Failed to fetch game record:', err); 173 + console.error('[fetchGameRecord] Exception:', err); 129 174 } 130 175 return null; 131 176 } ··· 244 289 playerTwoDid: string | null, 245 290 gameAtUri: string 246 291 ): Promise<{ moves: MoveRecord[]; passes: PassRecord[]; resigns: ResignRecord[] }> { 247 - console.log('[fetchGameActionsFromPds] Starting fetch for game:', gameAtUri); 248 - console.log('[fetchGameActionsFromPds] Players:', { playerOneDid, playerTwoDid }); 249 - 250 292 const moves: MoveRecord[] = []; 251 293 const passes: PassRecord[] = []; 252 294 const resigns: ResignRecord[] = []; ··· 255 297 if (playerTwoDid) dids.push(playerTwoDid); 256 298 257 299 for (const did of dids) { 258 - console.log('[fetchGameActionsFromPds] Processing DID:', did); 259 - 260 300 const pds = await resolvePdsHost(did); 261 301 if (!pds) { 262 - console.log('[fetchGameActionsFromPds] Could not resolve PDS for:', did); 302 + console.warn('[fetchGameActionsFromPds] Could not resolve PDS for:', did); 263 303 continue; 264 304 } 265 - console.log('[fetchGameActionsFromPds] Resolved PDS:', pds); 266 305 267 306 // Fetch moves with pagination 268 307 try { 269 308 let cursor: string | undefined; 270 - let pageCount = 0; 271 309 do { 272 - pageCount++; 273 - console.log('[fetchGameActionsFromPds] Fetching moves page', pageCount, 'for', did); 274 - 275 310 const moveParams = new URLSearchParams({ 276 311 repo: did, 277 312 collection: 'boo.sky.go.move', ··· 283 318 `${pds}/xrpc/com.atproto.repo.listRecords?${moveParams}` 284 319 ); 285 320 286 - console.log('[fetchGameActionsFromPds] Move fetch response:', moveRes.status); 287 - 288 321 if (moveRes.ok) { 289 322 const data = await moveRes.json(); 290 - console.log('[fetchGameActionsFromPds] Got', data.records?.length || 0, 'move records'); 291 323 292 - let matchCount = 0; 293 324 for (const rec of data.records || []) { 294 325 if (rec.value?.game === gameAtUri) { 295 - matchCount++; 296 326 moves.push({ 297 327 ...(rec.value as MoveRecord), 298 328 uri: rec.uri, // Include the AT URI 299 329 }); 300 330 } 301 331 } 302 - console.log('[fetchGameActionsFromPds]', matchCount, 'moves matched game URI'); 303 332 304 333 cursor = data.cursor; 305 334 } else { 306 - console.log('[fetchGameActionsFromPds] Move fetch failed, breaking pagination'); 307 335 break; 308 336 } 309 337 } while (cursor); 310 - 311 - console.log('[fetchGameActionsFromPds] Finished fetching moves for', did); 312 338 } catch (err) { 313 339 console.error('[fetchGameActionsFromPds] Failed to list move records from PDS for', did, err); 314 340 }
+10 -1
src/lib/components/Board.svelte
··· 2 2 import { untrack } from 'svelte'; 3 3 import JGO from 'jgoboard'; 4 4 import { isMobileDevice } from '$lib/mobile-detection'; 5 + import { getSoundManager } from '$lib/sound-manager'; 6 + 7 + const soundManager = getSoundManager(); 5 8 6 9 interface TerritoryData { 7 10 territory: Array<Array<'black' | 'white' | 'neutral' | null>>; ··· 376 379 // Desktop: immediate submission 377 380 // Place the stone 378 381 board.setType(coord, player); 382 + soundManager.play('played_stone'); 379 383 380 384 // Remove captured stones 381 385 if (play.captures.length > 0) { 382 386 for (const capture of play.captures) { 383 387 board.setType(capture, JGO.CLEAR); 384 388 } 389 + soundManager.play('capture'); 385 390 } 386 391 387 392 // Update ko point ··· 405 410 406 411 if (play.success) { 407 412 board.setType(coord, player); 413 + soundManager.play('played_stone'); 414 + 408 415 if (play.captures.length > 0) { 409 416 for (const capture of play.captures) { 410 417 board.setType(capture, JGO.CLEAR); 411 418 } 419 + soundManager.play('capture'); 412 420 } 413 421 ko = play.ko; 414 422 onMove(pendingMove.x, pendingMove.y, pendingMove.captures); ··· 555 563 // Place the stone 556 564 board.setType(coord, type); 557 565 558 - // Remove captured stones 566 + // Remove captured stones (opponent captured YOUR stones) 559 567 if (play.captures && play.captures.length > 0) { 560 568 for (const capture of play.captures) { 561 569 board.setType(capture, JGO.CLEAR); 562 570 } 571 + soundManager.play('captured'); 563 572 } 564 573 565 574 // Update ko point
+262 -23
src/lib/components/ProfileDropdown.svelte
··· 3 3 import { goto } from '$app/navigation'; 4 4 import { cubicOut } from 'svelte/easing'; 5 5 import type { TransitionConfig } from 'svelte/transition'; 6 + import { fetchCloudGoProfile } from '$lib/atproto-client'; 7 + import { getSoundManager } from '$lib/sound-manager'; 6 8 7 9 let { avatar, handle, did }: { avatar: string | null; handle: string; did: string } = $props(); 8 10 9 11 let isOpen = $state(false); 10 12 let dropdownRef: HTMLDivElement | null = $state(null); 13 + let currentStatus = $state<'playing' | 'watching' | 'offline'>('offline'); 14 + let isUpdatingStatus = $state(false); 15 + let sfxEnabled = $state(true); 11 16 12 17 function cloudMaterialize(node: Element): TransitionConfig { 13 18 return { ··· 52 57 window.location.reload(); 53 58 } 54 59 55 - onMount(() => { 60 + async function updateStatus(status: 'playing' | 'watching' | 'offline') { 61 + if (isUpdatingStatus) return; 62 + 63 + isUpdatingStatus = true; 64 + try { 65 + const response = await fetch('/api/profile', { 66 + method: 'POST', 67 + headers: { 'Content-Type': 'application/json' }, 68 + body: JSON.stringify({ status }) 69 + }); 70 + 71 + if (response.ok) { 72 + currentStatus = status; 73 + } 74 + } catch (err) { 75 + console.error('Failed to update status:', err); 76 + } finally { 77 + isUpdatingStatus = false; 78 + } 79 + } 80 + 81 + function toggleSfx() { 82 + const soundManager = getSoundManager(); 83 + sfxEnabled = !sfxEnabled; 84 + soundManager.setEnabled(sfxEnabled); 85 + } 86 + 87 + onMount(async () => { 56 88 document.addEventListener('click', handleClickOutside); 89 + 90 + // Fetch current status 91 + try { 92 + const profile = await fetchCloudGoProfile(did); 93 + if (profile?.status) { 94 + currentStatus = profile.status as 'playing' | 'watching' | 'offline'; 95 + } 96 + } catch (err) { 97 + console.error('Failed to fetch profile status:', err); 98 + } 99 + 100 + // Load SFX setting 101 + const soundManager = getSoundManager(); 102 + sfxEnabled = soundManager.isEnabled(); 103 + 57 104 return () => { 58 105 document.removeEventListener('click', handleClickOutside); 59 106 }; ··· 62 109 63 110 <div class="profile-dropdown" bind:this={dropdownRef}> 64 111 <button class="avatar-button" onclick={toggleDropdown} aria-label="Profile menu"> 65 - {#if avatar} 66 - <img src={avatar} alt={handle} class="avatar-img" /> 67 - {:else} 68 - <div class="avatar-fallback"> 69 - {handle.charAt(0).toUpperCase()} 70 - </div> 71 - {/if} 112 + <div class="avatar-wrapper status-{currentStatus}"> 113 + {#if avatar} 114 + <img src={avatar} alt={handle} class="avatar-img" /> 115 + {:else} 116 + <div class="avatar-fallback"> 117 + {handle.charAt(0).toUpperCase()} 118 + </div> 119 + {/if} 120 + </div> 72 121 </button> 73 122 74 123 {#if isOpen} ··· 76 125 <button onclick={handleProfileClick} class="dropdown-item"> 77 126 View Cloud Go Profile 78 127 </button> 128 + 129 + <div class="status-section"> 130 + <div class="status-label">Update Status</div> 131 + <div class="status-circles"> 132 + <button 133 + class="status-circle" 134 + class:active={currentStatus === 'playing'} 135 + onclick={() => updateStatus('playing')} 136 + disabled={isUpdatingStatus} 137 + aria-label="Set status to playing" 138 + > 139 + <div class="circle playing"></div> 140 + <span class="status-text">Playing</span> 141 + </button> 142 + <button 143 + class="status-circle" 144 + class:active={currentStatus === 'watching'} 145 + onclick={() => updateStatus('watching')} 146 + disabled={isUpdatingStatus} 147 + aria-label="Set status to watching" 148 + > 149 + <div class="circle watching"></div> 150 + <span class="status-text">Watching</span> 151 + </button> 152 + <button 153 + class="status-circle" 154 + class:active={currentStatus === 'offline'} 155 + onclick={() => updateStatus('offline')} 156 + disabled={isUpdatingStatus} 157 + aria-label="Set status to offline" 158 + > 159 + <div class="circle offline"></div> 160 + <span class="status-text">Offline</span> 161 + </button> 162 + </div> 163 + </div> 164 + 165 + <button onclick={toggleSfx} class="dropdown-item sfx-toggle"> 166 + <span class="sfx-label">Sound Effects</span> 167 + <span class="sfx-status">{sfxEnabled ? '🔊 On' : '🔇 Off'}</span> 168 + </button> 169 + 79 170 <button onclick={handleLogout} class="dropdown-item logout-btn"> 80 171 Logout 81 172 </button> ··· 97 188 transition: all 0.6s ease; 98 189 } 99 190 191 + .avatar-wrapper { 192 + position: relative; 193 + border-radius: 50%; 194 + padding: 3px; 195 + transition: all 0.6s ease; 196 + } 197 + 198 + .avatar-wrapper.status-playing { 199 + background: linear-gradient(135deg, #059669, #10b981); 200 + box-shadow: 201 + 0 0 20px rgba(5, 150, 105, 0.3), 202 + 0 0 40px rgba(5, 150, 105, 0.2), 203 + 0 0 60px rgba(5, 150, 105, 0.1); 204 + filter: blur(0.5px); 205 + } 206 + 207 + .avatar-wrapper.status-watching { 208 + background: linear-gradient(135deg, #ca8a04, #eab308); 209 + box-shadow: 210 + 0 0 20px rgba(202, 138, 4, 0.3), 211 + 0 0 40px rgba(202, 138, 4, 0.2), 212 + 0 0 60px rgba(202, 138, 4, 0.1); 213 + filter: blur(0.5px); 214 + } 215 + 216 + .avatar-wrapper.status-offline { 217 + background: linear-gradient(135deg, #94a3b8, #cbd5e1); 218 + box-shadow: 219 + 0 0 20px rgba(148, 163, 184, 0.25), 220 + 0 0 40px rgba(148, 163, 184, 0.15), 221 + 0 0 60px rgba(148, 163, 184, 0.1); 222 + filter: blur(0.5px); 223 + } 224 + 225 + .avatar-button:hover .avatar-wrapper { 226 + transform: scale(1.05); 227 + } 228 + 229 + .avatar-button:hover .avatar-wrapper.status-playing { 230 + box-shadow: 231 + 0 0 25px rgba(5, 150, 105, 0.4), 232 + 0 0 50px rgba(5, 150, 105, 0.3), 233 + 0 0 75px rgba(5, 150, 105, 0.15); 234 + } 235 + 236 + .avatar-button:hover .avatar-wrapper.status-watching { 237 + box-shadow: 238 + 0 0 25px rgba(202, 138, 4, 0.4), 239 + 0 0 50px rgba(202, 138, 4, 0.3), 240 + 0 0 75px rgba(202, 138, 4, 0.15); 241 + } 242 + 243 + .avatar-button:hover .avatar-wrapper.status-offline { 244 + box-shadow: 245 + 0 0 25px rgba(148, 163, 184, 0.35), 246 + 0 0 50px rgba(148, 163, 184, 0.2), 247 + 0 0 75px rgba(148, 163, 184, 0.1); 248 + } 249 + 100 250 .avatar-img { 101 251 width: 80px; 102 252 height: 80px; 103 253 border-radius: 50%; 104 - border: 2px solid var(--sky-blue-pale); 254 + border: 3px solid var(--sky-white); 105 255 object-fit: cover; 106 256 transition: all 0.6s ease; 107 - } 108 - 109 - .avatar-button:hover .avatar-img { 110 - border-color: var(--sky-apricot); 111 - box-shadow: 0 0 16px rgba(229, 168, 120, 0.6); 257 + display: block; 112 258 } 113 259 114 260 .avatar-fallback { 115 - width: 32px; 116 - height: 32px; 261 + width: 80px; 262 + height: 80px; 117 263 border-radius: 50%; 118 - border: 2px solid var(--sky-blue-pale); 264 + border: 3px solid var(--sky-white); 119 265 background: linear-gradient(135deg, var(--sky-apricot-light), var(--sky-rose-light)); 120 266 display: flex; 121 267 align-items: center; 122 268 justify-content: center; 123 269 font-weight: 600; 124 270 color: var(--sky-slate-dark); 125 - font-size: 1rem; 271 + font-size: 2rem; 126 272 transition: all 0.6s ease; 127 - } 128 - 129 - .avatar-button:hover .avatar-fallback { 130 - border-color: var(--sky-apricot); 131 - box-shadow: 0 0 16px rgba(229, 168, 120, 0.6); 132 273 } 133 274 134 275 .dropdown-menu { ··· 171 312 172 313 .logout-btn { 173 314 font-family: inherit; 315 + } 316 + 317 + .status-section { 318 + padding: 1rem 1.25rem; 319 + border-bottom: 1px solid var(--sky-cloud); 320 + } 321 + 322 + .status-label { 323 + font-size: 0.75rem; 324 + font-weight: 600; 325 + text-transform: uppercase; 326 + letter-spacing: 0.05em; 327 + color: var(--sky-gray); 328 + margin-bottom: 0.75rem; 329 + text-align: center; 330 + } 331 + 332 + .status-circles { 333 + display: flex; 334 + justify-content: space-around; 335 + gap: 0.5rem; 336 + } 337 + 338 + .status-circle { 339 + display: flex; 340 + flex-direction: column; 341 + align-items: center; 342 + gap: 0.35rem; 343 + background: none; 344 + border: none; 345 + cursor: pointer; 346 + padding: 0.25rem; 347 + transition: all 0.2s; 348 + border-radius: 0.5rem; 349 + } 350 + 351 + .status-circle:hover:not(:disabled) { 352 + background: var(--sky-cloud); 353 + } 354 + 355 + .status-circle:disabled { 356 + opacity: 0.5; 357 + cursor: not-allowed; 358 + } 359 + 360 + .status-circle.active .circle { 361 + box-shadow: 0 0 0 3px var(--sky-apricot-light); 362 + transform: scale(1.1); 363 + } 364 + 365 + .circle { 366 + width: 32px; 367 + height: 32px; 368 + border-radius: 50%; 369 + transition: all 0.2s; 370 + border: 2px solid transparent; 371 + } 372 + 373 + .circle.playing { 374 + background: radial-gradient(circle at 35% 35%, #22c55e, #16a34a); 375 + border-color: #15803d; 376 + } 377 + 378 + .circle.watching { 379 + background: radial-gradient(circle at 35% 35%, #facc15, #eab308); 380 + border-color: #ca8a04; 381 + } 382 + 383 + .circle.offline { 384 + background: radial-gradient(circle at 35% 35%, #cbd5e1, #94a3b8); 385 + border-color: #64748b; 386 + } 387 + 388 + .status-text { 389 + font-size: 0.7rem; 390 + font-weight: 500; 391 + color: var(--sky-slate); 392 + text-align: center; 393 + } 394 + 395 + .status-circle.active .status-text { 396 + font-weight: 700; 397 + color: var(--sky-slate-dark); 398 + } 399 + 400 + .sfx-toggle { 401 + display: flex; 402 + justify-content: space-between; 403 + align-items: center; 404 + } 405 + 406 + .sfx-label { 407 + font-weight: 500; 408 + } 409 + 410 + .sfx-status { 411 + font-size: 0.85rem; 412 + color: var(--sky-gray); 174 413 } 175 414 </style>
+13
src/routes/+layout.svelte
··· 3 3 import TutorialPopup from '$lib/components/TutorialPopup.svelte'; 4 4 import Footer from '$lib/components/Footer.svelte'; 5 5 import Header from '$lib/components/Header.svelte'; 6 + import ProverbBanner from '$lib/components/ProverbBanner.svelte'; 6 7 import type { LayoutData } from './$types'; 7 8 8 9 let { children, data }: { children: any; data: LayoutData } = $props(); ··· 24 25 25 26 <Header session={data.session} /> 26 27 28 + <div class="proverb-container"> 29 + <ProverbBanner /> 30 + </div> 31 + 27 32 <div class="page-content"> 28 33 {@render children()} 29 34 </div> ··· 31 36 <Footer onReplayTutorial={handleReplayTutorial} /> 32 37 33 38 <TutorialPopup bind:this={tutorialPopup} /> 39 + 40 + <style> 41 + .proverb-container { 42 + max-width: 1200px; 43 + margin: 0 auto; 44 + padding: 0 clamp(1rem, 3vw, 2rem); 45 + } 46 + </style>
+275 -44
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { getSession } from '$lib/server/auth'; 3 - import { getDb } from '$lib/server/db'; 4 3 import { gameTitle } from '$lib/game-titles'; 5 4 5 + interface UfosRecord { 6 + did: string; 7 + collection: string; 8 + rkey: string; 9 + record: { 10 + $type: string; 11 + playerOne?: string; 12 + playerTwo?: string; 13 + boardSize: number; 14 + status: 'waiting' | 'active' | 'completed'; 15 + createdAt: string; 16 + handicap?: number; 17 + handicapStones?: Array<{ x: number; y: number }>; 18 + winner?: string; 19 + blackScore?: number; 20 + whiteScore?: number; 21 + }; 22 + time_us: number; 23 + } 24 + 25 + type UfosResponse = UfosRecord[]; 26 + 6 27 export const load: PageServerLoad = async (event) => { 7 28 const session = await getSession(event); 8 - const db = getDb(); 29 + 30 + try { 31 + // Fetch all game records from UFOs API 32 + const gamesResponse = await fetch('https://ufos-api.microcosm.blue/records?collection=boo.sky.go.game&limit=500'); 33 + 34 + if (!gamesResponse.ok) { 35 + console.error('[Homepage] Failed to fetch games from UFOs:', gamesResponse.status); 36 + return { session: session ? { did: session.did } : null, games: [] }; 37 + } 38 + 39 + const gamesData: UfosResponse = await gamesResponse.json(); 40 + 41 + if (!Array.isArray(gamesData)) { 42 + console.error('[Homepage] UFOs games response is not an array'); 43 + return { session: session ? { did: session.did } : null, games: [] }; 44 + } 45 + 46 + // Fetch backlinks from Constellation for each game to count moves and detect activity 47 + const joinedGames = new Set<string>(); 48 + const resignedGames = new Set<string>(); 49 + const gamesWithActivity = new Set<string>(); 50 + const actionCountsByGame = new Map<string, number>(); 51 + const movesByGame = new Map<string, any[]>(); 52 + 53 + console.log(`[Homepage] Fetching backlinks from Constellation for ${gamesData.length} games...`); 54 + 55 + // Fetch backlinks for each game in parallel 56 + const backlinkPromises = gamesData.map(async (item) => { 57 + const atUri = `at://${item.did}/${item.collection}/${item.rkey}`; 58 + const title = gameTitle(item.rkey); 59 + 60 + try { 61 + // Fetch moves, passes, resigns, and actions backlinks in parallel 62 + // Note: We check both boo.sky.go.resign and boo.sky.go.action for backward compatibility 63 + const [movesRes, passesRes, resignsRes, actionsRes] = await Promise.all([ 64 + fetch(`https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(atUri)}&source=boo.sky.go.move:game&limit=1000`), 65 + fetch(`https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(atUri)}&source=boo.sky.go.pass:game&limit=1000`), 66 + fetch(`https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(atUri)}&source=boo.sky.go.resign:game&limit=1000`), 67 + fetch(`https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(atUri)}&source=boo.sky.go.action:game&limit=1000`) 68 + ]); 69 + 70 + let moveCount = 0; 71 + let passCount = 0; 72 + let resignCount = 0; 73 + let hasJoin = false; 74 + let hasResign = false; 75 + let moves: any[] = []; 76 + 77 + // Process moves 78 + if (movesRes.ok) { 79 + const movesData = await movesRes.json(); 80 + if (movesData.records && Array.isArray(movesData.records)) { 81 + moveCount = movesData.records.length; 82 + moves = movesData.records; 83 + if (moveCount > 0) { 84 + gamesWithActivity.add(atUri); 85 + movesByGame.set(atUri, moves); 86 + } 87 + } 88 + } else { 89 + console.error(`[Homepage] "${title}" (${item.rkey}) - moves fetch failed:`, movesRes.status); 90 + } 91 + 92 + // Process passes 93 + if (passesRes.ok) { 94 + const passesData = await passesRes.json(); 95 + if (passesData.records && Array.isArray(passesData.records)) { 96 + passCount = passesData.records.length; 97 + if (passCount > 0) { 98 + gamesWithActivity.add(atUri); 99 + } 100 + } 101 + } 102 + 103 + // Process resigns (legacy boo.sky.go.resign lexicon) 104 + if (resignsRes.ok) { 105 + const resignsData = await resignsRes.json(); 106 + if (resignsData.records && Array.isArray(resignsData.records)) { 107 + resignCount = resignsData.records.length; 108 + if (resignCount > 0) { 109 + hasResign = true; 110 + resignedGames.add(atUri); 111 + gamesWithActivity.add(atUri); 112 + } 113 + } 114 + } 115 + 116 + // Process actions (join, resign via action lexicon, etc) 117 + if (actionsRes.ok) { 118 + const actionsData = await actionsRes.json(); 119 + if (actionsData.records && Array.isArray(actionsData.records)) { 120 + for (const record of actionsData.records) { 121 + if (record.value?.action === 'join') { 122 + hasJoin = true; 123 + joinedGames.add(atUri); 124 + gamesWithActivity.add(atUri); 125 + } else if (record.value?.action === 'resign') { 126 + hasResign = true; 127 + resignedGames.add(atUri); 128 + gamesWithActivity.add(atUri); 129 + } else if (record.value?.action === 'pass') { 130 + gamesWithActivity.add(atUri); 131 + } 132 + } 133 + } 134 + } 135 + 136 + // Total action count includes moves and passes (passes from pass collection, not action collection) 137 + const totalActions = moveCount + passCount; 138 + if (totalActions > 0) { 139 + actionCountsByGame.set(atUri, totalActions); 140 + } 141 + 142 + // Log every game's backlink counts 143 + console.log(`[Homepage] "${title}" (${item.rkey}) backlinks:`, { 144 + moves: moveCount, 145 + passes: passCount, 146 + resigns: resignCount, 147 + hasJoin, 148 + hasResign, 149 + totalActions, 150 + inActivitySet: gamesWithActivity.has(atUri) 151 + }); 152 + } catch (err) { 153 + console.error(`[Homepage] Failed to fetch backlinks for "${title}" (${item.rkey}):`, err); 154 + } 155 + }); 156 + 157 + await Promise.all(backlinkPromises); 158 + 159 + console.log(`[Homepage] Summary: ${gamesWithActivity.size} games with activity, ${joinedGames.size} with joins, ${resignedGames.size} resigned`); 160 + 161 + const data = gamesData; 162 + 163 + // Calculate timestamps 164 + const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); 165 + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 166 + 167 + // Transform UFOs records to our game format 168 + const allGames = data.map((item) => { 169 + // Construct AT URI from did, collection, and rkey 170 + const atUri = `at://${item.did}/${item.collection}/${item.rkey}`; 171 + // Convert microseconds timestamp to ISO string 172 + const indexedAt = new Date(item.time_us / 1000).toISOString(); 173 + 174 + // Determine status purely from Constellation data (ignore stale game.status field) 175 + let actualStatus: 'waiting' | 'active' | 'completed'; 176 + 177 + // Check for completion markers (scores set, winner declared, or resigned) 178 + const hasScores = item.record.blackScore !== undefined || item.record.whiteScore !== undefined; 179 + const hasWinner = item.record.winner !== undefined; 180 + const hasResigned = resignedGames.has(atUri); 181 + 182 + if (hasScores || hasWinner || hasResigned) { 183 + actualStatus = 'completed'; 184 + } else if (gamesWithActivity.has(atUri)) { 185 + // Has any activity (moves, passes, join) = active game 186 + actualStatus = 'active'; 187 + } else { 188 + // No activity yet = waiting for opponent 189 + actualStatus = 'waiting'; 190 + } 191 + 192 + // Backward compatibility: infer player_two from second move if not set 193 + let playerTwo = item.record.playerTwo || null; 194 + if (!playerTwo && movesByGame.has(atUri)) { 195 + const gameMoves = movesByGame.get(atUri)!; 196 + // Sort moves by moveNumber to find the second move 197 + // Constellation backlinks have structure: { uri, value, indexedAt } 198 + gameMoves.sort((a, b) => { 199 + const moveNumA = a.value?.moveNumber || 0; 200 + const moveNumB = b.value?.moveNumber || 0; 201 + return moveNumA - moveNumB; 202 + }); 203 + if (gameMoves.length >= 2) { 204 + // Second move should be by player_two (white) 205 + // Extract DID from AT URI (format: at://did:plc:xxx/collection/rkey) 206 + const secondMoveUri = gameMoves[1].uri; 207 + if (secondMoveUri) { 208 + const match = secondMoveUri.match(/^at:\/\/(did:[^\/]+)\//); 209 + if (match) { 210 + playerTwo = match[1]; 211 + } 212 + } 213 + // Fallback to player field in record if available 214 + if (!playerTwo && gameMoves[1].value?.player) { 215 + playerTwo = gameMoves[1].value.player; 216 + } 217 + } 218 + } 219 + 220 + const actionCount = actionCountsByGame.get(atUri) || 0; 221 + 222 + // Get title for logging 223 + const gameLogTitle = gameTitle(item.rkey); 224 + 225 + // Log status assignment 226 + console.log(`[Homepage] "${gameLogTitle}" (${item.rkey}) status assignment:`, { 227 + actualStatus, 228 + hasActivity: gamesWithActivity.has(atUri), 229 + actionCount, 230 + hasResigned, 231 + hasScores, 232 + hasWinner 233 + }); 234 + 235 + return { 236 + rkey: item.rkey, 237 + id: atUri, 238 + player_one: item.record.playerOne || null, 239 + player_two: playerTwo, 240 + board_size: item.record.boardSize, 241 + status: actualStatus, 242 + created_at: item.record.createdAt, 243 + updated_at: indexedAt, 244 + handicap: item.record.handicap || 0, 245 + winner: item.record.winner || null, 246 + last_action_type: null, 247 + action_count: actionCount, 248 + }; 249 + }); 9 250 10 - // Calculate timestamps 11 - const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); 12 - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 251 + // Filter and sort games by status 252 + let activeGames = allGames 253 + .filter((g) => g.status === 'active') 254 + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); 13 255 14 - // Fetch recent active games (updated in last 12 hours) 15 - const activeGames = await db 16 - .selectFrom('games') 17 - .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count']) 18 - .where('status', '=', 'active') 19 - .where('updated_at', '>=', twelveHoursAgo) 20 - .orderBy('updated_at', 'desc') 21 - .limit(100) 22 - .execute(); 256 + // Only apply 12-hour limit if there are more than 15 active games 257 + if (activeGames.length > 15) { 258 + activeGames = activeGames.filter((g) => new Date(g.updated_at).getTime() > new Date(twelveHoursAgo).getTime()); 259 + } 23 260 24 - // Fetch all waiting games first 25 - const allWaitingGames = await db 26 - .selectFrom('games') 27 - .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count']) 28 - .where('status', '=', 'waiting') 29 - .orderBy('created_at', 'desc') 30 - .execute(); 261 + // Limit to top 10 for display 262 + activeGames = activeGames.slice(0, 10); 31 263 32 - // Only apply 12-hour filter if there are more than 10 waiting games 33 - const waitingGames = allWaitingGames.length > 10 34 - ? allWaitingGames.filter(g => g.updated_at >= twelveHoursAgo) 35 - : allWaitingGames; 264 + const waitingGames = allGames 265 + .filter((g) => g.status === 'waiting') 266 + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) 267 + .slice(0, 10); 36 268 37 - // Fetch completed games from last 7 days 38 - const completedGames = await db 39 - .selectFrom('games') 40 - .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count', 'winner']) 41 - .where('status', '=', 'completed') 42 - .where('updated_at', '>=', sevenDaysAgo) 43 - .orderBy('updated_at', 'desc') 44 - .limit(50) 45 - .execute(); 269 + const completedGames = allGames 270 + .filter((g) => g.status === 'completed') 271 + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) 272 + .slice(0, 10); 46 273 47 - // Combine all games 48 - const games = [...activeGames, ...waitingGames, ...completedGames]; 274 + // Combine all games 275 + const games = [...activeGames, ...waitingGames, ...completedGames]; 49 276 50 - const gamesWithTitles = games.map((game) => ({ 51 - ...game, 52 - title: gameTitle(game.rkey), 53 - })); 277 + const gamesWithTitles = games.map((game) => ({ 278 + ...game, 279 + title: gameTitle(game.rkey), 280 + })); 54 281 55 - return { 56 - session: session ? { did: session.did } : null, 57 - games: gamesWithTitles, 58 - }; 282 + return { 283 + session: session ? { did: session.did } : null, 284 + games: gamesWithTitles, 285 + }; 286 + } catch (err) { 287 + console.error('Failed to load games from UFOs:', err); 288 + return { session: session ? { did: session.did } : null, games: [] }; 289 + } 59 290 };
+61 -12
src/routes/+page.svelte
··· 111 111 // Split games by status 112 112 const currentGames = $derived( 113 113 (data.games || []) 114 - .filter((g) => g.status === 'active') 114 + .filter((g) => g.status != 'completed') 115 115 .filter((g) => !showMyGamesOnly || isMyGame(g)) 116 116 .filter((g) => !showMyTurnOnly || isMyTurn(g)) 117 117 .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) ··· 387 387 <div class="section-header-with-count"> 388 388 <div class="title-with-info"> 389 389 <h2>Current Games</h2> 390 - <span class="info-icon" title="These are games from the last 12 hours. View your profile to see all games with older moves.">?</span> 390 + <span class="info-icon" title="Active games with recent moves. View your profile to see all your games.">?</span> 391 391 </div> 392 392 {#if currentGames.length > ACTIVE_PAGE_SIZE} 393 393 <span class="game-count">{currentGames.length} games (showing {paginatedActiveGames.length})</span> ··· 407 407 <div class="game-title">{game.title}</div> 408 408 <div> 409 409 <strong>{game.board_size}x{game.board_size}</strong> board 410 + {#if game.handicap && game.handicap > 0} 411 + <span class="handicap-badge">H{game.handicap}</span> 412 + {/if} 410 413 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 411 414 </div> 412 415 <div class="last-move-time" class:stale={isStale(game.updated_at)}> ··· 461 464 </div> 462 465 {/if} 463 466 {:else} 464 - <p class="empty-state">No active games in the last 12 hours. <a href="/profile/{data.session?.did}" class="link">View all your games</a></p> 467 + <p class="empty-state">No active games right now. <a href="/profile/{data.session?.did}" class="link">View all your games</a></p> 465 468 {/if} 466 469 </div> 467 470 ··· 473 476 {#if waitingGames.length > 0} 474 477 <div class="waiting-games-grid"> 475 478 {#each waitingGames as game} 479 + {@const creatorDid = game.player_one || game.player_two} 476 480 <div class="game-item-compact"> 477 481 <div class="game-title">{game.title}</div> 478 482 <div class="game-meta"> 479 483 <strong>{game.board_size}x{game.board_size}</strong> 480 - <span class="player-link-small">by {handles[game.player_one] || game.player_one.slice(0, 20)}</span> 484 + {#if creatorDid} 485 + <span class="player-link-small">by {handles[creatorDid] || creatorDid.slice(0, 20)}</span> 486 + {/if} 481 487 </div> 482 488 <a href="/game/{game.rkey}" class="button button-secondary button-sm">Watch</a> 483 489 </div> ··· 528 534 </div> 529 535 </div> 530 536 <div class="archive-card-players"> 531 - <span class:winner={game.player_one === winnerDid}>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> 537 + {#if game.player_one} 538 + <span class:winner={game.player_one === winnerDid}>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> 539 + {/if} 532 540 {#if game.player_two} 533 - <span class="vs">vs</span> 541 + {#if game.player_one} 542 + <span class="vs">vs</span> 543 + {/if} 534 544 <span class:winner={game.player_two === winnerDid}>{handles[game.player_two] || game.player_two.slice(0, 15)}</span> 535 545 {/if} 536 546 </div> ··· 647 657 <div class="section-title-row"> 648 658 <div class="title-with-info"> 649 659 <h2>Current Games</h2> 650 - <span class="info-icon" title="These are games from the last 12 hours. View your profile to see all games with older moves.">?</span> 660 + <span class="info-icon" title="Active games with recent moves. View your profile to see all your games.">?</span> 651 661 </div> 652 662 {#if currentGames.length > ACTIVE_PAGE_SIZE} 653 663 <span class="game-count">{currentGames.length} games (showing {paginatedActiveGames.length})</span> ··· 692 702 </div> 693 703 <div> 694 704 <strong>{game.board_size}x{game.board_size}</strong> board 705 + {#if game.handicap && game.handicap > 0} 706 + <span class="handicap-badge">H{game.handicap}</span> 707 + {/if} 695 708 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 696 709 </div> 697 710 <div class="last-move-time" class:stale={isStale(game.updated_at)}> ··· 754 767 {/if} 755 768 {:else} 756 769 <p class="empty-state"> 757 - {showMyGamesOnly ? 'No active games you\'re in from the last 12 hours.' : 'No active games in the last 12 hours.'} 770 + {showMyGamesOnly ? 'No active games you\'re in right now.' : 'No active games right now.'} 758 771 <a href="/profile/{data.session?.did}" class="link">View all your games</a> 759 772 </p> 760 773 {/if} ··· 768 781 {#if waitingGames.length > 0} 769 782 <div class="waiting-games-grid"> 770 783 {#each waitingGames as game} 784 + {@const creatorDid = game.player_one || game.player_two} 785 + {@const isMyGame = creatorDid === data.session?.did} 771 786 <div class="game-item-compact"> 772 787 <div class="game-title">{game.title}</div> 773 788 <div class="game-meta"> 774 789 <strong>{game.board_size}x{game.board_size}</strong> 775 - <span class="player-link-small">by {handles[game.player_one] || game.player_one.slice(0, 20)}</span> 790 + {#if game.handicap && game.handicap > 0} 791 + <span class="handicap-badge">H{game.handicap}</span> 792 + {/if} 793 + {#if creatorDid} 794 + <span class="player-link-small">by {handles[creatorDid] || creatorDid.slice(0, 20)}</span> 795 + {/if} 776 796 </div> 777 - {#if game.player_one === data.session.did} 797 + {#if isMyGame} 778 798 <a href="/game/{game.rkey}" class="button button-secondary button-sm">View</a> 779 799 {:else} 780 800 <button onclick={() => joinGame(game.rkey)} class="button button-primary button-sm">Join</button> ··· 798 818 <div class="archive-grid"> 799 819 {#each paginatedArchivedGames as game} 800 820 {@const resignedBy = getResignedBy(game)} 821 + {@const winnerDid = game.winner} 822 + {@const winnerProfile = winnerDid ? userProfiles[winnerDid] : null} 801 823 <a href="/game/{game.rkey}" class="archive-card"> 802 824 <div class="archive-card-header"> 803 825 <div class="game-title">{game.title}</div> ··· 805 827 <span class="game-status game-status-cancelled"> 806 828 {resignedBy === 'black' ? '⚫' : '⚪'} resigned 807 829 </span> 830 + {:else if winnerDid && winnerProfile} 831 + <div class="winner-avatar"> 832 + {#if winnerProfile.avatar} 833 + <img src={winnerProfile.avatar} alt="Winner" /> 834 + {:else} 835 + <div class="winner-avatar-placeholder">🏆</div> 836 + {/if} 837 + </div> 808 838 {:else} 809 839 <span class="game-status game-status-completed">completed</span> 810 840 {/if} 811 841 </div> 812 842 <div class="archive-card-meta"> 813 843 <strong>{game.board_size}x{game.board_size}</strong> 844 + {#if game.handicap && game.handicap > 0} 845 + <span class="handicap-badge">H{game.handicap}</span> 846 + {/if} 814 847 <span class="move-count">{moveCounts[game.id] != null ? `${moveCounts[game.id]} moves` : '...'}</span> 815 848 <div class="last-move-time" class:stale={isStale(game.updated_at)}> 816 849 Last move: {formatElapsedTime(game.updated_at)} 817 850 </div> 818 851 </div> 819 852 <div class="archive-card-players"> 820 - <span>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> 853 + {#if game.player_one} 854 + <span>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> 855 + {/if} 821 856 {#if game.player_two} 822 - <span class="vs">vs</span> 857 + {#if game.player_one} 858 + <span class="vs">vs</span> 859 + {/if} 823 860 <span>{handles[game.player_two] || game.player_two.slice(0, 15)}</span> 824 861 {/if} 825 862 </div> ··· 1499 1536 overflow: hidden; 1500 1537 text-overflow: ellipsis; 1501 1538 white-space: nowrap; 1539 + } 1540 + 1541 + .handicap-badge { 1542 + display: inline-block; 1543 + padding: 0.15rem 0.4rem; 1544 + border-radius: 0.25rem; 1545 + font-size: 0.7rem; 1546 + font-weight: 700; 1547 + background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%); 1548 + color: white; 1549 + text-transform: uppercase; 1550 + letter-spacing: 0.05em; 1502 1551 } 1503 1552 1504 1553 .archive-section {
+56 -38
src/routes/api/games/[id]/join/+server.ts
··· 1 1 import { json, error } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 3 import { getSession, getAgent } from '$lib/server/auth'; 4 - import { getDb } from '$lib/server/db'; 4 + 5 + function generateTid(): string { 6 + const timestamp = Date.now() * 1000; 7 + const clockid = Math.floor(Math.random() * 1024); 8 + const tid = timestamp.toString(32).padStart(11, '0') + clockid.toString(32).padStart(2, '0'); 9 + return tid; 10 + } 5 11 6 12 export const POST: RequestHandler = async (event) => { 7 13 const session = await getSession(event); ··· 14 20 const { id: rkey } = params; 15 21 16 22 try { 17 - const db = getDb(); 23 + // Fetch game from UFOs to check status 24 + const ufosResponse = await fetch(`https://ufos-api.microcosm.blue/records?collection=boo.sky.go.game&limit=1000`); 25 + if (!ufosResponse.ok) { 26 + throw error(500, 'Failed to fetch game data'); 27 + } 18 28 19 - const game = await db 20 - .selectFrom('games') 21 - .selectAll() 22 - .where('rkey', '=', rkey) 23 - .executeTakeFirst(); 29 + const allGames = await ufosResponse.json(); 30 + const game = allGames.find((g: any) => g.rkey === rkey); 24 31 25 32 if (!game) { 26 33 throw error(404, 'Game not found'); 27 34 } 28 35 29 - if (game.status !== 'waiting') { 30 - throw error(400, 'Game is not waiting for players'); 36 + // Check if user is already a player 37 + if (game.record.playerOne === session.did || game.record.playerTwo === session.did) { 38 + throw error(400, 'Cannot join your own game'); 31 39 } 32 40 33 - if (game.player_one === session.did || game.player_two === session.did) { 34 - throw error(400, 'Cannot join your own game'); 41 + // Check if game already has both players 42 + if (game.record.playerOne && game.record.playerTwo) { 43 + throw error(400, 'Game already has two players'); 35 44 } 36 45 37 - // Determine which player slot to fill 38 - const needsPlayerOne = !game.player_one; 39 - const needsPlayerTwo = !game.player_two; 46 + // Check for existing join actions to prevent duplicate joins 47 + const actionsResponse = await fetch(`https://ufos-api.microcosm.blue/records?collection=boo.sky.go.action&limit=1000`); 48 + if (actionsResponse.ok) { 49 + const allActions = await actionsResponse.json(); 50 + const gameAtUri = `at://${game.did}/${game.collection}/${game.rkey}`; 51 + const existingJoin = allActions.find((a: any) => 52 + a.record.game === gameAtUri && a.record.action === 'join' 53 + ); 40 54 41 - if (!needsPlayerOne && !needsPlayerTwo) { 42 - throw error(400, 'Game already has two players'); 55 + if (existingJoin) { 56 + throw error(400, 'Someone already joined this game'); 57 + } 43 58 } 44 59 45 - // Update the PDS game record with player two and active status 46 60 const agent = await getAgent(event); 47 61 if (!agent) { 48 62 throw error(401, 'Failed to get authenticated agent'); 49 63 } 50 64 51 - // The game record lives in player one's repo — we need to update it. 52 - // However, only the repo owner can write to their own repo. 53 - // For now, we use the joining player's agent to create a reference, 54 - // but the game record update must happen via the creator's session. 55 - // Since we can't write to another user's repo, we update DB only here. 56 - // The PDS record will be updated when the game creator's client next loads. 57 - 65 + const actionRkey = generateTid(); 58 66 const now = new Date().toISOString(); 59 - const updateData: any = { 60 - status: 'active', 61 - updated_at: now, 67 + const gameAtUri = `at://${game.did}/${game.collection}/${game.rkey}`; 68 + 69 + // Create join action record 70 + const actionRecord = { 71 + $type: 'boo.sky.go.action', 72 + game: gameAtUri, 73 + player: session.did, 74 + action: 'join', 75 + createdAt: now, 62 76 }; 63 77 64 - if (needsPlayerOne) { 65 - updateData.player_one = session.did; 66 - } else { 67 - updateData.player_two = session.did; 78 + const result = await (agent as any).post('com.atproto.repo.createRecord', { 79 + input: { 80 + repo: session.did, 81 + collection: 'boo.sky.go.action', 82 + rkey: actionRkey, 83 + record: actionRecord, 84 + }, 85 + }); 86 + 87 + if (!result.ok) { 88 + throw new Error(`Failed to create action record: ${result.data.message}`); 68 89 } 69 90 70 - await db 71 - .updateTable('games') 72 - .set(updateData) 73 - .where('rkey', '=', rkey) 74 - .execute(); 75 - 76 - return json({ success: true }); 91 + return json({ success: true, uri: result.data.uri }); 77 92 } catch (err) { 78 93 console.error('Failed to join game:', err); 94 + if (err instanceof Error && 'status' in err) { 95 + throw err; 96 + } 79 97 throw error(500, 'Failed to join game'); 80 98 } 81 99 };
+5 -1
src/routes/api/games/[id]/pass/+server.ts
··· 101 101 102 102 if (isConsecutivePass) { 103 103 // Two consecutive passes — game over 104 + // Extract creator DID from AT URI to update the correct repo 105 + const atUriMatch = game.id.match(/^at:\/\/(did:[^/]+)\//); 106 + const creatorDid = atUriMatch ? atUriMatch[1] : game.creator_did || game.player_one; 107 + 104 108 // Update PDS game record 105 109 const updatedGameRecord = { 106 110 $type: 'boo.sky.go.game', ··· 113 117 114 118 const updateResult = await (agent as any).post('com.atproto.repo.putRecord', { 115 119 input: { 116 - repo: game.player_one, 120 + repo: creatorDid, 117 121 collection: 'boo.sky.go.game', 118 122 rkey: game.rkey, 119 123 record: updatedGameRecord,
+170 -150
src/routes/api/games/[id]/share-reaction/+server.ts
··· 1 - import { json, error } from '@sveltejs/kit'; 2 - import type { RequestHandler } from './$types'; 3 - import { getSession, getAgent } from '$lib/server/auth'; 4 - import { Resvg } from '@resvg/resvg-js'; 5 - import { parse } from 'twemoji-parser'; 6 - import { fetchUserProfile, resolveDidToHandle } from '$lib/atproto-client'; 1 + import { json, error } from "@sveltejs/kit"; 2 + import type { RequestHandler } from "./$types"; 3 + import { getSession, getAgent } from "$lib/server/auth"; 4 + import { getDb } from "$lib/server/db"; 5 + import { fetchUserProfile, fetchGameRecord } from "$lib/atproto-client"; 6 + import { readFileSync } from "fs"; 7 + import { join } from "path"; 8 + import { parse } from "twemoji-parser"; 7 9 8 10 async function fetchImageAsBase64(url: string): Promise<string | null> { 9 11 try { 10 12 const response = await fetch(url); 11 13 if (!response.ok) return null; 12 - 13 14 const arrayBuffer = await response.arrayBuffer(); 14 15 const buffer = Buffer.from(arrayBuffer); 15 - const base64 = buffer.toString('base64'); 16 - const contentType = response.headers.get('content-type') || 'image/jpeg'; 17 - 18 - return `data:${contentType};base64,${base64}`; 16 + return `data:${response.headers.get("content-type")};base64,${buffer.toString("base64")}`; 19 17 } catch (err) { 20 - console.error('Failed to fetch image:', err); 18 + console.error("Failed to fetch image:", err); 21 19 return null; 22 20 } 23 21 } 24 22 25 - async function getEmojiSvg(emoji: string): Promise<string> { 23 + function getEmojiUrl(emoji: string): string | null { 26 24 try { 27 25 const parsed = parse(emoji); 28 26 if (parsed.length === 0) { 29 - console.log('No emoji parsed'); 30 - return ''; 27 + return null; 31 28 } 32 - 33 - const url = parsed[0].url; 34 - const filename = url.split('/').pop() || ''; 35 - const codepoint = filename.replace(/\.(png|svg)$/, ''); 36 - console.log('Emoji codepoint:', codepoint); 37 - 38 - const svgUrl = `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/${codepoint}.svg`; 39 - console.log('Fetching from:', svgUrl); 29 + return parsed[0].url; 30 + } catch (err) { 31 + console.error("Failed to parse emoji:", err); 32 + return null; 33 + } 34 + } 40 35 41 - const response = await fetch(svgUrl); 42 - if (!response.ok) { 43 - console.log('Fetch failed:', response.status); 44 - return ''; 45 - } 36 + async function svgToPng(svgString: string): Promise<Buffer> { 37 + // Use puppeteer or similar if available, otherwise use sharp 38 + // For now, we'll use a canvas-based approach with node-canvas 39 + const { createCanvas, loadImage } = await import("canvas"); 46 40 47 - const svgContent = await response.text(); 48 - console.log('SVG content length:', svgContent.length); 41 + const img = await loadImage(Buffer.from(svgString)); 42 + const canvas = createCanvas(img.width, img.height); 43 + const ctx = canvas.getContext("2d"); 44 + ctx.drawImage(img, 0, 0); 49 45 50 - const svgMatch = svgContent.match(/<svg[^>]*>([\s\S]*)<\/svg>/); 51 - if (svgMatch) { 52 - return svgMatch[1]; 53 - } 54 - console.log('No SVG match found'); 55 - return ''; 56 - } catch (err) { 57 - console.error('Failed to fetch emoji SVG:', err); 58 - return ''; 59 - } 46 + return canvas.toBuffer("image/png"); 60 47 } 61 48 62 49 export const POST: RequestHandler = async (event) => { 63 50 const session = await getSession(event); 64 51 if (!session) { 65 - throw error(401, 'Not authenticated'); 52 + throw error(401, "Not authenticated"); 66 53 } 67 54 68 - const { request } = event; 55 + const { request, params } = event; 69 56 const { text, emoji, gameUrl } = await request.json(); 70 57 71 58 if (!text || !gameUrl) { 72 - throw error(400, 'Text and gameUrl are required'); 59 + throw error(400, "Text and gameUrl are required"); 73 60 } 74 61 75 62 try { 76 - const agent = await getAgent(event); 77 - if (!agent) { 78 - throw error(401, 'Failed to get authenticated agent'); 63 + const db = getDb(); 64 + const gameId = params.id; 65 + 66 + // Get game data from database 67 + const gameData = await db 68 + .selectFrom("games") 69 + .selectAll() 70 + .where("rkey", "=", gameId) 71 + .executeTakeFirst(); 72 + 73 + if (!gameData) { 74 + throw error(404, "Game not found"); 79 75 } 80 76 81 - // Format the post text with emoji 82 - const emojiText = emoji || '🎮'; 83 - const postText = `${emojiText} ${text} ${emojiText}\n\nView this game at ${gameUrl}`; 77 + // Fetch game record to get player info 78 + const gameRecord = await fetchGameRecord(gameData.player_one, gameId); 79 + if (!gameRecord) { 80 + throw error(404, "Game record not found"); 81 + } 84 82 85 - // Fetch user profile and avatar 86 - const userProfile = await fetchUserProfile(session.did); 87 - const userHandle = await resolveDidToHandle(session.did); 88 - const userAvatarBase64 = userProfile?.avatar ? await fetchImageAsBase64(userProfile.avatar) : null; 83 + // Determine user and opponent 84 + const userDid = session.did; 85 + const opponentDid = 86 + userDid === gameData.player_one 87 + ? gameData.player_two 88 + : gameData.player_one; 89 89 90 - // Fetch emoji SVG 91 - const emojiPath = await getEmojiSvg(emojiText); 90 + if (!opponentDid) { 91 + throw error(400, "No opponent in this game"); 92 + } 92 93 93 - // Generate a cloud image with emojis for the embed 94 - const svg = ` 95 - <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> 96 - <defs> 97 - <linearGradient id="cloudGradient" x1="0%" y1="0%" x2="0%" y2="100%"> 98 - <stop offset="0%" style="stop-color:#e8f4ff;stop-opacity:1" /> 99 - <stop offset="100%" style="stop-color:#b3d9ff;stop-opacity:1" /> 100 - </linearGradient> 101 - <linearGradient id="skyGradient" x1="0%" y1="0%" x2="0%" y2="100%"> 102 - <stop offset="0%" style="stop-color:#87ceeb;stop-opacity:1" /> 103 - <stop offset="100%" style="stop-color:#f0f8ff;stop-opacity:1" /> 104 - </linearGradient> 105 - </defs> 94 + // Fetch both profiles 95 + const [userProfile, opponentProfile] = await Promise.all([ 96 + fetchUserProfile(userDid), 97 + fetchUserProfile(opponentDid), 98 + ]); 106 99 107 - <!-- Sky background --> 108 - <rect width="600" height="400" fill="url(#skyGradient)"/> 100 + // Load template image as base64 101 + const templatePath = join(process.cwd(), "static", "reaction-template.png"); 102 + const templateBuffer = readFileSync(templatePath); 103 + const templateBase64 = `data:image/png;base64,${templateBuffer.toString("base64")}`; 109 104 110 - <!-- Clouds --> 111 - <ellipse cx="300" cy="250" rx="200" ry="120" fill="url(#cloudGradient)" opacity="0.9"/> 112 - <ellipse cx="200" cy="280" rx="140" ry="90" fill="url(#cloudGradient)" opacity="0.85"/> 113 - <ellipse cx="400" cy="280" rx="140" ry="90" fill="url(#cloudGradient)" opacity="0.85"/> 105 + // Fetch avatars as base64 106 + const userAvatarBase64 = userProfile?.avatar 107 + ? await fetchImageAsBase64(userProfile.avatar) 108 + : null; 114 109 115 - <!-- Cloud Go text --> 110 + const opponentAvatarBase64 = opponentProfile?.avatar 111 + ? await fetchImageAsBase64(opponentProfile.avatar) 112 + : null; 116 113 117 - <!-- User avatar --> 118 - <g transform="translate(300, 230)"> 119 - ${userAvatarBase64 120 - ? `<clipPath id="userClip"><circle cx="0" cy="0" r="70"/></clipPath> 121 - <image href="${userAvatarBase64}" x="-70" y="-70" width="140" height="140" clip-path="url(#userClip)"/>` 122 - : `<circle cx="0" cy="0" r="70" fill="#2d3748"/> 123 - <text x="0" y="20" font-family="Arial, sans-serif" font-size="56" fill="white" text-anchor="middle" font-weight="bold">${userHandle.charAt(0).toUpperCase()}</text>`} 124 - <circle cx="0" cy="0" r="70" fill="none" stroke="#5A7A90" stroke-width="4"/> 125 - </g> 114 + // Get emoji URL 115 + const emojiText = emoji || "🎮"; 116 + const emojiUrl = getEmojiUrl(emojiText); 126 117 127 - <!-- Emojis around the avatar --> 128 - ${emojiPath ? ` 129 - <g transform="translate(160, 155)"> 130 - <svg viewBox="0 0 36 36" width="60" height="60"> 131 - ${emojiPath} 132 - </svg> 133 - </g> 134 - <g transform="translate(380, 155)"> 135 - <svg viewBox="0 0 36 36" width="60" height="60"> 136 - ${emojiPath} 137 - </svg> 138 - </g> 139 - <g transform="translate(270, 325)"> 140 - <svg viewBox="0 0 36 36" width="60" height="60"> 141 - ${emojiPath} 142 - </svg> 143 - </g> 144 - <g transform="translate(160, 280)"> 145 - <svg viewBox="0 0 36 36" width="60" height="60"> 146 - ${emojiPath} 147 - </svg> 148 - </g> 149 - <g transform="translate(380, 280)"> 150 - <svg viewBox="0 0 36 36" width="60" height="60"> 151 - ${emojiPath} 152 - </svg> 153 - </g>` : `<!-- No emoji path -->`} 118 + // Create SVG with all elements 119 + const avatarSize = 150; 120 + const emojiSize = 80; 121 + 122 + const svgString = ` 123 + <svg width="1134" height="534" xmlns="http://www.w3.org/2000/svg"> 124 + <!-- Background template --> 125 + <image href="${templateBase64}" width="1134" height="534" /> 126 + 127 + <!-- User avatar (red circle - left player) --> 128 + ${ 129 + userAvatarBase64 130 + ? ` 131 + <defs> 132 + <clipPath id="user-circle"> 133 + <circle cx="${345 + avatarSize / 2}" cy="${105 + avatarSize / 2}" r="${avatarSize / 2}" /> 134 + </clipPath> 135 + </defs> 136 + <image 137 + href="${userAvatarBase64}" 138 + x="343" 139 + y="103" 140 + width="${avatarSize}" 141 + height="${avatarSize}" 142 + clip-path="url(#user-circle)" 143 + preserveAspectRatio="xMidYMid slice" 144 + /> 145 + ` 146 + : "" 147 + } 148 + 149 + <!-- Opponent avatar (blue circle - right player) --> 150 + ${ 151 + opponentAvatarBase64 152 + ? ` 153 + <defs> 154 + <clipPath id="opponent-circle"> 155 + <circle cx="${745 + avatarSize / 2}" cy="${195 + avatarSize / 2}" r="${avatarSize / 2}" /> 156 + </clipPath> 157 + </defs> 158 + <image 159 + href="${opponentAvatarBase64}" 160 + x="745" 161 + y="195" 162 + width="${avatarSize}" 163 + height="${avatarSize}" 164 + clip-path="url(#opponent-circle)" 165 + preserveAspectRatio="xMidYMid slice" 166 + /> 167 + ` 168 + : "" 169 + } 170 + 171 + <!-- Emoji in thought bubble --> 172 + ${ 173 + emojiUrl 174 + ? ` 175 + <image href="${emojiUrl}" x="670" y="110" width="${emojiSize}" height="${emojiSize}" /> 176 + ` 177 + : "" 178 + } 154 179 </svg> 155 - `.trim(); 180 + `; 156 181 157 - // Convert SVG to PNG using resvg 158 - const resvg = new Resvg(svg, { 159 - fitTo: { 160 - mode: 'width', 161 - value: 600, 162 - }, 163 - }); 164 - const pngData = resvg.render(); 165 - const pngBuffer = pngData.asPng(); 182 + // Convert SVG to PNG 183 + const finalImage = await svgToPng(svgString); 166 184 167 - // Verify PNG buffer is ready 168 - if (!pngBuffer || pngBuffer.length === 0) { 169 - throw new Error('PNG buffer is empty'); 185 + // Upload to Bluesky 186 + const agent = await getAgent(event); 187 + if (!agent) { 188 + throw error(401, "Failed to get authenticated agent"); 170 189 } 171 - console.log('PNG buffer size:', pngBuffer.length, 'bytes'); 172 190 173 - // Upload the blob using the handler directly with proper Content-Type 174 - const uploadResponse = await (agent as any).handler('/xrpc/com.atproto.repo.uploadBlob', { 175 - method: 'POST', 176 - headers: { 177 - 'Content-Type': 'image/png', 191 + const uploadResponse = await (agent as any).handler( 192 + "/xrpc/com.atproto.repo.uploadBlob", 193 + { 194 + method: "POST", 195 + headers: { 196 + "Content-Type": "image/png", 197 + }, 198 + body: finalImage, 178 199 }, 179 - body: pngBuffer, 180 - }); 200 + ); 181 201 182 202 if (!uploadResponse.ok) { 183 203 const errorText = await uploadResponse.text(); 184 - console.error('Blob upload failed:', uploadResponse.status, errorText); 204 + console.error("Blob upload failed:", uploadResponse.status, errorText); 185 205 throw new Error(`Blob upload failed: ${uploadResponse.status}`); 186 206 } 187 207 188 208 const blobData = await uploadResponse.json(); 189 - console.log('Blob upload response:', JSON.stringify(blobData, null, 2)); 190 - 191 - // Check if blob is in the response 192 209 const blob = blobData?.blob; 210 + 193 211 if (!blob) { 194 - console.error('Could not find blob in response. Full response:', blobData); 195 - throw new Error('Blob upload returned invalid response'); 212 + throw new Error("Blob upload returned invalid response"); 196 213 } 197 214 198 - console.log('Blob uploaded successfully:', blob); 215 + // Create post text 216 + const postText = `${emojiText} ${text} ${emojiText}\n\nView this game at ${gameUrl}`; 199 217 200 - // Calculate byte positions for the URL facet 218 + // Calculate byte positions for URL facet 201 219 const encoder = new TextEncoder(); 202 220 const textBeforeUrl = `${emojiText} ${text} ${emojiText}\n\nView this game at `; 203 221 const byteStart = encoder.encode(textBeforeUrl).byteLength; 204 222 const byteEnd = byteStart + encoder.encode(gameUrl).byteLength; 205 223 206 - // Create a Bluesky post with link facet and external embed 207 - const result = await (agent as any).post('com.atproto.repo.createRecord', { 224 + // Create Bluesky post 225 + const result = await (agent as any).post("com.atproto.repo.createRecord", { 208 226 input: { 209 227 repo: session.did, 210 - collection: 'app.bsky.feed.post', 228 + collection: "app.bsky.feed.post", 211 229 record: { 212 - $type: 'app.bsky.feed.post', 230 + $type: "app.bsky.feed.post", 213 231 text: postText, 214 232 facets: [ 215 233 { ··· 219 237 }, 220 238 features: [ 221 239 { 222 - $type: 'app.bsky.richtext.facet#link', 240 + $type: "app.bsky.richtext.facet#link", 223 241 uri: gameUrl, 224 242 }, 225 243 ], 226 244 }, 227 245 ], 228 246 embed: { 229 - $type: 'app.bsky.embed.external', 247 + $type: "app.bsky.embed.external", 230 248 external: { 231 249 uri: gameUrl, 232 - title: 'Cloud Go Game', 250 + title: "Cloud Go Game", 233 251 description: text, 234 252 thumb: blob, 235 253 }, ··· 240 258 }); 241 259 242 260 if (!result.ok) { 243 - throw new Error(`Failed to create post: ${result.data?.message || 'Unknown error'}`); 261 + throw new Error( 262 + `Failed to create post: ${result.data?.message || "Unknown error"}`, 263 + ); 244 264 } 245 265 246 266 return json({ success: true, uri: result.data.uri }); 247 267 } catch (err) { 248 - console.error('Failed to share to Bluesky:', err); 249 - throw error(500, 'Failed to share to Bluesky'); 268 + console.error("Failed to share to Bluesky:", err); 269 + throw error(500, "Failed to share to Bluesky"); 250 270 } 251 271 };
+19 -3
src/routes/game/[id]/+page.server.ts
··· 11 11 12 12 const game = await db 13 13 .selectFrom('games') 14 - .select(['rkey', 'id', 'creator_did', 'player_one', 'player_two', 'board_size', 'status', 'created_at']) 14 + .select(['rkey', 'id', 'creator_did', 'player_one', 'player_two', 'board_size', 'created_at']) 15 15 .where('rkey', '=', rkey) 16 16 .executeTakeFirst(); 17 17 ··· 23 23 const atUriMatch = game.id.match(/^at:\/\/(did:[^/]+)\//); 24 24 const creatorDidFromUri = atUriMatch ? atUriMatch[1] : null; 25 25 26 + const finalCreatorDid = creatorDidFromUri || game.creator_did || game.player_one; 27 + 28 + console.log('[game +page.server] Game data:', { 29 + gameId: game.id, 30 + rkey: game.rkey, 31 + creatorDidFromUri, 32 + creator_did: game.creator_did, 33 + player_one: game.player_one, 34 + player_two: game.player_two, 35 + finalCreatorDid 36 + }); 37 + 38 + if (!finalCreatorDid) { 39 + console.error('[game +page.server] WARNING: No creator DID found for game:', game.id); 40 + } 41 + 26 42 return { 27 43 session, 28 44 gameRkey: game.rkey, 29 - creatorDid: creatorDidFromUri || game.creator_did || game.player_one, 45 + creatorDid: finalCreatorDid, 46 + playerOneDid: game.player_one, 30 47 playerTwoDid: game.player_two, 31 48 gameAtUri: game.id, 32 49 boardSize: game.board_size, 33 - status: game.status, 34 50 }; 35 51 };
+142 -72
src/routes/game/[id]/+page.svelte
··· 115 115 return `${col}${row}`; 116 116 } 117 117 118 - // DB index is authoritative for status. PDS game record is authoritative for 119 - // player assignments, scores, and winner (written by game creator). 120 - const gameStatus = $derived(data.status); 118 + // PDS game record is authoritative for player assignments, scores, and winner (written by game creator). 121 119 const gameBoardSize = $derived(gameRecord?.boardSize ?? data.boardSize); 122 120 const gamePlayerOne = $derived(gameRecord?.playerOne ?? data.creatorDid); 123 121 const gamePlayerTwo = $derived(gameRecord?.playerTwo ?? data.playerTwoDid); 124 122 const gameWinner = $derived(gameRecord?.winner ?? null); 125 123 const gameBlackScore = $derived(gameRecord?.blackScore ?? null); 126 124 const gameWhiteScore = $derived(gameRecord?.whiteScore ?? null); 125 + 126 + // Derive game status from PDS data (moves, resigns, scores) 127 + const gameStatus = $derived.by(() => { 128 + const status = 129 + // Game is completed if someone resigned 130 + resigns.length > 0 ? 'completed' : 131 + // Game is completed if scores have been submitted 132 + gameBlackScore !== null ? 'completed' : 133 + // Game is active if there are any moves or passes 134 + moves.length > 0 || passes.length > 0 ? 'active' : 135 + // Game is active if a second player has joined 136 + gamePlayerTwo ? 'active' : 137 + // Otherwise waiting for opponent 138 + 'waiting'; 139 + 140 + console.log('[gameStatus] Computed status:', status, { 141 + resignsCount: resigns.length, 142 + movesCount: moves.length, 143 + passesCount: passes.length, 144 + hasBlackScore: gameBlackScore !== null, 145 + hasPlayerTwo: !!gamePlayerTwo 146 + }); 147 + 148 + return status; 149 + }); 150 + 127 151 // Get cancellation info from resign records (each player writes to their own PDS) 128 152 const gameCancelledBy = $derived(resigns.length > 0 ? resigns[0].color : null); 129 153 ··· 288 312 } 289 313 290 314 async function loadGameData() { 291 - // Fetch game record from PDS first to get the correct player assignments 292 - const record = await fetchGameRecord(data.creatorDid, data.gameRkey); 293 - if (record) { 294 - gameRecord = record; 315 + console.log('[loadGameData] Starting...', { creatorDid: data.creatorDid, gameRkey: data.gameRkey }); 316 + let record: GameRecord | null = null; 317 + 318 + try { 319 + // Fetch game record from PDS first to get the correct player assignments 320 + if (!data.creatorDid || !data.gameRkey) { 321 + console.warn('[loadGameData] Missing creatorDid or gameRkey, skipping PDS fetch'); 322 + loadingGame = false; 323 + } else { 324 + console.log('[loadGameData] Fetching game record...'); 325 + try { 326 + record = await fetchGameRecord(data.creatorDid, data.gameRkey); 327 + console.log('[loadGameData] Game record fetched:', record); 328 + if (record) { 329 + gameRecord = record; 330 + } 331 + } catch (fetchErr) { 332 + console.warn('[loadGameData] Failed to fetch game record, continuing with database values:', fetchErr); 333 + } 334 + loadingGame = false; 335 + console.log('[loadGameData] loadingGame set to false'); 336 + } 337 + 338 + // Use database values as fallback if PDS record is stale 339 + const playerOneDid = record?.playerOne || data.playerOneDid; 340 + const playerTwoDid = record?.playerTwo || data.playerTwoDid; 295 341 296 - // Resolve handles and fetch profiles based on the game record's player assignments 297 - if (record.playerOne) { 298 - resolveDidToHandle(record.playerOne).then((h) => { 342 + // Resolve handles and fetch profiles using the final player DIDs (with database fallback) 343 + if (playerOneDid) { 344 + resolveDidToHandle(playerOneDid).then((h) => { 299 345 playerOneHandle = h; 300 - }); 301 - fetchUserProfile(record.playerOne).then((p) => { 346 + }).catch(err => console.error('Failed to resolve player one handle:', err)); 347 + fetchUserProfile(playerOneDid).then((p) => { 302 348 playerOneProfile = p; 303 - }); 304 - fetchCloudGoProfile(record.playerOne).then((p) => { 349 + }).catch(err => console.error('Failed to fetch player one profile:', err)); 350 + fetchCloudGoProfile(playerOneDid).then((p) => { 305 351 playerOneCloudGoProfile = p; 306 - }); 352 + }).catch(err => console.error('Failed to fetch player one Cloud Go profile:', err)); 307 353 } 308 354 309 - if (record.playerTwo) { 310 - resolveDidToHandle(record.playerTwo).then((h) => { 355 + if (playerTwoDid) { 356 + resolveDidToHandle(playerTwoDid).then((h) => { 311 357 playerTwoHandle = h; 312 - }); 313 - fetchUserProfile(record.playerTwo).then((p) => { 358 + }).catch(err => console.error('Failed to resolve player two handle:', err)); 359 + fetchUserProfile(playerTwoDid).then((p) => { 314 360 playerTwoProfile = p; 315 - }); 316 - fetchCloudGoProfile(record.playerTwo).then((p) => { 361 + }).catch(err => console.error('Failed to fetch player two profile:', err)); 362 + fetchCloudGoProfile(playerTwoDid).then((p) => { 317 363 playerTwoCloudGoProfile = p; 318 - }); 364 + }).catch(err => console.error('Failed to fetch player two Cloud Go profile:', err)); 319 365 } 320 - } 321 - loadingGame = false; 322 366 323 - // Fetch moves, passes, and resigns from both players' PDS repos. 324 - // Use the actual player DIDs from the game record 325 - const playerOneDid = record?.playerOne || data.creatorDid; 326 - const playerTwoDid = record?.playerTwo || data.playerTwoDid; 327 - 328 - const result = await fetchGameActionsFromPds( 329 - playerOneDid, 330 - playerTwoDid, 331 - data.gameAtUri 332 - ); 333 - moves = result.moves; 334 - passes = result.passes; 335 - resigns = result.resigns; 336 - loadingMoves = false; 367 + // Fetch moves, passes, and resigns from both players' PDS repos. 368 + console.log('[loadGameData] Fetching game actions...', { playerOneDid, playerTwoDid, gameAtUri: data.gameAtUri }); 369 + const result = await fetchGameActionsFromPds( 370 + playerOneDid, 371 + playerTwoDid, 372 + data.gameAtUri 373 + ); 374 + console.log('[loadGameData] Game actions fetched:', { 375 + moves: result.moves.length, 376 + passes: result.passes.length, 377 + resigns: result.resigns.length 378 + }); 379 + moves = result.moves; 380 + passes = result.passes; 381 + resigns = result.resigns; 382 + loadingMoves = false; 383 + console.log('[loadGameData] loadingMoves set to false'); 337 384 338 - // Play sound when game opens 339 - soundManager.play('opened_game'); 385 + // Play sound when game opens 386 + soundManager.play('opened_game'); 340 387 341 - // Check for move parameter in URL after moves are loaded 342 - if (browser && moves.length > 0) { 343 - const moveParam = $page.url.searchParams.get('move'); 344 - if (moveParam) { 345 - const moveNum = parseInt(moveParam, 10); 346 - // moveNum is 1-indexed from URL, convert to 0-indexed 347 - if (!isNaN(moveNum) && moveNum >= 1 && moveNum <= moves.length) { 348 - // Delay to ensure boardRef is available 349 - setTimeout(() => { 350 - reviewMove(moveNum - 1, false); 351 - }, 100); 388 + // Check for move parameter in URL after moves are loaded 389 + if (browser && moves.length > 0) { 390 + const moveParam = $page.url.searchParams.get('move'); 391 + if (moveParam) { 392 + const moveNum = parseInt(moveParam, 10); 393 + // moveNum is 1-indexed from URL, convert to 0-indexed 394 + if (!isNaN(moveNum) && moveNum >= 1 && moveNum <= moves.length) { 395 + // Delay to ensure boardRef is available 396 + setTimeout(() => { 397 + reviewMove(moveNum - 1, false); 398 + }, 100); 399 + } 352 400 } 353 401 } 354 - } 355 402 356 - // Fetch reactions for the game (async, don't block) 357 - fetchGameReactions(data.gameAtUri).then((reactionsMap) => { 358 - reactions = reactionsMap; 359 - loadingReactions = false; 360 - console.log('Reactions fetched:', reactions); 403 + // Fetch reactions for the game (async, don't block) 404 + fetchGameReactions(data.gameAtUri).then((reactionsMap) => { 405 + reactions = reactionsMap; 406 + loadingReactions = false; 407 + console.log('Reactions fetched:', reactions); 361 408 362 - // Resolve handles for reaction authors 363 - const authorDids = new Set<string>(); 364 - for (const reacts of reactionsMap.values()) { 365 - for (const r of reacts) { 366 - authorDids.add(r.author); 409 + // Resolve handles for reaction authors 410 + const authorDids = new Set<string>(); 411 + for (const reacts of reactionsMap.values()) { 412 + for (const r of reacts) { 413 + authorDids.add(r.author); 414 + } 415 + } 416 + for (const did of authorDids) { 417 + resolveDidToHandle(did).then((h) => { 418 + reactionHandles = { ...reactionHandles, [did]: h }; 419 + }).catch(err => console.error('Failed to resolve reaction author handle:', err)); 367 420 } 368 - } 369 - for (const did of authorDids) { 370 - resolveDidToHandle(did).then((h) => { 371 - reactionHandles = { ...reactionHandles, [did]: h }; 372 - }); 373 - } 374 - }); 421 + }).catch(err => console.error('Failed to fetch reactions:', err)); 422 + } catch (err) { 423 + console.error('[loadGameData] ERROR - Failed to load game data:', err); 424 + console.error('[loadGameData] ERROR - Stack trace:', err instanceof Error ? err.stack : 'No stack trace'); 425 + // Ensure loading states are cleared even on error 426 + loadingGame = false; 427 + loadingMoves = false; 428 + console.log('[loadGameData] ERROR - Loading states cleared'); 429 + } 430 + console.log('[loadGameData] Completed'); 375 431 } 376 432 377 433 async function handleMove(x: number, y: number, captures: number) { ··· 696 752 } 697 753 698 754 onMount(() => { 699 - loadGameData(); 755 + console.log('[onMount] Starting, browser:', browser); 756 + 757 + // Safety timeout: force clear loading states after 10 seconds 758 + const timeoutId = setTimeout(() => { 759 + console.warn('[onMount] TIMEOUT - Forcing loading states to false'); 760 + loadingGame = false; 761 + loadingMoves = false; 762 + }, 10000); 763 + 764 + loadGameData().finally(() => { 765 + clearTimeout(timeoutId); 766 + console.log('[onMount] loadGameData completed'); 767 + }); 700 768 701 769 // Initialize notifications 702 770 notifications = new GameNotifications(); ··· 730 798 731 799 window.addEventListener('keydown', handleKeyPress); 732 800 733 - if (data.status === 'active') { 801 + // Start firehose connection for real-time updates 802 + // (Will be closed automatically if game is completed) 803 + if (browser) { 734 804 firehose = new GameFirehose( 735 805 data.gameAtUri, 736 806 data.creatorDid, ··· 973 1043 <svelte:head> 974 1044 <title>{gameTitle(data.gameRkey)} - Go Game</title> 975 1045 <meta property="og:title" content="{gameTitle(data.gameRkey)} - Cloud Go" /> 976 - <meta property="og:description" content="A {data.boardSize}x{data.boardSize} Go game on the AT Protocol. {data.status === 'active' ? 'Game in progress!' : data.status === 'waiting' ? 'Waiting for opponent.' : 'Game completed.'}" /> 1046 + <meta property="og:description" content="A {data.boardSize}x{data.boardSize} Go game on the AT Protocol. {gameStatus === 'active' ? 'Game in progress!' : gameStatus === 'waiting' ? 'Waiting for opponent.' : 'Game completed.'}" /> 977 1047 <meta property="og:type" content="website" /> 978 1048 <meta property="og:image" content="{typeof window !== 'undefined' ? window.location.origin : ''}/og-image/{data.gameRkey}" /> 979 1049 <meta property="og:image:width" content="1200" />