diff --git a/README.md b/README.md index 7453f41..45dd891 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Monorepo for `@geminixiang` packages that extend the [Pi coding agent](https://g | [`@geminixiang/pi-memory`](packages/pi-memory) | 0.1.0 | Store reviewed cross-session memory with provenance and an auditable lifecycle. | | [`@geminixiang/pi-hooks`](packages/pi-hooks) | 0.1.0 | Run project or user commands automatically on agent events, including denying a tool call. | | [`@geminixiang/pi-remember`](packages/pi-remember) | 0.1.0 | Skill that records durable decisions into `AGENTS.md`. | +| [`@geminixiang/pi-texas-holdem`](packages/pi-texas-holdem) | 0.1.0 | Texas Hold'em in Pi: local bots, LAN play, or federated personal Cloudflare rooms. | [`examples/full-workflow`](examples/full-workflow) walks one scenario through every runtime package — bring up a dev server, get stopped from pushing something unverified, fix it, and leave a reviewed decision behind. Its configuration files are checked against the parsers they target. diff --git a/package-lock.json b/package-lock.json index 19c640c..ab89594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -480,6 +480,59 @@ "node": ">=6.9.0" } }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260815.1", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260819.1", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@earendil-works/pi-ai": { "version": "0.82.1", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", @@ -513,10 +566,7 @@ }, "node_modules/@earendil-works/pi-coding-agent": { "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz", - "integrity": "sha512-zbkAhoIuDPMF3pKuja0ajZabrMWU29FUMV9A/XMXT/XC1yXs5xt6t6t13GogQFsDrDqbFP4DkZQO1w8rWRAzYA==", "dev": true, - "hasShrinkwrap": true, "license": "MIT", "dependencies": { "@earendil-works/pi-agent-core": "^0.82.1", @@ -548,2048 +598,188 @@ "@mariozechner/clipboard": "0.3.9" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.82.1", "dev": true, "license": "MIT", "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", - "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-node": "^3.972.42", - "@aws-sdk/eventstream-handler-node": "^3.972.16", - "@aws-sdk/middleware-eventstream": "^3.972.12", - "@aws-sdk/middleware-websocket": "^3.972.19", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { - "version": "3.974.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", - "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" + "@earendil-works/pi-ai": "^0.82.1", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", - "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.82.1", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", - "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, + "license": "MIT", + "optional": true, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", - "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-login": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" + "node": ">= 10" }, - "engines": { - "node": ">=20.0.0" + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", - "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", - "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-ini": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=20.0.0" + "node": ">= 10" } }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", - "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", - "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", - "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", - "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", - "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", - "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { - "version": "3.997.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", - "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", - "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz", - "dev": true, - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.82.1", - "diff": "8.0.4", - "ignore": "7.0.5", - "typebox": "1.1.38", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", - "dev": true, - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", - "@opentelemetry/api": "1.9.0", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.1.38" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", - "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.9", - "@mariozechner/clipboard-darwin-universal": "0.3.9", - "@mariozechner/clipboard-darwin-x64": "0.3.9", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", - "@mariozechner/clipboard-linux-x64-musl": "0.3.9", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", - "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", - "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", - "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", - "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", - "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", - "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", - "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", - "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", - "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", - "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", - "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.40.0", - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", - "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "dev": true, - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "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/@earendil-works/pi-coding-agent/node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, - "node_modules/@earendil-works/pi-tui": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz", - "integrity": "sha512-9yN8hALfKaxZq7n54EMxqhFCWnMi6LHkraMJ/1YjHiATq75XrI6XDMVppn9EDtiK7Fks8hUe1SDXUTrIvwRWfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ - "ppc64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "aix" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", "cpu": [ - "arm" + "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", "cpu": [ - "x64" + "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", "cpu": [ - "arm64" + "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", "cpu": [ "arm64" ], @@ -2597,16 +787,14 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", "cpu": [ "x64" ], @@ -2614,313 +802,320 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 10" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.7", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "balanced-match": "^4.0.2" + }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, "engines": { - "node": ">=18" + "node": ">= 8" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">= 4" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "shebang-regex": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": ">=18" + "node": ">=22.19.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, "engines": { - "node": ">=18" + "node": ">= 8" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">=18" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], + "node_modules/@earendil-works/pi-tui": { + "version": "0.82.1", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, "engines": { - "node": ">=18" + "node": ">=22.19.0" } }, - "node_modules/@esbuild/win32-x64": { + "node_modules/@esbuild/darwin-arm64": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "darwin" ], "engines": { "node": ">=18" @@ -2978,6 +1173,10 @@ "resolved": "packages/pi-task-protocol", "link": true }, + "node_modules/@geminixiang/pi-texas-holdem": { + "resolved": "packages/pi-texas-holdem", + "link": true + }, "node_modules/@geminixiang/pi-verification": { "resolved": "packages/pi-verification", "link": true @@ -3006,6 +1205,72 @@ } } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@mistralai/mistralai": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", @@ -3080,8 +1345,6 @@ }, "node_modules/@oxfmt/binding-darwin-arm64": { "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.60.0.tgz", - "integrity": "sha512-TTpzPug96Zxdyb46KvTyIUQDdsqbumXh2TKG9C23PCT0kF7JkW56Z/quPuG9rqOFKQIi1gpRNZ7DX18LwxXPnw==", "cpu": [ "arm64" ], @@ -3427,8 +1690,6 @@ }, "node_modules/@oxlint/binding-darwin-arm64": { "version": "1.75.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz", - "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==", "cpu": [ "arm64" ], @@ -3738,10 +1999,31 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "dev": true, + "license": "MIT" + }, "node_modules/@profoundlogic/hogan": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz", - "integrity": "sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==", "license": "Apache-2.0", "dependencies": { "nopt": "1.0.10" @@ -3807,6 +2089,17 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@smithy/core": { "version": "3.33.2", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz", @@ -3894,405 +2187,79 @@ "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-mimetype": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", - "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true, - "license": "MIT" - }, - "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/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], + "tslib": "^2.6.2" + }, "engines": { - "node": ">=16.20.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=16.20.0" + "node": ">=14.0.0" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=16.20.0" + "node": ">=14.0.0" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], + "node_modules/@speed-highlight/core": { + "version": "1.2.24", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" + "license": "CC0-1.0" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" } }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } + "license": "MIT" }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], + "node_modules/@types/ws": { + "version": "8.18.1", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT", + "dependencies": { + "@types/node": "*" } }, - "node_modules/@typescript/typescript-win32-x64": { + "node_modules/@typescript/typescript-darwin-arm64": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "darwin" ], "engines": { "node": ">=16.20.0" @@ -4300,8 +2267,6 @@ }, "node_modules/abbrev": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, "node_modules/agent-base": { @@ -4335,8 +2300,6 @@ }, "node_modules/beautiful-mermaid": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/beautiful-mermaid/-/beautiful-mermaid-1.1.3.tgz", - "integrity": "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg==", "license": "MIT", "dependencies": { "elkjs": "^0.11.0", @@ -4352,6 +2315,11 @@ "node": "*" } }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "dev": true, + "license": "MIT" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -4366,8 +2334,6 @@ }, "node_modules/buffer-image-size": { "version": "0.6.4", - "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", - "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4379,8 +2345,6 @@ }, "node_modules/chokidar": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -4392,6 +2356,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cookie": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -4418,10 +2394,16 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -4429,8 +2411,6 @@ }, "node_modules/diff2html": { "version": "3.4.56", - "resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.56.tgz", - "integrity": "sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==", "license": "MIT", "dependencies": { "@profoundlogic/hogan": "^3.0.4", @@ -4454,14 +2434,10 @@ }, "node_modules/elkjs": { "version": "0.11.1", - "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", - "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", "license": "EPL-2.0" }, "node_modules/entities": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -4470,10 +2446,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/esbuild": { "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4555,10 +2537,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -4598,8 +2577,6 @@ }, "node_modules/get-east-asian-width": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -4637,8 +2614,6 @@ }, "node_modules/happy-dom": { "version": "20.11.1", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", - "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", "dev": true, "license": "MIT", "dependencies": { @@ -4656,8 +2631,6 @@ }, "node_modules/highlight.js": { "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "license": "BSD-3-Clause", "optional": true, "engines": { @@ -4733,6 +2706,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -4741,8 +2722,6 @@ }, "node_modules/marked": { "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "dev": true, "license": "MIT", "bin": { @@ -4752,6 +2731,42 @@ "node": ">= 20" } }, + "node_modules/miniflare": { + "version": "5.20260815.0-alpha", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260815.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/ws": { + "version": "8.21.0", + "dev": true, + "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/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4798,17 +2813,12 @@ }, "node_modules/nopt": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", "license": "MIT", "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" } }, "node_modules/openai": { @@ -4834,8 +2844,6 @@ }, "node_modules/oxfmt": { "version": "0.60.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.60.0.tgz", - "integrity": "sha512-fViX6i+gJuZWY+jI/fnR6WRbRj70GZ9RlCd30MygJrHTUNc4DxvKHWw8vBjMjffv3PgU5qWDR0AzmojQByqaZA==", "dev": true, "license": "MIT", "dependencies": { @@ -4886,8 +2894,6 @@ }, "node_modules/oxlint": { "version": "1.75.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz", - "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==", "dev": true, "license": "MIT", "bin": { @@ -4952,6 +2958,16 @@ "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, "node_modules/protobufjs": { "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", @@ -4977,8 +2993,6 @@ }, "node_modules/readdirp": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -5017,10 +3031,73 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tinypool": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", - "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", "dev": true, "license": "MIT", "engines": { @@ -5035,14 +3112,10 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tsx": { "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5060,14 +3133,10 @@ }, "node_modules/typebox": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.8.tgz", - "integrity": "sha512-xYaJgF0KMvBViKWRaKTAtfR6sDt/yH6xAjGAHXYJxKxUF4pVyPXZZhIlwOg6cDIE81N7L0pyIHs136RHBm6rLQ==", "license": "MIT" }, "node_modules/typescript": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5099,12 +3168,26 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -5116,18 +3199,67 @@ }, "node_modules/whatwg-mimetype": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", "engines": { "node": ">=12" } }, + "node_modules/workerd": { + "version": "1.20260815.1", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260815.1", + "@cloudflare/workerd-darwin-arm64": "1.20260815.1", + "@cloudflare/workerd-linux-64": "1.20260815.1", + "@cloudflare/workerd-linux-arm64": "1.20260815.1", + "@cloudflare/workerd-windows-64": "1.20260815.1" + } + }, + "node_modules/wrangler": { + "version": "4.124.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260815.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260815.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260815.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "node_modules/ws": { "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5145,6 +3277,27 @@ } } }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -5186,8 +3339,6 @@ }, "packages/pi-agent-team/node_modules/@types/node": { "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5196,8 +3347,6 @@ }, "packages/pi-agent-team/node_modules/undici-types": { "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -5378,8 +3527,6 @@ }, "packages/pi-supervisor/node_modules/@types/node": { "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5388,8 +3535,6 @@ }, "packages/pi-supervisor/node_modules/undici-types": { "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -5411,8 +3556,6 @@ }, "packages/pi-task-protocol/node_modules/@types/node": { "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5421,11 +3564,36 @@ }, "packages/pi-task-protocol/node_modules/undici-types": { "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, + "packages/pi-texas-holdem": { + "name": "@geminixiang/pi-texas-holdem", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260819.1", + "@earendil-works/pi-coding-agent": "^0.82.1", + "@earendil-works/pi-tui": "^0.82.1", + "@types/node": "^24.10.1", + "@types/ws": "^8.5.13", + "tsx": "^4.20.6", + "typebox": "^1.0.52", + "typescript": "7.0.2", + "wrangler": "^4.124.0" + }, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + } + }, "packages/pi-verification": { "name": "@geminixiang/pi-verification", "version": "0.1.0", @@ -5447,8 +3615,6 @@ }, "packages/pi-verification/node_modules/@types/node": { "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5457,8 +3623,6 @@ }, "packages/pi-verification/node_modules/undici-types": { "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" } diff --git a/packages/pi-texas-holdem/README.md b/packages/pi-texas-holdem/README.md new file mode 100644 index 0000000..8b19ae7 --- /dev/null +++ b/packages/pi-texas-holdem/README.md @@ -0,0 +1,70 @@ +# pi-texas-holdem + +Play Texas Hold'em inside [pi](https://github.com/earendil-works/pi). + +## Quick start + +From this checkout: + +```sh +npm install +pi -e ./packages/pi-texas-holdem/extensions/poker.ts +``` + +Play locally against bots: + +```text +/poker local +``` + +## Online rooms + +To create internet rooms, first deploy [the bundled Cloudflare backend](cloudflare/README.md) to your personal Cloudflare account. Then start pi with: + +```sh +export PI_POKER_WORKER_URL="https://YOUR-WORKER.workers.dev" +export PI_POKER_CREATE_SECRET="your-creation-secret" +pi +``` + +Create a room and share the displayed invite URL: + +```text +/poker create +``` + +Join a room: + +```text +/poker join +``` + +## Commands + +```text +/poker local [seats] play against bots (2-6 seats, default 6) +/poker create create a room on your personal Worker +/poker join join an online room +/poker reopen the active table +/poker leave leave the current game +/poker help show command help +``` + +## Controls + +```text +F fold +C check/call +R raise +A all-in +/ chat +Enter confirm raise or start the next hand +Esc cancel or close the table overlay +``` + +## Development + +```sh +npm test --workspace @geminixiang/pi-texas-holdem +npm run check --workspace @geminixiang/pi-texas-holdem +``` diff --git a/packages/pi-texas-holdem/cloudflare/README.md b/packages/pi-texas-holdem/cloudflare/README.md new file mode 100644 index 0000000..dfb7099 --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/README.md @@ -0,0 +1,97 @@ +# Cloudflare backend + +A self-hosted Cloudflare Worker + Durable Object backend bundled with `pi-texas-holdem` for private internet rooms. It is not a Pi extension or a separate npm package. Each room creator deploys it to **their own Cloudflare account**; there is no shared service, account, credential, or domain in this repository. + +## MVP + +- `POST /rooms` creates an unguessable room and returns its shareable WebSocket URL. +- `GET /rooms/:uuid` upgrades to a WebSocket routed to one Durable Object per room. +- The Durable Object persists authoritative engine state and uses the WebSocket Hibernation API (`acceptWebSocket`, attachments, hibernation event handlers). +- Existing engine state transitions, redacted views, and protocol v2 messages are reused directly. +- Rooms expire by alarm (default 24 hours, configurable from 5 minutes to 7 days). +- `hello`, `welcome`, `state`, `action`, `startHand`, `ping`/`pong`, capacity and protocol errors work. Joins wait until the current hand finishes. Disconnects fold while preserving committed chips during a hand, then free seats when disconnected between hands. + +The terminal extension supports this backend through `/poker create` when `PI_POKER_WORKER_URL` and `PI_POKER_CREATE_SECRET` are configured. Public directory browsing and server-side chat are not included in this MVP. + +## Local setup and deployment + +Requirements: Node.js 22+, a personal Cloudflare account, and Durable Objects availability on that account. No deployment or login is performed by repository scripts. + +```sh +npm install +cp packages/pi-texas-holdem/cloudflare/wrangler.example.jsonc \ + packages/pi-texas-holdem/cloudflare/wrangler.jsonc +# Pick your own globally unique Worker name in wrangler.jsonc. +# Set a long random deployer-only secret; the value is never committed. +printf '%s' "$(openssl rand -hex 32)" | npx wrangler secret put CREATE_ROOM_SECRET \ + --config packages/pi-texas-holdem/cloudflare/wrangler.jsonc +npx wrangler dev --config packages/pi-texas-holdem/cloudflare/wrangler.jsonc +``` + +Only when you intentionally want to publish to your own account: + +```sh +npx wrangler login +npx wrangler deploy --config packages/pi-texas-holdem/cloudflare/wrangler.jsonc +``` + +Do not commit credentials, account IDs, routes, or custom domains. The example uses the account's ordinary `workers.dev` URL and contains no organization-specific resource. + +## HTTP/WebSocket example + +```sh +curl -X POST https://YOUR-WORKER.workers.dev/rooms \ + -H 'authorization: Bearer YOUR_PRIVATE_CREATE_ROOM_SECRET' \ + -H 'content-type: application/json' \ + -d '{"seatCount":6,"smallBlind":5,"bigBlind":10,"startingStack":500,"telemetry":false}' +``` + +The response contains two URLs: + +- `url`: the guest URL to share, e.g. `wss://YOUR-WORKER.workers.dev/rooms/`. +- `creatorUrl`: the private host URL containing an unguessable `?creator=...` capability. Do not share this URL; only a connection presenting this capability may start hands. The Durable Object stores only its SHA-256 hash, and public table state never contains it. + +Both URLs use the existing protocol's `hello` first. The current `RoomClient` automatically copies the `creator` query capability into this optional field; guests omit it: + +```json +{"type":"hello","protocolVersion":2,"playerId":"ephemeral-client-id","displayName":"Alice","creatorCapability":"creator-only-if-present"} +``` + +A guest room URL is a bearer secret: anyone with it can join until the room fills or expires. Use HTTPS/WSS and share it privately. The stronger creator URL additionally authorizes starting hands and must remain with the creator. + +`POST /rooms` is protected by the deployer-configured `CREATE_ROOM_SECRET` bearer secret so strangers cannot consume a personal Free Plan's room quota. Configure it only with `wrangler secret put`; never place it in `wrangler.jsonc`, source, shell history, or a share URL. Missing server configuration fails closed with HTTP 401. + +## Privacy and federation boundaries + +### Directory (not implemented) + +`RoomDirectory` in `src/types.ts` documents the future optional seam. This MVP has no D1 binding and never lists or registers a room. A future public-room directory must be a separate optional service and require an explicit creator action; private room creation must remain unregistered by default. + +### Telemetry (collector intentionally not implemented) + +`telemetry: true` is the sole opt-in and defaults to false. Consent is stored with room metadata, but this package sends nothing because no telemetry destination is configured. A future implementation may submit only daily anonymous counters (`room_created`, `player_joined`) through `AnonymousTelemetry`. It must not send or derive stable player/room identifiers, display names, IP addresses, full timestamps, room URLs, or cross-day linkage. It must also remain inert when consent is absent. + +## Lifecycle and limits + +Each random UUID deterministically selects one Durable Object. The object stores room metadata and `TableState`, persists after every mutation, and uses one alarm for both room expiry and pending-handshake cleanup. Expiry closes sockets and deletes all room storage. + +Per room, at most 12 WebSockets may exist, of which at most 4 may be pending the initial `hello`. This preserves ordinary 2–6 seat play while bounding unauthenticated hibernating sockets. A pending socket must send one valid `hello` within 10 seconds; the deadline is stored in its hibernation attachment and enforced by the Durable Object alarm. Binary/oversized/malformed pre-auth messages, non-`hello` pre-auth messages, and repeated `hello` messages close the socket with policy code 1008 rather than leaving it resident. + +These conservative limits are designed for personal Free Plan deployments; actual Cloudflare quotas and Durable Objects Free Plan availability can change, so check current Cloudflare documentation before deployment. + +No authentication or reconnect token exists in protocol v2. Duplicate live `playerId` values are rejected, but IDs are client-provided and are not credentials. + +## Integration gaps + +- The terminal client can create a room through `/poker create`, consume guest/creator URLs, and join direct `ws://`/`wss://` room URLs. The creation secret is sent only in the authenticated HTTP `Authorization` header. +- Protocol v2 now has an optional `creatorCapability` on `hello`; the creator URL carries it into the WebSocket handshake so only the creator can start hands. +- Protocol decoding in the core package only checks `type`. The Worker applies stricter boundary validation locally, including sanitizing display names with the existing ANSI/OSC/control sanitizer before enforcing the 40-character limit. +- Chat messages receive a nonfatal `error` response (`Chat is not available in the worker MVP`). The connection remains usable; no unsafe chat content is broadcast. +- Durable Object storage serializes `TableState` structurally, including hidden deck/hole cards. Redaction still occurs per viewer before every state broadcast. + +## Verification + +```sh +npm run check:cloudflare --workspace @geminixiang/pi-texas-holdem +npm run test:cloudflare --workspace @geminixiang/pi-texas-holdem +``` diff --git a/packages/pi-texas-holdem/cloudflare/src/config.ts b/packages/pi-texas-holdem/cloudflare/src/config.ts new file mode 100644 index 0000000..d70fc8f --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/src/config.ts @@ -0,0 +1,34 @@ +import type { RoomConfig } from "./types.ts"; + +export const DEFAULT_ROOM_CONFIG: RoomConfig = { + seatCount: 6, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + telemetryEnabled: false, +}; + +function integerIn(value: unknown, fallback: number, min: number, max: number): number { + return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : fallback; +} + +export function parseRoomConfig(value: unknown): RoomConfig { + const input = typeof value === "object" && value !== null ? (value as Record) : {}; + const smallBlind = integerIn(input.smallBlind, DEFAULT_ROOM_CONFIG.smallBlind, 1, 10_000); + const bigBlind = integerIn(input.bigBlind, DEFAULT_ROOM_CONFIG.bigBlind, smallBlind, 20_000); + const minimumStack = bigBlind * 2; + const defaultStack = Math.max(DEFAULT_ROOM_CONFIG.startingStack, minimumStack); + return { + seatCount: integerIn(input.seatCount, DEFAULT_ROOM_CONFIG.seatCount, 2, 10), + smallBlind, + bigBlind, + startingStack: integerIn(input.startingStack, defaultStack, minimumStack, 10_000_000), + // Deliberately strict opt-in. Truthy strings and omitted values remain off. + telemetryEnabled: input.telemetry === true, + }; +} + +export function roomTtlMs(raw: string | undefined): number { + const seconds = Number(raw ?? 86_400); + return Number.isFinite(seconds) && seconds >= 300 ? Math.min(seconds, 604_800) * 1_000 : 86_400_000; +} diff --git a/packages/pi-texas-holdem/cloudflare/src/index.ts b/packages/pi-texas-holdem/cloudflare/src/index.ts new file mode 100644 index 0000000..0e7d2fb --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/src/index.ts @@ -0,0 +1,11 @@ +import { PokerRoom } from "./poker-room.ts"; +import { route } from "./router.ts"; +import type { Env } from "./types.ts"; + +export { PokerRoom }; + +export default { + fetch(request: Request, env: Env): Promise { + return route(request, env); + }, +} satisfies ExportedHandler; diff --git a/packages/pi-texas-holdem/cloudflare/src/poker-room.ts b/packages/pi-texas-holdem/cloudflare/src/poker-room.ts new file mode 100644 index 0000000..125698e --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/src/poker-room.ts @@ -0,0 +1,355 @@ +import { applyAction, createTable, forceFold, leaveSeat, seatPlayer, startHand } from "../../src/engine/table.ts"; +import type { Action, TableState } from "../../src/engine/types.ts"; +import { redactStateFor } from "../../src/engine/view.ts"; +import { + encode, + PROTOCOL_VERSION, + type ClientMessage, + type ServerMessage, +} from "../../src/net/protocol.ts"; +import { sanitizeChatText } from "../../src/net/sanitize.ts"; +import { roomTtlMs } from "./config.ts"; +import type { Env, RoomConfig, RoomMetadata } from "./types.ts"; + +const TABLE_KEY = "table"; +const META_KEY = "metadata"; +const MAX_MESSAGE_BYTES = 16 * 1024; +export const MAX_ROOM_CONNECTIONS = 12; +export const MAX_PENDING_CONNECTIONS = 4; +export const HANDSHAKE_TIMEOUT_MS = 10_000; + +interface ConnectionAttachment { + playerId?: string; + seatIndex?: number; + isCreator?: boolean; + acceptedAt?: number; +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +export function sanitizeDisplayName(raw: string): string { + return sanitizeChatText(raw, 40); +} + +export function assertCreatorCanStartHand(isCreator: boolean, street: TableState["street"]): void { + if (!isCreator) throw new Error("Only the room creator can start a hand"); + if (street !== "showdown") throw new Error("A hand is already in progress"); +} + +export function canAcceptConnection(attachments: ConnectionAttachment[]): boolean { + return ( + attachments.length < MAX_ROOM_CONNECTIONS && + attachments.filter((connection) => connection.playerId === undefined).length < MAX_PENDING_CONNECTIONS + ); +} + +export function handshakeDeadline(connection: ConnectionAttachment): number | null { + return connection.playerId === undefined && connection.acceptedAt !== undefined + ? connection.acceptedAt + HANDSHAKE_TIMEOUT_MS + : null; +} + +async function sha256(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function isAction(value: unknown): value is Action { + if (typeof value !== "object" || value === null) return false; + const action = value as Record; + if (action.type === "fold" || action.type === "check" || action.type === "call" || action.type === "allin") return true; + return (action.type === "bet" || action.type === "raise") && Number.isInteger(action.amount) && (action.amount as number) >= 0; +} + +function parseClientMessage(raw: string): ClientMessage { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null) throw new Error("Malformed message"); + const message = value as Record; + switch (message.type) { + case "hello": + if ( + typeof message.protocolVersion === "number" && + typeof message.playerId === "string" && + message.playerId.length >= 1 && + message.playerId.length <= 128 && + typeof message.displayName === "string" && + sanitizeDisplayName(message.displayName).length >= 1 && + (typeof message.creatorCapability === "undefined" || typeof message.creatorCapability === "string") + ) + return message as unknown as ClientMessage; + break; + case "action": + if (isAction(message.action)) return message as unknown as ClientMessage; + break; + case "startHand": + case "ping": + return message as unknown as ClientMessage; + } + throw new Error("Malformed or unsupported message"); +} + +function firstOpenSeat(table: TableState): number | null { + const index = table.seats.findIndex((seat) => seat === null); + return index < 0 ? null : index; +} + +function attachment(ws: WebSocket): ConnectionAttachment { + return (ws.deserializeAttachment() as ConnectionAttachment | null) ?? {}; +} + +/** One authoritative room. State is persisted after every mutation; sockets use DO hibernation. */ +export class PokerRoom implements DurableObject { + private readonly state: DurableObjectState; + private readonly env: Env; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/create" && request.method === "POST") return this.create(request); + + const metadata = await this.state.storage.get(META_KEY); + if (!metadata) return json({ error: "Room not found or expired" }, 404); + if (metadata.expiresAt <= Date.now()) { + await this.expire(); + return json({ error: "Room expired" }, 410); + } + if (request.method === "HEAD") return new Response(null, { status: 204 }); + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return json({ error: "Connect with WebSocket" }, 426); + } + const attachments = this.state.getWebSockets().map(attachment); + if (!canAcceptConnection(attachments)) return json({ error: "Room connection limit reached" }, 503); + + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + server.serializeAttachment({ acceptedAt: Date.now() } satisfies ConnectionAttachment); + this.state.acceptWebSocket(server); + await this.scheduleAlarm(metadata); + return new Response(null, { status: 101, webSocket: client }); + } + + async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise { + const connection = attachment(ws); + if (typeof raw !== "string" || new TextEncoder().encode(raw).byteLength > MAX_MESSAGE_BYTES) { + this.send(ws, { type: "error", message: "Message must be text and at most 16 KiB" }); + if (connection.playerId === undefined) ws.close(1008, "Invalid handshake"); + return; + } + + let message: ClientMessage; + try { + message = parseClientMessage(raw); + } catch { + this.send(ws, { type: "error", message: "Malformed message" }); + if (connection.playerId === undefined) ws.close(1008, "Invalid handshake"); + return; + } + + if (message.type === "hello") { + if (connection.playerId !== undefined) { + this.send(ws, { type: "error", message: "Already joined" }); + ws.close(1008, "Repeated handshake"); + return; + } + if (message.protocolVersion !== PROTOCOL_VERSION) { + this.send(ws, { type: "protocolMismatch", hostVersion: PROTOCOL_VERSION }); + ws.close(1002, "Protocol mismatch"); + return; + } + const hello = message as Extract; + await this.join( + ws, + hello.playerId, + sanitizeDisplayName(hello.displayName), + hello.creatorCapability, + ); + return; + } + + if (connection.playerId === undefined || connection.seatIndex === undefined) { + this.send(ws, { type: "error", message: "Send hello first" }); + ws.close(1008, "Handshake required"); + return; + } + if (message.type === "ping") { + this.send(ws, { type: "pong" }); + return; + } + + try { + let table = await this.table(); + if (message.type === "action") table = applyAction(table, connection.seatIndex, message.action); + else if (message.type === "startHand") { + assertCreatorCanStartHand(connection.isCreator === true, table.street); + table = startHand(table); + } else { + this.send(ws, { type: "error", message: "Chat is not available in the worker MVP" }); + return; + } + await this.persistAndBroadcast(table); + } catch (error) { + this.send(ws, { type: "error", message: error instanceof Error ? error.message : "Action failed" }); + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { + await this.disconnect(ws); + ws.close(code, reason); + } + + async webSocketError(ws: WebSocket): Promise { + await this.disconnect(ws); + } + + async alarm(): Promise { + const metadata = await this.state.storage.get(META_KEY); + if (!metadata) return; + const now = Date.now(); + if (metadata.expiresAt <= now) { + await this.expire(); + return; + } + for (const socket of this.state.getWebSockets()) { + const deadline = handshakeDeadline(attachment(socket)); + if (deadline !== null && deadline <= now) socket.close(1008, "Handshake timeout"); + } + await this.scheduleAlarm(metadata); + } + + private async create(request: Request): Promise { + const existing = await this.state.storage.get(META_KEY); + if (existing) return json({ expiresAt: existing.expiresAt }); + const input = (await request.json()) as { config: RoomConfig; creatorCapability: string }; + if (!input.creatorCapability || typeof input.creatorCapability !== "string") return json({ error: "Invalid creator capability" }, 400); + const config = input.config; + const now = Date.now(); + const metadata: RoomMetadata = { + ...config, + createdAt: now, + expiresAt: now + roomTtlMs(this.env.ROOM_TTL_SECONDS), + creatorCapabilityHash: await sha256(input.creatorCapability), + }; + const table = createTable(config); + await this.state.storage.put({ [META_KEY]: metadata, [TABLE_KEY]: table }); + await this.state.storage.setAlarm(metadata.expiresAt); + // telemetryEnabled is stored as consent, but this package intentionally ships no collector binding. + return json({ expiresAt: metadata.expiresAt }, 201); + } + + private async join( + ws: WebSocket, + playerId: string, + displayName: string, + creatorCapability: string | undefined, + ): Promise { + let table = await this.table(); + if (table.street !== "showdown") { + this.send(ws, { type: "error", message: "Wait for the current hand to finish" }); + return; + } + if (table.seats.some((seat) => seat?.id === playerId)) { + this.send(ws, { type: "error", message: "Player ID is already seated" }); + return; + } + const seatIndex = firstOpenSeat(table); + if (seatIndex === null) { + this.send(ws, { type: "roomFull" }); + ws.close(1008, "Room full"); + return; + } + const metadata = await this.metadata(); + const isCreator = + typeof creatorCapability === "string" && + creatorCapability.length > 0 && + (await sha256(creatorCapability)) === metadata.creatorCapabilityHash; + table = seatPlayer(table, seatIndex, { id: playerId, displayName, stack: metadata.startingStack }); + ws.serializeAttachment({ playerId, seatIndex, isCreator } satisfies ConnectionAttachment); + await this.scheduleAlarm(metadata); + this.send(ws, { + type: "welcome", + youId: playerId, + seatIndex, + seatCount: table.seats.length, + smallBlind: table.smallBlind, + bigBlind: table.bigBlind, + }); + await this.persistAndBroadcast(table); + } + + private async disconnect(ws: WebSocket): Promise { + const connection = attachment(ws); + if (connection.seatIndex === undefined) return; + let table = await this.table(); + const seat = table.seats[connection.seatIndex]; + if (!seat || seat.id !== connection.playerId) return; + if (table.street !== "showdown") { + table = forceFold(table, connection.seatIndex); + await this.persistAndBroadcast(table); + return; + } + table = leaveSeat(table, connection.seatIndex); + await this.persistAndBroadcast(table); + } + + private async persistAndBroadcast(table: TableState): Promise { + if (table.street === "showdown") table = this.removeDisconnectedSeats(table); + await this.state.storage.put(TABLE_KEY, table); + for (const socket of this.state.getWebSockets()) { + const viewer = attachment(socket).playerId; + this.send(socket, { type: "state", state: redactStateFor(table, viewer) }); + } + } + + private removeDisconnectedSeats(table: TableState): TableState { + const connectedPlayerIds = new Set( + this.state.getWebSockets().map((socket) => attachment(socket).playerId).filter((id): id is string => id !== undefined), + ); + const seats = table.seats.map((seat) => (seat && !connectedPlayerIds.has(seat.id) ? null : seat)); + return seats.some((seat, index) => seat !== table.seats[index]) ? { ...table, seats } : table; + } + + private send(ws: WebSocket, message: ServerMessage): void { + try { + ws.send(encode(message)); + } catch { + // A close can race a broadcast; the close callback owns cleanup. + } + } + + private async table(): Promise { + const table = await this.state.storage.get(TABLE_KEY); + if (!table) throw new Error("Room is not initialized"); + return table; + } + + private async metadata(): Promise { + const metadata = await this.state.storage.get(META_KEY); + if (!metadata) throw new Error("Room is not initialized"); + return metadata; + } + + private async scheduleAlarm(metadata: RoomMetadata): Promise { + const now = Date.now(); + const pendingDeadlines = this.state + .getWebSockets() + .map((socket) => handshakeDeadline(attachment(socket))) + .filter((deadline): deadline is number => deadline !== null && deadline > now); + await this.state.storage.setAlarm(Math.min(metadata.expiresAt, ...pendingDeadlines)); + } + + private async expire(): Promise { + for (const socket of this.state.getWebSockets()) socket.close(1001, "Room expired"); + await this.state.storage.deleteAll(); + } +} diff --git a/packages/pi-texas-holdem/cloudflare/src/router.ts b/packages/pi-texas-holdem/cloudflare/src/router.ts new file mode 100644 index 0000000..ce2a6a4 --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/src/router.ts @@ -0,0 +1,58 @@ +import { parseRoomConfig } from "./config.ts"; +import type { Env } from "./types.ts"; + +const ROOM_PATH = /^\/rooms\/([a-f0-9-]{36})$/; + +function json(value: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json; charset=utf-8"); + return new Response(JSON.stringify(value), { ...init, headers }); +} + +function hasCreationAccess(request: Request, secret: string | undefined): boolean { + if (!secret) return false; + const authorization = request.headers.get("authorization"); + return authorization === `Bearer ${secret}`; +} + +export async function route(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/health") return json({ ok: true }); + + if (request.method === "POST" && url.pathname === "/rooms") { + if (!hasCreationAccess(request, env.CREATE_ROOM_SECRET)) { + return json({ error: "Unauthorized" }, { status: 401, headers: { "www-authenticate": "Bearer" } }); + } + let body: unknown = {}; + try { + body = await request.json(); + } catch { + return json({ error: "Request body must be JSON" }, { status: 400 }); + } + const roomId = crypto.randomUUID(); + const creatorCapability = `${crypto.randomUUID()}${crypto.randomUUID()}`.replaceAll("-", ""); + const stub = env.POKER_ROOMS.get(env.POKER_ROOMS.idFromName(roomId)); + const created = await stub.fetch("https://room.internal/create", { + method: "POST", + body: JSON.stringify({ config: parseRoomConfig(body), creatorCapability }), + }); + if (!created.ok) return json({ error: "Could not create room" }, { status: 502 }); + const roomUrl = new URL(`/rooms/${roomId}`, url); + roomUrl.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const metadata = (await created.json()) as { expiresAt: number }; + const creatorUrl = new URL(roomUrl); + creatorUrl.searchParams.set("creator", creatorCapability); + return json( + { roomId, url: roomUrl.toString(), creatorUrl: creatorUrl.toString(), expiresAt: metadata.expiresAt }, + { status: 201 }, + ); + } + + const match = ROOM_PATH.exec(url.pathname); + if (match && (request.method === "GET" || request.method === "HEAD")) { + const stub = env.POKER_ROOMS.get(env.POKER_ROOMS.idFromName(match[1]!)); + return stub.fetch(request); + } + + return json({ error: "Not found" }, { status: 404 }); +} diff --git a/packages/pi-texas-holdem/cloudflare/src/types.ts b/packages/pi-texas-holdem/cloudflare/src/types.ts new file mode 100644 index 0000000..2bc1a68 --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/src/types.ts @@ -0,0 +1,32 @@ +export interface Env { + POKER_ROOMS: DurableObjectNamespace; + /** Set with `wrangler secret put CREATE_ROOM_SECRET`; never commit its value. */ + CREATE_ROOM_SECRET: string; + ROOM_TTL_SECONDS?: string; +} + +export interface RoomConfig { + seatCount: number; + smallBlind: number; + bigBlind: number; + startingStack: number; + telemetryEnabled: boolean; +} + +export interface RoomMetadata extends RoomConfig { + createdAt: number; + expiresAt: number; + /** SHA-256 of the unguessable creator capability; the plaintext is never stored. */ + creatorCapabilityHash: string; +} + +/** Optional future seam. Private rooms are never registered unless a creator explicitly requests it. */ +export interface RoomDirectory { + register(room: { url: string; expiresAt: number }): Promise; + unregister(url: string): Promise; +} + +/** Receives anonymous daily counters only: never room/player IDs, names, IPs, or URLs. */ +export interface AnonymousTelemetry { + increment(day: string, event: "room_created" | "player_joined"): Promise; +} diff --git a/packages/pi-texas-holdem/cloudflare/test/config.test.ts b/packages/pi-texas-holdem/cloudflare/test/config.test.ts new file mode 100644 index 0000000..953883b --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/test/config.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DEFAULT_ROOM_CONFIG, parseRoomConfig, roomTtlMs } from "../src/config.ts"; + +test("room config defaults conservatively and telemetry is explicit opt-in", () => { + assert.deepEqual(parseRoomConfig({}), DEFAULT_ROOM_CONFIG); + assert.equal(parseRoomConfig({ telemetry: "true" }).telemetryEnabled, false); + assert.equal(parseRoomConfig({ telemetry: true }).telemetryEnabled, true); +}); + +test("room config constrains public input", () => { + assert.deepEqual(parseRoomConfig({ seatCount: 99, smallBlind: -1, bigBlind: 0, startingStack: 1 }), DEFAULT_ROOM_CONFIG); + assert.equal(parseRoomConfig({ bigBlind: 20_000 }).startingStack, 40_000); + assert.deepEqual(parseRoomConfig({ seatCount: 2, smallBlind: 10, bigBlind: 20, startingStack: 1_000 }), { + seatCount: 2, + smallBlind: 10, + bigBlind: 20, + startingStack: 1_000, + telemetryEnabled: false, + }); +}); + +test("room TTL has a floor, default, and seven-day cap", () => { + assert.equal(roomTtlMs(undefined), 86_400_000); + assert.equal(roomTtlMs("600"), 600_000); + assert.equal(roomTtlMs("1"), 86_400_000); + assert.equal(roomTtlMs("9999999"), 604_800_000); +}); diff --git a/packages/pi-texas-holdem/cloudflare/test/integration.test.ts b/packages/pi-texas-holdem/cloudflare/test/integration.test.ts new file mode 100644 index 0000000..4dd11d8 --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/test/integration.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { forceFold, seatPlayer, startHand, createTable } from "../../src/engine/table.ts"; +import { + assertCreatorCanStartHand, + canAcceptConnection, + handshakeDeadline, + HANDSHAKE_TIMEOUT_MS, + MAX_PENDING_CONNECTIONS, + MAX_ROOM_CONNECTIONS, + sanitizeDisplayName, +} from "../src/poker-room.ts"; + +test("worker display-name boundary strips ANSI, OSC, controls, and newlines before limiting length", () => { + const malicious = `\x1b[31mAlice\x1b[0m\x1b]0;owned\x07\n${"x".repeat(100)}\x00`; + const clean = sanitizeDisplayName(malicious); + assert.equal(clean, `Alice ${"x".repeat(34)}`); + assert.equal(clean.length, 40); + assert.doesNotMatch(clean, /[\x00-\x1f\x7f-\x9f]/); + assert.equal(sanitizeDisplayName("\x1b[31m\x1b[0m"), ""); + assert.equal(sanitizeDisplayName("A\u0085B"), "AB"); +}); + +test("connection limits preserve normal tables while bounding pending handshakes", () => { + const joined = Array.from({ length: 6 }, (_, seatIndex) => ({ playerId: `p${seatIndex}`, seatIndex })); + assert.equal(canAcceptConnection(joined), true, "a full six-seat table can still accept bounded transport overhead"); + const pending = Array.from({ length: MAX_PENDING_CONNECTIONS }, (_, index) => ({ acceptedAt: index })); + assert.equal(canAcceptConnection([...joined, ...pending]), false); + const total = Array.from({ length: MAX_ROOM_CONNECTIONS }, (_, seatIndex) => ({ playerId: `p${seatIndex}`, seatIndex })); + assert.equal(canAcceptConnection(total), false); +}); + +test("pending handshake deadline survives hibernation through the socket attachment", () => { + assert.equal(handshakeDeadline({ acceptedAt: 1_000 }), 1_000 + HANDSHAKE_TIMEOUT_MS); + assert.equal(handshakeDeadline({ acceptedAt: 1_000, playerId: "joined", seatIndex: 0 }), null); + assert.equal(handshakeDeadline({}), null); +}); + +test("only the creator capability authorizes starting hands", () => { + assert.throws(() => assertCreatorCanStartHand(false, "showdown"), /Only the room creator/); + assert.throws(() => assertCreatorCanStartHand(true, "preflop"), /already in progress/); + assert.doesNotThrow(() => assertCreatorCanStartHand(true, "showdown")); +}); + +test("engine forceFold preserves disconnected chips and settles heads-up", () => { + let table = createTable({ seatCount: 2, smallBlind: 5, bigBlind: 10 }); + table = seatPlayer(table, 0, { id: "creator", displayName: "Creator", stack: 500 }); + table = seatPlayer(table, 1, { id: "guest", displayName: "Guest", stack: 500 }); + table = startHand(table, () => 0.5); + const disconnected = table.toActIndex!; + const committed = table.seats[disconnected]!.totalCommitted; + const totalBefore = table.seats.reduce((sum, seat) => sum + (seat?.stack ?? 0) + (seat?.totalCommitted ?? 0), 0); + const next = forceFold(table, disconnected); + assert.equal(next.street, "showdown"); + assert.equal(next.seats[disconnected]!.status, "folded"); + assert.ok(committed > 0); + const totalAfter = next.seats.reduce((sum, seat) => sum + (seat?.stack ?? 0) + (seat?.totalCommitted ?? 0), 0); + assert.equal(totalAfter, totalBefore); +}); diff --git a/packages/pi-texas-holdem/cloudflare/test/router.test.ts b/packages/pi-texas-holdem/cloudflare/test/router.test.ts new file mode 100644 index 0000000..408ad42 --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/test/router.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { route } from "../src/router.ts"; +import type { Env } from "../src/types.ts"; + +const CREATE_SECRET = "test-only-creation-secret"; + +function fakeEnv(handler: (request: Request) => Response | Promise) { + const names: string[] = []; + const namespace = { + idFromName(name: string) { + names.push(name); + return { toString: () => name }; + }, + get() { + return { + fetch(input: RequestInfo | URL, init?: RequestInit) { + return handler(input instanceof Request ? input : new Request(input, init)); + }, + }; + }, + } as unknown as DurableObjectNamespace; + return { env: { POKER_ROOMS: namespace, CREATE_ROOM_SECRET: CREATE_SECRET } as Env, names }; +} + +function createRequest(body = "{}", authorization = `Bearer ${CREATE_SECRET}`): Request { + return new Request("https://cards.example/rooms", { + method: "POST", + headers: { "content-type": "application/json", authorization }, + body, + }); +} + +test("health route does not touch Durable Objects", async () => { + const { env, names } = fakeEnv(() => new Response()); + const response = await route(new Request("https://example.test/health"), env); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.deepEqual(names, []); +}); + +test("room creation requires the deployer's bearer secret", async () => { + const { env, names } = fakeEnv(() => assert.fail("unauthorized request reached a Durable Object")); + assert.equal((await route(createRequest("{}", ""), env)).status, 401); + assert.equal((await route(createRequest("{}", "Bearer wrong"), env)).status, 401); + assert.deepEqual(names, []); +}); + +test("create returns separate guest and creator URLs and initializes its room", async () => { + let forwarded: Request | undefined; + const { env, names } = fakeEnv((request) => { + forwarded = request; + return Response.json({ expiresAt: 1234 }, { status: 201 }); + }); + const response = await route(createRequest(JSON.stringify({ seatCount: 4, telemetry: true })), env); + const result = (await response.json()) as { roomId: string; url: string; creatorUrl: string; expiresAt: number }; + assert.equal(response.status, 201); + assert.match(result.roomId, /^[a-f0-9-]{36}$/); + assert.equal(result.url, `wss://cards.example/rooms/${result.roomId}`); + const creatorUrl = new URL(result.creatorUrl); + assert.equal(`${creatorUrl.origin}${creatorUrl.pathname}`, `wss://cards.example/rooms/${result.roomId}`); + assert.match(creatorUrl.searchParams.get("creator") ?? "", /^[a-f0-9]{64}$/); + assert.deepEqual(names, [result.roomId]); + assert.equal(forwarded?.url, "https://room.internal/create"); + const initialization = (await forwarded?.json()) as { + config: { telemetryEnabled: boolean }; + creatorCapability: string; + }; + assert.equal(initialization.config.telemetryEnabled, true); + assert.equal(initialization.creatorCapability, creatorUrl.searchParams.get("creator")); + assert.equal(JSON.stringify(result).includes(initialization.creatorCapability), true, "capability appears only in creator URL"); + assert.equal(result.url.includes("creator="), false, "guest URL does not grant host authority"); +}); + +test("room URL forwards only GET/HEAD and malformed authorized JSON is rejected", async () => { + const { env } = fakeEnv(() => new Response("upgrade here", { status: 426 })); + const roomId = "00000000-0000-4000-8000-000000000000"; + assert.equal((await route(new Request(`https://x.test/rooms/${roomId}`), env)).status, 426); + assert.equal((await route(new Request(`https://x.test/rooms/${roomId}`, { method: "DELETE" }), env)).status, 404); + assert.equal((await route(createRequest("{"), env)).status, 400); +}); diff --git a/packages/pi-texas-holdem/cloudflare/tsconfig.json b/packages/pi-texas-holdem/cloudflare/tsconfig.json new file mode 100644 index 0000000..d4b7048 --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types", "node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "../src/engine/**/*.ts", "../src/net/protocol.ts", "../src/net/sanitize.ts"] +} diff --git a/packages/pi-texas-holdem/cloudflare/wrangler.example.jsonc b/packages/pi-texas-holdem/cloudflare/wrangler.example.jsonc new file mode 100644 index 0000000..69c622a --- /dev/null +++ b/packages/pi-texas-holdem/cloudflare/wrangler.example.jsonc @@ -0,0 +1,19 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "my-pi-texas-holdem", + "main": "src/index.ts", + "compatibility_date": "2026-03-01", + "durable_objects": { + "bindings": [ + { "name": "POKER_ROOMS", "class_name": "PokerRoom" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["PokerRoom"] } + ], + "vars": { + "ROOM_TTL_SECONDS": "86400" + } + // Required separately: `npx wrangler secret put CREATE_ROOM_SECRET`. + // Never put the secret value in this file. +} diff --git a/packages/pi-texas-holdem/extensions/poker.ts b/packages/pi-texas-holdem/extensions/poker.ts new file mode 100644 index 0000000..486780d --- /dev/null +++ b/packages/pi-texas-holdem/extensions/poker.ts @@ -0,0 +1,567 @@ +import crypto from "node:crypto"; +import os from "node:os"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; +import { matchesKey, type Component, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui"; +import { describeHand, evaluateBestHand } from "../src/engine/evaluator.ts"; +import { formatCard } from "../src/engine/cards.ts"; +import type { Action, Street } from "../src/engine/types.ts"; +import { centerOnRow, layoutTable, stampBlock } from "../src/engine/ring.ts"; +import type { PublicSeat, PublicTableState } from "../src/engine/view.ts"; +import type { ChatMessage } from "../src/net/protocol.ts"; +import { createBotsSession } from "../src/session/bots.ts"; +import { createHostSession } from "../src/session/host.ts"; +import { createJoinSession } from "../src/session/join.ts"; +import type { GameSession } from "../src/session/types.ts"; +import { createPersonalRoom, normalizeWorkerEndpoint } from "../src/ui/cloud.ts"; +import { normalizeRoomUrl, parsePokerCommand, shareableRoomUrl } from "../src/ui/commands.ts"; + +const CHAT_PANEL_LINES = 4; + +const RING_WIDTH = 75; +const RING_HEIGHT = 15; +const SEAT_HALF_WIDTH = 10; +const SEAT_HALF_HEIGHT = 3; +const BOX_INNER = 17; +const DEFAULT_STARTING_STACK = 2000; +const DEFAULT_SMALL_BLIND = 10; +const DEFAULT_BIG_BLIND = 20; + +// ---------- token usage ---------- + +interface UsageLike { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +function isUsageLike(value: unknown): value is UsageLike { + if (typeof value !== "object" || value === null) return false; + const usage = value as Record; + return ( + typeof usage.input === "number" && + typeof usage.output === "number" && + typeof usage.cacheRead === "number" && + typeof usage.cacheWrite === "number" + ); +} + +/** Sums input+output+cache tokens for today (local time) across the current session's entries. */ +function todaysTokenUsage(ctx: ExtensionContext): number { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + let total = 0; + for (const entry of ctx.sessionManager.getEntries()) { + const timestamp = (entry as { timestamp?: string }).timestamp; + if (timestamp && new Date(timestamp) < startOfDay) continue; + + if (entry.type === "message") { + const message = (entry as { message?: { role?: string; usage?: unknown } }).message; + if (message && (message.role === "assistant" || message.role === "toolResult") && isUsageLike(message.usage)) { + total += message.usage.input + message.usage.output + message.usage.cacheRead + message.usage.cacheWrite; + } + } else if (entry.type === "compaction" || entry.type === "branch_summary") { + const usage = (entry as { usage?: unknown }).usage; + if (isUsageLike(usage)) total += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + } + } + return total; +} + +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +// ---------- rendering ---------- + +function padTo(text: string, width: number): string { + const chars = [...text]; + if (chars.length >= width) return chars.slice(0, width).join(""); + return text + " ".repeat(width - chars.length); +} + +function formatSeatBox( + seat: PublicSeat | null, + opts: { isMe: boolean; isDealer: boolean; isSB: boolean; isBB: boolean; isTurn: boolean; folded: boolean }, +): string[] { + const top = opts.isTurn ? `┏${"━".repeat(BOX_INNER)}┓` : `┌${"─".repeat(BOX_INNER)}┐`; + const bottom = opts.isTurn ? `┗${"━".repeat(BOX_INNER)}┛` : `└${"─".repeat(BOX_INNER)}┘`; + const side = opts.isTurn ? "┃" : "│"; + + if (!seat) { + return [top, `${side} ${padTo("(open seat)", BOX_INNER - 1)}${side}`, `${side}${" ".repeat(BOX_INNER)}${side}`, `${side}${" ".repeat(BOX_INNER)}${side}`, bottom]; + } + + const tags = [opts.isDealer && "D", opts.isSB && "SB", opts.isBB && "BB", opts.isMe && "YOU"].filter(Boolean).join(" "); + const nameText = tags ? `[${tags}] ${seat.displayName}` : seat.displayName; + const cardsText = seat.status === "folded" ? "folded" : seat.holeCards.map((c) => (c ? formatCard(c) : "??")).join(" "); + + return [ + top, + `${side} ${padTo(nameText, BOX_INNER - 1)}${side}`, + `${side} ${padTo(`$${seat.stack.toLocaleString()}`, BOX_INNER - 1)}${side}`, + `${side} ${padTo(cardsText, BOX_INNER - 1)}${side}`, + bottom, + ]; +} + +function buildTableGrid(state: PublicTableState, mySeatIndex: number | null): string[] { + const layout = layoutTable(RING_WIDTH, RING_HEIGHT, state.seats.length, { + seatHalfWidth: SEAT_HALF_WIDTH, + seatHalfHeight: SEAT_HALF_HEIGHT, + }); + let rows = layout.rows.slice(); + + const cardsText = state.communityCards.length + ? state.communityCards.map((c) => `[${formatCard(c)}]`).join(" ") + : "-- pre-flop --"; + const potTotal = state.seats.reduce((sum, s) => sum + (s ? s.committed : 0), 0) + state.pots.reduce((sum, p) => sum + p.amount, 0); + const communityRow = layout.padTop + 6; + const potRow = layout.padTop + 7; + const handRow = layout.padTop + 8; + rows[communityRow] = centerOnRow(rows[communityRow] as string, cardsText); + rows[potRow] = centerOnRow(rows[potRow] as string, `Pot ${potTotal.toLocaleString()}`); + const mySeat = mySeatIndex === null ? null : state.seats[mySeatIndex]; + const holeCards = mySeat?.holeCards.filter((card): card is NonNullable => card !== null) ?? []; + if (holeCards.length === 2) { + const availableCards = [...holeCards, ...state.communityCards]; + const handText = availableCards.length >= 5 + ? describeHand(evaluateBestHand(availableCards)) + : describeHand(holeCards); + rows[handRow] = centerOnRow(rows[handRow] as string, `Your hand: ${handText}`); + } + + state.seats.forEach((seat, index) => { + const anchor = layout.seatAnchors[index]; + if (!anchor) return; + const box = formatSeatBox(seat, { + isMe: mySeatIndex === index, + isDealer: state.dealerIndex === index, + isSB: false, + isBB: false, + isTurn: state.toActIndex === index, + folded: !!seat && seat.status === "folded", + }); + rows = stampBlock(rows, anchor, box); + }); + + return rows; +} + +function streetLabel(street: Street): string { + switch (street) { + case "preflop": + return "Preflop"; + case "flop": + return "Flop"; + case "turn": + return "Turn"; + case "river": + return "River"; + case "showdown": + return "Showdown"; + } +} + +interface RaiseUiState { + active: boolean; + amount: number; +} + +interface ChatUiState { + active: boolean; + draft: string; +} + +function formatChatLine(theme: Theme, msg: ChatMessage): string { + if (msg.seatIndex === null) return theme.fg("dim", `· ${msg.text}`); + return `${theme.fg("accent", msg.displayName)}: ${msg.text}`; +} + +function renderOverlay( + session: GameSession, + theme: Theme, + raiseState: RaiseUiState, + chatState: ChatUiState, + ctx: ExtensionCommandContext, +): string[] { + const state = session.getState(); + const mySeatIndex = session.info.mySeatIndex; + const tokensToday = todaysTokenUsage(ctx); + + const lines: string[] = []; + const headline = [ + session.info.roomLabel, + `hand #${state.handNumber || 0}`, + streetLabel(state.street), + `today's tokens ${formatTokenCount(tokensToday)}`, + ].join(" · "); + lines.push(theme.fg("accent", theme.bold(headline))); + lines.push(""); + + const grid = buildTableGrid(state, mySeatIndex); + for (const row of grid) lines.push(theme.fg("borderMuted", row)); + lines.push(""); + + if (state.street === "showdown" && state.awards.length > 0) { + const idToName = new Map(state.seats.filter((s): s is NonNullable => !!s).map((s) => [s.id, s.displayName])); + const summary = state.awards.map((a) => `${idToName.get(a.seatIds[0] ?? "") ?? "?"} +${a.amount}`).join(" · "); + lines.push(theme.fg("success", `Winners: ${summary}`)); + lines.push(""); + } + + const chatLog = session.getChatLog(); + lines.push(theme.fg("dim", theme.bold("Chat"))); + if (chatLog.length === 0) { + lines.push(theme.fg("dim", " (no messages yet)")); + } else { + for (const msg of chatLog.slice(-CHAT_PANEL_LINES)) lines.push(` ${formatChatLine(theme, msg)}`); + } + if (chatState.active) { + lines.push(theme.fg("accent", `> ${chatState.draft}_`)); + } + lines.push(""); + + const legal = session.legalActionsForMe(); + if (mySeatIndex === null) { + lines.push(theme.fg("warning", "Waiting for the host...")); + } else if (state.street === "showdown" && state.toActIndex === null) { + if (session.canStartHand()) { + lines.push(theme.fg("accent", "Press Enter to start the next hand · Esc back to work")); + } else { + lines.push(theme.fg("dim", "Waiting for the host to start the next hand · Esc back to work")); + } + } else if (legal && legal.canFold && raiseState.active) { + lines.push( + theme.fg( + "accent", + `Raise to ${raiseState.amount} · +/- adjust · Enter confirm · Esc cancel`, + ), + ); + } else if (legal && legal.canFold) { + const parts = [`${theme.fg("error", "F")} fold`]; + parts.push(legal.canCheck ? `${theme.fg("accent", "C")} check` : `${theme.fg("accent", "C")} call ${legal.callAmount}`); + if (legal.canBetOrRaise) parts.push(`${theme.fg("accent", "R")} raise`); + parts.push(`${theme.fg("accent", "A")} all-in`); + parts.push(`${theme.fg("dim", "Esc")} back to work`); + lines.push(parts.join(" ")); + } else { + lines.push(theme.fg("dim", "Watching · Esc back to work")); + } + + if (!chatState.active && !raiseState.active) { + lines.push(theme.fg("dim", "Press / to chat")); + } + + return lines; +} + +// ---------- widget ---------- + +function widgetLines(session: GameSession | null, ctx: ExtensionContext): string[] | undefined { + if (!session) return undefined; + const state = session.getState(); + const tokensToday = todaysTokenUsage(ctx); + const mySeatIndex = session.info.mySeatIndex; + const legal = session.legalActionsForMe(); + + const parts = [`♠ ${session.info.roomLabel}`, `hand #${state.handNumber || 0}`]; + if (mySeatIndex !== null && legal?.canFold) { + parts.push("your turn"); + } else if (state.toActIndex !== null) { + const actingName = state.seats[state.toActIndex]?.displayName ?? "?"; + parts.push(`${actingName} to act`); + } + const pot = state.seats.reduce((sum, s) => sum + (s ? s.committed : 0), 0) + state.pots.reduce((sum, p) => sum + p.amount, 0); + parts.push(`pot ${pot}`); + parts.push(`today's tokens ${formatTokenCount(tokensToday)}`); + const chatCount = session.getChatLog().length; + if (chatCount > 0) parts.push(`chat ${chatCount}`); + parts.push("/poker to open the table"); + return [parts.join(" · ")]; +} + +// ---------- extension ---------- + +export default function pokerExtension(pi: ExtensionAPI) { + let session: GameSession | null = null; + + const refreshWidget = (ctx: ExtensionContext) => { + ctx.ui.setWidget("poker", widgetLines(session, ctx), { placement: "belowEditor" }); + }; + + const closeSession = (ctx: ExtensionContext) => { + session?.close(); + session = null; + ctx.ui.setWidget("poker", undefined); + }; + + const openTable = async (ctx: ExtensionCommandContext) => { + if (!session) { + ctx.ui.notify("No game is active. Start with /poker local, create a personal room with /poker create, or join with /poker join .", "warning"); + return; + } + const activeSession = session; + const raiseState: RaiseUiState = { active: false, amount: 0 }; + const chatState: ChatUiState = { active: false, draft: "" }; + const unsubscribe = activeSession.subscribe(() => refreshWidget(ctx)); + + await ctx.ui.custom((tui: TUI, theme: Theme, _keybindings: KeybindingsManager, done: (result: void) => void) => { + const rerender = () => tui.requestRender(); + const stop = activeSession.subscribe(rerender); + + const submitAction = (action: Action) => { + try { + activeSession.act(action); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + } + }; + + return { + render(_width: number) { + return renderOverlay(activeSession, theme, raiseState, chatState, ctx); + }, + invalidate() {}, + handleInput(data: string) { + if (chatState.active) { + if (matchesKey(data, "escape")) { + chatState.active = false; + chatState.draft = ""; + } else if (matchesKey(data, "enter")) { + const text = chatState.draft.trim(); + chatState.active = false; + chatState.draft = ""; + if (text) activeSession.sendChat(text); + } else if (matchesKey(data, "backspace")) { + chatState.draft = chatState.draft.slice(0, -1); + } else if (!data.startsWith("\x1B") && data.length > 0 && chatState.draft.length < 240) { + chatState.draft += data; + } + tui.requestRender(); + return; + } + + if (matchesKey(data, "escape")) { + stop(); + unsubscribe(); + refreshWidget(ctx); + done(); + return; + } + + if (matchesKey(data, "/")) { + chatState.active = true; + tui.requestRender(); + return; + } + + const legal = activeSession.legalActionsForMe(); + const mySeatIndex = activeSession.info.mySeatIndex; + + if (mySeatIndex !== null && activeSession.getState().street === "showdown" && activeSession.getState().toActIndex === null) { + if (matchesKey(data, "enter") && activeSession.canStartHand()) { + activeSession.startHand(); + } + return; + } + + if (raiseState.active) { + if (matchesKey(data, "enter")) { + submitAction({ type: "raise", amount: raiseState.amount }); + raiseState.active = false; + tui.requestRender(); + } else if (matchesKey(data, "+") || matchesKey(data, "=")) { + const step = activeSession.getState().bigBlind || 20; + raiseState.amount = Math.min(raiseState.amount + step, legal?.maxRaiseTo ?? raiseState.amount); + tui.requestRender(); + } else if (matchesKey(data, "-")) { + const step = activeSession.getState().bigBlind || 20; + raiseState.amount = Math.max(raiseState.amount - step, legal?.minRaiseTo ?? raiseState.amount); + tui.requestRender(); + } else if (matchesKey(data, "escape")) { + raiseState.active = false; + tui.requestRender(); + } + return; + } + + if (!legal || !legal.canFold) return; + + if (matchesKey(data, "f")) submitAction({ type: "fold" }); + else if (matchesKey(data, "c")) submitAction(legal.canCheck ? { type: "check" } : { type: "call" }); + else if (matchesKey(data, "a")) submitAction({ type: "allin" }); + else if (matchesKey(data, "r") && legal.canBetOrRaise) { + raiseState.active = true; + raiseState.amount = legal.minRaiseTo; + tui.requestRender(); + } + }, + } satisfies Component; + }); + }; + + pi.registerCommand("poker", { + description: "Texas Hold'em: local | create | join | leave | help", + handler: async (rawArgs, ctx) => { + if (ctx.mode !== "tui") { + ctx.ui.notify("/poker needs the interactive terminal UI", "warning"); + return; + } + const command = parsePokerCommand(rawArgs); + const hostname = os.hostname(); + const myId = `${hostname}-${crypto.randomBytes(3).toString("hex")}`; + + if (command.kind === "open") { + await openTable(ctx); + return; + } + + if (command.kind === "local") { + closeSession(ctx); + session = createBotsSession({ + myId, + displayName: hostname, + seatCount: command.seatCount, + smallBlind: DEFAULT_SMALL_BLIND, + bigBlind: DEFAULT_BIG_BLIND, + startingStack: DEFAULT_STARTING_STACK, + }); + if (command.legacyAlias) ctx.ui.notify("/poker bots still works; /poker local is the clearer name.", "info"); + refreshWidget(ctx); + await openTable(ctx); + return; + } + + if (command.kind === "create") { + const rawEndpoint = process.env.PI_POKER_WORKER_URL; + if (!rawEndpoint) { + ctx.ui.notify( + "Personal Worker is not configured. Deploy packages/pi-texas-holdem/cloudflare to your Cloudflare account, then set PI_POKER_WORKER_URL=https://.workers.dev before starting pi. If your Worker protects POST /rooms, also set PI_POKER_CREATE_SECRET. Secrets are sent only in the Authorization header and are never shown by /poker.", + "warning", + ); + return; + } + let endpoint: string; + try { + endpoint = normalizeWorkerEndpoint(rawEndpoint); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); + return; + } + ctx.ui.notify(`Creating a private room on ${new URL(endpoint).host}…`, "info"); + try { + const created = await createPersonalRoom({ + endpoint, + creationSecret: process.env.PI_POKER_CREATE_SECRET, + }); + const creatorUrl = normalizeRoomUrl(created.creatorUrl); + closeSession(ctx); + session = createJoinSession({ url: creatorUrl, myId, displayName: hostname, canStartHands: true }, (kind, message) => { + if (kind === "welcome") { + ctx.ui.notify(`Private room created. Share only this invite URL: ${created.shareUrl}`, "info"); + } else if (kind === "error") { + ctx.ui.notify(`Room error: ${message ?? "request rejected"}`, "warning"); + } else if (kind === "rejected") { + ctx.ui.notify(`Room was created, but joining it failed: ${message ?? "unknown error"}. Invite URL: ${created.shareUrl}`, "error"); + closeSession(ctx); + } else if (kind === "hostDisconnected") { + ctx.ui.notify("Personal room went offline. Room closed.", "warning"); + closeSession(ctx); + } + }); + refreshWidget(ctx); + await openTable(ctx); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + } + return; + } + + if (command.kind === "lanHost") { + if (!command.confirmed) { + ctx.ui.notify( + "LAN host mode is friends-only: the host can technically see every hand. Do not use it for money. Re-run with `/poker lan-host [port] --i-know` to confirm. (`/poker host` remains an alias.)", + "warning", + ); + return; + } + closeSession(ctx); + session = createHostSession( + { + myId, + displayName: hostname, + seatCount: 6, + smallBlind: DEFAULT_SMALL_BLIND, + bigBlind: DEFAULT_BIG_BLIND, + startingStack: DEFAULT_STARTING_STACK, + port: command.port, + onLog: (line) => ctx.ui.notify(line, "info"), + }, + (boundPort) => ctx.ui.notify(`LAN room ready on port ${boundPort}. Share ws://:${boundPort} only with people you trust.`, "info"), + ); + if (command.legacyAlias) ctx.ui.notify("/poker host still works; /poker lan-host makes its LAN-only behavior explicit.", "info"); + refreshWidget(ctx); + await openTable(ctx); + return; + } + + if (command.kind === "join") { + if (!command.address) { + ctx.ui.notify("Usage: /poker join (for example wss://room.example or 192.168.1.20:4551)", "warning"); + return; + } + let url: string; + try { + url = normalizeRoomUrl(command.address); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); + return; + } + const hasCreatorCapability = new URL(url).searchParams.has("creator"); + closeSession(ctx); + ctx.ui.notify(`Joining ${shareableRoomUrl(url)}. The room operator runs the game and can technically see every hand; only join URLs you trust.`, "info"); + session = createJoinSession({ url, myId, displayName: hostname, canStartHands: hasCreatorCapability }, (kind, message) => { + if (kind === "error") { + ctx.ui.notify(`Room error: ${message ?? "request rejected"}`, "warning"); + } else if (kind === "rejected") { + ctx.ui.notify(`Could not join: ${message ?? "unknown error"}`, "error"); + closeSession(ctx); + } else if (kind === "hostDisconnected") { + ctx.ui.notify("Room went offline — this hand ended and any bets are returned. Room closed.", "warning"); + closeSession(ctx); + } + }); + refreshWidget(ctx); + await openTable(ctx); + return; + } + + if (command.kind === "rooms") { + ctx.ui.notify("Public room discovery is coming later; there is no directory backend yet. No network request was made. Use a room URL from someone you trust with /poker join .", "info"); + return; + } + + if (command.kind === "privacy") { + ctx.ui.notify("Telemetry is off. This extension does not send usage or gameplay analytics. Any future telemetry will require explicit opt-in and an obvious way to turn it off.", "info"); + return; + } + + if (command.kind === "leave") { + closeSession(ctx); + ctx.ui.notify("Left the poker table", "info"); + return; + } + + if (command.kind === "invalid") ctx.ui.notify(`Unknown poker command: ${command.input}`, "warning"); + ctx.ui.notify("/poker local [seats] | create | join | lan-host [port] --i-know | rooms | privacy | leave", "info"); + }, + }); + + pi.on("session_shutdown", () => { + session?.close(); + session = null; + }); +} diff --git a/packages/pi-texas-holdem/package.json b/packages/pi-texas-holdem/package.json new file mode 100644 index 0000000..192cdf4 --- /dev/null +++ b/packages/pi-texas-holdem/package.json @@ -0,0 +1,63 @@ +{ + "name": "@geminixiang/pi-texas-holdem", + "version": "0.1.0", + "description": "Play Texas Hold'em against bots or other machines on your network, right inside the Pi terminal UI", + "type": "module", + "license": "MIT", + "author": "geminixiang", + "repository": { + "type": "git", + "url": "https://github.com/geminixiang/pi-stuff.git", + "directory": "packages/pi-texas-holdem" + }, + "keywords": [ + "pi-package", + "pi-extension", + "poker", + "texas-holdem", + "terminal", + "game" + ], + "files": [ + "extensions", + "src", + "cloudflare/README.md", + "cloudflare/src", + "cloudflare/tsconfig.json", + "cloudflare/wrangler.example.jsonc", + "README.md" + ], + "pi": { + "extensions": [ + "extensions/poker.ts" + ] + }, + "scripts": { + "check": "tsc --noEmit && npm run check:cloudflare", + "check:cloudflare": "tsc --noEmit -p cloudflare/tsconfig.json", + "test": "node --import tsx --test test/*.test.ts && npm run test:cloudflare", + "test:cloudflare": "node --import tsx --test cloudflare/test/*.test.ts" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260819.1", + "@earendil-works/pi-coding-agent": "^0.82.1", + "@earendil-works/pi-tui": "^0.82.1", + "@types/node": "^24.10.1", + "@types/ws": "^8.5.13", + "tsx": "^4.20.6", + "typebox": "^1.0.52", + "typescript": "7.0.2", + "wrangler": "^4.124.0" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + }, + "engines": { + "node": ">=22.19.0" + } +} diff --git a/packages/pi-texas-holdem/src/engine/cards.ts b/packages/pi-texas-holdem/src/engine/cards.ts new file mode 100644 index 0000000..5437faa --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/cards.ts @@ -0,0 +1,66 @@ +import { secureRandom } from "./random.ts"; +import type { Card, Rank, Suit } from "./types.ts"; + +export const SUITS: Suit[] = ["s", "h", "d", "c"]; +export const RANKS: Rank[] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; + +const RANK_LABEL: Record = { + 2: "2", + 3: "3", + 4: "4", + 5: "5", + 6: "6", + 7: "7", + 8: "8", + 9: "9", + 10: "10", + 11: "J", + 12: "Q", + 13: "K", + 14: "A", +}; + +const SUIT_GLYPH: Record = { + s: "♠", + h: "♥", + d: "♦", + c: "♣", +}; + +export function rankLabel(rank: Rank): string { + return RANK_LABEL[rank]; +} + +export function suitGlyph(suit: Suit): string { + return SUIT_GLYPH[suit]; +} + +export function isRedSuit(suit: Suit): boolean { + return suit === "h" || suit === "d"; +} + +export function formatCard(card: Card): string { + return `${rankLabel(card.rank)}${suitGlyph(card.suit)}`; +} + +export function createDeck(): Card[] { + const deck: Card[] = []; + for (const suit of SUITS) { + for (const rank of RANKS) deck.push({ rank, suit }); + } + return deck; +} + +/** Fisher-Yates shuffle. Returns a new array; does not mutate the input. */ +export function shuffleDeck(deck: Card[], rng: () => number = secureRandom): Card[] { + const result = deck.slice(); + for (let i = result.length - 1; i > 0; i--) { + const random = rng(); + if (!Number.isFinite(random) || random < 0 || random >= 1) { + throw new RangeError("Random source must return a finite number in [0, 1)"); + } + const j = Math.floor(random * (i + 1)); + [result[i], result[j]] = [result[j] as Card, result[i] as Card]; + } + return result; +} diff --git a/packages/pi-texas-holdem/src/engine/evaluator.ts b/packages/pi-texas-holdem/src/engine/evaluator.ts new file mode 100644 index 0000000..882d126 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/evaluator.ts @@ -0,0 +1,158 @@ +import type { Card, HandCategory, HandEvaluation } from "./types.ts"; + +const CATEGORY_BY_RANK: HandCategory[] = [ + "highCard", + "pair", + "twoPair", + "trips", + "straight", + "flush", + "fullHouse", + "quads", + "straightFlush", +]; + +function straightHighCard(ranksDesc: number[]): number | null { + const uniq = [...new Set(ranksDesc)]; + const highs: number[] = []; + for (let i = 0; i + 4 < uniq.length; i++) { + if ((uniq[i] as number) - (uniq[i + 4] as number) === 4) highs.push(uniq[i] as number); + } + if ([14, 5, 4, 3, 2].every((r) => uniq.includes(r))) highs.push(5); + return highs.length ? Math.max(...highs) : null; +} + +function evaluate5(cards: Card[]): Omit { + const ranksDesc = cards.map((c) => c.rank as number).sort((a, b) => b - a); + const isFlush = cards.every((c) => c.suit === cards[0]?.suit); + + const counts = new Map(); + for (const r of ranksDesc) counts.set(r, (counts.get(r) ?? 0) + 1); + const groups = [...counts.entries()].sort((a, b) => b[1] - a[1] || b[0] - a[0]); + const pattern = groups.map(([, count]) => count).join(""); + + const straightHigh = straightHighCard(ranksDesc); + const isStraight = straightHigh !== null; + + if (isStraight && isFlush) return { category: "straightFlush", rank: 8, tiebreak: [straightHigh as number] }; + if (pattern === "41") return { category: "quads", rank: 7, tiebreak: [groups[0]![0], groups[1]![0]] }; + if (pattern === "32") return { category: "fullHouse", rank: 6, tiebreak: [groups[0]![0], groups[1]![0]] }; + if (isFlush) return { category: "flush", rank: 5, tiebreak: ranksDesc }; + if (isStraight) return { category: "straight", rank: 4, tiebreak: [straightHigh as number] }; + if (pattern === "311") + return { category: "trips", rank: 3, tiebreak: [groups[0]![0], groups[1]![0], groups[2]![0]] }; + if (pattern === "221") + return { category: "twoPair", rank: 2, tiebreak: [groups[0]![0], groups[1]![0], groups[2]![0]] }; + if (pattern === "2111") + return { + category: "pair", + rank: 1, + tiebreak: [groups[0]![0], groups[1]![0], groups[2]![0], groups[3]![0]], + }; + return { category: "highCard", rank: 0, tiebreak: ranksDesc }; +} + +function combinations5(cards: Card[]): Card[][] { + const result: Card[][] = []; + const n = cards.length; + for (let a = 0; a < n; a++) { + for (let b = a + 1; b < n; b++) { + for (let c = b + 1; c < n; c++) { + for (let d = c + 1; d < n; d++) { + for (let e = d + 1; e < n; e++) { + result.push([cards[a]!, cards[b]!, cards[c]!, cards[d]!, cards[e]!]); + } + } + } + } + } + return result; +} + +export function compareEvaluations(a: HandEvaluation, b: HandEvaluation): number { + if (a.rank !== b.rank) return a.rank - b.rank; + const len = Math.max(a.tiebreak.length, b.tiebreak.length); + for (let i = 0; i < len; i++) { + const diff = (a.tiebreak[i] ?? 0) - (b.tiebreak[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** Evaluates the best 5-card hand out of 5-7 cards (hole cards + community cards). */ +export function evaluateBestHand(cards: Card[]): HandEvaluation { + if (cards.length < 5) throw new Error(`evaluateBestHand needs at least 5 cards, got ${cards.length}`); + const combos = cards.length === 5 ? [cards] : combinations5(cards); + let best: HandEvaluation | undefined; + for (const combo of combos) { + const evaluated = { ...evaluate5(combo), hand: combo }; + if (!best || compareEvaluations(evaluated, best) > 0) best = evaluated; + } + return best as HandEvaluation; +} + +export function categoryLabel(category: HandCategory): string { + switch (category) { + case "highCard": + return "High Card"; + case "pair": + return "Pair"; + case "twoPair": + return "Two Pair"; + case "trips": + return "Three of a Kind"; + case "straight": + return "Straight"; + case "flush": + return "Flush"; + case "fullHouse": + return "Full House"; + case "quads": + return "Four of a Kind"; + case "straightFlush": + return "Straight Flush"; + } +} + +function rankLabel(rank: number): string { + if (rank === 14) return "Ace"; + if (rank === 13) return "King"; + if (rank === 12) return "Queen"; + if (rank === 11) return "Jack"; + return String(rank); +} + +/** A beginner-facing description of a made hand or two-card starting hand. */ +export function describeHand(input: HandEvaluation | Card[]): string { + if (Array.isArray(input)) { + if (input.length !== 2) throw new Error("A starting hand description needs exactly two cards"); + const [first, second] = input; + if (first!.rank === second!.rank) return `Pair of ${rankLabel(first!.rank)}s`; + return `${rankLabel(Math.max(first!.rank, second!.rank))} high${first!.suit === second!.suit ? " · suited" : ""}`; + } + + const label = categoryLabel(input.category); + switch (input.category) { + case "highCard": + return `${rankLabel(input.tiebreak[0]!)} High`; + case "pair": + return `Pair of ${rankLabel(input.tiebreak[0]!)}s`; + case "twoPair": + return `Two Pair · ${rankLabel(input.tiebreak[0]!)}s and ${rankLabel(input.tiebreak[1]!)}s`; + case "trips": + return `Three of a Kind · ${rankLabel(input.tiebreak[0]!)}s`; + case "straight": + case "straightFlush": + return `${label} · ${rankLabel(input.tiebreak[0]!)} high`; + case "flush": + return `Flush · ${rankLabel(input.tiebreak[0]!)} high`; + case "fullHouse": + return `Full House · ${rankLabel(input.tiebreak[0]!)}s over ${rankLabel(input.tiebreak[1]!)}s`; + case "quads": + return `Four of a Kind · ${rankLabel(input.tiebreak[0]!)}s`; + } +} + +export function categoryFromRank(rank: number): HandCategory { + return CATEGORY_BY_RANK[rank] as HandCategory; +} diff --git a/packages/pi-texas-holdem/src/engine/random.ts b/packages/pi-texas-holdem/src/engine/random.ts new file mode 100644 index 0000000..95a90ce --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/random.ts @@ -0,0 +1,15 @@ +const UINT32_RANGE = 0x1_0000_0000; + +/** + * Runtime-neutral, cryptographically secure random value in [0, 1). + * Web Crypto is available in modern Node.js and Cloudflare Workers. + */ +export function secureRandom(): number { + const crypto = globalThis.crypto; + if (!crypto?.getRandomValues) { + throw new Error("Secure randomness requires the Web Crypto API"); + } + const value = new Uint32Array(1); + crypto.getRandomValues(value); + return (value[0] as number) / UINT32_RANGE; +} diff --git a/packages/pi-texas-holdem/src/engine/ring.ts b/packages/pi-texas-holdem/src/engine/ring.ts new file mode 100644 index 0000000..b945597 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/ring.ts @@ -0,0 +1,230 @@ +/** + * Draws the oval table as box-drawing characters on a fixed character grid, and + * lays out seat anchor points around its rail. Pure text-grid math so the exact + * output can be unit tested without a running terminal. + */ + +export interface RingGrid { + width: number; + height: number; + rows: string[]; +} + +export interface SeatAnchor { + /** Column, in character units (fractional; round before indexing text). */ + x: number; + /** Row, in character units (fractional; round before indexing text). */ + y: number; +} + +export interface TableLayout { + width: number; + height: number; + rows: string[]; + seatAnchors: SeatAnchor[]; + /** Offset of the ring's own row 0 / column 0 within `rows`. */ + padTop: number; + padLeft: number; +} + +const MIRROR_GLYPH: Record = { + "╭": "╮", + "╮": "╭", + "╰": "╯", + "╯": "╰", + "─": "─", + "│": "│", +}; + +/** Ellipse radii used for the table rail, given the drawable box. */ +function railRadii(width: number, height: number) { + return { a: width / 2 - 3, b: height / 2 - 0.9 }; +} + +/** + * Draw a connected terminal oval one scanline at a time. + * + * Independently rounded ellipse points leave gaps on a character grid. Each row + * instead owns a connected horizontal part of the rail and joins the prior row. + */ +export function generateRing(width: number, height: number): RingGrid { + if (width < 5 || height < 3) throw new Error("A table ring needs at least a 5×3 grid"); + + const cx = (width - 1) / 2; + const cy = (height - 1) / 2; + const { a } = railRadii(width, height); + const sampleRadiusY = height / 2; + const leftEdges = Array.from({ length: height }, (_, y) => { + // Sampling through the cell center gives the oval a useful flat top rather + // than collapsing its first row to a single point. + const normalizedY = Math.abs(y - cy) / sampleRadiusY; + const extent = a * Math.sqrt(Math.max(0, 1 - normalizedY * normalizedY)); + return Math.round(cx - extent); + }); + const rows = Array.from({ length: height }, () => Array.from({ length: width }, () => " ")); + + const drawHorizontal = (row: string[], from: number, to: number) => { + for (let x = Math.max(0, from); x <= Math.min(width - 1, to); x++) row[x] = "─"; + }; + + for (let y = 0; y < height; y++) { + const left = leftEdges[y]!; + const right = width - 1 - left; + const row = rows[y]!; + + if (y === 0 || y === height - 1) { + const edge = y === 0 ? left : leftEdges[y - 1]!; + const oppositeEdge = width - 1 - edge; + drawHorizontal(row, edge + 1, oppositeEdge - 1); + row[edge] = y === 0 ? "╭" : "╰"; + row[oppositeEdge] = y === 0 ? "╮" : "╯"; + continue; + } + + const previousLeft = leftEdges[y - 1]!; + const previousRight = width - 1 - previousLeft; + if (left < previousLeft) { + drawHorizontal(row, left + 1, previousLeft - 1); + row[left] = "╭"; + row[previousLeft] = "╯"; + row[previousRight] = "╰"; + drawHorizontal(row, previousRight + 1, right - 1); + row[right] = "╮"; + } else if (left > previousLeft) { + row[previousLeft] = "╰"; + drawHorizontal(row, previousLeft + 1, left - 1); + row[left] = "╮"; + row[right] = "╭"; + drawHorizontal(row, right + 1, previousRight - 1); + row[previousRight] = "╯"; + } else { + row[left] = "│"; + row[right] = "│"; + } + } + + // Preserve exact left/right symmetry for even-width grids too. + for (const row of rows) { + for (let x = 0; x < Math.floor(width / 2); x++) { + row[width - 1 - x] = MIRROR_GLYPH[row[x]!] ?? " "; + } + } + + return { width, height, rows: rows.map((row) => row.join("")) }; +} + +/** + * Angles (degrees, math orientation, y-up) for `count` points spaced evenly by + * *arc length* around an a×b ellipse. Plain even angle-spacing looks bunched up + * on a wide, flat table oval, so this walks the perimeter instead. + */ +function ellipseArcAngles(a: number, b: number, count: number, startDeg: number): number[] { + const samples = 1440; + const points: { deg: number; x: number; y: number }[] = []; + for (let i = 0; i <= samples; i++) { + const deg = startDeg - (360 * i) / samples; + const rad = (deg * Math.PI) / 180; + points.push({ deg, x: a * Math.cos(rad), y: b * Math.sin(rad) }); + } + const cumulative = [0]; + for (let i = 1; i < points.length; i++) { + const dx = points[i]!.x - points[i - 1]!.x; + const dy = points[i]!.y - points[i - 1]!.y; + cumulative.push((cumulative[i - 1] as number) + Math.hypot(dx, dy)); + } + const total = cumulative[cumulative.length - 1] as number; + const angles: number[] = []; + for (let s = 0; s < count; s++) { + const target = (total * s) / count; + let idx = cumulative.findIndex((c) => c >= target); + if (idx < 0) idx = cumulative.length - 1; + angles.push(points[idx]!.deg); + } + return angles; +} + +export interface LayoutOptions { + /** How far outside the rail seat anchors sit, in columns/rows. */ + outsetCols?: number; + outsetRows?: number; + /** Half the footprint of a seat box, so it never clips off the canvas. */ + seatHalfWidth?: number; + seatHalfHeight?: number; + /** Starting angle (degrees) for seat 0; conventionally "you", bottom-right. */ + startDeg?: number; +} + +/** + * Composes the ring plus a seat layout onto a padded canvas, so seat boxes can + * safely overhang the ring's own bounding box without clipping. + */ +export function layoutTable(ringWidth: number, ringHeight: number, seatCount: number, options: LayoutOptions = {}): TableLayout { + const outsetCols = options.outsetCols ?? 8.5; + const outsetRows = options.outsetRows ?? 3; + const seatHalfWidth = options.seatHalfWidth ?? 9; + const seatHalfHeight = options.seatHalfHeight ?? 3; + const startDeg = options.startDeg ?? -58; + const margin = 1; + + const cx = (ringWidth - 1) / 2; + const cy = (ringHeight - 1) / 2; + const { a, b } = railRadii(ringWidth, ringHeight); + const outerA = a + outsetCols; + const outerB = b + outsetRows; + + const padLeft = Math.ceil(Math.max(0, outerA - cx)) + seatHalfWidth + margin; + const padRight = padLeft; + const padTop = Math.ceil(Math.max(0, outerB - cy)) + seatHalfHeight + margin; + const padBottom = padTop; + + const ring = generateRing(ringWidth, ringHeight); + const width = ringWidth + padLeft + padRight; + const height = ringHeight + padTop + padBottom; + const rows = Array.from({ length: height }, (_, y) => { + if (y < padTop || y >= padTop + ringHeight) return " ".repeat(width); + return " ".repeat(padLeft) + ring.rows[y - padTop] + " ".repeat(padRight); + }); + + const angles = seatCount > 0 ? ellipseArcAngles(outerA, outerB, seatCount, startDeg) : []; + const seatAnchors = angles.map((deg) => { + const rad = (deg * Math.PI) / 180; + return { x: padLeft + cx + outerA * Math.cos(rad), y: padTop + cy - outerB * Math.sin(rad) }; + }); + + return { width, height, rows, seatAnchors, padTop, padLeft }; +} + +/** Overwrites `content` lines onto `rows`, centered on `anchor`, clipped to bounds. */ +export function stampBlock(rows: string[], anchor: SeatAnchor, content: string[]): string[] { + const out = rows.slice(); + const top = Math.round(anchor.y - (content.length - 1) / 2); + for (let li = 0; li < content.length; li++) { + const rowIndex = top + li; + if (rowIndex < 0 || rowIndex >= out.length) continue; + const line = content[li] as string; + const left = Math.round(anchor.x - line.length / 2); + const row = out[rowIndex] as string; + const chars = row.split(""); + for (let ci = 0; ci < line.length; ci++) { + const col = left + ci; + if (col < 0 || col >= chars.length) continue; + const ch = line[ci] as string; + if (ch !== " ") chars[col] = ch; + } + out[rowIndex] = chars.join(""); + } + return out; +} + +/** Writes `text` centered on a single ring row, without disturbing the border glyphs at both ends. */ +export function centerOnRow(row: string, text: string): string { + const chars = row.split(""); + const first = chars.findIndex((c) => c !== " "); + const last = chars.length - 1 - [...chars].reverse().findIndex((c) => c !== " "); + if (first < 0 || last <= first) return row; + const innerWidth = last - first - 1; + const pad = Math.max(0, innerWidth - text.length); + const left = Math.floor(pad / 2); + const content = " ".repeat(left) + text + " ".repeat(Math.max(0, pad - left)); + return chars.slice(0, first + 1).join("") + content.slice(0, innerWidth) + chars.slice(last).join(""); +} diff --git a/packages/pi-texas-holdem/src/engine/table.ts b/packages/pi-texas-holdem/src/engine/table.ts new file mode 100644 index 0000000..bb65f47 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/table.ts @@ -0,0 +1,465 @@ +import { createDeck, shuffleDeck } from "./cards.ts"; +import { secureRandom } from "./random.ts"; +import { compareEvaluations, evaluateBestHand } from "./evaluator.ts"; +import type { Action, Card, HandResult, LegalActions, Pot, PotAward, Seat, TableState } from "./types.ts"; + +export interface TableConfig { + seatCount: number; + smallBlind: number; + bigBlind: number; +} + +export function createTable(config: TableConfig): TableState { + return { + seats: Array.from({ length: config.seatCount }, () => null), + dealerIndex: -1, + smallBlind: config.smallBlind, + bigBlind: config.bigBlind, + street: "showdown", + communityCards: [], + deck: [], + toActIndex: null, + currentBet: 0, + minRaise: config.bigBlind, + lastAggressorIndex: null, + handNumber: 0, + pots: [], + winners: [], + awards: [], + log: [], + }; +} + +export function seatPlayer( + state: TableState, + seatIndex: number, + player: { id: string; displayName: string; stack: number }, +): TableState { + const seats = state.seats.slice(); + seats[seatIndex] = { + id: player.id, + displayName: player.displayName, + stack: player.stack, + holeCards: [], + status: "active", + committed: 0, + totalCommitted: 0, + hasActed: false, + sittingOut: false, + }; + return { ...state, seats }; +} + +export function leaveSeat(state: TableState, seatIndex: number): TableState { + const seats = state.seats.slice(); + seats[seatIndex] = null; + return { ...state, seats }; +} + +/** + * Forces an active seat to fold without requiring it to be that seat's turn. + * Used by authoritative runtimes when a player disconnects. The transition + * settles the hand, advances the street, or preserves the current actor using + * the same engine rules as a voluntary fold. + */ +export function forceFold(state: TableState, seatIndex: number): TableState { + const seat = state.seats[seatIndex]; + if (!seat || seat.status !== "active" || state.street === "showdown") return state; + + const seats = state.seats.map((candidate, index) => + index === seatIndex && candidate ? { ...candidate, status: "folded" as const } : candidate, + ); + let next: TableState = { ...state, seats, log: [...state.log, `${seat.displayName} folds`] }; + + if (contenders(next).length <= 1) return finishHandByFold(next); + if (bettingRoundComplete(next)) { + if (next.street === "river") return runShowdown(next); + return dealStreet(next); + } + if (state.toActIndex === seatIndex) { + next = { ...next, toActIndex: nextActingSeatIndex(seats, seatIndex) }; + } + return next; +} + +function occupiedCount(state: TableState): number { + return state.seats.filter((s) => s && !s.sittingOut && s.stack > 0).length; +} + +/** Active (occupied, playable) seat indices in circular order, starting at and including `from`. */ +function activeSeatIndicesFrom(seats: (Seat | null)[], from: number): number[] { + const n = seats.length; + const result: number[] = []; + for (let i = 0; i < n; i++) { + const idx = (from + i) % n; + const seat = seats[idx]; + if (seat && !seat.sittingOut && seat.stack > 0) result.push(idx); + } + return result; +} + +/** Next seat (strictly after `from`) that can still act this street, or null. */ +function nextActingSeatIndex(seats: (Seat | null)[], from: number): number | null { + const n = seats.length; + for (let i = 1; i <= n; i++) { + const idx = (from + i) % n; + const seat = seats[idx]; + if (seat && seat.status === "active") return idx; + } + return null; +} + +function contenders(state: TableState): { index: number; seat: Seat }[] { + return state.seats + .map((seat, index) => ({ seat, index })) + .filter((entry): entry is { index: number; seat: Seat } => !!entry.seat && entry.seat.status !== "folded"); +} + +export function startHand(state: TableState, rng: () => number = secureRandom): TableState { + if (occupiedCount(state) < 2) throw new Error("Need at least 2 seated players with chips to start a hand"); + + const dealerIndex = + state.dealerIndex < 0 + ? (activeSeatIndicesFrom(state.seats, 0)[0] as number) + : (nextActingDealerIndex(state.seats, state.dealerIndex) ?? state.dealerIndex); + + const seats = state.seats.map((seat) => { + if (!seat) return null; + if (seat.sittingOut || seat.stack <= 0) return { ...seat, holeCards: [], committed: 0, totalCommitted: 0 }; + return { + ...seat, + holeCards: [], + status: "active" as const, + committed: 0, + totalCommitted: 0, + hasActed: false, + }; + }); + + const order = activeSeatIndicesFrom(seats, dealerIndex); + const headsUp = order.length === 2; + const sbIndex = headsUp ? (order[0] as number) : (order[1] as number); + const bbIndex = headsUp ? (order[1] as number) : (order[2] as number); + + let deck = shuffleDeck(createDeck(), rng); + const log: string[] = []; + + const postBlind = (index: number, amount: number, label: string) => { + const seat = seats[index] as Seat; + const posted = Math.min(amount, seat.stack); + seat.stack -= posted; + seat.committed += posted; + seat.totalCommitted += posted; + if (seat.stack === 0) seat.status = "allIn"; + log.push(`${seat.displayName} posts ${label} ${posted}`); + }; + + postBlind(sbIndex, state.smallBlind, "small blind"); + postBlind(bbIndex, state.bigBlind, "big blind"); + + for (let round = 0; round < 2; round++) { + for (const idx of activeSeatIndicesFrom(seats, sbIndex)) { + const seat = seats[idx] as Seat; + const card = deck.pop() as Card; + seat.holeCards.push(card); + } + } + + const toActIndex = nextActingSeatIndex(seats, bbIndex); + + return { + ...state, + seats, + dealerIndex, + street: "preflop", + communityCards: [], + deck, + toActIndex, + currentBet: state.bigBlind, + minRaise: state.bigBlind, + lastAggressorIndex: bbIndex, + handNumber: state.handNumber + 1, + pots: [], + winners: [], + awards: [], + log, + }; +} + +function nextActingDealerIndex(seats: (Seat | null)[], from: number): number | null { + const n = seats.length; + const wrapped = activeSeatIndicesFrom(seats, (from + 1) % n); + return wrapped[0] ?? null; +} + +export function legalActions(state: TableState, seatIndex: number): LegalActions { + const seat = state.seats[seatIndex]; + if (!seat || state.toActIndex !== seatIndex || seat.status !== "active") { + return { canFold: false, canCheck: false, canCall: false, callAmount: 0, canBetOrRaise: false, minRaiseTo: 0, maxRaiseTo: 0 }; + } + const toCall = Math.max(0, state.currentBet - seat.committed); + const callAmount = Math.min(toCall, seat.stack); + const canCheck = toCall === 0; + const canCall = toCall > 0 && seat.stack > 0; + const maxRaiseTo = seat.committed + seat.stack; + const desiredMinRaiseTo = state.currentBet + state.minRaise; + const minRaiseTo = Math.min(desiredMinRaiseTo, maxRaiseTo); + const canBetOrRaise = seat.stack > callAmount; + + return { canFold: true, canCheck, canCall, callAmount, canBetOrRaise, minRaiseTo, maxRaiseTo }; +} + +function computeSidePots(seats: Seat[]): Pot[] { + const contributors = seats.filter((s) => s.totalCommitted > 0); + const levels = [...new Set(contributors.map((s) => s.totalCommitted))].sort((a, b) => a - b); + const pots: Pot[] = []; + let prev = 0; + for (const level of levels) { + const layer = contributors.filter((s) => s.totalCommitted >= level); + const amount = (level - prev) * layer.length; + if (amount > 0) { + const eligible = layer.filter((s) => s.status !== "folded").map((s) => s.id); + // A contribution above every opponent's commitment is uncalled and + // returns to its sole contributor, even if that seat just folded. + if (eligible.length === 0 && layer.length === 1) eligible.push((layer[0] as Seat).id); + pots.push({ amount, eligible }); + } + prev = level; + } + return pots; +} + +function awardPots(pots: Pot[], seats: Seat[], evaluations: Map>): PotAward[] { + const seatOrder = seats.map((s) => s.id); + const awards: PotAward[] = []; + pots.forEach((pot, potIndex) => { + const eligible = pot.eligible.filter((id) => evaluations.has(id) || pot.eligible.length === 1); + let winners: string[]; + if (evaluations.size === 0) { + winners = eligible; + } else { + let best: string[] = []; + let bestEval: ReturnType | undefined; + for (const id of eligible) { + const evaluation = evaluations.get(id); + if (!evaluation) continue; + if (!bestEval || compareEvaluations(evaluation, bestEval) > 0) { + bestEval = evaluation; + best = [id]; + } else if (compareEvaluations(evaluation, bestEval) === 0) { + best.push(id); + } + } + winners = best.length ? best : eligible; + } + winners.sort((a, b) => seatOrder.indexOf(a) - seatOrder.indexOf(b)); + const share = Math.floor(pot.amount / winners.length); + let remainder = pot.amount - share * winners.length; + for (const id of winners) { + const bonus = remainder > 0 ? 1 : 0; + if (remainder > 0) remainder--; + awards.push({ potIndex, amount: share + bonus, seatIds: [id] }); + const seat = seats.find((s) => s.id === id) as Seat; + seat.stack += share + bonus; + } + }); + return awards; +} + +function resetHandChips(seats: (Seat | null)[]): (Seat | null)[] { + return seats.map((seat) => (seat ? { ...seat, committed: 0, totalCommitted: 0 } : null)); +} + +function finishHandByFold(state: TableState): TableState { + const remaining = contenders(state); + const winner = remaining[0] as { index: number; seat: Seat }; + const seats = state.seats.map((s) => (s ? { ...s } : null)); + const pots = computeSidePots(seats.filter((s): s is Seat => !!s)); + const awards = awardPots(pots, seats.filter((s): s is Seat => !!s), new Map()); + const log = [...state.log, `${winner.seat.displayName} wins uncontested`]; + return { + ...state, + seats: resetHandChips(seats), + street: "showdown", + toActIndex: null, + pots, + winners: [], + awards, + log, + }; +} + +function runShowdown(state: TableState): TableState { + const seats = state.seats.map((s) => (s ? { ...s } : null)); + const inHand = seats.filter((s): s is Seat => !!s && s.status !== "folded"); + const evaluations = new Map>(); + const winners: HandResult[] = []; + for (const seat of inHand) { + const evaluation = evaluateBestHand([...seat.holeCards, ...state.communityCards]); + evaluations.set(seat.id, evaluation); + winners.push({ seatId: seat.id, evaluation }); + } + const pots = computeSidePots(seats.filter((s): s is Seat => !!s)); + const awards = awardPots(pots, seats.filter((s): s is Seat => !!s), evaluations); + const log = [...state.log, "Showdown"]; + return { + ...state, + seats: resetHandChips(seats), + street: "showdown", + toActIndex: null, + pots, + winners, + awards, + log, + }; +} + +function dealStreet(state: TableState): TableState { + const deck = state.deck.slice(); + const communityCards = state.communityCards.slice(); + let street = state.street; + + const dealNext = () => { + deck.pop(); // burn + if (street === "preflop") { + communityCards.push(deck.pop() as Card, deck.pop() as Card, deck.pop() as Card); + street = "flop"; + } else if (street === "flop") { + communityCards.push(deck.pop() as Card); + street = "turn"; + } else if (street === "turn") { + communityCards.push(deck.pop() as Card); + street = "river"; + } + }; + dealNext(); + + const seats = state.seats.map((seat) => (seat ? { ...seat, committed: 0, hasActed: false } : null)); + const stillActing = seats.filter((s) => s && s.status === "active").length; + + let next: TableState = { + ...state, + seats, + street, + communityCards, + deck, + currentBet: 0, + minRaise: state.bigBlind, + lastAggressorIndex: null, + toActIndex: stillActing > 0 ? nextActingSeatIndex(seats, state.dealerIndex) : null, + log: [...state.log, `-- ${street} --`], + }; + + if (stillActing <= 1) { + // Everyone left is all-in (or only one seat can still act): run the board out. + if (street === "river") return runShowdown(next); + return dealStreet(next); + } + return next; +} + +function bettingRoundComplete(state: TableState): boolean { + const inHand = contenders(state); + const actionable = inHand.filter((c) => c.seat.status === "active"); + if (actionable.length === 0) return true; + return actionable.every((c) => c.seat.hasActed && c.seat.committed === state.currentBet); +} + +export function applyAction(state: TableState, seatIndex: number, action: Action): TableState { + if (state.toActIndex !== seatIndex) throw new Error("It is not this seat's turn to act"); + const seat = state.seats[seatIndex]; + if (!seat || seat.status !== "active") throw new Error("Seat cannot act"); + const legal = legalActions(state, seatIndex); + + const seats = state.seats.map((s) => (s ? { ...s } : null)); + const acting = seats[seatIndex] as Seat; + let currentBet = state.currentBet; + let minRaise = state.minRaise; + let lastAggressorIndex = state.lastAggressorIndex; + let raised = false; + const log = [...state.log]; + + switch (action.type) { + case "fold": { + acting.status = "folded"; + log.push(`${acting.displayName} folds`); + break; + } + case "check": { + if (!legal.canCheck) throw new Error("Cannot check facing a bet"); + acting.hasActed = true; + log.push(`${acting.displayName} checks`); + break; + } + case "call": { + if (!legal.canCheck && !legal.canCall) throw new Error("Nothing to call"); + const amount = legal.callAmount; + acting.stack -= amount; + acting.committed += amount; + acting.totalCommitted += amount; + if (acting.stack === 0) acting.status = "allIn"; + acting.hasActed = true; + log.push(`${acting.displayName} calls ${amount}`); + break; + } + case "bet": + case "raise": { + const amount = action.amount; + if (amount < legal.minRaiseTo || amount > legal.maxRaiseTo) { + throw new Error(`Raise must be between ${legal.minRaiseTo} and ${legal.maxRaiseTo}`); + } + const delta = amount - acting.committed; + acting.stack -= delta; + acting.committed = amount; + acting.totalCommitted += delta; + if (acting.stack === 0) acting.status = "allIn"; + acting.hasActed = true; + minRaise = Math.max(minRaise, amount - currentBet); + currentBet = amount; + lastAggressorIndex = seatIndex; + raised = true; + log.push(`${acting.displayName} ${action.type}s to ${amount}`); + break; + } + case "allin": { + const delta = acting.stack; + const amount = acting.committed + delta; + acting.stack = 0; + acting.committed = amount; + acting.totalCommitted += delta; + acting.status = "allIn"; + acting.hasActed = true; + log.push(`${acting.displayName} goes all-in for ${amount}`); + if (amount > currentBet) { + minRaise = Math.max(minRaise, amount - currentBet); + currentBet = amount; + lastAggressorIndex = seatIndex; + raised = true; + } + break; + } + } + + if (raised) { + for (const s of seats) { + if (s && s.status === "active" && s.id !== acting.id) s.hasActed = false; + } + } + + let next: TableState = { ...state, seats, currentBet, minRaise, lastAggressorIndex, log }; + + const stillIn = contenders(next); + if (stillIn.length <= 1) { + return finishHandByFold(next); + } + + if (bettingRoundComplete(next)) { + if (next.street === "river") return runShowdown(next); + return dealStreet(next); + } + + next = { ...next, toActIndex: nextActingSeatIndex(seats, seatIndex) }; + return next; +} + +export { computeSidePots, bettingRoundComplete }; diff --git a/packages/pi-texas-holdem/src/engine/types.ts b/packages/pi-texas-holdem/src/engine/types.ts new file mode 100644 index 0000000..44386e2 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/types.ts @@ -0,0 +1,103 @@ +export type Suit = "s" | "h" | "d" | "c"; + +/** 11=J, 12=Q, 13=K, 14=A */ +export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; + +export interface Card { + rank: Rank; + suit: Suit; +} + +export type Street = "preflop" | "flop" | "turn" | "river" | "showdown"; + +export type SeatStatus = "active" | "folded" | "allIn"; + +export interface Seat { + id: string; + displayName: string; + stack: number; + holeCards: Card[]; + status: SeatStatus; + /** Chips committed in the current betting round. */ + committed: number; + /** Chips committed across the whole hand, used for side-pot math. */ + totalCommitted: number; + /** Whether this seat has acted since the last bet/raise. */ + hasActed: boolean; + sittingOut: boolean; +} + +export type Action = + | { type: "fold" } + | { type: "check" } + | { type: "call" } + /** `amount` is the total this seat will have committed this round after the action. */ + | { type: "bet"; amount: number } + | { type: "raise"; amount: number } + | { type: "allin" }; + +export interface Pot { + amount: number; + /** Seat ids allowed to win this pot. */ + eligible: string[]; +} + +export type HandCategory = + | "highCard" + | "pair" + | "twoPair" + | "trips" + | "straight" + | "flush" + | "fullHouse" + | "quads" + | "straightFlush"; + +export interface HandEvaluation { + category: HandCategory; + /** 0 (high card) .. 8 (straight flush) */ + rank: number; + tiebreak: number[]; + hand: Card[]; +} + +export interface HandResult { + seatId: string; + evaluation: HandEvaluation; +} + +export interface PotAward { + potIndex: number; + amount: number; + /** Split evenly among these seat ids (odd chips go to the first). */ + seatIds: string[]; +} + +export interface TableState { + seats: (Seat | null)[]; + dealerIndex: number; + smallBlind: number; + bigBlind: number; + street: Street; + communityCards: Card[]; + deck: Card[]; + toActIndex: number | null; + currentBet: number; + minRaise: number; + lastAggressorIndex: number | null; + handNumber: number; + pots: Pot[]; + winners: HandResult[]; + awards: PotAward[]; + log: string[]; +} + +export interface LegalActions { + canFold: boolean; + canCheck: boolean; + canCall: boolean; + callAmount: number; + canBetOrRaise: boolean; + minRaiseTo: number; + maxRaiseTo: number; +} diff --git a/packages/pi-texas-holdem/src/engine/view.ts b/packages/pi-texas-holdem/src/engine/view.ts new file mode 100644 index 0000000..50e3df7 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/view.ts @@ -0,0 +1,63 @@ +import type { Card, HandResult, PotAward, Pot, Seat, Street, TableState } from "./types.ts"; + +export interface PublicSeat { + id: string; + displayName: string; + stack: number; + committed: number; + status: Seat["status"]; + sittingOut: boolean; + /** null entries are face-down cards hidden from this viewer. */ + holeCards: (Card | null)[]; +} + +export interface PublicTableState { + seats: (PublicSeat | null)[]; + dealerIndex: number; + smallBlind: number; + bigBlind: number; + street: Street; + communityCards: Card[]; + toActIndex: number | null; + currentBet: number; + minRaise: number; + handNumber: number; + pots: Pot[]; + winners: HandResult[]; + awards: PotAward[]; + log: string[]; +} + +/** Redacts hole cards other players shouldn't see yet for the given viewer. */ +export function redactStateFor(state: TableState, viewerId: string | undefined): PublicTableState { + const seats = state.seats.map((seat): PublicSeat | null => { + if (!seat) return null; + const revealed = seat.id === viewerId || (state.street === "showdown" && seat.status !== "folded"); + return { + id: seat.id, + displayName: seat.displayName, + stack: seat.stack, + committed: seat.committed, + status: seat.status, + sittingOut: seat.sittingOut, + holeCards: revealed ? seat.holeCards : seat.holeCards.map(() => null), + }; + }); + + return { + seats, + dealerIndex: state.dealerIndex, + smallBlind: state.smallBlind, + bigBlind: state.bigBlind, + street: state.street, + communityCards: state.communityCards, + toActIndex: state.toActIndex, + currentBet: state.currentBet, + minRaise: state.minRaise, + handNumber: state.handNumber, + pots: state.pots, + winners: state.winners, + awards: state.awards, + log: state.log, + }; +} diff --git a/packages/pi-texas-holdem/src/net/client.ts b/packages/pi-texas-holdem/src/net/client.ts new file mode 100644 index 0000000..70ea800 --- /dev/null +++ b/packages/pi-texas-holdem/src/net/client.ts @@ -0,0 +1,103 @@ +import { WebSocket } from "ws"; +import type { Action } from "../engine/types.ts"; +import type { PublicTableState } from "../engine/view.ts"; +import { type ChatMessage, decodeServerMessage, encode, PROTOCOL_VERSION } from "./protocol.ts"; + +export interface RoomClientOptions { + url: string; + playerId: string; + displayName: string; + onWelcome: (info: { seatIndex: number; seatCount: number; smallBlind: number; bigBlind: number }) => void; + onState: (state: PublicTableState) => void; + onChatMessage: (message: ChatMessage) => void; + onChatHistory: (messages: ChatMessage[]) => void; + onRejected: (reason: "roomFull" | "protocolMismatch" | "error", message?: string) => void; + /** Nonfatal server or socket error after the welcome handshake. */ + onError?: (message: string) => void; + onClose: () => void; +} + +export class RoomClient { + private ws: WebSocket; + seatIndex: number | null = null; + + constructor(private readonly opts: RoomClientOptions) { + const url = new URL(opts.url); + const creatorCapability = url.searchParams.get("creator") ?? undefined; + url.searchParams.delete("creator"); + this.ws = new WebSocket(url); + this.ws.on("open", () => { + this.ws.send( + encode({ + type: "hello", + protocolVersion: PROTOCOL_VERSION, + playerId: opts.playerId, + displayName: opts.displayName, + ...(creatorCapability ? { creatorCapability } : {}), + }), + ); + }); + this.ws.on("message", (raw) => this.handleMessage(raw.toString())); + this.ws.on("close", () => opts.onClose()); + this.ws.on("error", () => this.reportError("Connection failed")); + } + + sendAction(action: Action): void { + this.ws.send(encode({ type: "action", action })); + } + + startHand(): void { + this.ws.send(encode({ type: "startHand" })); + } + + sendChat(text: string): void { + this.ws.send(encode({ type: "chat", text })); + } + + close(): void { + this.ws.close(); + } + + private handleMessage(raw: string): void { + let message: ReturnType; + try { + message = decodeServerMessage(raw); + } catch { + return; + } + switch (message.type) { + case "welcome": + this.seatIndex = message.seatIndex; + this.opts.onWelcome(message); + break; + case "state": + this.opts.onState(message.state); + break; + case "chatMessage": + this.opts.onChatMessage(message.message); + break; + case "chatHistory": + this.opts.onChatHistory(message.messages); + break; + case "roomFull": + this.opts.onRejected("roomFull"); + break; + case "protocolMismatch": + this.opts.onRejected("protocolMismatch", `Host is running protocol v${message.hostVersion}`); + break; + case "error": + this.reportError(message.message); + break; + case "pong": + break; + } + } + + private reportError(message: string): void { + if (this.seatIndex === null) { + this.opts.onRejected("error", message); + } else { + this.opts.onError?.(message); + } + } +} diff --git a/packages/pi-texas-holdem/src/net/host.ts b/packages/pi-texas-holdem/src/net/host.ts new file mode 100644 index 0000000..93d3d32 --- /dev/null +++ b/packages/pi-texas-holdem/src/net/host.ts @@ -0,0 +1,250 @@ +import { WebSocket, WebSocketServer } from "ws"; +import { applyAction, createTable, forceFold, leaveSeat, seatPlayer, startHand } from "../engine/table.ts"; +import type { Action, TableState } from "../engine/types.ts"; +import { redactStateFor } from "../engine/view.ts"; +import { type ChatMessage, decodeClientMessage, encode, PROTOCOL_VERSION } from "./protocol.ts"; +import { sanitizeChatText } from "./sanitize.ts"; + +const CHAT_HISTORY_LIMIT = 200; +const CHAT_HISTORY_SENT_ON_JOIN = 30; +const CHAT_MIN_INTERVAL_MS = 400; + +export interface HostRoomOptions { + port: number; + seatCount: number; + smallBlind: number; + bigBlind: number; + startingStack: number; + onStateChange: (state: TableState) => void; + onChatMessage?: (message: ChatMessage) => void; + onLog?: (line: string) => void; + createMessageId?: () => string; + now?: () => number; +} + +interface Connection { + ws: WebSocket; + seatIndex: number; + playerId: string; + lastChatAt: number; +} + +/** + * Authoritative game server. The host's own UI mutates `state` directly through + * `applyLocalAction` / `startHand`; remote players connect over WebSocket and are + * validated the same way any seat is (must be their turn, seat must be theirs). + */ +export class HostRoom { + state: TableState; + private wss: WebSocketServer; + private connections = new Set(); + private readonly opts: HostRoomOptions; + private chatLog: ChatMessage[] = []; + private announcedLogLength = 0; + + constructor(opts: HostRoomOptions) { + this.opts = opts; + this.state = createTable({ seatCount: opts.seatCount, smallBlind: opts.smallBlind, bigBlind: opts.bigBlind }); + this.wss = new WebSocketServer({ port: opts.port }); + this.wss.on("connection", (ws) => this.handleConnection(ws)); + } + + /** Resolves once the server is actually listening, with the bound port. */ + waitForListening(): Promise { + return new Promise((resolve, reject) => { + this.wss.once("listening", () => { + const address = this.wss.address(); + resolve(typeof address === "object" && address ? address.port : this.opts.port); + }); + this.wss.once("error", reject); + }); + } + + seatLocalPlayer(seatIndex: number, id: string, displayName: string): void { + this.state = seatPlayer(this.state, seatIndex, { id, displayName, stack: this.opts.startingStack }); + this.broadcast(); + } + + firstOpenSeat(): number | null { + for (let i = 0; i < this.state.seats.length; i++) { + if (!this.state.seats[i]) return i; + } + return null; + } + + seatedCount(): number { + return this.state.seats.filter((s) => !!s).length; + } + + canStartHand(): boolean { + return this.state.seats.filter((s) => s && s.stack > 0).length >= 2; + } + + startHand(): void { + this.state = startHand(this.state); + this.announcedLogLength = 0; + this.announceNewLog(); + this.log(`-- hand ${this.state.handNumber} --`); + this.broadcast(); + } + + applyLocalAction(seatIndex: number, action: Action): void { + this.state = applyAction(this.state, seatIndex, action); + this.announceNewLog(); + this.broadcast(); + } + + getChatHistory(): readonly ChatMessage[] { + return this.chatLog; + } + + /** Chat from the host's own local seat; bypasses the per-connection rate limit. */ + sendLocalChat(seatIndex: number, text: string): void { + const seat = this.state.seats[seatIndex]; + if (!seat) return; + const clean = sanitizeChatText(text); + if (!clean) return; + this.pushChat(seatIndex, seat.displayName, clean); + } + + close(): void { + for (const conn of this.connections) conn.ws.close(); + this.wss.close(); + } + + private log(line: string): void { + this.opts.onLog?.(line); + } + + /** Mirrors new engine log lines (folds, calls, showdown results, ...) into chat as system messages. */ + private announceNewLog(): void { + while (this.announcedLogLength < this.state.log.length) { + const line = this.state.log[this.announcedLogLength] as string; + this.pushChat(null, "table", line); + this.announcedLogLength++; + } + } + + private pushChat(seatIndex: number | null, displayName: string, text: string): void { + const message: ChatMessage = { + id: this.opts.createMessageId?.() ?? globalThis.crypto.randomUUID(), + seatIndex, + displayName, + text, + ts: this.opts.now?.() ?? Date.now(), + }; + this.chatLog.push(message); + if (this.chatLog.length > CHAT_HISTORY_LIMIT) this.chatLog.shift(); + this.opts.onChatMessage?.(message); + const encoded = encode({ type: "chatMessage", message }); + for (const conn of this.connections) { + if (conn.ws.readyState === WebSocket.OPEN) conn.ws.send(encoded); + } + } + + private handleConnection(ws: WebSocket): void { + let conn: Connection | undefined; + + ws.on("message", (raw) => { + let message: ReturnType; + try { + message = decodeClientMessage(raw.toString()); + } catch { + ws.send(encode({ type: "error", message: "Malformed message" })); + return; + } + + if (message.type === "hello") { + if (message.protocolVersion !== PROTOCOL_VERSION) { + ws.send(encode({ type: "protocolMismatch", hostVersion: PROTOCOL_VERSION })); + ws.close(); + return; + } + const seatIndex = this.firstOpenSeat(); + if (seatIndex === null) { + ws.send(encode({ type: "roomFull" })); + ws.close(); + return; + } + this.state = seatPlayer(this.state, seatIndex, { + id: message.playerId, + displayName: message.displayName, + stack: this.opts.startingStack, + }); + conn = { ws, seatIndex, playerId: message.playerId, lastChatAt: 0 }; + this.connections.add(conn); + this.log(`${message.displayName} joined at seat ${seatIndex}`); + this.pushChat(null, "table", `${message.displayName} joined the table`); + ws.send( + encode({ + type: "welcome", + youId: message.playerId, + seatIndex, + seatCount: this.state.seats.length, + smallBlind: this.state.smallBlind, + bigBlind: this.state.bigBlind, + }), + ); + ws.send(encode({ type: "chatHistory", messages: this.chatLog.slice(-CHAT_HISTORY_SENT_ON_JOIN) })); + this.broadcast(); + return; + } + + if (!conn) { + ws.send(encode({ type: "error", message: "Send hello first" })); + return; + } + + if (message.type === "action") { + try { + this.applyLocalAction(conn.seatIndex, message.action); + } catch (error) { + ws.send(encode({ type: "error", message: error instanceof Error ? error.message : String(error) })); + } + return; + } + + if (message.type === "chat") { + const now = Date.now(); + if (now - conn.lastChatAt < CHAT_MIN_INTERVAL_MS) { + ws.send(encode({ type: "error", message: "You're sending messages too fast" })); + return; + } + conn.lastChatAt = now; + const clean = sanitizeChatText(message.text); + if (!clean) return; + const seat = this.state.seats[conn.seatIndex]; + this.pushChat(conn.seatIndex, seat?.displayName ?? conn.playerId, clean); + return; + } + + if (message.type === "ping") { + ws.send(encode({ type: "pong" })); + } + }); + + ws.on("close", () => { + if (!conn) return; + this.connections.delete(conn); + const seat = this.state.seats[conn.seatIndex]; + if (seat) { + // The engine owns forced-fold settlement for both in-turn and + // out-of-turn disconnects; the transport only removes the seat afterward. + this.state = forceFold(this.state, conn.seatIndex); + this.announceNewLog(); + this.state = leaveSeat(this.state, conn.seatIndex); + this.log(`${seat.displayName} disconnected`); + this.pushChat(null, "table", `${seat.displayName} disconnected`); + this.broadcast(); + } + }); + } + + private broadcast(): void { + this.opts.onStateChange(this.state); + for (const conn of this.connections) { + if (conn.ws.readyState !== WebSocket.OPEN) continue; + conn.ws.send(encode({ type: "state", state: redactStateFor(this.state, conn.playerId) })); + } + } +} diff --git a/packages/pi-texas-holdem/src/net/protocol.ts b/packages/pi-texas-holdem/src/net/protocol.ts new file mode 100644 index 0000000..a772921 --- /dev/null +++ b/packages/pi-texas-holdem/src/net/protocol.ts @@ -0,0 +1,50 @@ +import type { Action } from "../engine/types.ts"; +import type { PublicTableState } from "../engine/view.ts"; + +export const PROTOCOL_VERSION = 2; + +export interface ChatMessage { + id: string; + /** null identifies a system/log message (joins, folds, showdown results, ...). */ + seatIndex: number | null; + displayName: string; + text: string; + ts: number; +} + +export type ClientMessage = + | { type: "hello"; protocolVersion: number; playerId: string; displayName: string; creatorCapability?: string } + | { type: "action"; action: Action } + | { type: "startHand" } + | { type: "chat"; text: string } + | { type: "ping" }; + +export type ServerMessage = + | { type: "welcome"; youId: string; seatIndex: number; seatCount: number; smallBlind: number; bigBlind: number } + | { type: "state"; state: PublicTableState } + | { type: "chatMessage"; message: ChatMessage } + | { type: "chatHistory"; messages: ChatMessage[] } + | { type: "roomFull" } + | { type: "protocolMismatch"; hostVersion: number } + | { type: "error"; message: string } + | { type: "pong" }; + +export function encode(message: ClientMessage | ServerMessage): string { + return JSON.stringify(message); +} + +export function decodeClientMessage(raw: string): ClientMessage { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string") { + throw new Error("Malformed client message"); + } + return parsed as ClientMessage; +} + +export function decodeServerMessage(raw: string): ServerMessage { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string") { + throw new Error("Malformed server message"); + } + return parsed as ServerMessage; +} diff --git a/packages/pi-texas-holdem/src/net/sanitize.ts b/packages/pi-texas-holdem/src/net/sanitize.ts new file mode 100644 index 0000000..7d86d77 --- /dev/null +++ b/packages/pi-texas-holdem/src/net/sanitize.ts @@ -0,0 +1,22 @@ +/** + * Chat text is rendered verbatim inside a terminal UI, so it must never carry + * raw ANSI/OSC escape sequences or other control characters through to the + * screen — a malicious peer could otherwise repaint colors, move the cursor, + * or (on some terminals) inject OSC title/clipboard sequences via chat. + */ + +// CSI sequences (ESC [ ... letter), OSC sequences (ESC ] ... BEL or ST), and +// simple two-byte escapes (ESC followed by a single @-Z / \-_ byte). +// eslint-disable-next-line no-control-regex -- matching control chars is the point of a sanitizer +const ANSI_ESCAPE = /\x1B(\[[0-9;?]*[a-zA-Z]|\][^\x07\x1B]*(\x07|\x1B\\)|[@-Z\\-_])/g; +// Anything else in C0/C1 control ranges except tab/newlines, which are +// harmlessly collapsed/trimmed by the caller where appropriate. +// eslint-disable-next-line no-control-regex -- matching control chars is the point of a sanitizer +const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g; + +export function sanitizeChatText(raw: string, maxLength = 240): string { + const withoutAnsi = raw.replace(ANSI_ESCAPE, ""); + const withoutControl = withoutAnsi.replace(CONTROL_CHARS, ""); + const singleLine = withoutControl.replace(/[\r\n]+/g, " "); + return singleLine.trim().slice(0, maxLength); +} diff --git a/packages/pi-texas-holdem/src/session/bots.ts b/packages/pi-texas-holdem/src/session/bots.ts new file mode 100644 index 0000000..1d26419 --- /dev/null +++ b/packages/pi-texas-holdem/src/session/bots.ts @@ -0,0 +1,112 @@ +import { secureRandom } from "../engine/random.ts"; +import { applyAction, createTable, legalActions, seatPlayer, startHand } from "../engine/table.ts"; +import type { Action, TableState } from "../engine/types.ts"; +import { redactStateFor } from "../engine/view.ts"; +import type { ChatMessage } from "../net/protocol.ts"; +import { sanitizeChatText } from "../net/sanitize.ts"; +import { createSessionEmitter } from "./emitter.ts"; +import type { GameSession, LocalSessionOptions } from "./types.ts"; + +const CHAT_LOG_LIMIT = 200; +const BOT_DELAY_MS = 450; + +export interface BotsSessionDependencies { + random?: () => number; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (timer: ReturnType) => void; + now?: () => number; +} + +function botDecision(state: TableState, seatIndex: number, random: () => number): Action { + const legal = legalActions(state, seatIndex); + const roll = random(); + if (legal.canCheck) { + if (legal.canBetOrRaise && roll < 0.12) return { type: "raise", amount: legal.minRaiseTo }; + return { type: "check" }; + } + if (!legal.canCall) return { type: "fold" }; + const seat = state.seats[seatIndex]; + const stackBase = seat ? seat.stack + seat.committed : 1; + const callRatio = legal.callAmount / Math.max(1, stackBase); + if (callRatio > 0.55 && roll < 0.5) return { type: "fold" }; + if (legal.canBetOrRaise && roll < 0.07) return { type: "raise", amount: legal.minRaiseTo }; + return { type: "call" }; +} + +/** Solo play against simple local bots. No networking or Node-specific APIs. */ +export function createBotsSession(opts: LocalSessionOptions, dependencies: BotsSessionDependencies = {}): GameSession { + const random = dependencies.random ?? secureRandom; + const setTimer = dependencies.setTimer ?? setTimeout; + const clearTimer = dependencies.clearTimer ?? clearTimeout; + const now = dependencies.now ?? Date.now; + let state = createTable({ seatCount: opts.seatCount, smallBlind: opts.smallBlind, bigBlind: opts.bigBlind }); + state = seatPlayer(state, 0, { id: opts.myId, displayName: opts.displayName, stack: opts.startingStack }); + for (let i = 1; i < opts.seatCount; i++) { + state = seatPlayer(state, i, { id: `bot-${i}`, displayName: `bot-${i}`, stack: opts.startingStack }); + } + const emitter = createSessionEmitter(); + let botTimer: ReturnType | undefined; + let chatLog: ChatMessage[] = []; + let announcedLogLength = 0; + let chatCounter = 0; + let closed = false; + + const pushChat = (seatIndex: number | null, displayName: string, text: string) => { + chatLog = [...chatLog, { id: `local-${++chatCounter}`, seatIndex, displayName, text, ts: now() }].slice(-CHAT_LOG_LIMIT); + }; + const announceNewLog = () => { + while (announcedLogLength < state.log.length) pushChat(null, "table", state.log[announcedLogLength++] as string); + }; + const scheduleBotTurn = () => { + if (closed || botTimer) return; + const toAct = state.toActIndex; + if (toAct === null || toAct === 0 || state.street === "showdown") return; + botTimer = setTimer(() => { + botTimer = undefined; + const index = state.toActIndex; + if (closed || index === null || index === 0 || state.street === "showdown") return; + try { + state = applyAction(state, index, botDecision(state, index, random)); + } catch { + state = applyAction(state, index, { type: "fold" }); + } + announceNewLog(); + emitter.emit(); + scheduleBotTurn(); + }, BOT_DELAY_MS); + }; + + return { + info: { roomLabel: "Local vs bots", myId: opts.myId, mySeatIndex: 0 }, + getState: () => redactStateFor(state, opts.myId), + legalActionsForMe: () => legalActions(state, 0), + canStartHand: () => state.street === "showdown" && state.seats.filter((seat) => seat && seat.stack > 0).length >= 2, + startHand: () => { + state = startHand(state, random); + announcedLogLength = 0; + announceNewLog(); + emitter.emit(); + scheduleBotTurn(); + }, + act: (action) => { + state = applyAction(state, 0, action); + announceNewLog(); + emitter.emit(); + scheduleBotTurn(); + }, + getChatLog: () => chatLog, + sendChat: (text) => { + const clean = sanitizeChatText(text); + if (!clean) return; + pushChat(0, opts.displayName, clean); + emitter.emit(); + }, + subscribe: emitter.subscribe, + close: () => { + closed = true; + if (botTimer) clearTimer(botTimer); + botTimer = undefined; + emitter.clear(); + }, + }; +} diff --git a/packages/pi-texas-holdem/src/session/emitter.ts b/packages/pi-texas-holdem/src/session/emitter.ts new file mode 100644 index 0000000..6fd3b4e --- /dev/null +++ b/packages/pi-texas-holdem/src/session/emitter.ts @@ -0,0 +1,21 @@ +export interface SessionEmitter { + subscribe(listener: () => void): () => void; + emit(): void; + clear(): void; +} + +export function createSessionEmitter(): SessionEmitter { + const listeners = new Set<() => void>(); + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emit() { + for (const listener of listeners) listener(); + }, + clear() { + listeners.clear(); + }, + }; +} diff --git a/packages/pi-texas-holdem/src/session/host.ts b/packages/pi-texas-holdem/src/session/host.ts new file mode 100644 index 0000000..d77c08e --- /dev/null +++ b/packages/pi-texas-holdem/src/session/host.ts @@ -0,0 +1,38 @@ +import { legalActions } from "../engine/table.ts"; +import { redactStateFor } from "../engine/view.ts"; +import { HostRoom } from "../net/host.ts"; +import { createSessionEmitter } from "./emitter.ts"; +import type { GameSession, HostSessionOptions } from "./types.ts"; + +/** Node WebSocket adapter for the runtime-neutral GameSession boundary. */ +export function createHostSession(opts: HostSessionOptions, onReady: (port: number) => void): GameSession { + const emitter = createSessionEmitter(); + const room = new HostRoom({ + port: opts.port, + seatCount: opts.seatCount, + smallBlind: opts.smallBlind, + bigBlind: opts.bigBlind, + startingStack: opts.startingStack, + onStateChange: () => emitter.emit(), + onChatMessage: () => emitter.emit(), + onLog: opts.onLog, + }); + room.seatLocalPlayer(0, opts.myId, opts.displayName); + room.waitForListening().then(onReady).catch(() => {}); + + return { + info: { roomLabel: "Hosting", myId: opts.myId, mySeatIndex: 0 }, + getState: () => redactStateFor(room.state, opts.myId), + legalActionsForMe: () => legalActions(room.state, 0), + canStartHand: () => room.canStartHand() && room.state.street === "showdown", + startHand: () => room.startHand(), + act: (action) => room.applyLocalAction(0, action), + getChatLog: () => room.getChatHistory(), + sendChat: (text) => room.sendLocalChat(0, text), + subscribe: emitter.subscribe, + close: () => { + emitter.clear(); + room.close(); + }, + }; +} diff --git a/packages/pi-texas-holdem/src/session/join.ts b/packages/pi-texas-holdem/src/session/join.ts new file mode 100644 index 0000000..7679cd4 --- /dev/null +++ b/packages/pi-texas-holdem/src/session/join.ts @@ -0,0 +1,109 @@ +import type { LegalActions } from "../engine/types.ts"; +import type { PublicTableState } from "../engine/view.ts"; +import { RoomClient } from "../net/client.ts"; +import type { ChatMessage } from "../net/protocol.ts"; +import { createSessionEmitter } from "./emitter.ts"; +import type { GameSession, JoinSessionEvent, JoinSessionOptions } from "./types.ts"; +import { shareableRoomUrl } from "../ui/commands.ts"; + +const CHAT_LOG_LIMIT = 200; +const NO_LEGAL_ACTIONS: LegalActions = { + canFold: false, + canCheck: false, + canCall: false, + callAmount: 0, + canBetOrRaise: false, + minRaiseTo: 0, + maxRaiseTo: 0, +}; +const EMPTY_STATE: PublicTableState = { + seats: [], + dealerIndex: -1, + smallBlind: 0, + bigBlind: 0, + street: "showdown", + communityCards: [], + toActIndex: null, + currentBet: 0, + minRaise: 0, + handNumber: 0, + pots: [], + winners: [], + awards: [], + log: [], +}; + +export function legalActionsFromPublicState(state: PublicTableState, seatIndex: number): LegalActions { + const seat = state.seats[seatIndex]; + if (!seat || state.toActIndex !== seatIndex || seat.status !== "active") return NO_LEGAL_ACTIONS; + const toCall = Math.max(0, state.currentBet - seat.committed); + const callAmount = Math.min(toCall, seat.stack); + const maxRaiseTo = seat.committed + seat.stack; + return { + canFold: true, + canCheck: toCall === 0, + canCall: toCall > 0 && seat.stack > 0, + callAmount, + canBetOrRaise: seat.stack > callAmount, + minRaiseTo: Math.min(state.currentBet + state.minRaise, maxRaiseTo), + maxRaiseTo, + }; +} + +/** Node WebSocket client adapter for the runtime-neutral GameSession boundary. */ +export function createJoinSession( + opts: JoinSessionOptions, + onEvent: (event: JoinSessionEvent, message?: string) => void, +): GameSession { + const emitter = createSessionEmitter(); + let lastState: PublicTableState | undefined; + let mySeatIndex: number | null = null; + let chatLog: ChatMessage[] = []; + let welcomed = false; + const info = { roomLabel: `Connected to ${shareableRoomUrl(opts.url)}`, myId: opts.myId, get mySeatIndex() { return mySeatIndex; } }; + + const client = new RoomClient({ + url: opts.url, + playerId: opts.myId, + displayName: opts.displayName, + onWelcome: (welcome) => { + welcomed = true; + mySeatIndex = welcome.seatIndex; + onEvent("welcome"); + emitter.emit(); + }, + onState: (state) => { + lastState = state; + emitter.emit(); + }, + onChatMessage: (message) => { + chatLog = [...chatLog, message].slice(-CHAT_LOG_LIMIT); + emitter.emit(); + }, + onChatHistory: (messages) => { + chatLog = messages.slice(-CHAT_LOG_LIMIT); + emitter.emit(); + }, + onRejected: (reason, message) => onEvent("rejected", message ?? reason), + onError: (message) => onEvent("error", message), + onClose: () => onEvent(welcomed ? "hostDisconnected" : "rejected", welcomed ? undefined : "Connection closed"), + }); + + return { + info, + getState: () => lastState ?? EMPTY_STATE, + legalActionsForMe: () => (mySeatIndex === null || !lastState ? null : legalActionsFromPublicState(lastState, mySeatIndex)), + canStartHand: () => opts.canStartHands === true && welcomed && lastState?.street === "showdown", + startHand: () => { + if (opts.canStartHands === true) client.startHand(); + }, + act: (action) => client.sendAction(action), + getChatLog: () => chatLog, + sendChat: (text) => client.sendChat(text), + subscribe: emitter.subscribe, + close: () => { + emitter.clear(); + client.close(); + }, + }; +} diff --git a/packages/pi-texas-holdem/src/session/types.ts b/packages/pi-texas-holdem/src/session/types.ts new file mode 100644 index 0000000..83ef621 --- /dev/null +++ b/packages/pi-texas-holdem/src/session/types.ts @@ -0,0 +1,47 @@ +import type { Action, LegalActions } from "../engine/types.ts"; +import type { PublicTableState } from "../engine/view.ts"; +import type { ChatMessage } from "../net/protocol.ts"; + +export interface SessionInfo { + readonly roomLabel: string; + readonly myId: string; + readonly mySeatIndex: number | null; +} + +/** Runtime-neutral boundary consumed by the poker UI for local and online games. */ +export interface GameSession { + readonly info: SessionInfo; + getState(): PublicTableState; + legalActionsForMe(): LegalActions | null; + canStartHand(): boolean; + startHand(): void; + act(action: Action): void; + getChatLog(): readonly ChatMessage[]; + sendChat(text: string): void; + subscribe(listener: () => void): () => void; + close(): void; +} + +export interface LocalSessionOptions { + myId: string; + displayName: string; + seatCount: number; + smallBlind: number; + bigBlind: number; + startingStack: number; +} + +export interface HostSessionOptions extends LocalSessionOptions { + port: number; + onLog?: (line: string) => void; +} + +export interface JoinSessionOptions { + url: string; + myId: string; + displayName: string; + /** Only creator-authorized cloud sessions may request a new hand. */ + canStartHands?: boolean; +} + +export type JoinSessionEvent = "welcome" | "rejected" | "hostDisconnected" | "error"; diff --git a/packages/pi-texas-holdem/src/ui/cloud.ts b/packages/pi-texas-holdem/src/ui/cloud.ts new file mode 100644 index 0000000..9375928 --- /dev/null +++ b/packages/pi-texas-holdem/src/ui/cloud.ts @@ -0,0 +1,99 @@ +export interface PersonalWorkerConfig { + endpoint: string; + creationSecret?: string; +} + +export interface CreatedPersonalRoom { + /** URL safe to share with invited players. */ + shareUrl: string; + /** Creator-authorized URL; may contain a bearer capability and must not be shared accidentally. */ + creatorUrl: string; + expiresAt?: number; +} + +export interface CreatePersonalRoomOptions { + seatCount?: number; + smallBlind?: number; + bigBlind?: number; + startingStack?: number; +} + +export function normalizeWorkerEndpoint(value: string): string { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw new Error("Set a valid personal Worker URL, for example https://my-poker.workers.dev."); + } + if (url.username || url.password) throw new Error("The Worker URL cannot contain credentials."); + const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"; + if (url.protocol !== "https:" && !(isLocal && url.protocol === "http:")) { + throw new Error("Personal Worker URLs must use HTTPS (HTTP is allowed only for local development)."); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); +} + +function capabilityUrl(shareUrl: string, capability: string): string { + const url = new URL(shareUrl); + url.searchParams.set("creator", capability); + return url.toString(); +} + +/** Create a room without ever placing the creation secret in a URL or returned error. */ +export async function createPersonalRoom( + config: PersonalWorkerConfig, + options: CreatePersonalRoomOptions = {}, + fetchImpl: typeof fetch = fetch, +): Promise { + const endpoint = normalizeWorkerEndpoint(config.endpoint); + const headers = new Headers({ "content-type": "application/json" }); + if (config.creationSecret) headers.set("authorization", `Bearer ${config.creationSecret}`); + + let response: Response; + try { + response = await fetchImpl(`${endpoint}/rooms`, { + method: "POST", + headers, + body: JSON.stringify({ + seatCount: options.seatCount ?? 6, + smallBlind: options.smallBlind ?? 10, + bigBlind: options.bigBlind ?? 20, + startingStack: options.startingStack ?? 2_000, + telemetry: false, + }), + }); + } catch { + throw new Error("Could not reach the personal Worker. Check its URL and deployment, then try again."); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + body = undefined; + } + if (!response.ok) { + const message = typeof body === "object" && body !== null && typeof (body as { error?: unknown }).error === "string" + ? (body as { error: string }).error + : `Worker returned HTTP ${response.status}`; + if (response.status === 401 || response.status === 403) throw new Error("Room creation was denied. Check PI_POKER_CREATE_SECRET and the Worker's creation policy."); + throw new Error(`Could not create room: ${message}`); + } + if (typeof body !== "object" || body === null) throw new Error("Worker returned an invalid room response."); + const result = body as Record; + if (typeof result.url !== "string") throw new Error("Worker response is missing the shareable room URL."); + const shareUrl = result.url; + const creatorUrl = typeof result.creatorUrl === "string" + ? result.creatorUrl + : typeof result.capability === "string" + ? capabilityUrl(shareUrl, result.capability) + : shareUrl; + return { + shareUrl, + creatorUrl, + expiresAt: typeof result.expiresAt === "number" ? result.expiresAt : undefined, + }; +} diff --git a/packages/pi-texas-holdem/src/ui/commands.ts b/packages/pi-texas-holdem/src/ui/commands.ts new file mode 100644 index 0000000..93a71d6 --- /dev/null +++ b/packages/pi-texas-holdem/src/ui/commands.ts @@ -0,0 +1,68 @@ +export type PokerCommand = + | { kind: "open" } + | { kind: "local"; seatCount: number; legacyAlias: boolean } + | { kind: "create" } + | { kind: "join"; address?: string } + | { kind: "lanHost"; port: number; confirmed: boolean; legacyAlias: boolean } + | { kind: "rooms" } + | { kind: "privacy" } + | { kind: "leave" } + | { kind: "help" } + | { kind: "invalid"; input: string }; + +export const DEFAULT_POKER_PORT = 4551; + +/** Parse the user-facing command vocabulary while retaining pre-0.2 aliases. */ +export function parsePokerCommand(rawArgs: string): PokerCommand { + const args = rawArgs.trim().split(/\s+/).filter(Boolean); + const command = args[0]?.toLowerCase(); + + if (!command) return { kind: "open" }; + if (command === "local" || command === "bots") { + const requestedSeats = Number(args[1]); + const seatCount = Number.isFinite(requestedSeats) && requestedSeats > 0 ? Math.min(6, Math.max(2, Math.trunc(requestedSeats))) : 6; + return { kind: "local", seatCount, legacyAlias: command === "bots" }; + } + if (command === "create" || command === "cloud") return { kind: "create" }; + if (command === "join") return { kind: "join", address: args[1] }; + if (command === "lan-host" || command === "host") { + const portArg = args.find((arg, index) => index > 0 && !arg.startsWith("--")); + const requestedPort = Number(portArg); + const port = Number.isInteger(requestedPort) && requestedPort > 0 && requestedPort <= 65_535 ? requestedPort : DEFAULT_POKER_PORT; + return { kind: "lanHost", port, confirmed: args.includes("--i-know"), legacyAlias: command === "host" }; + } + if (command === "rooms" || command === "public") return { kind: "rooms" }; + if (command === "privacy" || command === "telemetry") return { kind: "privacy" }; + if (command === "leave" || command === "quit") return { kind: "leave" }; + if (command === "help") return { kind: "help" }; + return { kind: "invalid", input: command }; +} + +/** Accept shareable HTTP(S) room links as well as direct WebSocket URLs. */ +export function normalizeRoomUrl(address: string): string { + const trimmed = address.trim(); + if (!trimmed) throw new Error("Enter a room URL."); + + let candidate = trimmed; + if (candidate.startsWith("https://")) candidate = `wss://${candidate.slice("https://".length)}`; + else if (candidate.startsWith("http://")) candidate = `ws://${candidate.slice("http://".length)}`; + else if (!candidate.startsWith("ws://") && !candidate.startsWith("wss://")) candidate = `ws://${candidate}`; + + let url: URL; + try { + url = new URL(candidate); + } catch { + throw new Error("Enter a valid room URL, such as wss://room.example or 192.168.1.20:4551."); + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new Error("Room URLs must use https, http, wss, or ws."); + if (!url.hostname) throw new Error("The room URL is missing a host."); + if (url.username || url.password) throw new Error("Room URLs cannot contain usernames or passwords."); + return url.toString(); +} + +/** Strip creator authority before displaying or sharing a room URL. */ +export function shareableRoomUrl(address: string): string { + const url = new URL(normalizeRoomUrl(address)); + url.searchParams.delete("creator"); + return url.toString(); +} diff --git a/packages/pi-texas-holdem/test/cards.test.ts b/packages/pi-texas-holdem/test/cards.test.ts new file mode 100644 index 0000000..50c898b --- /dev/null +++ b/packages/pi-texas-holdem/test/cards.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDeck, formatCard, shuffleDeck } from "../src/engine/cards.ts"; + +test("creates a standard 52-card deck with no duplicates", () => { + const deck = createDeck(); + assert.equal(deck.length, 52); + const unique = new Set(deck.map((c) => `${c.rank}${c.suit}`)); + assert.equal(unique.size, 52); +}); + +test("shuffle is a pure permutation and does not mutate the input", () => { + const deck = createDeck(); + const shuffled = shuffleDeck(deck, () => 0.5); + assert.equal(deck.length, 52); + assert.deepEqual( + [...deck].sort((a, b) => a.rank - b.rank || a.suit.localeCompare(b.suit)), + [...shuffled].sort((a, b) => a.rank - b.rank || a.suit.localeCompare(b.suit)), + ); +}); + +test("shuffle rejects random sources outside the [0, 1) contract", () => { + assert.throws(() => shuffleDeck(createDeck(), () => 1), /\[0, 1\)/); + assert.throws(() => shuffleDeck(createDeck(), () => Number.NaN), /\[0, 1\)/); +}); + +test("formats cards with rank labels and suit glyphs", () => { + assert.equal(formatCard({ rank: 14, suit: "s" }), "A♠"); + assert.equal(formatCard({ rank: 10, suit: "h" }), "10♥"); + assert.equal(formatCard({ rank: 11, suit: "c" }), "J♣"); +}); diff --git a/packages/pi-texas-holdem/test/chat.test.ts b/packages/pi-texas-holdem/test/chat.test.ts new file mode 100644 index 0000000..f181d22 --- /dev/null +++ b/packages/pi-texas-holdem/test/chat.test.ts @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ChatMessage } from "../src/net/protocol.ts"; +import { HostRoom } from "../src/net/host.ts"; +import { RoomClient, type RoomClientOptions } from "../src/net/client.ts"; + +function connect(port: number, playerId: string, displayName: string, extra: Partial = {}): Promise { + return new Promise((resolve) => { + const client = new RoomClient({ + url: `ws://127.0.0.1:${port}`, + playerId, + displayName, + onWelcome: () => resolve(client), + onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, + onRejected: () => resolve(client), + onClose: () => {}, + ...extra, + }); + }); +} + +test("a client's chat message is broadcast to the host and other clients", async () => { + const hostChat: ChatMessage[] = []; + const host = new HostRoom({ + port: 0, + seatCount: 4, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + onChatMessage: (msg) => hostChat.push(msg), + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + + const guestChat: ChatMessage[] = []; + let guest: RoomClient | undefined; + let watcher: RoomClient | undefined; + try { + guest = await connect(port, "guest-id", "guest-machine"); + watcher = await new Promise((resolve) => { + const client = new RoomClient({ + url: `ws://127.0.0.1:${port}`, + playerId: "watcher-id", + displayName: "watcher-machine", + onWelcome: () => resolve(client), + onState: () => {}, + onChatMessage: (msg) => guestChat.push(msg), + onChatHistory: () => {}, + onRejected: () => resolve(client), + onClose: () => {}, + }); + }); + + guest.sendChat("nice hand!"); + await new Promise((r) => setTimeout(r, 50)); + + assert.equal(hostChat.at(-1)?.text, "nice hand!"); + assert.equal(hostChat.at(-1)?.displayName, "guest-machine"); + assert.equal(guestChat.at(-1)?.text, "nice hand!", "a third party at the table also receives it"); + } finally { + guest?.close(); + watcher?.close(); + host.close(); + } +}); + +test("chat text is sanitized before it ever reaches other clients", async () => { + const hostChat: ChatMessage[] = []; + const host = new HostRoom({ + port: 0, + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + onChatMessage: (msg) => hostChat.push(msg), + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + + let guest: RoomClient | undefined; + try { + guest = await connect(port, "guest-id", "guest-machine"); + guest.sendChat("\x1B[31mnormal text\x1B[0m\nwith a fake newline"); + await new Promise((r) => setTimeout(r, 50)); + + assert.equal(hostChat.at(-1)?.text, "normal text with a fake newline"); + } finally { + guest?.close(); + host.close(); + } +}); + +test("a newly joined client receives recent chat history", async () => { + const host = new HostRoom({ + port: 0, + seatCount: 3, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + host.sendLocalChat(0, "welcome to the table"); + + let guest: RoomClient | undefined; + try { + let historyResolve: (msgs: ChatMessage[]) => void = () => {}; + const historyPromise = new Promise((resolve) => { + historyResolve = resolve; + }); + guest = await connect(port, "guest-id", "guest-machine", { onChatHistory: (msgs) => historyResolve(msgs) }); + const history = await historyPromise; + + assert.ok(history.some((m) => m.text === "welcome to the table")); + } finally { + guest?.close(); + host.close(); + } +}); + +test("rapid chat messages from the same client are rate-limited without rejecting the session", async () => { + const hostChat: ChatMessage[] = []; + const clientErrors: string[] = []; + let rejectionCount = 0; + const host = new HostRoom({ + port: 0, + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + onChatMessage: (msg) => hostChat.push(msg), + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + + let guest: RoomClient | undefined; + try { + guest = await connect(port, "guest-id", "guest-machine", { + onError: (message) => clientErrors.push(message), + onRejected: () => rejectionCount++, + }); + guest.sendChat("first"); + guest.sendChat("second, immediately after"); + await new Promise((r) => setTimeout(r, 50)); + + const fromGuest = hostChat.filter((m) => m.displayName === "guest-machine"); + assert.equal(fromGuest.length, 1, "the second message within the rate-limit window is dropped"); + assert.equal(fromGuest[0]?.text, "first"); + assert.deepEqual(clientErrors, ["You're sending messages too fast"]); + assert.equal(rejectionCount, 0); + assert.equal(host.seatedCount(), 2, "a nonfatal chat error keeps the client seated"); + } finally { + guest?.close(); + host.close(); + } +}); + +test("host chat identifiers and timestamps can use runtime-provided sources", () => { + const messages: ChatMessage[] = []; + const host = new HostRoom({ + port: 0, + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + onChatMessage: (message) => messages.push(message), + createMessageId: () => "message-id", + now: () => 123, + }); + try { + host.seatLocalPlayer(0, "host", "host"); + host.sendLocalChat(0, "hello"); + assert.equal(messages.at(-1)?.id, "message-id"); + assert.equal(messages.at(-1)?.ts, 123); + } finally { + host.close(); + } +}); + +test("game actions are mirrored into chat as system messages", async () => { + const state: ChatMessage[] = []; + const host = new HostRoom({ + port: 0, + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + onChatMessage: (msg) => state.push(msg), + }); + host.seatLocalPlayer(0, "a", "a"); + host.seatLocalPlayer(1, "b", "b"); + host.startHand(); + + assert.ok(state.some((m) => m.seatIndex === null && /posts (small|big) blind/.test(m.text))); + + host.close(); +}); diff --git a/packages/pi-texas-holdem/test/cloud.test.ts b/packages/pi-texas-holdem/test/cloud.test.ts new file mode 100644 index 0000000..1b79d8d --- /dev/null +++ b/packages/pi-texas-holdem/test/cloud.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPersonalRoom } from "../src/ui/cloud.ts"; + +test("creates a private room with telemetry off and keeps the creation secret out of URLs", async () => { + let requestUrl = ""; + let requestInit: RequestInit | undefined; + const room = await createPersonalRoom( + { endpoint: "https://cards.example/", creationSecret: "super-secret" }, + {}, + async (input, init) => { + requestUrl = String(input); + requestInit = init; + return Response.json({ + url: "wss://cards.example/rooms/invite", + creatorUrl: "wss://cards.example/rooms/invite?creator=creator-token", + expiresAt: 123, + }, { status: 201 }); + }, + ); + + assert.equal(requestUrl, "https://cards.example/rooms"); + assert.equal(requestUrl.includes("super-secret"), false); + assert.equal(new Headers(requestInit?.headers).get("authorization"), "Bearer super-secret"); + assert.deepEqual(JSON.parse(String(requestInit?.body)), { + seatCount: 6, + smallBlind: 10, + bigBlind: 20, + startingStack: 2000, + telemetry: false, + }); + assert.deepEqual(room, { + shareUrl: "wss://cards.example/rooms/invite", + creatorUrl: "wss://cards.example/rooms/invite?creator=creator-token", + expiresAt: 123, + }); +}); + +test("supports a separate capability response while preserving the share URL", async () => { + const room = await createPersonalRoom( + { endpoint: "https://cards.example" }, + {}, + async () => Response.json({ url: "wss://cards.example/rooms/invite", capability: "a b" }, { status: 201 }), + ); + assert.equal(room.shareUrl, "wss://cards.example/rooms/invite"); + assert.equal(room.creatorUrl, "wss://cards.example/rooms/invite?creator=a+b"); +}); + +test("creation auth failures are actionable and never echo the secret", async () => { + await assert.rejects( + createPersonalRoom( + { endpoint: "https://cards.example", creationSecret: "do-not-echo" }, + {}, + async () => Response.json({ error: "denied do-not-echo" }, { status: 403 }), + ), + (error: Error) => { + assert.match(error.message, /PI_POKER_CREATE_SECRET/); + assert.equal(error.message.includes("do-not-echo"), false); + return true; + }, + ); +}); diff --git a/packages/pi-texas-holdem/test/commands.test.ts b/packages/pi-texas-holdem/test/commands.test.ts new file mode 100644 index 0000000..e7f9b82 --- /dev/null +++ b/packages/pi-texas-holdem/test/commands.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeWorkerEndpoint } from "../src/ui/cloud.ts"; +import { normalizeRoomUrl, parsePokerCommand, shareableRoomUrl } from "../src/ui/commands.ts"; + +test("parses the three primary entry paths", () => { + assert.deepEqual(parsePokerCommand("local 4"), { kind: "local", seatCount: 4, legacyAlias: false }); + assert.deepEqual(parsePokerCommand("create"), { kind: "create" }); + assert.deepEqual(parsePokerCommand("join https://cards.example/room/abc"), { + kind: "join", + address: "https://cards.example/room/abc", + }); +}); + +test("retains legacy command aliases", () => { + assert.deepEqual(parsePokerCommand("bots 3"), { kind: "local", seatCount: 3, legacyAlias: true }); + assert.deepEqual(parsePokerCommand("host 4666 --i-know"), { + kind: "lanHost", + port: 4666, + confirmed: true, + legacyAlias: true, + }); + assert.deepEqual(parsePokerCommand("quit"), { kind: "leave" }); +}); + +test("local seats and LAN ports use safe bounds and defaults", () => { + assert.equal(parsePokerCommand("local 99").kind, "local"); + assert.deepEqual(parsePokerCommand("local 99"), { kind: "local", seatCount: 6, legacyAlias: false }); + assert.deepEqual(parsePokerCommand("local nope"), { kind: "local", seatCount: 6, legacyAlias: false }); + assert.deepEqual(parsePokerCommand("lan-host 99999 --i-know"), { + kind: "lanHost", + port: 4551, + confirmed: true, + legacyAlias: false, + }); +}); + +test("normalizes share URLs and direct LAN addresses", () => { + assert.equal(normalizeRoomUrl("https://cards.example/room/abc"), "wss://cards.example/room/abc"); + assert.equal(normalizeRoomUrl("http://localhost:4551"), "ws://localhost:4551/"); + assert.equal(normalizeRoomUrl("192.168.1.20:4551"), "ws://192.168.1.20:4551/"); + assert.equal(normalizeRoomUrl("wss://cards.example/r?id=abc"), "wss://cards.example/r?id=abc"); + assert.equal(shareableRoomUrl("wss://cards.example/r?creator=secret&id=abc"), "wss://cards.example/r?id=abc"); +}); + +test("normalizes personal Worker endpoints and requires secure remote transport", () => { + assert.equal(normalizeWorkerEndpoint("https://cards.example/"), "https://cards.example"); + assert.equal(normalizeWorkerEndpoint("http://localhost:8787/"), "http://localhost:8787"); + assert.throws(() => normalizeWorkerEndpoint("http://cards.example"), /must use HTTPS/); + assert.throws(() => normalizeWorkerEndpoint("https://user:secret@cards.example"), /cannot contain credentials/); +}); + +test("rejects unsafe or malformed room URLs before replacing the active session", () => { + assert.throws(() => normalizeRoomUrl(""), /Enter a room URL/); + assert.throws(() => normalizeRoomUrl("ws://user:secret@example.com"), /usernames or passwords/); + assert.throws(() => normalizeRoomUrl("not a room url"), /valid room URL/); +}); diff --git a/packages/pi-texas-holdem/test/evaluator.test.ts b/packages/pi-texas-holdem/test/evaluator.test.ts new file mode 100644 index 0000000..abe6006 --- /dev/null +++ b/packages/pi-texas-holdem/test/evaluator.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Card } from "../src/engine/types.ts"; +import { compareEvaluations, describeHand, evaluateBestHand } from "../src/engine/evaluator.ts"; + +function cards(spec: string): Card[] { + // spec like "As Kd Qh Jc 10s" -> rank 2-10 or A/K/Q/J, suit s/h/d/c + return spec.split(/\s+/).map((token) => { + const suit = token.slice(-1) as Card["suit"]; + const rankToken = token.slice(0, -1); + const rank = + rankToken === "A" ? 14 : rankToken === "K" ? 13 : rankToken === "Q" ? 12 : rankToken === "J" ? 11 : Number(rankToken); + return { rank: rank as Card["rank"], suit }; + }); +} + +test("recognizes a straight flush", () => { + const evaluation = evaluateBestHand(cards("9s 8s 7s 6s 5s 2h 3c")); + assert.equal(evaluation.category, "straightFlush"); + assert.equal(evaluation.tiebreak[0], 9); +}); + +test("recognizes the wheel (A-5 straight) as low", () => { + const evaluation = evaluateBestHand(cards("As 2s 3d 4h 5c 9h Kc")); + assert.equal(evaluation.category, "straight"); + assert.equal(evaluation.tiebreak[0], 5); +}); + +test("recognizes four of a kind over a full house", () => { + const quads = evaluateBestHand(cards("7s 7h 7d 7c 2h 3c 4d")); + const fullHouse = evaluateBestHand(cards("Ks Kh Kd 9c 9h 2c 3d")); + assert.equal(quads.category, "quads"); + assert.equal(fullHouse.category, "fullHouse"); + assert.ok(compareEvaluations(quads, fullHouse) > 0); +}); + +test("full house beats flush beats straight beats trips", () => { + const fullHouse = evaluateBestHand(cards("Ks Kh Kd 9c 9h 2c 3d")); + const flush = evaluateBestHand(cards("2s 5s 9s Js Ks 3h 4d")); + const straight = evaluateBestHand(cards("5s 6h 7d 8c 9h 2c 3d")); + const trips = evaluateBestHand(cards("4s 4h 4d 9c 2h 6c 7d")); + assert.ok(compareEvaluations(fullHouse, flush) > 0); + assert.ok(compareEvaluations(flush, straight) > 0); + assert.ok(compareEvaluations(straight, trips) > 0); +}); + +test("picks the best 5 of 7 cards (kicker matters)", () => { + // Board pairs the board; both players use the same board pair plus best kicker. + const board = cards("Ks Kh 2d 5c 9h"); + const strongKicker = evaluateBestHand([...cards("As Qs"), ...board]); + const weakKicker = evaluateBestHand([...cards("4h 3c"), ...board]); + assert.equal(strongKicker.category, "pair"); + assert.equal(weakKicker.category, "pair"); + assert.ok(compareEvaluations(strongKicker, weakKicker) > 0); +}); + +test("two pair compares the higher pair first, then the lower pair, then kicker", () => { + const better = evaluateBestHand(cards("Ks Kh 9d 9c 2h 3c 7d")); + const worse = evaluateBestHand(cards("Qs Qh 9d 9c Ah 3c 7d")); + assert.equal(better.category, "twoPair"); + assert.equal(worse.category, "twoPair"); + assert.ok(compareEvaluations(better, worse) > 0); +}); + +test("describes hands in beginner-friendly language", () => { + assert.equal(describeHand(cards("As Ah")), "Pair of Aces"); + assert.equal(describeHand(cards("As 10s")), "Ace high · suited"); + assert.equal(describeHand(evaluateBestHand(cards("Ks Kh 9d 9c 2h 3c 7d"))), "Two Pair · Kings and 9s"); + assert.equal(describeHand(evaluateBestHand(cards("Ks Kh Kd 9c 9h 2c 3d"))), "Full House · Kings over 9s"); + assert.equal(describeHand(evaluateBestHand(cards("As 2s 3d 4h 5c 9h Kc"))), "Straight · 5 high"); +}); + +test("identical hands tie", () => { + const a = evaluateBestHand(cards("As Ks Qs Js 9s 2h 3c")); + const b = evaluateBestHand(cards("Ah Kh Qh Jh 9h 2c 3d")); + assert.equal(compareEvaluations(a, b), 0); +}); diff --git a/packages/pi-texas-holdem/test/net.test.ts b/packages/pi-texas-holdem/test/net.test.ts new file mode 100644 index 0000000..bdbb80c --- /dev/null +++ b/packages/pi-texas-holdem/test/net.test.ts @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { WebSocketServer } from "ws"; +import { HostRoom } from "../src/net/host.ts"; +import { RoomClient } from "../src/net/client.ts"; +import { encode, decodeClientMessage } from "../src/net/protocol.ts"; +import { createJoinSession } from "../src/session/join.ts"; + +function once(fn: (resolve: (value: T) => void) => void): Promise { + return new Promise((resolve) => fn(resolve)); +} + +async function waitFor(predicate: () => boolean, message: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(message); +} + +test("creator capability is sent only in hello, never the socket URL or session label", async () => { + const server = new WebSocketServer({ port: 0 }); + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + assert.equal(typeof address, "object"); + const port = typeof address === "object" && address ? address.port : 0; + const capability = "creator-secret-capability"; + let requestPath = ""; + const helloPromise = once>((resolve) => { + server.once("connection", (socket, request) => { + requestPath = request.url ?? ""; + socket.once("message", (raw) => resolve(decodeClientMessage(raw.toString()))); + }); + }); + + const session = createJoinSession( + { + url: `ws://127.0.0.1:${port}/rooms/test?id=invite&creator=${capability}`, + myId: "creator-id", + displayName: "creator", + canStartHands: true, + }, + () => {}, + ); + try { + assert.equal(session.info.roomLabel, `Connected to ws://127.0.0.1:${port}/rooms/test?id=invite`); + assert.equal(session.info.roomLabel.includes(capability), false); + const hello = await helloPromise; + assert.equal(requestPath, "/rooms/test?id=invite"); + assert.equal(requestPath.includes("creator"), false); + assert.equal(hello.type, "hello"); + assert.equal(hello.type === "hello" ? hello.creatorCapability : undefined, capability); + } finally { + session.close(); + server.close(); + } +}); + +test("a remote client can join a hosted room and see redacted state", async () => { + const host = new HostRoom({ + port: 0, + seatCount: 4, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + + let client: RoomClient | undefined; + try { + const welcome = await once<{ seatIndex: number; seatCount: number }>((resolve) => { + client = new RoomClient({ + url: `ws://127.0.0.1:${port}`, + playerId: "guest-id", + displayName: "guest-machine", + onWelcome: (info) => resolve(info), + onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, + onRejected: (reason, message) => assert.fail(`unexpected rejection: ${reason} ${message ?? ""}`), + onClose: () => {}, + }); + }); + + assert.equal(welcome.seatIndex, 1, "guest takes the next open seat after the host"); + assert.equal(welcome.seatCount, 4); + assert.equal(host.seatedCount(), 2); + } finally { + client?.close(); + host.close(); + } +}); + +test("heads-up client disconnect settles the hand in-turn and out-of-turn", async (t) => { + for (const timing of ["out-of-turn", "in-turn"] as const) { + await t.test(timing, async () => { + const host = new HostRoom({ + port: 0, + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + let client: RoomClient | undefined; + try { + await once((resolve) => { + client = new RoomClient({ + url: `ws://127.0.0.1:${port}`, + playerId: "guest-id", + displayName: "guest-machine", + onWelcome: () => resolve(), + onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, + onRejected: (reason, message) => assert.fail(`unexpected rejection: ${reason} ${message ?? ""}`), + onClose: () => {}, + }); + }); + host.startHand(); + const guestIndex = host.state.seats.findIndex((seat) => seat?.id === "guest-id"); + assert.equal(guestIndex, 1); + if (timing === "in-turn") { + assert.equal(host.state.toActIndex, 0); + host.applyLocalAction(0, { type: "call" }); + assert.equal(host.state.toActIndex, guestIndex); + } else { + assert.notEqual(host.state.toActIndex, guestIndex); + } + + client!.close(); + await waitFor(() => host.state.street === "showdown" && host.seatedCount() === 1, "disconnect did not settle the hand"); + assert.equal(host.state.toActIndex, null); + assert.ok(host.state.log.some((line) => line === "guest-machine folds")); + } finally { + client?.close(); + host.close(); + } + }); + } +}); + +test("server errors reject before welcome but are nonfatal after welcome", async (t) => { + await t.test("an error before welcome rejects the handshake", async () => { + const server = new WebSocketServer({ port: 0 }); + await new Promise((resolve) => server.once("listening", resolve)); + const address = server.address(); + assert.equal(typeof address, "object"); + const port = typeof address === "object" && address ? address.port : 0; + server.on("connection", (socket) => socket.send(encode({ type: "error", message: "Handshake failed" }))); + + let client: RoomClient | undefined; + try { + const rejection = await once((resolve) => { + client = new RoomClient({ + url: `ws://127.0.0.1:${port}`, + playerId: "guest-id", + displayName: "guest-machine", + onWelcome: () => assert.fail("unexpected welcome"), + onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, + onRejected: (_reason, message) => resolve(message ?? ""), + onError: (message) => assert.fail(`unexpected in-session error: ${message}`), + onClose: () => {}, + }); + }); + assert.equal(rejection, "Handshake failed"); + } finally { + client?.close(); + server.close(); + } + }); + + await t.test("an action error after welcome uses the nonfatal callback", async () => { + const host = new HostRoom({ + port: 0, + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: () => {}, + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + let client: RoomClient | undefined; + let rejectionCount = 0; + try { + const errorPromise = once((resolve) => { + client = new RoomClient({ + url: `ws://127.0.0.1:${port}`, + playerId: "guest-id", + displayName: "guest-machine", + onWelcome: () => { + host.startHand(); + client!.sendAction({ type: "check" }); + }, + onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, + onRejected: () => rejectionCount++, + onError: resolve, + onClose: () => {}, + }); + }); + assert.match(await errorPromise, /not this seat's turn/); + assert.equal(rejectionCount, 0); + assert.equal(host.seatedCount(), 2, "nonfatal action errors keep the welcomed client seated"); + } finally { + client?.close(); + host.close(); + } + }); +}); + +test("actions from a connected client update the host's authoritative state", async () => { + const stateUpdates: number[] = []; + const host = new HostRoom({ + port: 0, + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 500, + onStateChange: (state) => stateUpdates.push(state.handNumber), + }); + const port = await host.waitForListening(); + host.seatLocalPlayer(0, "host-id", "host-machine"); + + let client: RoomClient | undefined; + try { + await once((resolve) => { + client = new RoomClient({ + url: `ws://127.0.0.1:${port}`, + playerId: "guest-id", + displayName: "guest-machine", + onWelcome: () => resolve(), + onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, + onRejected: () => resolve(), + onClose: () => {}, + }); + }); + + assert.ok(host.canStartHand()); + host.startHand(); + + const guestSeatIndex = host.state.seats.findIndex((s) => s?.id === "guest-id"); + assert.notEqual(guestSeatIndex, -1); + + // Drive the hand to completion using whichever seat is actually up first. + let guard = 0; + while (host.state.street !== "showdown" && guard < 50) { + guard++; + const toAct = host.state.toActIndex; + if (toAct === null) break; + const seat = host.state.seats[toAct]!; + const toCall = host.state.currentBet - seat.committed; + const action = toCall > 0 ? ({ type: "call" } as const) : ({ type: "check" } as const); + if (toAct === guestSeatIndex) { + client!.sendAction(action); + await new Promise((r) => setTimeout(r, 20)); + } else { + host.applyLocalAction(toAct, action); + } + } + + assert.equal(host.state.street, "showdown"); + } finally { + client?.close(); + host.close(); + } +}); diff --git a/packages/pi-texas-holdem/test/ring.test.ts b/packages/pi-texas-holdem/test/ring.test.ts new file mode 100644 index 0000000..1217ed4 --- /dev/null +++ b/packages/pi-texas-holdem/test/ring.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { centerOnRow, generateRing, layoutTable, stampBlock } from "../src/engine/ring.ts"; + +test("generateRing produces the requested dimensions using only box-drawing glyphs and spaces", () => { + const ring = generateRing(74, 15); + assert.equal(ring.rows.length, 15); + for (const row of ring.rows) { + assert.equal([...row].length, 74); + for (const ch of row) assert.ok(/[│─╮╭╰╯ ]/.test(ch), `unexpected glyph: ${JSON.stringify(ch)}`); + } +}); + +test("the ring is left-right symmetric", () => { + // Odd width so there's a true center column and rounding can't introduce a 1-column skew. + const ring = generateRing(75, 15); + for (const row of ring.rows) { + const chars = [...row]; + const mirrored = [...chars].reverse(); + // Mirroring swaps ╮<->╭ and ╯<->╰; │/─/space are self-symmetric. + const swap = (c: string) => (c === "╮" ? "╭" : c === "╭" ? "╮" : c === "╯" ? "╰" : c === "╰" ? "╯" : c); + assert.deepEqual( + chars.map(swap), + mirrored, + `row not symmetric: ${row}`, + ); + } +}); + +test("the ring is one connected rail without detached glyphs", () => { + const ring = generateRing(75, 15); + const occupied = ring.rows.map((row) => [...row].map((ch) => ch !== " ")); + const startY = occupied.findIndex((row) => row.some(Boolean)); + const startX = occupied[startY]!.findIndex(Boolean); + const seen = new Set(); + const queue: Array<[number, number]> = [[startX, startY]]; + while (queue.length > 0) { + const [x, y] = queue.shift()!; + const key = `${x},${y}`; + if (seen.has(key) || !occupied[y]?.[x]) continue; + seen.add(key); + for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) queue.push([x + dx, y + dy]); + } + const glyphCount = occupied.reduce((total, row) => total + row.filter(Boolean).length, 0); + assert.equal(seen.size, glyphCount, "the table rail contains disconnected segments"); + assert.match(ring.rows[0]!, /╭─+╮/); + assert.match(ring.rows.at(-1)!, /╰─+╯/); +}); + +for (const seatCount of [2, 3, 4, 5, 6, 7, 8, 9]) { + test(`layoutTable places ${seatCount} seat anchors fully inside the padded canvas`, () => { + const table = layoutTable(74, 15, seatCount); + assert.equal(table.seatAnchors.length, seatCount); + const boxHalfWidth = 9; + const boxHalfHeight = 2; + for (const anchor of table.seatAnchors) { + assert.ok(anchor.x - boxHalfWidth >= -1, `seat box runs off the left edge: ${anchor.x}`); + assert.ok(anchor.x + boxHalfWidth <= table.width + 1, `seat box runs off the right edge: ${anchor.x}`); + assert.ok(anchor.y - boxHalfHeight >= -1, `seat box runs off the top edge: ${anchor.y}`); + assert.ok(anchor.y + boxHalfHeight <= table.height + 1, `seat box runs off the bottom edge: ${anchor.y}`); + } + }); +} + +test("layoutTable spaces seats out (no two anchors coincide)", () => { + const table = layoutTable(74, 15, 6); + for (let i = 0; i < table.seatAnchors.length; i++) { + for (let j = i + 1; j < table.seatAnchors.length; j++) { + const a = table.seatAnchors[i]!; + const b = table.seatAnchors[j]!; + const dist = Math.hypot(a.x - b.x, a.y - b.y); + assert.ok(dist > 5, `seats ${i} and ${j} are too close together (${dist.toFixed(1)})`); + } + } +}); + +test("stampBlock centers content on the anchor and clips at the canvas edge", () => { + const rows = [" ", " ", " "]; + // Content is 2 rows tall; centered on y=1 -> top row at round(1 - 0.5) = 1. + const stamped = stampBlock(rows, { x: 1, y: 1 }, ["AB", "CD"]); + assert.equal(stamped[0], " "); + assert.equal(stamped[1]?.slice(0, 2), "AB"); + assert.equal(stamped[2]?.slice(0, 2), "CD"); +}); + +test("centerOnRow keeps the border glyphs and centers text between them", () => { + const row = " ╭ ╮ "; + const centered = centerOnRow(row, "Pot 100"); + assert.ok(centered.startsWith(" ╭")); + assert.ok(centered.endsWith("╮ ")); + assert.ok(centered.includes("Pot 100")); +}); diff --git a/packages/pi-texas-holdem/test/sanitize.test.ts b/packages/pi-texas-holdem/test/sanitize.test.ts new file mode 100644 index 0000000..fc5a319 --- /dev/null +++ b/packages/pi-texas-holdem/test/sanitize.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sanitizeChatText } from "../src/net/sanitize.ts"; + +test("strips ANSI color/cursor escape sequences", () => { + assert.equal(sanitizeChatText("\x1B[31mhello\x1B[0m"), "hello"); + assert.equal(sanitizeChatText("\x1B[2J\x1B[Hpwned"), "pwned"); +}); + +test("strips OSC sequences (e.g. terminal title injection)", () => { + assert.equal(sanitizeChatText("\x1B]0;evil title\x07hi"), "hi"); +}); + +test("strips other C0/C1 control characters but keeps normal punctuation", () => { + assert.equal(sanitizeChatText("go\x07od\u0085 nice hand!"), "good nice hand!"); +}); + +test("collapses embedded newlines into spaces instead of allowing multi-line output", () => { + assert.equal(sanitizeChatText("line one\nline two\r\nline three"), "line one line two line three"); +}); + +test("trims surrounding whitespace and enforces a max length", () => { + assert.equal(sanitizeChatText(" hi "), "hi"); + assert.equal(sanitizeChatText("a".repeat(300), 10), "a".repeat(10)); +}); + +test("leaves normal unicode/CJK text untouched", () => { + assert.equal(sanitizeChatText("這手牌不錯 nice hand 👍"), "這手牌不錯 nice hand 👍"); +}); + +test("empty or whitespace-only input sanitizes to an empty string", () => { + assert.equal(sanitizeChatText(" \x1B[31m \x1B[0m "), ""); +}); diff --git a/packages/pi-texas-holdem/test/session.test.ts b/packages/pi-texas-holdem/test/session.test.ts new file mode 100644 index 0000000..c56a93b --- /dev/null +++ b/packages/pi-texas-holdem/test/session.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createBotsSession } from "../src/session/bots.ts"; + +const options = { + myId: "me", + displayName: "player", + seatCount: 2, + smallBlind: 5, + bigBlind: 10, + startingStack: 100, +}; + +test("bots session exposes the shared boundary and emits state/chat changes", () => { + const timers: (() => void)[] = []; + const session = createBotsSession(options, { + random: () => 0.5, + now: () => 123, + setTimer: (callback) => { + timers.push(callback); + return 1 as unknown as ReturnType; + }, + clearTimer: () => {}, + }); + let emissions = 0; + const unsubscribe = session.subscribe(() => emissions++); + + assert.equal(session.info.mySeatIndex, 0); + assert.equal(session.canStartHand(), true); + session.sendChat(" hello\nworld "); + assert.equal(session.getChatLog().at(-1)?.text, "hello world"); + assert.equal(session.getChatLog().at(-1)?.ts, 123); + + session.startHand(); + assert.equal(session.getState().handNumber, 1); + assert.ok(emissions >= 2); + assert.ok(timers.length <= 1, "at most one bot turn is scheduled"); + + unsubscribe(); + session.close(); +}); + +test("bots session uses the injected random source to shuffle deterministically", () => { + const first = createBotsSession(options, { random: () => 0.25, setTimer: setTimeout }); + const second = createBotsSession(options, { random: () => 0.25, setTimer: setTimeout }); + try { + first.startHand(); + second.startHand(); + assert.deepEqual(first.getState(), second.getState()); + } finally { + first.close(); + second.close(); + } +}); diff --git a/packages/pi-texas-holdem/test/table.test.ts b/packages/pi-texas-holdem/test/table.test.ts new file mode 100644 index 0000000..4f64943 --- /dev/null +++ b/packages/pi-texas-holdem/test/table.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyAction, computeSidePots, createTable, forceFold, legalActions, seatPlayer, startHand } from "../src/engine/table.ts"; +import type { Seat, TableState } from "../src/engine/types.ts"; + +function seat(id: string, stack: number, committed = 0, status: Seat["status"] = "active"): Seat { + return { + id, + displayName: id, + stack, + holeCards: [], + status, + committed, + totalCommitted: committed, + hasActed: false, + sittingOut: false, + }; +} + +function baseState(seats: (Seat | null)[], overrides: Partial = {}): TableState { + return { + seats, + dealerIndex: 0, + smallBlind: 5, + bigBlind: 10, + street: "preflop", + communityCards: [], + deck: [], + toActIndex: 0, + currentBet: 10, + minRaise: 10, + lastAggressorIndex: 1, + handNumber: 1, + pots: [], + winners: [], + awards: [], + log: [], + ...overrides, + }; +} + +test("legalActions reports call amount and raise bounds", () => { + const state = baseState([seat("a", 100), seat("b", 90, 10), seat("c", 30, 10)]); + const legal = legalActions(state, 0); + assert.equal(legal.canCheck, false); + assert.equal(legal.canCall, true); + assert.equal(legal.callAmount, 10); + assert.equal(legal.minRaiseTo, 20); + assert.equal(legal.maxRaiseTo, 100); +}); + +test("legalActions caps raise-to at the seat's stack and disallows raising with a short stack", () => { + const state = baseState([seat("a", 15), seat("b", 100, 30), seat("c", 100, 30)], { + toActIndex: 0, + currentBet: 30, + }); + const legal = legalActions(state, 0); + assert.equal(legal.callAmount, 15); + assert.equal(legal.canBetOrRaise, false, "a 15-stack facing a 30 bet can only call all-in or fold"); + assert.equal(legal.maxRaiseTo, 15); +}); + +test("raise reopens action for other active players and updates min-raise size", () => { + const state = baseState([seat("a", 100), seat("b", 90, 10), seat("c", 90, 10)]); + const next = applyAction(state, 0, { type: "raise", amount: 30 }); + const a = next.seats[0] as Seat; + assert.equal(a.committed, 30); + assert.equal(a.stack, 70); + assert.equal(next.currentBet, 30); + assert.equal(next.minRaise, 20); // raised by 20 over the previous bet of 10 + assert.equal(next.lastAggressorIndex, 0); + assert.equal((next.seats[1] as Seat).hasActed, false, "other active seats must act again after a raise"); + assert.equal(next.toActIndex, 1); +}); + +test("calling all-in for less than the current bet does not reopen betting", () => { + const state = baseState([seat("a", 15), seat("b", 100, 30, "active"), seat("c", 100, 30, "active")], { + toActIndex: 0, + currentBet: 30, + lastAggressorIndex: 1, + }); + const next = applyAction(state, 0, { type: "call" }); + const a = next.seats[0] as Seat; + assert.equal(a.status, "allIn"); + assert.equal(a.stack, 0); + assert.equal(a.committed, 15); + assert.equal(next.currentBet, 30, "current bet is unchanged by a short call"); + assert.equal(next.lastAggressorIndex, 1, "short all-in call is not an aggressive action"); +}); + +test("forced fold settles a heads-up hand when the current actor disconnects", () => { + const state = baseState([seat("leaver", 90, 10), seat("winner", 80, 20)], { + toActIndex: 0, + currentBet: 20, + }); + const next = forceFold(state, 0); + + assert.equal(next.street, "showdown"); + assert.equal(next.toActIndex, null); + assert.equal(next.seats[0]?.status, "folded"); + assert.equal(next.seats[1]?.stack, 110, "remaining player receives both committed stacks"); + assert.equal(next.log.at(-2), "leaver folds"); +}); + +test("forced fold settles a heads-up hand when an out-of-turn player disconnects", () => { + const state = baseState([seat("actor", 90, 10), seat("leaver", 80, 20)], { + toActIndex: 0, + currentBet: 20, + }); + const next = forceFold(state, 1); + + assert.equal(next.street, "showdown"); + assert.equal(next.toActIndex, null); + assert.equal(next.seats[1]?.status, "folded"); + assert.equal(next.seats[0]?.stack, 110, "current actor wins the matched pot without taking a stale turn"); + assert.equal(next.seats[1]?.stack, 90, "the disconnected player receives their unmatched excess back"); + assert.equal(next.log.at(-2), "leaver folds"); +}); + +test("computeSidePots splits a main pot and a side pot for an uneven all-in", () => { + const seats: Seat[] = [ + seat("short", 0, 20), // all-in for 20 total + seat("mid", 0, 20), // called the short stack, itself all-in too... but committed equals short here + seat("big", 70, 60), // committed 60 total, more than the others + ]; + seats[2]!.totalCommitted = 60; + seats[0]!.totalCommitted = 20; + seats[1]!.totalCommitted = 20; + const pots = computeSidePots(seats); + // Layer 1: everyone contributes up to 20 -> pot of 60, all three eligible. + // Layer 2: only "big" contributes the remaining 40 -> side pot of 40, only "big" eligible. + assert.equal(pots.length, 2); + assert.equal(pots[0]!.amount, 60); + assert.deepEqual(new Set(pots[0]!.eligible), new Set(["short", "mid", "big"])); + assert.equal(pots[1]!.amount, 40); + assert.deepEqual(pots[1]!.eligible, ["big"]); +}); + +test("computeSidePots excludes folded players from eligibility but keeps their chips in the pot", () => { + const seats: Seat[] = [seat("folder", 0, 20, "folded"), seat("stayer", 80, 20)]; + seats[0]!.totalCommitted = 20; + seats[1]!.totalCommitted = 20; + const pots = computeSidePots(seats); + assert.equal(pots.length, 1); + assert.equal(pots[0]!.amount, 40); + assert.deepEqual(pots[0]!.eligible, ["stayer"]); +}); + +test("full hand simulation conserves total chips across many hands", () => { + let state = createTable({ seatCount: 4, smallBlind: 5, bigBlind: 10 }); + const startingStack = 200; + for (let i = 0; i < 4; i++) { + state = seatPlayer(state, i, { id: `p${i}`, displayName: `p${i}`, stack: startingStack }); + } + const totalChips = startingStack * 4; + + for (let hand = 0; hand < 25; hand++) { + const seated = state.seats.filter((s) => s && s.stack > 0); + if (seated.length < 2) break; + state = startHand(state); + + let guard = 0; + while (state.street !== "showdown" && guard < 200) { + guard++; + const idx = state.toActIndex; + if (idx === null) break; + const legal = legalActions(state, idx); + // Bots always check/call to keep the simulation simple and deterministic-ish. + const action = legal.canCheck ? ({ type: "check" } as const) : ({ type: "call" } as const); + state = applyAction(state, idx, action); + } + + const sumStacks = state.seats.reduce((total, s) => total + (s ? s.stack : 0), 0); + assert.equal(sumStacks, totalChips, `chips must be conserved after hand ${hand + 1}`); + for (const s of state.seats) { + if (s) assert.ok(s.stack >= 0, "no seat should have a negative stack"); + } + } +}); diff --git a/packages/pi-texas-holdem/test/view.test.ts b/packages/pi-texas-holdem/test/view.test.ts new file mode 100644 index 0000000..94b5908 --- /dev/null +++ b/packages/pi-texas-holdem/test/view.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTable, seatPlayer, startHand } from "../src/engine/table.ts"; +import { redactStateFor } from "../src/engine/view.ts"; + +test("redactStateFor hides other players' hole cards but keeps your own", () => { + let state = createTable({ seatCount: 3, smallBlind: 5, bigBlind: 10 }); + state = seatPlayer(state, 0, { id: "a", displayName: "a", stack: 100 }); + state = seatPlayer(state, 1, { id: "b", displayName: "b", stack: 100 }); + state = startHand(state); + + const viewA = redactStateFor(state, "a"); + const seatA = viewA.seats[0]!; + const seatB = viewA.seats[1]!; + assert.equal(seatA.holeCards.every((c) => c !== null), true, "you can see your own hole cards"); + assert.equal(seatB.holeCards.every((c) => c === null), true, "opponent hole cards are hidden"); +}); + +test("redactStateFor reveals non-folded hands to everyone at showdown", () => { + let state = createTable({ seatCount: 2, smallBlind: 5, bigBlind: 10 }); + state = seatPlayer(state, 0, { id: "a", displayName: "a", stack: 100 }); + state = seatPlayer(state, 1, { id: "b", displayName: "b", stack: 100 }); + state = startHand(state); + state = { ...state, street: "showdown" }; + + const spectatorView = redactStateFor(state, "someone-else"); + for (const seat of spectatorView.seats) { + if (!seat) continue; + assert.equal(seat.holeCards.every((c) => c !== null), true, "showdown reveals non-folded hands"); + } +}); + +test("redactStateFor keeps a folded seat's cards hidden even at showdown", () => { + let state = createTable({ seatCount: 2, smallBlind: 5, bigBlind: 10 }); + state = seatPlayer(state, 0, { id: "a", displayName: "a", stack: 100 }); + state = seatPlayer(state, 1, { id: "b", displayName: "b", stack: 100 }); + state = startHand(state); + const seats = state.seats.map((s) => (s ? { ...s } : null)); + seats[0]!.status = "folded"; + state = { ...state, seats, street: "showdown" }; + + const view = redactStateFor(state, "b"); + assert.equal(view.seats[0]!.holeCards.every((c) => c === null), true, "folded hands stay face down"); +}); diff --git a/packages/pi-texas-holdem/tsconfig.json b/packages/pi-texas-holdem/tsconfig.json new file mode 100644 index 0000000..1c217c6 --- /dev/null +++ b/packages/pi-texas-holdem/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["extensions/**/*.ts", "src/**/*.ts", "test/**/*.ts"] +}