From 8366c9d7ff2ca21c5ed2cc84883aa4b31a21dc5e Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 22 Sep 2026 16:54:29 -0400 Subject: [PATCH] A duel server: rooms, seat tokens, ledgers, and resolution when all have moved Plain HTTP for actions and a websocket that only says "fetch again". A room is its append-only ledger replayed from the top; the engine is deterministic given the seed and the recorded inputs. Each seat sees a view filtered to what the rules let it know. Bot seats are filled at resolution time. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 + package-lock.json | 557 ++++++++++++++++++++++++++++++++++++++++ package.json | 11 +- server/src/index.ts | 124 +++++++++ server/src/rooms.ts | 246 ++++++++++++++++++ server/src/store.ts | 42 +++ server/tsconfig.json | 14 + src/lib/game/resolve.ts | 6 +- src/lib/game/state.ts | 2 + src/lib/game/view.ts | 16 ++ vite.config.ts | 7 + 11 files changed, 1024 insertions(+), 4 deletions(-) create mode 100644 server/src/index.ts create mode 100644 server/src/rooms.ts create mode 100644 server/src/store.ts create mode 100644 server/tsconfig.json create mode 100644 src/lib/game/view.ts diff --git a/.gitignore b/.gitignore index 3af846b..89e1602 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ vite.config.ts.timestamp-* # Playwright MCP output .playwright-mcp + +# Duel ledgers written by the server +/data diff --git a/package-lock.json b/package-lock.json index b0b2465..3a96a6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,17 +7,465 @@ "": { "name": "waving-hands", "version": "0.0.1", + "dependencies": { + "ws": "^8.21.3" + }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.6.2", + "@types/ws": "^8.18.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", + "tsx": "^4.23.15", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^5.0.1" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -488,6 +936,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/mocker": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", @@ -659,6 +1127,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1350,6 +1860,25 @@ "node": ">=6" } }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1364,6 +1893,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", @@ -1572,6 +2108,27 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zimmerframe": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", diff --git a/package.json b/package.json index c9a0858..9020b69 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,25 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "server": "tsx watch server/src/index.ts", + "server:start": "tsx server/src/index.ts", + "check:server": "tsc --noEmit -p server" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.6.2", + "@types/ws": "^8.18.1", "svelte": "^5.56.1", "svelte-check": "^4.6.0", + "tsx": "^4.23.15", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^5.0.1" + }, + "dependencies": { + "ws": "^8.21.3" } } diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..50ac3a0 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,124 @@ +// The front door for duels between people. Plain HTTP for actions, a +// websocket only to say "something changed, fetch the view again". +// +// POST /api/rooms {name, size?} create a duel and take seat A +// POST /api/rooms/:id/join {name} take the next seat +// POST /api/rooms/:id/bot {token} seat a bot (any seated player may) +// GET /api/rooms/:id?token= the view for that seat +// POST /api/rooms/:id/turn {token, input} this seat's move +// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes +// +// Caddy serves the static site and proxies /api and /ws here. + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { WebSocketServer, WebSocket } from 'ws'; +import { RoomError, Rooms } from './rooms'; +import { Store } from './store'; + +const PORT = Number(process.env.PORT ?? 8788); +const HOST = process.env.HOST ?? '127.0.0.1'; +const DATA_DIR = process.env.WH_DATA_DIR ?? './data/rooms'; +const BODY_LIMIT = 16 * 1024; + +const rooms = new Rooms(new Store(DATA_DIR)); + +function send(res: ServerResponse, status: number, body: unknown): void { + const json = JSON.stringify(body); + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(json); +} + +function readBody(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > BODY_LIMIT) { + reject(new RoomError('That is more than a turn needs.', 413)); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + if (chunks.length === 0) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new RoomError('The request was not JSON.')); + } + }); + req.on('error', reject); + }); +} + +async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', 'http://localhost'); + const parts = url.pathname.split('/').filter(Boolean); + if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); + + if (parts.length === 2 && req.method === 'POST') { + const body = await readBody(req); + const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2)); + return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); + } + + const room = rooms.get(parts[2] ?? ''); + const action = parts[3]; + if (!action && req.method === 'GET') { + const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined); + return send(res, 200, rooms.view(room, seat.id)); + } + if (req.method !== 'POST') throw new RoomError('Not here.', 404); + const body = await readBody(req); + if (action === 'join') { + const seat = rooms.join(room, String(body.name ?? '')); + return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); + } + if (action === 'bot') { + rooms.addBot(room, typeof body.token === 'string' ? body.token : undefined); + const seat = rooms.seatOf(room, body.token as string); + return send(res, 200, rooms.view(room, seat.id)); + } + if (action === 'turn') { + const token = typeof body.token === 'string' ? body.token : undefined; + rooms.submit(room, token, body.input); + const seat = rooms.seatOf(room, token); + return send(res, 200, rooms.view(room, seat.id)); + } + throw new RoomError('Not here.', 404); +} + +const server = createServer((req, res) => { + handle(req, res).catch((err: unknown) => { + if (err instanceof RoomError) return send(res, err.status, { error: err.message }); + console.error(err); + send(res, 500, { error: 'Something went wrong on the server.' }); + }); +}); + +const wss = new WebSocketServer({ noServer: true }); +server.on('upgrade', (req, socket, head) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + if (url.pathname !== '/ws') return socket.destroy(); + let seatId: string; + let roomId: string; + try { + roomId = url.searchParams.get('room') ?? ''; + seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id; + } catch { + return socket.destroy(); + } + wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { + const unsubscribe = rooms.subscribe(roomId, (room) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq })); + }); + ws.on('close', unsubscribe); + ws.send(JSON.stringify({ type: 'hello', seat: seatId })); + }); +}); + +server.listen(PORT, HOST, () => { + console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`); +}); diff --git a/server/src/rooms.ts b/server/src/rooms.ts new file mode 100644 index 0000000..bb34ce8 --- /dev/null +++ b/server/src/rooms.ts @@ -0,0 +1,246 @@ +// Rooms: who sits where, whose move it is, and the duel itself. Every change +// goes to the ledger first and is then applied, so a restart replays to the +// same place. Inputs wait in memory until every seat that must move has moved; +// then the turn resolves at once, bots included. + +import { randomBytes } from 'node:crypto'; +import { chooseBotTurn } from '../../src/lib/game/bot'; +import { resolveTurn } from '../../src/lib/game/resolve'; +import { GESTURES, type Gesture } from '../../src/lib/game/spells'; +import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; +import { viewFor } from '../../src/lib/game/view'; +import type { LedgerLine, Store } from './store'; + +const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D']; +const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura']; +export const MAX_SEATS = SEAT_IDS.length; + +export interface Seat { + id: WizardId; + name: string; + token: string; + bot: boolean; +} + +export interface Room { + id: string; + size: number; + createdAt: number; + seats: Seat[]; + state: GameState | null; + /** Inputs received for the turn in progress, by seat. */ + pending: Record; + /** Bumped on every change a client might want to see. */ + seq: number; + updatedAt: number; +} + +/** What a seated player is told. Other seats' pending inputs and tokens never leave the server. */ +export interface View { + roomId: string; + seq: number; + size: number; + me: WizardId; + seats: { id: WizardId; name: string; bot: boolean }[]; + /** Seats that still have to move before the turn resolves. */ + waitingOn: WizardId[]; + state: GameState | null; +} + +export class RoomError extends Error { + constructor( + message: string, + public status = 400 + ) { + super(message); + } +} + +function newId(bytes: number): string { + return randomBytes(bytes).toString('base64url'); +} + +export class Rooms { + private rooms = new Map(); + private listeners = new Map void>>(); + + constructor(private store: Store) { + for (const id of store.roomIds()) { + const room = replay(id, store.read(id)); + if (room) this.rooms.set(id, room); + } + } + + get(id: string): Room { + const room = this.rooms.get(id); + if (!room) throw new RoomError('No such duel.', 404); + return room; + } + + /** The seat a token unlocks. */ + seatOf(room: Room, token: string | undefined): Seat { + const seat = room.seats.find((s) => !s.bot && s.token === token); + if (!seat) throw new RoomError('That token opens no seat at this duel.', 403); + return seat; + } + + create(name: string, size: number): { room: Room; seat: Seat } { + if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel seats 2 to ${MAX_SEATS} wizards.`); + let id = newId(6); + while (this.rooms.has(id)) id = newId(6); + const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() }; + this.rooms.set(id, room); + this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt }); + const seat = this.sit(room, name, false); + return { room, seat }; + } + + join(room: Room, name: string): Seat { + return this.sit(room, name, false); + } + + addBot(room: Room, token: string | undefined): Seat { + this.seatOf(room, token); + const taken = new Set(room.seats.map((s) => s.name.toLowerCase())); + const pool = BOT_NAMES.filter((n) => !taken.has(n.toLowerCase())); + const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct'; + return this.sit(room, name, true); + } + + private sit(room: Room, rawName: string, bot: boolean): Seat { + if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409); + const name = cleanName(rawName); + if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409); + const seat: Seat = { id: SEAT_IDS[room.seats.length], name, token: bot ? '' : newId(18), bot }; + this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot }); + if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 }); + return seat; + } + + /** Record a seat's move; resolve the turn once nobody else is awaited. */ + submit(room: Room, token: string | undefined, raw: unknown): void { + const seat = this.seatOf(room, token); + if (!room.state) throw new RoomError('The duel has not begun: seats are still empty.', 409); + if (room.state.over) throw new RoomError('The duel is over.', 409); + if (!waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409); + room.pending[seat.id] = cleanInput(raw); + room.updatedAt = Date.now(); + room.seq += 1; + if (waitingOn(room).length === 0) this.resolve(room); + this.notify(room); + } + + private resolve(room: Room): void { + const state = room.state!; + const inputs: Record = { ...room.pending }; + for (const seat of room.seats) { + if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id); + } + this.commit(room, { t: 'turn', at: Date.now(), inputs }); + // A time stop owed to a bot resolves at once; a human's waits for them. + while (room.state && !room.state.over && room.state.timeStops[0] && room.seats.find((s) => s.id === room.state!.timeStops[0])?.bot) { + const actor = room.state.timeStops[0]; + this.commit(room, { t: 'turn', at: Date.now(), inputs: { [actor]: chooseBotTurn(room.state, actor) } }); + } + } + + view(room: Room, seatId: WizardId): View { + return { + roomId: room.id, + seq: room.seq, + size: room.size, + me: seatId, + seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })), + waitingOn: waitingOn(room), + state: room.state ? viewFor(room.state, seatId) : null + }; + } + + subscribe(roomId: string, fn: (room: Room) => void): () => void { + if (!this.listeners.has(roomId)) this.listeners.set(roomId, new Set()); + this.listeners.get(roomId)!.add(fn); + return () => this.listeners.get(roomId)?.delete(fn); + } + + /** Write to the ledger, then apply; the file is the truth a restart returns to. */ + private commit(room: Room, line: LedgerLine): void { + this.store.append(room.id, line); + apply(room, line); + room.seq += 1; + room.updatedAt = Date.now(); + this.notify(room); + } + + private notify(room: Room): void { + for (const fn of this.listeners.get(room.id) ?? []) fn(room); + } +} + +/** Seats whose input the next resolution needs and has not yet received. */ +export function waitingOn(room: Room): WizardId[] { + if (!room.state || room.state.over) return []; + return room.seats + .filter((s) => !s.bot && needsInput(room.state!, s.id) && !(s.id in room.pending)) + .map((s) => s.id); +} + +function needsInput(state: GameState, id: WizardId): boolean { + if (state.timeStops.length > 0) return state.timeStops[0] === id; + return inDuel(state.wizards[id]); +} + +function apply(room: Room, line: LedgerLine): void { + switch (line.t) { + case 'room': + break; + case 'seat': + room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot }); + break; + case 'start': + room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed); + break; + case 'turn': + if (!room.state) return; + room.state = resolveTurn(room.state, line.inputs); + room.pending = {}; + break; + } +} + +function replay(id: string, lines: LedgerLine[]): Room | null { + const first = lines[0]; + if (!first || first.t !== 'room') return null; + const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, seq: lines.length, updatedAt: first.createdAt }; + for (const line of lines) { + apply(room, line); + if (line.t === 'turn') room.updatedAt = line.at; + } + return room; +} + +export function cleanName(raw: unknown): string { + const name = String(raw ?? '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 24); + if (!name) throw new RoomError('A wizard needs a name.'); + return name; +} + +/** Only the shapes the engine understands get through; the engine validates the rest. */ +function cleanInput(raw: unknown): TurnInput { + const r = (raw ?? {}) as Record; + const gesture = (g: unknown): Gesture => (GESTURES.includes(g as Gesture) ? (g as Gesture) : '-'); + const casts = Array.isArray(r.casts) ? (r.casts as TurnInput['casts']).slice(0, 4) : []; + const input: TurnInput = { left: gesture(r.left), right: gesture(r.right), casts }; + if (r.second && typeof r.second === 'object') { + const s = r.second as Record; + input.second = { left: gesture(s.left), right: gesture(s.right), casts: Array.isArray(s.casts) ? (s.casts as TurnInput['casts']).slice(0, 4) : [] }; + if (typeof s.stabTarget === 'string') input.second.stabTarget = s.stabTarget; + } + if (r.release && typeof r.release === 'object') input.release = r.release as TurnInput['release']; + if (typeof r.stabTarget === 'string') input.stabTarget = r.stabTarget; + if (r.monsterOrders && typeof r.monsterOrders === 'object') input.monsterOrders = r.monsterOrders as TurnInput['monsterOrders']; + if (typeof r.charmGesture === 'string') input.charmGesture = gesture(r.charmGesture); + return input; +} diff --git a/server/src/store.ts b/server/src/store.ts new file mode 100644 index 0000000..3685693 --- /dev/null +++ b/server/src/store.ts @@ -0,0 +1,42 @@ +// One append-only JSONL file per room. A room is its ledger replayed from the +// top: the engine is deterministic given the seed and the recorded inputs, so +// nothing else needs saving. + +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { TurnInput, WizardId } from '../../src/lib/game/state'; + +export type LedgerLine = + | { t: 'room'; id: string; seats: number; createdAt: number } + | { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean } + | { t: 'start'; seed: number } + | { t: 'turn'; at: number; inputs: Record }; + +export class Store { + constructor(private dir: string) { + mkdirSync(dir, { recursive: true }); + } + + private path(roomId: string): string { + return join(this.dir, `${roomId}.jsonl`); + } + + append(roomId: string, line: LedgerLine): void { + appendFileSync(this.path(roomId), JSON.stringify(line) + '\n'); + } + + read(roomId: string): LedgerLine[] { + const path = this.path(roomId); + if (!existsSync(path)) return []; + return readFileSync(path, 'utf8') + .split('\n') + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as LedgerLine); + } + + roomIds(): string[] { + return readdirSync(this.dir) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => f.slice(0, -'.jsonl'.length)); + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..675458c --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "../src/lib/game/**/*.ts"], + "exclude": ["../src/lib/game/*.test.ts", "../src/lib/game/*.svelte.ts", "../src/lib/game/test-helpers.ts"] +} diff --git a/src/lib/game/resolve.ts b/src/lib/game/resolve.ts index 423a4e1..ffedf35 100644 --- a/src/lib/game/resolve.ts +++ b/src/lib/game/resolve.ts @@ -153,8 +153,8 @@ export function resolveTurn(previous: GameState, inputs: Record { - state.log.push({ turn, kind, text }); + const log = (text: string, kind: LogKind = 'info', surface = kind !== 'gesture' && kind !== 'damage', hiddenFrom?: WizardId[]) => { + state.log.push(hiddenFrom?.length ? { turn, kind, text, hiddenFrom } : { turn, kind, text }); if (surface) events.push(text); }; const rng = () => nextRandom(state); @@ -256,7 +256,7 @@ export function resolveTurn(previous: GameState, inputs: Record = Object.fromEntries(seats.map((id) => [id, []])); diff --git a/src/lib/game/state.ts b/src/lib/game/state.ts index 3706c00..2ee400c 100644 --- a/src/lib/game/state.ts +++ b/src/lib/game/state.ts @@ -107,6 +107,8 @@ export interface LogEntry { turn: number; kind: LogKind; text: string; + /** Wizards who may not read this line: it describes gestures they could not see. */ + hiddenFrom?: WizardId[]; } /** What a reveal did to each wizard's health, for the result strip. */ diff --git a/src/lib/game/view.ts b/src/lib/game/view.ts new file mode 100644 index 0000000..6d93055 --- /dev/null +++ b/src/lib/game/view.ts @@ -0,0 +1,16 @@ +// What one wizard is allowed to know. Gestures made while they were blind, or +// while the other wizard was invisible, are unknown to them; the log lines that +// describe those gestures are withheld too. Spells cast are always visible, as +// the rules say. + +import { visibleHistory, type GameState, type WizardId } from './state'; + +export function viewFor(state: GameState, viewer: WizardId): GameState { + const view = structuredClone(state); + for (const id of view.seats) { + if (id === viewer) continue; + view.wizards[id].history = visibleHistory(state, viewer, id); + } + view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer)); + return view; +} diff --git a/vite.config.ts b/vite.config.ts index 7f80fcc..1652f65 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,13 @@ export default defineConfig({ adapter: adapter({ fallback: 'index.html' }) }) ], + server: { + // In development the duel server runs beside Vite; in production Caddy does this. + proxy: { + '/api': 'http://localhost:8788', + '/ws': { target: 'ws://localhost:8788', ws: true } + } + }, test: { include: ['src/**/*.test.ts'] }