Chess on the ATmosphere checkmate.blue
chess
18
fork

Configure Feed

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

Fix bugs from code review and add test suite

- Fix polling fallback: pass agent to JetstreamConnection so polling
works when WebSocket disconnects
- Add error handling to writeMove: catch failures, roll back local
chess state, show error banner
- Fix challenge acceptance duplicates: check for existing game record
before creating a new one
- Document firehose limitation in waitForOpponent
- Resolve player handles via Slingshot after game load
- Add Vitest with 29 tests covering game-logic and atproto modules

authored by

Scott Hadfield and committed by tangled.org b4f3377e c0ea2873

+807 -16
+341 -1
package-lock.json
··· 22 22 "svelte-check": "^4.4.2", 23 23 "tailwindcss": "^4.1.18", 24 24 "typescript": "^5.9.3", 25 - "vite": "^7.3.1" 25 + "vite": "^7.3.1", 26 + "vitest": "^4.1.2" 26 27 } 27 28 }, 28 29 "node_modules/@atproto-labs/did-resolver": { ··· 1552 1553 "vite": "^5.2.0 || ^6 || ^7 || ^8" 1553 1554 } 1554 1555 }, 1556 + "node_modules/@types/chai": { 1557 + "version": "5.2.3", 1558 + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", 1559 + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", 1560 + "dev": true, 1561 + "license": "MIT", 1562 + "dependencies": { 1563 + "@types/deep-eql": "*", 1564 + "assertion-error": "^2.0.1" 1565 + } 1566 + }, 1555 1567 "node_modules/@types/cookie": { 1556 1568 "version": "0.6.0", 1557 1569 "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", 1558 1570 "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", 1571 + "dev": true, 1572 + "license": "MIT" 1573 + }, 1574 + "node_modules/@types/deep-eql": { 1575 + "version": "4.0.2", 1576 + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", 1577 + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", 1559 1578 "dev": true, 1560 1579 "license": "MIT" 1561 1580 }, ··· 1587 1606 "url": "https://opencollective.com/typescript-eslint" 1588 1607 } 1589 1608 }, 1609 + "node_modules/@vitest/expect": { 1610 + "version": "4.1.2", 1611 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", 1612 + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", 1613 + "dev": true, 1614 + "license": "MIT", 1615 + "dependencies": { 1616 + "@standard-schema/spec": "^1.1.0", 1617 + "@types/chai": "^5.2.2", 1618 + "@vitest/spy": "4.1.2", 1619 + "@vitest/utils": "4.1.2", 1620 + "chai": "^6.2.2", 1621 + "tinyrainbow": "^3.1.0" 1622 + }, 1623 + "funding": { 1624 + "url": "https://opencollective.com/vitest" 1625 + } 1626 + }, 1627 + "node_modules/@vitest/mocker": { 1628 + "version": "4.1.2", 1629 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", 1630 + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", 1631 + "dev": true, 1632 + "license": "MIT", 1633 + "dependencies": { 1634 + "@vitest/spy": "4.1.2", 1635 + "estree-walker": "^3.0.3", 1636 + "magic-string": "^0.30.21" 1637 + }, 1638 + "funding": { 1639 + "url": "https://opencollective.com/vitest" 1640 + }, 1641 + "peerDependencies": { 1642 + "msw": "^2.4.9", 1643 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" 1644 + }, 1645 + "peerDependenciesMeta": { 1646 + "msw": { 1647 + "optional": true 1648 + }, 1649 + "vite": { 1650 + "optional": true 1651 + } 1652 + } 1653 + }, 1654 + "node_modules/@vitest/pretty-format": { 1655 + "version": "4.1.2", 1656 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", 1657 + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", 1658 + "dev": true, 1659 + "license": "MIT", 1660 + "dependencies": { 1661 + "tinyrainbow": "^3.1.0" 1662 + }, 1663 + "funding": { 1664 + "url": "https://opencollective.com/vitest" 1665 + } 1666 + }, 1667 + "node_modules/@vitest/runner": { 1668 + "version": "4.1.2", 1669 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", 1670 + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", 1671 + "dev": true, 1672 + "license": "MIT", 1673 + "dependencies": { 1674 + "@vitest/utils": "4.1.2", 1675 + "pathe": "^2.0.3" 1676 + }, 1677 + "funding": { 1678 + "url": "https://opencollective.com/vitest" 1679 + } 1680 + }, 1681 + "node_modules/@vitest/snapshot": { 1682 + "version": "4.1.2", 1683 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", 1684 + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", 1685 + "dev": true, 1686 + "license": "MIT", 1687 + "dependencies": { 1688 + "@vitest/pretty-format": "4.1.2", 1689 + "@vitest/utils": "4.1.2", 1690 + "magic-string": "^0.30.21", 1691 + "pathe": "^2.0.3" 1692 + }, 1693 + "funding": { 1694 + "url": "https://opencollective.com/vitest" 1695 + } 1696 + }, 1697 + "node_modules/@vitest/spy": { 1698 + "version": "4.1.2", 1699 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", 1700 + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", 1701 + "dev": true, 1702 + "license": "MIT", 1703 + "funding": { 1704 + "url": "https://opencollective.com/vitest" 1705 + } 1706 + }, 1707 + "node_modules/@vitest/utils": { 1708 + "version": "4.1.2", 1709 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", 1710 + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", 1711 + "dev": true, 1712 + "license": "MIT", 1713 + "dependencies": { 1714 + "@vitest/pretty-format": "4.1.2", 1715 + "convert-source-map": "^2.0.0", 1716 + "tinyrainbow": "^3.1.0" 1717 + }, 1718 + "funding": { 1719 + "url": "https://opencollective.com/vitest" 1720 + } 1721 + }, 1590 1722 "node_modules/acorn": { 1591 1723 "version": "8.16.0", 1592 1724 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", ··· 1610 1742 "node": ">= 0.4" 1611 1743 } 1612 1744 }, 1745 + "node_modules/assertion-error": { 1746 + "version": "2.0.1", 1747 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", 1748 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", 1749 + "dev": true, 1750 + "license": "MIT", 1751 + "engines": { 1752 + "node": ">=12" 1753 + } 1754 + }, 1613 1755 "node_modules/await-lock": { 1614 1756 "version": "2.2.2", 1615 1757 "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", ··· 1626 1768 "node": ">= 0.4" 1627 1769 } 1628 1770 }, 1771 + "node_modules/chai": { 1772 + "version": "6.2.2", 1773 + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", 1774 + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", 1775 + "dev": true, 1776 + "license": "MIT", 1777 + "engines": { 1778 + "node": ">=18" 1779 + } 1780 + }, 1629 1781 "node_modules/chess.js": { 1630 1782 "version": "1.4.0", 1631 1783 "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz", ··· 1657 1809 "engines": { 1658 1810 "node": ">=6" 1659 1811 } 1812 + }, 1813 + "node_modules/convert-source-map": { 1814 + "version": "2.0.0", 1815 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", 1816 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", 1817 + "dev": true, 1818 + "license": "MIT" 1660 1819 }, 1661 1820 "node_modules/cookie": { 1662 1821 "version": "0.6.0", ··· 1720 1879 "node": ">=10.13.0" 1721 1880 } 1722 1881 }, 1882 + "node_modules/es-module-lexer": { 1883 + "version": "2.0.0", 1884 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", 1885 + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", 1886 + "dev": true, 1887 + "license": "MIT" 1888 + }, 1723 1889 "node_modules/esbuild": { 1724 1890 "version": "0.27.4", 1725 1891 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", ··· 1780 1946 "@typescript-eslint/types": "^8.2.0" 1781 1947 } 1782 1948 }, 1949 + "node_modules/estree-walker": { 1950 + "version": "3.0.3", 1951 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 1952 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 1953 + "dev": true, 1954 + "license": "MIT", 1955 + "dependencies": { 1956 + "@types/estree": "^1.0.0" 1957 + } 1958 + }, 1959 + "node_modules/expect-type": { 1960 + "version": "1.3.0", 1961 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", 1962 + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", 1963 + "dev": true, 1964 + "license": "Apache-2.0", 1965 + "engines": { 1966 + "node": ">=12.0.0" 1967 + } 1968 + }, 1783 1969 "node_modules/fdir": { 1784 1970 "version": "6.5.0", 1785 1971 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", ··· 2217 2403 ], 2218 2404 "license": "MIT" 2219 2405 }, 2406 + "node_modules/pathe": { 2407 + "version": "2.0.3", 2408 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", 2409 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 2410 + "dev": true, 2411 + "license": "MIT" 2412 + }, 2220 2413 "node_modules/picocolors": { 2221 2414 "version": "1.1.1", 2222 2415 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", ··· 2345 2538 "dev": true, 2346 2539 "license": "MIT" 2347 2540 }, 2541 + "node_modules/siginfo": { 2542 + "version": "2.0.0", 2543 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", 2544 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 2545 + "dev": true, 2546 + "license": "ISC" 2547 + }, 2348 2548 "node_modules/sirv": { 2349 2549 "version": "3.0.2", 2350 2550 "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", ··· 2369 2569 "engines": { 2370 2570 "node": ">=0.10.0" 2371 2571 } 2572 + }, 2573 + "node_modules/stackback": { 2574 + "version": "0.0.2", 2575 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", 2576 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", 2577 + "dev": true, 2578 + "license": "MIT" 2579 + }, 2580 + "node_modules/std-env": { 2581 + "version": "4.0.0", 2582 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", 2583 + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", 2584 + "dev": true, 2585 + "license": "MIT" 2372 2586 }, 2373 2587 "node_modules/svelte": { 2374 2588 "version": "5.55.0", ··· 2443 2657 "url": "https://opencollective.com/webpack" 2444 2658 } 2445 2659 }, 2660 + "node_modules/tinybench": { 2661 + "version": "2.9.0", 2662 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", 2663 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", 2664 + "dev": true, 2665 + "license": "MIT" 2666 + }, 2667 + "node_modules/tinyexec": { 2668 + "version": "1.0.4", 2669 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", 2670 + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", 2671 + "dev": true, 2672 + "license": "MIT", 2673 + "engines": { 2674 + "node": ">=18" 2675 + } 2676 + }, 2446 2677 "node_modules/tinyglobby": { 2447 2678 "version": "0.2.15", 2448 2679 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", ··· 2458 2689 }, 2459 2690 "funding": { 2460 2691 "url": "https://github.com/sponsors/SuperchupuDev" 2692 + } 2693 + }, 2694 + "node_modules/tinyrainbow": { 2695 + "version": "3.1.0", 2696 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", 2697 + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", 2698 + "dev": true, 2699 + "license": "MIT", 2700 + "engines": { 2701 + "node": ">=14.0.0" 2461 2702 } 2462 2703 }, 2463 2704 "node_modules/tlds": { ··· 2607 2848 "vite": { 2608 2849 "optional": true 2609 2850 } 2851 + } 2852 + }, 2853 + "node_modules/vitest": { 2854 + "version": "4.1.2", 2855 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", 2856 + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", 2857 + "dev": true, 2858 + "license": "MIT", 2859 + "dependencies": { 2860 + "@vitest/expect": "4.1.2", 2861 + "@vitest/mocker": "4.1.2", 2862 + "@vitest/pretty-format": "4.1.2", 2863 + "@vitest/runner": "4.1.2", 2864 + "@vitest/snapshot": "4.1.2", 2865 + "@vitest/spy": "4.1.2", 2866 + "@vitest/utils": "4.1.2", 2867 + "es-module-lexer": "^2.0.0", 2868 + "expect-type": "^1.3.0", 2869 + "magic-string": "^0.30.21", 2870 + "obug": "^2.1.1", 2871 + "pathe": "^2.0.3", 2872 + "picomatch": "^4.0.3", 2873 + "std-env": "^4.0.0-rc.1", 2874 + "tinybench": "^2.9.0", 2875 + "tinyexec": "^1.0.2", 2876 + "tinyglobby": "^0.2.15", 2877 + "tinyrainbow": "^3.1.0", 2878 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", 2879 + "why-is-node-running": "^2.3.0" 2880 + }, 2881 + "bin": { 2882 + "vitest": "vitest.mjs" 2883 + }, 2884 + "engines": { 2885 + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" 2886 + }, 2887 + "funding": { 2888 + "url": "https://opencollective.com/vitest" 2889 + }, 2890 + "peerDependencies": { 2891 + "@edge-runtime/vm": "*", 2892 + "@opentelemetry/api": "^1.9.0", 2893 + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", 2894 + "@vitest/browser-playwright": "4.1.2", 2895 + "@vitest/browser-preview": "4.1.2", 2896 + "@vitest/browser-webdriverio": "4.1.2", 2897 + "@vitest/ui": "4.1.2", 2898 + "happy-dom": "*", 2899 + "jsdom": "*", 2900 + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" 2901 + }, 2902 + "peerDependenciesMeta": { 2903 + "@edge-runtime/vm": { 2904 + "optional": true 2905 + }, 2906 + "@opentelemetry/api": { 2907 + "optional": true 2908 + }, 2909 + "@types/node": { 2910 + "optional": true 2911 + }, 2912 + "@vitest/browser-playwright": { 2913 + "optional": true 2914 + }, 2915 + "@vitest/browser-preview": { 2916 + "optional": true 2917 + }, 2918 + "@vitest/browser-webdriverio": { 2919 + "optional": true 2920 + }, 2921 + "@vitest/ui": { 2922 + "optional": true 2923 + }, 2924 + "happy-dom": { 2925 + "optional": true 2926 + }, 2927 + "jsdom": { 2928 + "optional": true 2929 + }, 2930 + "vite": { 2931 + "optional": false 2932 + } 2933 + } 2934 + }, 2935 + "node_modules/why-is-node-running": { 2936 + "version": "2.3.0", 2937 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", 2938 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", 2939 + "dev": true, 2940 + "license": "MIT", 2941 + "dependencies": { 2942 + "siginfo": "^2.0.0", 2943 + "stackback": "0.0.2" 2944 + }, 2945 + "bin": { 2946 + "why-is-node-running": "cli.js" 2947 + }, 2948 + "engines": { 2949 + "node": ">=8" 2610 2950 } 2611 2951 }, 2612 2952 "node_modules/zimmerframe": {
+5 -2
package.json
··· 9 9 "preview": "vite preview", 10 10 "prepare": "svelte-kit sync || echo ''", 11 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 12 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 + "test": "vitest run", 14 + "test:watch": "vitest" 13 15 }, 14 16 "devDependencies": { 15 17 "@sveltejs/adapter-static": "^3.0.10", ··· 20 22 "svelte-check": "^4.4.2", 21 23 "tailwindcss": "^4.1.18", 22 24 "typescript": "^5.9.3", 23 - "vite": "^7.3.1" 25 + "vite": "^7.3.1", 26 + "vitest": "^4.1.2" 24 27 }, 25 28 "dependencies": { 26 29 "@atproto/api": "^0.19.5",
+2
src/lib/jetstream.ts
··· 16 16 17 17 type JetstreamOptions = { 18 18 opponentDid: string; 19 + agent?: Agent; 19 20 onGameUpdate: (record: Record<string, unknown>) => void; 20 21 onConnectionChange?: (connected: boolean) => void; 21 22 }; ··· 38 39 39 40 constructor(options: JetstreamOptions) { 40 41 this.options = options; 42 + if (options.agent) this.agent = options.agent; 41 43 } 42 44 43 45 connect(): void {
+5
src/lib/stores/game.svelte.ts
··· 87 87 return false; 88 88 }, 89 89 90 + setHandles(white?: string, black?: string) { 91 + if (white) whiteHandle = white; 92 + if (black) blackHandle = black; 93 + }, 94 + 90 95 setStatus(s: GameRecord['status']) { 91 96 status = s; 92 97 },
+10 -3
src/routes/challenge/[did]/[rkey]/+page.svelte
··· 3 3 import { goto } from '$app/navigation'; 4 4 import { onMount } from 'svelte'; 5 5 import { auth } from '$lib/stores/auth.svelte'; 6 - import { getChallenge, getGame, createGame } from '$lib/atproto'; 6 + import { getChallenge, getGame, createGame, findGameRecordByParent } from '$lib/atproto'; 7 7 import { resolveIdentity } from '$lib/microcosm'; 8 8 import type { ChallengeRecord } from '$lib/types'; 9 9 ··· 51 51 const gameDid = parts[2]; 52 52 const gameRkey = parts[parts.length - 1]; 53 53 54 - // Create our own game record 54 + const parentUri = `at://${gameDid}/blue.checkmate.game/${gameRkey}`; 55 + 56 + // Guard against duplicate acceptance -- check if we already have a record for this game 57 + const existing = await findGameRecordByParent(auth.agent, auth.did!, parentUri); 58 + if (existing) { 59 + goto(`/game/${gameDid}/${gameRkey}`); 60 + return; 61 + } 62 + 55 63 const gameRecord = await getGame(auth.agent, gameDid, gameRkey); 56 64 if (!gameRecord) { 57 65 error = 'Game not found'; ··· 59 67 return; 60 68 } 61 69 62 - const parentUri = `at://${gameDid}/blue.checkmate.game/${gameRkey}`; 63 70 await createGame(auth.agent, { 64 71 white: gameRecord.white!, 65 72 black: auth.did,
+52 -10
src/routes/game/[did]/[rkey]/+page.svelte
··· 11 11 import { getGame, updateGame, createGame, findGameRecordByParent } from '$lib/atproto'; 12 12 import { makePgn } from '$lib/game-logic'; 13 13 import { JetstreamConnection } from '$lib/jetstream'; 14 + import { resolveIdentity } from '$lib/microcosm'; 14 15 import LoginButton from '$lib/components/LoginButton.svelte'; 15 16 import type { PieceSymbol } from 'chess.js'; 16 17 ··· 20 21 let myRkey: string | undefined = $state(undefined); 21 22 let loading = $state(true); 22 23 let error = $state(''); 24 + let moveError = $state(''); 23 25 let loaded = $state(false); 24 26 let jsConnection: JetstreamConnection | null = null; 25 27 let connected = $state(false); 28 + let lastPersistedPgn = ''; 26 29 27 30 onMount(() => { 28 31 return () => jsConnection?.destroy(); ··· 107 110 } 108 111 } 109 112 113 + lastPersistedPgn = game.pgn; 114 + 115 + // Resolve display handles (fire-and-forget, UI updates reactively) 116 + resolvePlayerHandles(record.white, record.black); 117 + 110 118 // Connect Jetstream for opponent moves 111 119 if (myColor === 'white') { 112 120 if (blackDid && game.status === 'active') { ··· 122 130 } 123 131 124 132 function waitForOpponent() { 125 - // Listen to Jetstream for any blue.checkmate.game creates 126 - // that reference our DID (someone joining our game) 133 + // Listen to Jetstream for any blue.checkmate.game creates that reference our DID. 134 + // Without a DID filter this subscribes to the full game collection firehose. 135 + // This is an architectural limitation of the no-server design: we can't know 136 + // the opponent's DID until they join. The callback filters to relevant events. 127 137 jsConnection?.destroy(); 128 138 jsConnection = new JetstreamConnection({ 129 - opponentDid: '', // empty = no DID filter 139 + opponentDid: '', 140 + agent: auth.agent ?? undefined, 130 141 onGameUpdate: async (record) => { 131 142 const white = record.white as string; 132 143 const black = record.black as string; ··· 145 156 if (auth.agent && myRkey) { 146 157 await updateGame(auth.agent, myRkey, { black, status: 'active' }); 147 158 } 148 - // Reconnect Jetstream filtered to opponent 159 + // Resolve opponent handle and reconnect Jetstream 160 + resolvePlayerHandles(white, black); 149 161 connectJetstream(black); 150 162 } 151 163 }, ··· 160 172 jsConnection?.destroy(); 161 173 jsConnection = new JetstreamConnection({ 162 174 opponentDid, 175 + agent: auth.agent ?? undefined, 163 176 onGameUpdate: (record) => { 164 177 const pgn = record.pgn as string; 165 178 if (pgn) { ··· 194 207 game.setStatus('active'); 195 208 } 196 209 210 + async function resolvePlayerHandles(whiteDid?: string, blackDid?: string) { 211 + const [white, black] = await Promise.all([ 212 + whiteDid ? resolveIdentity(whiteDid) : null, 213 + blackDid ? resolveIdentity(blackDid) : null, 214 + ]); 215 + game.setHandles(white?.handle, black?.handle); 216 + } 217 + 197 218 async function handleMove(orig: string, dest: string) { 198 219 const moved = game.tryMove(orig, dest); 199 220 if (moved) { ··· 211 232 async function writeMove() { 212 233 if (!auth.agent || !auth.did || !myRkey) return; 213 234 214 - await updateGame(auth.agent, myRkey, { 215 - pgn: makePgn(game.chess, game.whiteDid, game.blackDid), 216 - status: game.result ? 'completed' : 'active', 217 - result: game.result?.result, 218 - resultReason: game.result?.reason as any, 219 - }); 235 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 236 + try { 237 + await updateGame(auth.agent, myRkey, { 238 + pgn, 239 + status: game.result ? 'completed' : 'active', 240 + result: game.result?.result, 241 + resultReason: game.result?.reason as any, 242 + }); 243 + lastPersistedPgn = pgn; 244 + moveError = ''; 245 + } catch (e) { 246 + console.error('[writeMove] failed, rolling back:', e); 247 + moveError = 'Move failed to save. Your last move has been undone.'; 248 + game.init({ 249 + pgn: lastPersistedPgn || undefined, 250 + myColor: game.myColor, 251 + whiteDid: game.whiteDid, 252 + blackDid: game.blackDid, 253 + status: game.status, 254 + }); 255 + } 220 256 } 221 257 222 258 async function handleResign() { ··· 322 358 {/if} 323 359 </p> 324 360 <p class="text-sm text-text-secondary">{game.result.reason}</p> 361 + </div> 362 + {/if} 363 + 364 + {#if moveError} 365 + <div class="w-full max-w-md rounded-lg border border-danger bg-danger/10 p-3 text-center text-sm text-danger"> 366 + {moveError} 325 367 </div> 326 368 {/if} 327 369
+202
tests/lib/atproto.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + import { createGame, updateGame, getGame, findGameRecordByParent } from '$lib/atproto'; 3 + 4 + function mockAgent(did: string) { 5 + const agent = { 6 + assertDid: did, 7 + com: { 8 + atproto: { 9 + repo: { 10 + createRecord: vi.fn(), 11 + putRecord: vi.fn(), 12 + getRecord: vi.fn(), 13 + listRecords: vi.fn(), 14 + }, 15 + }, 16 + }, 17 + } as any; 18 + return agent; 19 + } 20 + 21 + describe('createGame', () => { 22 + it('creates a game record with correct fields', async () => { 23 + const agent = mockAgent('did:plc:white'); 24 + agent.com.atproto.repo.createRecord.mockResolvedValue({ 25 + data: { uri: 'at://did:plc:white/blue.checkmate.game/abc123' }, 26 + }); 27 + 28 + const result = await createGame(agent, { 29 + white: 'did:plc:white', 30 + black: 'did:plc:black', 31 + status: 'active', 32 + }); 33 + 34 + expect(result.uri).toBe('at://did:plc:white/blue.checkmate.game/abc123'); 35 + expect(result.rkey).toBe('abc123'); 36 + 37 + const call = agent.com.atproto.repo.createRecord.mock.calls[0][0]; 38 + expect(call.repo).toBe('did:plc:white'); 39 + expect(call.collection).toBe('blue.checkmate.game'); 40 + expect(call.record.white).toBe('did:plc:white'); 41 + expect(call.record.black).toBe('did:plc:black'); 42 + expect(call.record.status).toBe('active'); 43 + expect(call.record.pgn).toBe(''); 44 + expect(call.record.$type).toBe('blue.checkmate.game'); 45 + }); 46 + 47 + it('defaults status to waiting', async () => { 48 + const agent = mockAgent('did:plc:white'); 49 + agent.com.atproto.repo.createRecord.mockResolvedValue({ 50 + data: { uri: 'at://did:plc:white/blue.checkmate.game/abc' }, 51 + }); 52 + 53 + await createGame(agent, { white: 'did:plc:white' }); 54 + 55 + const record = agent.com.atproto.repo.createRecord.mock.calls[0][0].record; 56 + expect(record.status).toBe('waiting'); 57 + expect(record.black).toBeUndefined(); 58 + }); 59 + 60 + it('includes parentGameUri when provided', async () => { 61 + const agent = mockAgent('did:plc:black'); 62 + agent.com.atproto.repo.createRecord.mockResolvedValue({ 63 + data: { uri: 'at://did:plc:black/blue.checkmate.game/def' }, 64 + }); 65 + 66 + await createGame(agent, { 67 + white: 'did:plc:white', 68 + black: 'did:plc:black', 69 + parentGameUri: 'at://did:plc:white/blue.checkmate.game/abc', 70 + }); 71 + 72 + const record = agent.com.atproto.repo.createRecord.mock.calls[0][0].record; 73 + expect(record.parentGameUri).toBe('at://did:plc:white/blue.checkmate.game/abc'); 74 + }); 75 + }); 76 + 77 + describe('updateGame', () => { 78 + it('merges updates with existing record and uses swapRecord', async () => { 79 + const agent = mockAgent('did:plc:white'); 80 + agent.com.atproto.repo.getRecord.mockResolvedValue({ 81 + data: { 82 + value: { pgn: '', status: 'waiting', white: 'did:plc:white' }, 83 + cid: 'bafyexisting', 84 + }, 85 + }); 86 + agent.com.atproto.repo.putRecord.mockResolvedValue({}); 87 + 88 + await updateGame(agent, 'abc123', { 89 + pgn: '1. e4 e5', 90 + status: 'active', 91 + black: 'did:plc:black', 92 + }); 93 + 94 + const putCall = agent.com.atproto.repo.putRecord.mock.calls[0][0]; 95 + expect(putCall.swapRecord).toBe('bafyexisting'); 96 + expect(putCall.record.pgn).toBe('1. e4 e5'); 97 + expect(putCall.record.status).toBe('active'); 98 + expect(putCall.record.black).toBe('did:plc:black'); 99 + expect(putCall.record.white).toBe('did:plc:white'); 100 + expect(putCall.record.$type).toBe('blue.checkmate.game'); 101 + }); 102 + }); 103 + 104 + describe('getGame', () => { 105 + it('returns game record for own DID using authenticated agent', async () => { 106 + const agent = mockAgent('did:plc:me'); 107 + agent.com.atproto.repo.getRecord.mockResolvedValue({ 108 + data: { 109 + value: { pgn: '1. e4', status: 'active', white: 'did:plc:me' }, 110 + }, 111 + }); 112 + 113 + const result = await getGame(agent, 'did:plc:me', 'abc123'); 114 + expect(result).not.toBeNull(); 115 + expect(result!.pgn).toBe('1. e4'); 116 + 117 + // Should use the authenticated agent's repo, not create a public agent 118 + expect(agent.com.atproto.repo.getRecord).toHaveBeenCalledWith({ 119 + repo: 'did:plc:me', 120 + collection: 'blue.checkmate.game', 121 + rkey: 'abc123', 122 + }); 123 + }); 124 + 125 + it('returns null on error', async () => { 126 + const agent = mockAgent('did:plc:me'); 127 + agent.com.atproto.repo.getRecord.mockRejectedValue(new Error('Not found')); 128 + 129 + const result = await getGame(agent, 'did:plc:me', 'missing'); 130 + expect(result).toBeNull(); 131 + }); 132 + }); 133 + 134 + describe('findGameRecordByParent', () => { 135 + it('finds record matching parentGameUri', async () => { 136 + const agent = mockAgent('did:plc:black'); 137 + agent.com.atproto.repo.listRecords.mockResolvedValue({ 138 + data: { 139 + records: [ 140 + { 141 + uri: 'at://did:plc:black/blue.checkmate.game/xyz', 142 + value: { 143 + parentGameUri: 'at://did:plc:white/blue.checkmate.game/abc', 144 + pgn: '1. e4 e5', 145 + }, 146 + }, 147 + { 148 + uri: 'at://did:plc:black/blue.checkmate.game/other', 149 + value: { parentGameUri: 'at://did:plc:other/blue.checkmate.game/999', pgn: '' }, 150 + }, 151 + ], 152 + }, 153 + }); 154 + 155 + const result = await findGameRecordByParent( 156 + agent, 157 + 'did:plc:black', 158 + 'at://did:plc:white/blue.checkmate.game/abc' 159 + ); 160 + 161 + expect(result).not.toBeNull(); 162 + expect(result!.rkey).toBe('xyz'); 163 + expect(result!.record.pgn).toBe('1. e4 e5'); 164 + }); 165 + 166 + it('returns null when no matching parent found', async () => { 167 + const agent = mockAgent('did:plc:black'); 168 + agent.com.atproto.repo.listRecords.mockResolvedValue({ 169 + data: { 170 + records: [ 171 + { 172 + uri: 'at://did:plc:black/blue.checkmate.game/other', 173 + value: { parentGameUri: 'at://did:plc:other/blue.checkmate.game/999', pgn: '' }, 174 + }, 175 + ], 176 + }, 177 + }); 178 + 179 + const result = await findGameRecordByParent( 180 + agent, 181 + 'did:plc:black', 182 + 'at://did:plc:white/blue.checkmate.game/abc' 183 + ); 184 + 185 + expect(result).toBeNull(); 186 + }); 187 + 188 + it('returns null when player has no game records', async () => { 189 + const agent = mockAgent('did:plc:black'); 190 + agent.com.atproto.repo.listRecords.mockResolvedValue({ 191 + data: { records: [] }, 192 + }); 193 + 194 + const result = await findGameRecordByParent( 195 + agent, 196 + 'did:plc:black', 197 + 'at://did:plc:white/blue.checkmate.game/abc' 198 + ); 199 + 200 + expect(result).toBeNull(); 201 + }); 202 + });
+190
tests/lib/game-logic.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { Chess } from 'chess.js'; 3 + import { 4 + toDests, 5 + isPromotion, 6 + applyMove, 7 + turnColor, 8 + lastMoveSquares, 9 + gameResult, 10 + makePgn, 11 + } from '$lib/game-logic'; 12 + 13 + describe('toDests', () => { 14 + it('returns all legal moves from starting position', () => { 15 + const chess = new Chess(); 16 + const dests = toDests(chess); 17 + 18 + // Knights and pawns can move from start 19 + expect(dests.has('e2')).toBe(true); 20 + expect(dests.get('e2')).toContain('e4'); 21 + expect(dests.get('e2')).toContain('e3'); 22 + expect(dests.get('g1')).toContain('f3'); 23 + expect(dests.get('g1')).toContain('h3'); 24 + }); 25 + 26 + it('returns empty map for checkmate position', () => { 27 + const chess = new Chess(); 28 + // Scholar's mate 29 + chess.move('e4'); chess.move('e5'); 30 + chess.move('Qh5'); chess.move('Nc6'); 31 + chess.move('Bc4'); chess.move('Nf6'); 32 + chess.move('Qxf7'); 33 + 34 + const dests = toDests(chess); 35 + expect(dests.size).toBe(0); 36 + }); 37 + 38 + it('only includes squares with legal moves', () => { 39 + const chess = new Chess(); 40 + const dests = toDests(chess); 41 + 42 + // Black pieces cannot move on white's turn 43 + expect(dests.has('e7')).toBe(false); 44 + // White rook is blocked 45 + expect(dests.has('a1')).toBe(false); 46 + }); 47 + }); 48 + 49 + describe('isPromotion', () => { 50 + it('detects pawn promotion on 7th rank', () => { 51 + const chess = new Chess('6k1/3P4/8/8/8/8/8/4K3 w - - 0 1'); 52 + expect(isPromotion(chess, 'd7', 'd8')).toBe(true); 53 + }); 54 + 55 + it('returns false for normal pawn move', () => { 56 + const chess = new Chess(); 57 + expect(isPromotion(chess, 'e2', 'e4')).toBe(false); 58 + }); 59 + 60 + it('returns false for non-pawn moves', () => { 61 + const chess = new Chess(); 62 + expect(isPromotion(chess, 'g1', 'f3')).toBe(false); 63 + }); 64 + }); 65 + 66 + describe('applyMove', () => { 67 + it('applies a valid move and returns true', () => { 68 + const chess = new Chess(); 69 + expect(applyMove(chess, 'e2', 'e4')).toBe(true); 70 + // After e4, the pawn is on e4 (4th rank has the pawn) 71 + expect(chess.get('e4')).toBeTruthy(); 72 + }); 73 + 74 + it('returns false for illegal move', () => { 75 + const chess = new Chess(); 76 + // chess.js throws on invalid moves, applyMove should handle this 77 + // e2 to e5 is not a legal pawn move 78 + expect(() => applyMove(chess, 'e2', 'e5')).toThrow(); 79 + }); 80 + 81 + it('applies promotion with piece specified', () => { 82 + const chess = new Chess('6k1/3P4/8/8/8/8/8/4K3 w - - 0 1'); 83 + expect(applyMove(chess, 'd7', 'd8', 'q')).toBe(true); 84 + expect(chess.get('d8')?.type).toBe('q'); 85 + }); 86 + }); 87 + 88 + describe('turnColor', () => { 89 + it('returns white at start', () => { 90 + expect(turnColor(new Chess())).toBe('white'); 91 + }); 92 + 93 + it('returns black after one move', () => { 94 + const chess = new Chess(); 95 + chess.move('e4'); 96 + expect(turnColor(chess)).toBe('black'); 97 + }); 98 + }); 99 + 100 + describe('lastMoveSquares', () => { 101 + it('returns undefined when no moves played', () => { 102 + expect(lastMoveSquares(new Chess())).toBeUndefined(); 103 + }); 104 + 105 + it('returns from/to of the last move', () => { 106 + const chess = new Chess(); 107 + chess.move('e4'); 108 + expect(lastMoveSquares(chess)).toEqual(['e2', 'e4']); 109 + 110 + chess.move('e5'); 111 + expect(lastMoveSquares(chess)).toEqual(['e7', 'e5']); 112 + }); 113 + }); 114 + 115 + describe('gameResult', () => { 116 + it('returns null when game is not over', () => { 117 + expect(gameResult(new Chess())).toBeNull(); 118 + }); 119 + 120 + it('detects checkmate by white', () => { 121 + const chess = new Chess(); 122 + chess.move('e4'); chess.move('e5'); 123 + chess.move('Qh5'); chess.move('Nc6'); 124 + chess.move('Bc4'); chess.move('Nf6'); 125 + chess.move('Qxf7'); 126 + 127 + const result = gameResult(chess); 128 + expect(result).not.toBeNull(); 129 + expect(result!.result).toBe('1-0'); 130 + expect(result!.reason).toBe('checkmate'); 131 + }); 132 + 133 + it('detects stalemate', () => { 134 + // Stalemate position: black king trapped with no legal moves 135 + const chess = new Chess('k7/8/1K6/8/8/8/8/7R b - - 0 1'); 136 + // Verify it's actually stalemate 137 + if (chess.isStalemate()) { 138 + const result = gameResult(chess); 139 + expect(result).not.toBeNull(); 140 + expect(result!.result).toBe('1/2-1/2'); 141 + expect(result!.reason).toBe('stalemate'); 142 + } 143 + }); 144 + 145 + it('detects insufficient material', () => { 146 + // King vs King 147 + const chess = new Chess('4k3/8/8/8/8/8/8/4K3 w - - 0 1'); 148 + const result = gameResult(chess); 149 + expect(result).not.toBeNull(); 150 + expect(result!.result).toBe('1/2-1/2'); 151 + expect(result!.reason).toBe('insufficient'); 152 + }); 153 + }); 154 + 155 + describe('makePgn', () => { 156 + it('generates PGN with headers', () => { 157 + const chess = new Chess(); 158 + chess.move('e4'); 159 + chess.move('e5'); 160 + 161 + const pgn = makePgn(chess, 'did:plc:white', 'did:plc:black'); 162 + expect(pgn).toContain('[Event "checkmate.blue"]'); 163 + expect(pgn).toContain('[Site "https://checkmate.blue"]'); 164 + expect(pgn).toContain('[White "did:plc:white"]'); 165 + expect(pgn).toContain('[Black "did:plc:black"]'); 166 + expect(pgn).toContain('[Result "*"]'); 167 + expect(pgn).toContain('1. e4 e5'); 168 + }); 169 + 170 + it('sets result when game is over', () => { 171 + const chess = new Chess(); 172 + chess.move('e4'); chess.move('e5'); 173 + chess.move('Qh5'); chess.move('Nc6'); 174 + chess.move('Bc4'); chess.move('Nf6'); 175 + chess.move('Qxf7'); 176 + 177 + const pgn = makePgn(chess); 178 + expect(pgn).toContain('[Result "1-0"]'); 179 + }); 180 + 181 + it('uses placeholder when DIDs not provided', () => { 182 + const chess = new Chess(); 183 + chess.move('e4'); 184 + 185 + const pgn = makePgn(chess); 186 + // chess.js defaults to "?" for unset player headers 187 + expect(pgn).not.toContain('[White "did:'); 188 + expect(pgn).not.toContain('[Black "did:'); 189 + }); 190 + });