From 003914a17975fead60ad7b222e7c16868e287de2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 13:15:04 +0000 Subject: [PATCH 1/8] Add pi-texas-holdem: Texas Hold'em on an oval terminal table Adds a new Pi extension with a tested no-limit Hold'em engine (deck, hand evaluator, betting rounds, side pots), a box-drawing oval table renderer, and WebSocket host/join networking for playing over the LAN. Identity is just the machine hostname, and the table shows today's token usage. /poker bots plays solo against simple bots right away; /poker host and /poker join wire up multiplayer. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015iGzsop5jB18LBJk4EtVko --- README.md | 1 + package-lock.json | 78 +-- packages/pi-texas-holdem/README.md | 60 ++ packages/pi-texas-holdem/extensions/poker.ts | 643 ++++++++++++++++++ packages/pi-texas-holdem/package.json | 55 ++ packages/pi-texas-holdem/src/engine/cards.ts | 61 ++ .../pi-texas-holdem/src/engine/evaluator.ts | 119 ++++ packages/pi-texas-holdem/src/engine/ring.ts | 192 ++++++ packages/pi-texas-holdem/src/engine/table.ts | 435 ++++++++++++ packages/pi-texas-holdem/src/engine/types.ts | 103 +++ packages/pi-texas-holdem/src/engine/view.ts | 63 ++ packages/pi-texas-holdem/src/net/client.ts | 73 ++ packages/pi-texas-holdem/src/net/host.ts | 185 +++++ packages/pi-texas-holdem/src/net/protocol.ts | 38 ++ packages/pi-texas-holdem/test/cards.test.ts | 26 + .../pi-texas-holdem/test/evaluator.test.ts | 69 ++ packages/pi-texas-holdem/test/net.test.ts | 100 +++ packages/pi-texas-holdem/test/ring.test.ts | 72 ++ packages/pi-texas-holdem/test/table.test.ts | 150 ++++ packages/pi-texas-holdem/test/view.test.ts | 44 ++ packages/pi-texas-holdem/tsconfig.json | 13 + 21 files changed, 2531 insertions(+), 49 deletions(-) create mode 100644 packages/pi-texas-holdem/README.md create mode 100644 packages/pi-texas-holdem/extensions/poker.ts create mode 100644 packages/pi-texas-holdem/package.json create mode 100644 packages/pi-texas-holdem/src/engine/cards.ts create mode 100644 packages/pi-texas-holdem/src/engine/evaluator.ts create mode 100644 packages/pi-texas-holdem/src/engine/ring.ts create mode 100644 packages/pi-texas-holdem/src/engine/table.ts create mode 100644 packages/pi-texas-holdem/src/engine/types.ts create mode 100644 packages/pi-texas-holdem/src/engine/view.ts create mode 100644 packages/pi-texas-holdem/src/net/client.ts create mode 100644 packages/pi-texas-holdem/src/net/host.ts create mode 100644 packages/pi-texas-holdem/src/net/protocol.ts create mode 100644 packages/pi-texas-holdem/test/cards.test.ts create mode 100644 packages/pi-texas-holdem/test/evaluator.test.ts create mode 100644 packages/pi-texas-holdem/test/net.test.ts create mode 100644 packages/pi-texas-holdem/test/ring.test.ts create mode 100644 packages/pi-texas-holdem/test/table.test.ts create mode 100644 packages/pi-texas-holdem/test/view.test.ts create mode 100644 packages/pi-texas-holdem/tsconfig.json diff --git a/README.md b/README.md index 62e0262..baa289b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,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 on an oval terminal table, solo vs. bots or hosted over the LAN. | [`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 c6ad264..19cc184 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2474,6 +2474,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 @@ -2605,9 +2609,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2625,9 +2626,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2645,9 +2643,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2665,9 +2660,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2685,9 +2677,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2705,9 +2694,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2725,9 +2711,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2745,9 +2728,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2952,9 +2932,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2972,9 +2949,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2992,9 +2966,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3012,9 +2983,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3032,9 +3000,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3052,9 +3017,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3072,9 +3034,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3092,9 +3051,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3970,7 +3926,6 @@ "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4199,6 +4154,31 @@ "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": { + "@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" + }, + "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", diff --git a/packages/pi-texas-holdem/README.md b/packages/pi-texas-holdem/README.md new file mode 100644 index 0000000..b92c811 --- /dev/null +++ b/packages/pi-texas-holdem/README.md @@ -0,0 +1,60 @@ +# pi-texas-holdem + +A [pi](https://github.com/earendil-works/pi) extension that plays Texas Hold'em on an oval table drawn with box-drawing characters, inside the same terminal you're coding in. + +## Features + +- `/poker` opens the table as a focused overlay; `Esc` drops back to your normal session +- A persistent status widget keeps showing whose turn it is and the pot while you're back to working +- No accounts: your seat name is your machine's hostname +- Today's cumulative token usage is shown in the table header and the widget +- Play solo against simple bots, or host/join a private room over the LAN +- Full no-limit Hold'em rules: blinds, betting rounds, side pots, showdown + +## Install + +From this checkout: + +```sh +npm install +pi install . +``` + +For development without installing the package: + +```sh +npm install +pi -e ./extensions/poker.ts +``` + +## Usage + +```text +/poker bots [seats] start a solo game against bots (2-6 seats, default 6) +/poker host [port] host a private room (default port 4551) and wait for others +/poker join join someone else's room +/poker reopen the table for the session already in progress +/poker leave close the current session +``` + +While the table is open: + +- `F` fold, `C` check/call, `R` raise, `A` all-in +- While raising: `+`/`-` adjust the amount, `Enter` confirms, `Esc` cancels +- `Enter` starts the next hand once at least two seats have chips +- `Esc` closes the table overlay without leaving the game; the widget keeps tracking it + +## Development + +```sh +npm install +npm test +npm run check +``` + +## Notes / current limitations + +- Hosting binds a plain WebSocket server on your machine; players connect with `/poker join :`, so this only works when reachable (LAN, VPN, or a manually forwarded port). A relay/matchmaking server for public games without port-forwarding is a possible follow-up, not built here yet. +- Reconnecting to a room after a dropped connection isn't handled — a disconnect auto-folds the seat's current hand and frees the seat. +- Bots use a simple fold/call/raise heuristic, not real hand-strength evaluation. +- "Today's token usage" sums the current pi session's entries for the local calendar day; it doesn't merge usage from other session files. diff --git a/packages/pi-texas-holdem/extensions/poker.ts b/packages/pi-texas-holdem/extensions/poker.ts new file mode 100644 index 0000000..0d1e3ef --- /dev/null +++ b/packages/pi-texas-holdem/extensions/poker.ts @@ -0,0 +1,643 @@ +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 { formatCard } from "../src/engine/cards.ts"; +import { applyAction, createTable, legalActions as computeLegalActions, seatPlayer, startHand as engineStartHand } from "../src/engine/table.ts"; +import type { Action, LegalActions, Street, TableState } from "../src/engine/types.ts"; +import { centerOnRow, layoutTable, stampBlock } from "../src/engine/ring.ts"; +import type { PublicSeat, PublicTableState } from "../src/engine/view.ts"; +import { redactStateFor } from "../src/engine/view.ts"; +import { HostRoom } from "../src/net/host.ts"; +import { RoomClient } from "../src/net/client.ts"; + +const RING_WIDTH = 75; +const RING_HEIGHT = 15; +const SEAT_HALF_WIDTH = 10; +const SEAT_HALF_HEIGHT = 3; +const BOX_INNER = 17; +const DEFAULT_PORT = 4551; +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); +} + +// ---------- bot AI ---------- + +function botDecision(state: TableState, seatIndex: number): Action { + const legal = computeLegalActions(state, seatIndex); + const roll = Math.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" }; +} + +// ---------- game session abstraction ---------- + +interface SessionInfo { + roomLabel: string; + myId: string; + mySeatIndex: number | null; +} + +interface GameSession { + readonly info: SessionInfo; + getState(): PublicTableState; + legalActionsForMe(): LegalActions | null; + canStartHand(): boolean; + startHand(): void; + act(action: Action): void; + subscribe(fn: () => void): () => void; + close(): void; +} + +function makeEmitter() { + const listeners = new Set<() => void>(); + return { + subscribe(fn: () => void) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + emit() { + for (const fn of listeners) fn(); + }, + }; +} + +interface LocalOptions { + myId: string; + displayName: string; + seatCount: number; + smallBlind: number; + bigBlind: number; + startingStack: number; +} + +/** Solo play against simple local bots. No networking. */ +function createBotsSession(opts: LocalOptions): GameSession { + 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 = makeEmitter(); + let botTimer: NodeJS.Timeout | undefined; + + const scheduleBotTurn = () => { + if (botTimer) return; + const toAct = state.toActIndex; + if (toAct === null || toAct === 0 || state.street === "showdown") return; + botTimer = setTimeout(() => { + botTimer = undefined; + const idx = state.toActIndex; + if (idx === null || idx === 0 || state.street === "showdown") return; + try { + state = applyAction(state, idx, botDecision(state, idx)); + } catch { + state = applyAction(state, idx, { type: "fold" }); + } + emitter.emit(); + scheduleBotTurn(); + }, 450); + }; + + return { + info: { roomLabel: "Local vs bots", myId: opts.myId, mySeatIndex: 0 }, + getState: () => redactStateFor(state, opts.myId), + legalActionsForMe: () => computeLegalActions(state, 0), + canStartHand: () => state.street === "showdown" && state.seats.filter((s) => s && s.stack > 0).length >= 2, + startHand: () => { + state = engineStartHand(state); + emitter.emit(); + scheduleBotTurn(); + }, + act: (action) => { + state = applyAction(state, 0, action); + emitter.emit(); + scheduleBotTurn(); + }, + subscribe: emitter.subscribe, + close: () => { + if (botTimer) clearTimeout(botTimer); + }, + }; +} + +interface HostOptions extends LocalOptions { + port: number; + onLog?: (line: string) => void; +} + +function createHostSession(opts: HostOptions, onReady: (port: number) => void): GameSession { + const room = new HostRoom({ + port: opts.port, + seatCount: opts.seatCount, + smallBlind: opts.smallBlind, + bigBlind: opts.bigBlind, + startingStack: opts.startingStack, + onStateChange: () => emitter.emit(), + onLog: opts.onLog, + }); + const emitter = makeEmitter(); + room.seatLocalPlayer(0, opts.myId, opts.displayName); + room + .waitForListening() + .then((port) => onReady(port)) + .catch(() => {}); + + return { + info: { roomLabel: "Hosting", myId: opts.myId, mySeatIndex: 0 }, + getState: () => redactStateFor(room.state, opts.myId), + legalActionsForMe: () => computeLegalActions(room.state, 0), + canStartHand: () => room.canStartHand() && room.state.street === "showdown", + startHand: () => room.startHand(), + act: (action) => room.applyLocalAction(0, action), + subscribe: emitter.subscribe, + close: () => room.close(), + }; +} + +interface JoinOptions { + url: string; + myId: string; + displayName: string; +} + +function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejected", message?: string) => void): GameSession { + const emitter = makeEmitter(); + let lastState: PublicTableState | undefined; + let mySeatIndex: number | null = null; + + const client = new RoomClient({ + url: opts.url, + playerId: opts.myId, + displayName: opts.displayName, + onWelcome: (info) => { + mySeatIndex = info.seatIndex; + onEvent("welcome"); + emitter.emit(); + }, + onState: (state) => { + lastState = state; + emitter.emit(); + }, + onRejected: (reason, message) => onEvent("rejected", message ?? reason), + onClose: () => onEvent("rejected", "Connection closed"), + }); + + const emptyState: PublicTableState = { + seats: [], + dealerIndex: -1, + smallBlind: 0, + bigBlind: 0, + street: "showdown", + communityCards: [], + toActIndex: null, + currentBet: 0, + minRaise: 0, + handNumber: 0, + pots: [], + winners: [], + awards: [], + log: [], + }; + + return { + info: { roomLabel: `Connected to ${opts.url}`, myId: opts.myId, mySeatIndex }, + getState: () => lastState ?? emptyState, + legalActionsForMe: () => { + if (mySeatIndex === null || !lastState) return null; + // Clients don't run the engine locally; derive a minimal legality check + // from the public state so the UI can grey out actions consistently. + const seat = lastState.seats[mySeatIndex]; + if (!seat || lastState.toActIndex !== mySeatIndex || seat.status !== "active") { + return { canFold: false, canCheck: false, canCall: false, callAmount: 0, canBetOrRaise: false, minRaiseTo: 0, maxRaiseTo: 0 }; + } + const toCall = Math.max(0, lastState.currentBet - seat.committed); + const callAmount = Math.min(toCall, seat.stack); + const maxRaiseTo = seat.committed + seat.stack; + const minRaiseTo = Math.min(lastState.currentBet + lastState.minRaise, maxRaiseTo); + return { + canFold: true, + canCheck: toCall === 0, + canCall: toCall > 0 && seat.stack > 0, + callAmount, + canBetOrRaise: seat.stack > callAmount, + minRaiseTo, + maxRaiseTo, + }; + }, + canStartHand: () => false, + startHand: () => {}, + act: (action) => client.sendAction(action), + subscribe: emitter.subscribe, + close: () => client.close(), + }; +} + +// ---------- 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; + rows[communityRow] = centerOnRow(rows[communityRow] as string, cardsText); + rows[potRow] = centerOnRow(rows[potRow] as string, `Pot ${potTotal.toLocaleString()}`); + + 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; +} + +function renderOverlay(session: GameSession, theme: Theme, raiseState: RaiseUiState, 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.log.length > 0) { + lines.push(theme.fg("dim", state.log.slice(-1)[0] as string)); + } + + 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}`)); + } + + 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")); + } + + 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)}`); + 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 poker session active. Try /poker bots, /poker host, or /poker join
.", "warning"); + return; + } + const activeSession = session; + const raiseState: RaiseUiState = { active: false, amount: 0 }; + 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, ctx); + }, + invalidate() {}, + handleInput(data: string) { + if (matchesKey(data, "escape")) { + stop(); + unsubscribe(); + refreshWidget(ctx); + done(); + 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: "Play Texas Hold'em: /poker bots | host [port] | join | (reopen table)", + handler: async (rawArgs, ctx) => { + if (ctx.mode !== "tui") { + ctx.ui.notify("/poker needs the interactive terminal UI", "warning"); + return; + } + const args = rawArgs.trim().split(/\s+/).filter(Boolean); + const sub = args[0]; + const hostname = os.hostname(); + const myId = `${hostname}-${crypto.randomBytes(3).toString("hex")}`; + + if (!sub) { + await openTable(ctx); + return; + } + + if (sub === "bots") { + closeSession(ctx); + const seatCount = Math.min(6, Math.max(2, Number(args[1]) || 6)); + session = createBotsSession({ + myId, + displayName: hostname, + seatCount, + smallBlind: DEFAULT_SMALL_BLIND, + bigBlind: DEFAULT_BIG_BLIND, + startingStack: DEFAULT_STARTING_STACK, + }); + refreshWidget(ctx); + await openTable(ctx); + return; + } + + if (sub === "host") { + closeSession(ctx); + const port = Number(args[1]) || DEFAULT_PORT; + session = createHostSession( + { + myId, + displayName: hostname, + seatCount: 6, + smallBlind: DEFAULT_SMALL_BLIND, + bigBlind: DEFAULT_BIG_BLIND, + startingStack: DEFAULT_STARTING_STACK, + port, + onLog: (line) => ctx.ui.notify(line, "info"), + }, + (boundPort) => ctx.ui.notify(`Hosting on port ${boundPort}. Share :${boundPort} for others to join.`, "info"), + ); + refreshWidget(ctx); + await openTable(ctx); + return; + } + + if (sub === "join") { + const address = args[1]; + if (!address) { + ctx.ui.notify("Usage: /poker join ", "warning"); + return; + } + closeSession(ctx); + const url = address.startsWith("ws://") || address.startsWith("wss://") ? address : `ws://${address}`; + session = createJoinSession({ url, myId, displayName: hostname }, (kind, message) => { + if (kind === "rejected") { + ctx.ui.notify(`Could not join: ${message ?? "unknown error"}`, "error"); + closeSession(ctx); + } + }); + refreshWidget(ctx); + await openTable(ctx); + return; + } + + if (sub === "leave" || sub === "quit") { + closeSession(ctx); + ctx.ui.notify("Left the poker table", "info"); + return; + } + + ctx.ui.notify("Usage: /poker bots [seats] | host [port] | join | leave", "warning"); + }, + }); + + 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..7c8fbfe --- /dev/null +++ b/packages/pi-texas-holdem/package.json @@ -0,0 +1,55 @@ +{ + "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", + "README.md" + ], + "pi": { + "extensions": [ + "extensions/poker.ts" + ] + }, + "scripts": { + "check": "tsc --noEmit", + "test": "node --import tsx --test test/*.test.ts" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@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" + }, + "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..687f0be --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/cards.ts @@ -0,0 +1,61 @@ +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 = Math.random): Card[] { + const result = deck.slice(); + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (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..d12f5f4 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/evaluator.ts @@ -0,0 +1,119 @@ +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"; + } +} + +export function categoryFromRank(rank: number): HandCategory { + return CATEGORY_BY_RANK[rank] as HandCategory; +} 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..0c4b522 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/ring.ts @@ -0,0 +1,192 @@ +/** + * 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 BORDER_GLYPHS = ["│", "╮", "─", "╭", "│", "╰", "─", "╯"] as const; + +function borderGlyph(dx: number, dy: number): string { + const angle = (Math.atan2(-dy, dx) * 180) / Math.PI; + const normalized = ((angle % 360) + 360) % 360; + const idx = Math.floor(((normalized + 22.5) % 360) / 45); + return BORDER_GLYPHS[idx] as string; +} + +/** 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 }; +} + +export function generateRing(width: number, height: number): RingGrid { + const cx = (width - 1) / 2; + const cy = (height - 1) / 2; + const { a, b } = railRadii(width, height); + const grid: string[][] = Array.from({ length: height }, () => Array.from({ length: width }, () => " ")); + + const setCell = (x: number, y: number) => { + const xi = Math.round(x); + const yi = Math.round(y); + if (xi < 0 || xi >= width || yi < 0 || yi >= height) return; + grid[yi]![xi] = borderGlyph(xi - cx, yi - cy); + }; + + // Round the radius offset once, then apply it symmetrically to both sides of the + // integer center. Rounding cx-d and cx+d independently instead can pick different + // directions for a .5 offset and skew the oval by a column. + for (let xi = 0; xi < width; xi++) { + const ndx = (xi - cx) / a; + if (Math.abs(ndx) > 1) continue; + const dy = Math.round(b * Math.sqrt(Math.max(0, 1 - ndx * ndx))); + setCell(xi, cy - dy); + setCell(xi, cy + dy); + } + for (let yi = 0; yi < height; yi++) { + const ndy = (yi - cy) / b; + if (Math.abs(ndy) > 1) continue; + const dx = Math.round(a * Math.sqrt(Math.max(0, 1 - ndy * ndy))); + setCell(cx - dx, yi); + setCell(cx + dx, yi); + } + + return { width, height, rows: grid.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..707dda4 --- /dev/null +++ b/packages/pi-texas-holdem/src/engine/table.ts @@ -0,0 +1,435 @@ +import { createDeck, shuffleDeck } from "./cards.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 }; +} + +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 = Math.random): 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); + 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..83598d8 --- /dev/null +++ b/packages/pi-texas-holdem/src/net/client.ts @@ -0,0 +1,73 @@ +import { WebSocket } from "ws"; +import type { Action } from "../engine/types.ts"; +import type { PublicTableState } from "../engine/view.ts"; +import { 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; + onRejected: (reason: "roomFull" | "protocolMismatch" | "error", message?: string) => void; + onClose: () => void; +} + +export class RoomClient { + private ws: WebSocket; + seatIndex: number | null = null; + + constructor(private readonly opts: RoomClientOptions) { + this.ws = new WebSocket(opts.url); + this.ws.on("open", () => { + this.ws.send( + encode({ + type: "hello", + protocolVersion: PROTOCOL_VERSION, + playerId: opts.playerId, + displayName: opts.displayName, + }), + ); + }); + this.ws.on("message", (raw) => this.handleMessage(raw.toString())); + this.ws.on("close", () => opts.onClose()); + this.ws.on("error", () => opts.onRejected("error", "Connection failed")); + } + + sendAction(action: Action): void { + this.ws.send(encode({ type: "action", action })); + } + + 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 "roomFull": + this.opts.onRejected("roomFull"); + break; + case "protocolMismatch": + this.opts.onRejected("protocolMismatch", `Host is running protocol v${message.hostVersion}`); + break; + case "error": + this.opts.onRejected("error", message.message); + break; + case "pong": + break; + } + } +} 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..c1ce738 --- /dev/null +++ b/packages/pi-texas-holdem/src/net/host.ts @@ -0,0 +1,185 @@ +import { WebSocket, WebSocketServer } from "ws"; +import { applyAction, createTable, leaveSeat, seatPlayer, startHand } from "../engine/table.ts"; +import type { Action, TableState } from "../engine/types.ts"; +import { redactStateFor } from "../engine/view.ts"; +import { decodeClientMessage, encode, PROTOCOL_VERSION } from "./protocol.ts"; + +export interface HostRoomOptions { + port: number; + seatCount: number; + smallBlind: number; + bigBlind: number; + startingStack: number; + onStateChange: (state: TableState) => void; + onLog?: (line: string) => void; +} + +interface Connection { + ws: WebSocket; + seatIndex: number; + playerId: string; +} + +/** + * 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; + + 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.log(`-- hand ${this.state.handNumber} --`); + this.broadcast(); + } + + applyLocalAction(seatIndex: number, action: Action): void { + this.state = applyAction(this.state, seatIndex, action); + this.broadcast(); + } + + close(): void { + for (const conn of this.connections) conn.ws.close(); + this.wss.close(); + } + + private log(line: string): void { + this.opts.onLog?.(line); + } + + 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 }; + this.connections.add(conn); + this.log(`${message.displayName} joined at seat ${seatIndex}`); + ws.send( + encode({ + type: "welcome", + youId: message.playerId, + seatIndex, + seatCount: this.state.seats.length, + smallBlind: this.state.smallBlind, + bigBlind: this.state.bigBlind, + }), + ); + 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 === "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) { + // A player who disconnects mid-hand auto-folds if it was their turn; + // re-joining under the same connection isn't handled yet (v1 limitation). + if (this.state.toActIndex === conn.seatIndex && seat.status === "active") { + try { + this.applyLocalAction(conn.seatIndex, { type: "fold" }); + } catch { + // hand may have already ended between the disconnect and this fold + } + } + this.state = leaveSeat(this.state, conn.seatIndex); + this.log(`${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..727497b --- /dev/null +++ b/packages/pi-texas-holdem/src/net/protocol.ts @@ -0,0 +1,38 @@ +import type { Action } from "../engine/types.ts"; +import type { PublicTableState } from "../engine/view.ts"; + +export const PROTOCOL_VERSION = 1; + +export type ClientMessage = + | { type: "hello"; protocolVersion: number; playerId: string; displayName: string } + | { type: "action"; action: Action } + | { type: "startHand" } + | { type: "ping" }; + +export type ServerMessage = + | { type: "welcome"; youId: string; seatIndex: number; seatCount: number; smallBlind: number; bigBlind: number } + | { type: "state"; state: PublicTableState } + | { 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/test/cards.test.ts b/packages/pi-texas-holdem/test/cards.test.ts new file mode 100644 index 0000000..f40a33d --- /dev/null +++ b/packages/pi-texas-holdem/test/cards.test.ts @@ -0,0 +1,26 @@ +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("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/evaluator.test.ts b/packages/pi-texas-holdem/test/evaluator.test.ts new file mode 100644 index 0000000..629898d --- /dev/null +++ b/packages/pi-texas-holdem/test/evaluator.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Card } from "../src/engine/types.ts"; +import { compareEvaluations, 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("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..e0bbf09 --- /dev/null +++ b/packages/pi-texas-holdem/test/net.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { HostRoom } from "../src/net/host.ts"; +import { RoomClient } from "../src/net/client.ts"; + +function once(fn: (resolve: (value: T) => void) => void): Promise { + return new Promise((resolve) => fn(resolve)); +} + +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: () => {}, + 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("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: () => {}, + 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..47ee5a7 --- /dev/null +++ b/packages/pi-texas-holdem/test/ring.test.ts @@ -0,0 +1,72 @@ +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}`, + ); + } +}); + +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/table.test.ts b/packages/pi-texas-holdem/test/table.test.ts new file mode 100644 index 0000000..d71f4bc --- /dev/null +++ b/packages/pi-texas-holdem/test/table.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyAction, computeSidePots, createTable, 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("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"] +} From adf4a327cb807d0af11be8a0d5e5e2e04d95088d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:04:00 +0000 Subject: [PATCH 2/8] Add table chat to pi-texas-holdem Chat rides the existing host-authoritative WebSocket protocol: a new "chat"/"chatMessage"/"chatHistory" message trio, a bounded per-room history with replay for late joiners, and per-connection rate limiting. Game events (blinds, folds, calls, raises, showdown) are mirrored into the same feed as system messages, so the small terminal panel doubles as chat and action log instead of needing two. Chat text is rendered straight into other players' terminals, so it's sanitized before broadcast: ANSI/OSC escape sequences and other control characters are stripped, newlines collapsed, length capped. Covered by dedicated sanitize + host/client integration tests. UI: press / to open a compose line inside the table overlay, Enter to send, Esc to cancel, without colliding with the single-key action shortcuts (f/c/r/a). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015iGzsop5jB18LBJk4EtVko --- packages/pi-texas-holdem/README.md | 3 + packages/pi-texas-holdem/extensions/poker.ts | 117 +++++++++++- packages/pi-texas-holdem/src/net/client.ts | 14 +- packages/pi-texas-holdem/src/net/host.ts | 67 ++++++- packages/pi-texas-holdem/src/net/protocol.ts | 14 +- packages/pi-texas-holdem/src/net/sanitize.ts | 21 +++ packages/pi-texas-holdem/test/chat.test.ts | 174 ++++++++++++++++++ packages/pi-texas-holdem/test/net.test.ts | 4 + .../pi-texas-holdem/test/sanitize.test.ts | 33 ++++ 9 files changed, 436 insertions(+), 11 deletions(-) create mode 100644 packages/pi-texas-holdem/src/net/sanitize.ts create mode 100644 packages/pi-texas-holdem/test/chat.test.ts create mode 100644 packages/pi-texas-holdem/test/sanitize.test.ts diff --git a/packages/pi-texas-holdem/README.md b/packages/pi-texas-holdem/README.md index b92c811..e44e789 100644 --- a/packages/pi-texas-holdem/README.md +++ b/packages/pi-texas-holdem/README.md @@ -10,6 +10,7 @@ A [pi](https://github.com/earendil-works/pi) extension that plays Texas Hold'em - Today's cumulative token usage is shown in the table header and the widget - Play solo against simple bots, or host/join a private room over the LAN - Full no-limit Hold'em rules: blinds, betting rounds, side pots, showdown +- Table chat: talk to the other seats and see fold/call/raise/showdown events in the same feed ## Install @@ -41,6 +42,7 @@ While the table is open: - `F` fold, `C` check/call, `R` raise, `A` all-in - While raising: `+`/`-` adjust the amount, `Enter` confirms, `Esc` cancels +- `/` opens a chat line, `Enter` sends it, `Esc` cancels the draft - `Enter` starts the next hand once at least two seats have chips - `Esc` closes the table overlay without leaving the game; the widget keeps tracking it @@ -57,4 +59,5 @@ npm run check - Hosting binds a plain WebSocket server on your machine; players connect with `/poker join :`, so this only works when reachable (LAN, VPN, or a manually forwarded port). A relay/matchmaking server for public games without port-forwarding is a possible follow-up, not built here yet. - Reconnecting to a room after a dropped connection isn't handled — a disconnect auto-folds the seat's current hand and frees the seat. - Bots use a simple fold/call/raise heuristic, not real hand-strength evaluation. +- Chat is sanitized (ANSI/control characters stripped, length-capped, rate-limited) before it's ever broadcast, since it's rendered straight into other players' terminals. - "Today's token usage" sums the current pi session's entries for the local calendar day; it doesn't merge usage from other session files. diff --git a/packages/pi-texas-holdem/extensions/poker.ts b/packages/pi-texas-holdem/extensions/poker.ts index 0d1e3ef..b918d43 100644 --- a/packages/pi-texas-holdem/extensions/poker.ts +++ b/packages/pi-texas-holdem/extensions/poker.ts @@ -10,6 +10,11 @@ import type { PublicSeat, PublicTableState } from "../src/engine/view.ts"; import { redactStateFor } from "../src/engine/view.ts"; import { HostRoom } from "../src/net/host.ts"; import { RoomClient } from "../src/net/client.ts"; +import type { ChatMessage } from "../src/net/protocol.ts"; +import { sanitizeChatText } from "../src/net/sanitize.ts"; + +const CHAT_LOG_LIMIT = 200; +const CHAT_PANEL_LINES = 4; const RING_WIDTH = 75; const RING_HEIGHT = 15; @@ -102,6 +107,8 @@ interface GameSession { canStartHand(): boolean; startHand(): void; act(action: Action): void; + getChatLog(): readonly ChatMessage[]; + sendChat(text: string): void; subscribe(fn: () => void): () => void; close(): void; } @@ -137,6 +144,21 @@ function createBotsSession(opts: LocalOptions): GameSession { } const emitter = makeEmitter(); let botTimer: NodeJS.Timeout | undefined; + let chatLog: ChatMessage[] = []; + let announcedLogLength = 0; + let chatCounter = 0; + + const pushChat = (seatIndex: number | null, displayName: string, text: string) => { + chatLog = [...chatLog, { id: `local-${++chatCounter}`, seatIndex, displayName, text, ts: Date.now() }]; + if (chatLog.length > CHAT_LOG_LIMIT) chatLog = chatLog.slice(-CHAT_LOG_LIMIT); + }; + + const announceNewLog = () => { + while (announcedLogLength < state.log.length) { + pushChat(null, "table", state.log[announcedLogLength] as string); + announcedLogLength++; + } + }; const scheduleBotTurn = () => { if (botTimer) return; @@ -151,6 +173,7 @@ function createBotsSession(opts: LocalOptions): GameSession { } catch { state = applyAction(state, idx, { type: "fold" }); } + announceNewLog(); emitter.emit(); scheduleBotTurn(); }, 450); @@ -163,14 +186,24 @@ function createBotsSession(opts: LocalOptions): GameSession { canStartHand: () => state.street === "showdown" && state.seats.filter((s) => s && s.stack > 0).length >= 2, startHand: () => { state = engineStartHand(state); + 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: () => { if (botTimer) clearTimeout(botTimer); @@ -184,6 +217,7 @@ interface HostOptions extends LocalOptions { } function createHostSession(opts: HostOptions, onReady: (port: number) => void): GameSession { + const emitter = makeEmitter(); const room = new HostRoom({ port: opts.port, seatCount: opts.seatCount, @@ -191,9 +225,9 @@ function createHostSession(opts: HostOptions, onReady: (port: number) => void): bigBlind: opts.bigBlind, startingStack: opts.startingStack, onStateChange: () => emitter.emit(), + onChatMessage: () => emitter.emit(), onLog: opts.onLog, }); - const emitter = makeEmitter(); room.seatLocalPlayer(0, opts.myId, opts.displayName); room .waitForListening() @@ -207,6 +241,8 @@ function createHostSession(opts: HostOptions, onReady: (port: number) => void): 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: () => room.close(), }; @@ -222,6 +258,7 @@ function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejec const emitter = makeEmitter(); let lastState: PublicTableState | undefined; let mySeatIndex: number | null = null; + let chatLog: ChatMessage[] = []; const client = new RoomClient({ url: opts.url, @@ -236,6 +273,14 @@ function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejec 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), onClose: () => onEvent("rejected", "Connection closed"), }); @@ -285,6 +330,8 @@ function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejec canStartHand: () => false, startHand: () => {}, act: (action) => client.sendAction(action), + getChatLog: () => chatLog, + sendChat: (text) => client.sendChat(text), subscribe: emitter.subscribe, close: () => client.close(), }; @@ -376,7 +423,23 @@ interface RaiseUiState { amount: number; } -function renderOverlay(session: GameSession, theme: Theme, raiseState: RaiseUiState, ctx: ExtensionCommandContext): string[] { +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); @@ -395,15 +458,24 @@ function renderOverlay(session: GameSession, theme: Theme, raiseState: RaiseUiSt for (const row of grid) lines.push(theme.fg("borderMuted", row)); lines.push(""); - if (state.log.length > 0) { - lines.push(theme.fg("dim", state.log.slice(-1)[0] as string)); - } - 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) { @@ -432,6 +504,10 @@ function renderOverlay(session: GameSession, theme: Theme, raiseState: RaiseUiSt 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; } @@ -454,6 +530,8 @@ function widgetLines(session: GameSession | null, ctx: ExtensionContext): string 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(" · ")]; } @@ -480,6 +558,7 @@ export default function pokerExtension(pi: ExtensionAPI) { } 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) => { @@ -496,10 +575,28 @@ export default function pokerExtension(pi: ExtensionAPI) { return { render(_width: number) { - return renderOverlay(activeSession, theme, raiseState, ctx); + 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(); @@ -508,6 +605,12 @@ export default function pokerExtension(pi: ExtensionAPI) { return; } + if (matchesKey(data, "/")) { + chatState.active = true; + tui.requestRender(); + return; + } + const legal = activeSession.legalActionsForMe(); const mySeatIndex = activeSession.info.mySeatIndex; diff --git a/packages/pi-texas-holdem/src/net/client.ts b/packages/pi-texas-holdem/src/net/client.ts index 83598d8..6828074 100644 --- a/packages/pi-texas-holdem/src/net/client.ts +++ b/packages/pi-texas-holdem/src/net/client.ts @@ -1,7 +1,7 @@ import { WebSocket } from "ws"; import type { Action } from "../engine/types.ts"; import type { PublicTableState } from "../engine/view.ts"; -import { decodeServerMessage, encode, PROTOCOL_VERSION } from "./protocol.ts"; +import { type ChatMessage, decodeServerMessage, encode, PROTOCOL_VERSION } from "./protocol.ts"; export interface RoomClientOptions { url: string; @@ -9,6 +9,8 @@ export interface RoomClientOptions { 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; onClose: () => void; } @@ -38,6 +40,10 @@ export class RoomClient { this.ws.send(encode({ type: "action", action })); } + sendChat(text: string): void { + this.ws.send(encode({ type: "chat", text })); + } + close(): void { this.ws.close(); } @@ -57,6 +63,12 @@ export class RoomClient { 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; diff --git a/packages/pi-texas-holdem/src/net/host.ts b/packages/pi-texas-holdem/src/net/host.ts index c1ce738..b05b8a3 100644 --- a/packages/pi-texas-holdem/src/net/host.ts +++ b/packages/pi-texas-holdem/src/net/host.ts @@ -1,8 +1,14 @@ +import crypto from "node:crypto"; import { WebSocket, WebSocketServer } from "ws"; import { applyAction, createTable, leaveSeat, seatPlayer, startHand } from "../engine/table.ts"; import type { Action, TableState } from "../engine/types.ts"; import { redactStateFor } from "../engine/view.ts"; -import { decodeClientMessage, encode, PROTOCOL_VERSION } from "./protocol.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; @@ -11,6 +17,7 @@ export interface HostRoomOptions { bigBlind: number; startingStack: number; onStateChange: (state: TableState) => void; + onChatMessage?: (message: ChatMessage) => void; onLog?: (line: string) => void; } @@ -18,6 +25,7 @@ interface Connection { ws: WebSocket; seatIndex: number; playerId: string; + lastChatAt: number; } /** @@ -30,6 +38,8 @@ export class HostRoom { private wss: WebSocketServer; private connections = new Set(); private readonly opts: HostRoomOptions; + private chatLog: ChatMessage[] = []; + private announcedLogLength = 0; constructor(opts: HostRoomOptions) { this.opts = opts; @@ -71,15 +81,31 @@ export class HostRoom { 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(); @@ -89,6 +115,26 @@ export class HostRoom { 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: crypto.randomUUID(), seatIndex, displayName, text, ts: 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; @@ -118,9 +164,10 @@ export class HostRoom { displayName: message.displayName, stack: this.opts.startingStack, }); - conn = { ws, seatIndex, playerId: message.playerId }; + 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", @@ -131,6 +178,7 @@ export class HostRoom { bigBlind: this.state.bigBlind, }), ); + ws.send(encode({ type: "chatHistory", messages: this.chatLog.slice(-CHAT_HISTORY_SENT_ON_JOIN) })); this.broadcast(); return; } @@ -149,6 +197,20 @@ export class HostRoom { 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" })); } @@ -170,6 +232,7 @@ export class HostRoom { } this.state = leaveSeat(this.state, conn.seatIndex); this.log(`${seat.displayName} disconnected`); + this.pushChat(null, "table", `${seat.displayName} disconnected`); this.broadcast(); } }); diff --git a/packages/pi-texas-holdem/src/net/protocol.ts b/packages/pi-texas-holdem/src/net/protocol.ts index 727497b..cc0b670 100644 --- a/packages/pi-texas-holdem/src/net/protocol.ts +++ b/packages/pi-texas-holdem/src/net/protocol.ts @@ -1,17 +1,29 @@ import type { Action } from "../engine/types.ts"; import type { PublicTableState } from "../engine/view.ts"; -export const PROTOCOL_VERSION = 1; +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 } | { 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 } 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..c98aa84 --- /dev/null +++ b/packages/pi-texas-holdem/src/net/sanitize.ts @@ -0,0 +1,21 @@ +/** + * 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 range except the newlines we collapse separately. +// 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]/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/test/chat.test.ts b/packages/pi-texas-holdem/test/chat.test.ts new file mode 100644 index 0000000..c3af80e --- /dev/null +++ b/packages/pi-texas-holdem/test/chat.test.ts @@ -0,0 +1,174 @@ +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", 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("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"); + } finally { + guest?.close(); + 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/net.test.ts b/packages/pi-texas-holdem/test/net.test.ts index e0bbf09..75a553d 100644 --- a/packages/pi-texas-holdem/test/net.test.ts +++ b/packages/pi-texas-holdem/test/net.test.ts @@ -28,6 +28,8 @@ test("a remote client can join a hosted room and see redacted state", async () = displayName: "guest-machine", onWelcome: (info) => resolve(info), onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, onRejected: (reason, message) => assert.fail(`unexpected rejection: ${reason} ${message ?? ""}`), onClose: () => {}, }); @@ -64,6 +66,8 @@ test("actions from a connected client update the host's authoritative state", as displayName: "guest-machine", onWelcome: () => resolve(), onState: () => {}, + onChatMessage: () => {}, + onChatHistory: () => {}, onRejected: () => resolve(), onClose: () => {}, }); 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..fab6b90 --- /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 control characters but keeps normal punctuation", () => { + assert.equal(sanitizeChatText("go\x07od 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 "), ""); +}); From e0d67f6a57804d596bcb668946833dca60051aad Mon Sep 17 00:00:00 2001 From: Ying Xiang Date: Tue, 18 Aug 2026 23:51:44 +0800 Subject: [PATCH 3/8] pi-texas-holdem: friends-only disclaimer + host-disconnect toast - /poker host now requires --i-know flag; first run prints the disclaimer (host can see hole cards, do not use for money) instead of a modal we don't have a native prompt for. - Join flow prints a trust warning before connecting. - Client distinguishes "never connected" from "host went away mid-game" and surfaces a plain host-offline notice + closes the room. Follow-ups (separate diff): display-name validation, grace-period reconnect toast with countdown, render three cleanups. Co-Authored-By: Claude Opus 4.7 --- packages/pi-texas-holdem/extensions/poker.ts | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/pi-texas-holdem/extensions/poker.ts b/packages/pi-texas-holdem/extensions/poker.ts index b918d43..f3b07ba 100644 --- a/packages/pi-texas-holdem/extensions/poker.ts +++ b/packages/pi-texas-holdem/extensions/poker.ts @@ -254,17 +254,19 @@ interface JoinOptions { displayName: string; } -function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejected", message?: string) => void): GameSession { +function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejected" | "hostDisconnected", message?: string) => void): GameSession { const emitter = makeEmitter(); let lastState: PublicTableState | undefined; let mySeatIndex: number | null = null; let chatLog: ChatMessage[] = []; + let welcomed = false; const client = new RoomClient({ url: opts.url, playerId: opts.myId, displayName: opts.displayName, onWelcome: (info) => { + welcomed = true; mySeatIndex = info.seatIndex; onEvent("welcome"); emitter.emit(); @@ -282,7 +284,8 @@ function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejec emitter.emit(); }, onRejected: (reason, message) => onEvent("rejected", message ?? reason), - onClose: () => onEvent("rejected", "Connection closed"), + // ponytail: welcomed==false means we never connected; welcomed==true means host went away mid-game. + onClose: () => onEvent(welcomed ? "hostDisconnected" : "rejected", welcomed ? undefined : "Connection closed"), }); const emptyState: PublicTableState = { @@ -690,8 +693,16 @@ export default function pokerExtension(pi: ExtensionAPI) { } if (sub === "host") { + // ponytail: no native y/n prompt in this TUI; require an explicit --i-know flag instead of building a modal. + if (!args.includes("--i-know")) { + ctx.ui.notify( + "🎴 Friends-only poker. YOU (the host) can technically see everyone's hole cards — this is not cheat-proof. Do not use for money. Re-run with `/poker host [port] --i-know` to confirm.", + "warning", + ); + return; + } closeSession(ctx); - const port = Number(args[1]) || DEFAULT_PORT; + const port = Number(args[1] && !args[1].startsWith("--") ? args[1] : "") || DEFAULT_PORT; session = createHostSession( { myId, @@ -718,10 +729,14 @@ export default function pokerExtension(pi: ExtensionAPI) { } closeSession(ctx); const url = address.startsWith("ws://") || address.startsWith("wss://") ? address : `ws://${address}`; + ctx.ui.notify(`Joining ${address} — the host's machine runs the game and can see your hole cards. Only join hosts you trust.`, "info"); session = createJoinSession({ url, myId, displayName: hostname }, (kind, message) => { if (kind === "rejected") { ctx.ui.notify(`Could not join: ${message ?? "unknown error"}`, "error"); closeSession(ctx); + } else if (kind === "hostDisconnected") { + ctx.ui.notify("Host went offline — this hand ended and any bets are returned. Room closed.", "warning"); + closeSession(ctx); } }); refreshWidget(ctx); From fb76cf5c69f9c4707a4df2f4550d35e79e1475dd Mon Sep 17 00:00:00 2001 From: Ying Xiang Date: Wed, 19 Aug 2026 20:14:42 +0800 Subject: [PATCH 4/8] feat(pi-texas-holdem): add federated cloud rooms Extract reusable sessions and runtime-neutral game logic, add a personal Cloudflare Worker with Durable Object rooms, and wire local/create/join terminal flows. --- package-lock.json | 1328 +++++++++++++++-- packages/pi-texas-holdem-worker/README.md | 97 ++ packages/pi-texas-holdem-worker/package.json | 21 + packages/pi-texas-holdem-worker/src/config.ts | 34 + packages/pi-texas-holdem-worker/src/index.ts | 11 + .../pi-texas-holdem-worker/src/poker-room.ts | 355 +++++ packages/pi-texas-holdem-worker/src/router.ts | 58 + packages/pi-texas-holdem-worker/src/types.ts | 32 + .../test/config.test.ts | 28 + .../test/integration.test.ts | 59 + .../test/router.test.ts | 81 + packages/pi-texas-holdem-worker/tsconfig.json | 13 + .../wrangler.example.jsonc | 19 + packages/pi-texas-holdem/README.md | 82 +- packages/pi-texas-holdem/extensions/poker.ts | 395 ++--- packages/pi-texas-holdem/src/engine/cards.ts | 9 +- packages/pi-texas-holdem/src/engine/random.ts | 15 + packages/pi-texas-holdem/src/engine/table.ts | 32 +- packages/pi-texas-holdem/src/net/client.ts | 24 +- packages/pi-texas-holdem/src/net/host.ts | 26 +- packages/pi-texas-holdem/src/net/protocol.ts | 2 +- packages/pi-texas-holdem/src/net/sanitize.ts | 5 +- packages/pi-texas-holdem/src/session/bots.ts | 112 ++ .../pi-texas-holdem/src/session/emitter.ts | 21 + packages/pi-texas-holdem/src/session/host.ts | 38 + packages/pi-texas-holdem/src/session/join.ts | 109 ++ packages/pi-texas-holdem/src/session/types.ts | 47 + packages/pi-texas-holdem/src/ui/cloud.ts | 99 ++ packages/pi-texas-holdem/src/ui/commands.ts | 68 + packages/pi-texas-holdem/test/cards.test.ts | 5 + packages/pi-texas-holdem/test/chat.test.ts | 35 +- packages/pi-texas-holdem/test/cloud.test.ts | 62 + .../pi-texas-holdem/test/commands.test.ts | 57 + packages/pi-texas-holdem/test/net.test.ts | 173 +++ .../pi-texas-holdem/test/sanitize.test.ts | 4 +- packages/pi-texas-holdem/test/session.test.ts | 54 + packages/pi-texas-holdem/test/table.test.ts | 31 +- 37 files changed, 3192 insertions(+), 449 deletions(-) create mode 100644 packages/pi-texas-holdem-worker/README.md create mode 100644 packages/pi-texas-holdem-worker/package.json create mode 100644 packages/pi-texas-holdem-worker/src/config.ts create mode 100644 packages/pi-texas-holdem-worker/src/index.ts create mode 100644 packages/pi-texas-holdem-worker/src/poker-room.ts create mode 100644 packages/pi-texas-holdem-worker/src/router.ts create mode 100644 packages/pi-texas-holdem-worker/src/types.ts create mode 100644 packages/pi-texas-holdem-worker/test/config.test.ts create mode 100644 packages/pi-texas-holdem-worker/test/integration.test.ts create mode 100644 packages/pi-texas-holdem-worker/test/router.test.ts create mode 100644 packages/pi-texas-holdem-worker/tsconfig.json create mode 100644 packages/pi-texas-holdem-worker/wrangler.example.jsonc create mode 100644 packages/pi-texas-holdem/src/engine/random.ts create mode 100644 packages/pi-texas-holdem/src/session/bots.ts create mode 100644 packages/pi-texas-holdem/src/session/emitter.ts create mode 100644 packages/pi-texas-holdem/src/session/host.ts create mode 100644 packages/pi-texas-holdem/src/session/join.ts create mode 100644 packages/pi-texas-holdem/src/session/types.ts create mode 100644 packages/pi-texas-holdem/src/ui/cloud.ts create mode 100644 packages/pi-texas-holdem/src/ui/commands.ts create mode 100644 packages/pi-texas-holdem/test/cloud.test.ts create mode 100644 packages/pi-texas-holdem/test/commands.test.ts create mode 100644 packages/pi-texas-holdem/test/session.test.ts diff --git a/package-lock.json b/package-lock.json index 19cc184..12f63db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,137 @@ "node": ">=22.19.0" } }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "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-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260815.1.tgz", + "integrity": "sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260815.1.tgz", + "integrity": "sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260815.1.tgz", + "integrity": "sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260815.1.tgz", + "integrity": "sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260815.1.tgz", + "integrity": "sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260819.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260819.1.tgz", + "integrity": "sha512-nUoWrl+16WfocHgXAARAvpQ7dLMF4tSmTvvgLLg5clYhP2j8CYerZ2KC8agnlWHqOAKp45B3F+LcsjNZrZyiRA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "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", @@ -1992,6 +2123,17 @@ "node": ">=22.19.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -2478,246 +2620,853 @@ "resolved": "packages/pi-texas-holdem", "link": true }, + "node_modules/@geminixiang/pi-texas-holdem-worker": { + "resolved": "packages/pi-texas-holdem-worker", + "link": true + }, "node_modules/@geminixiang/pi-verification": { "resolved": "packages/pi-verification", "link": true }, - "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.60.0.tgz", - "integrity": "sha512-1q4q4Jc8FlOMVojEisyFAVyl8h1yawNv6phjgmhGVEDeyeOdsSnSr9x0+D4mOnEKvpO5L4mxKZ/DP9X6U3A/Mw==", - "cpu": [ - "arm" - ], + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.60.0.tgz", - "integrity": "sha512-tD41I6nCt9k8SQXft0CSjjU9jg6SwG7uMu7PxodSEHXl+GDW0868oy6tTtoJkyUze8YKFgTpz/k5LuPUnFiGLw==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" } }, - "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==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" } }, - "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.60.0.tgz", - "integrity": "sha512-CnOoWgQ7L+JL/YQaRJ+NyATciSfcftncm7y3kqyte1cGtFEGnStaCd1TAyrinkfQ7nRBfHrTs1/vTwUJr3WF2Q==", - "cpu": [ - "x64" - ], + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "freebsd" ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.60.0.tgz", - "integrity": "sha512-ychJo7S3hZxdO6eDZ9zM6F2lM9fpJS3EKS5CAUSWyprdLYxTu4gbaUKV/VBPTcMJwQa2Bpo+643y3OJ537pihA==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.60.0.tgz", - "integrity": "sha512-36IH5o55T2Fx7E0feDttt+mifxN6yk9pWv4KfhAIsP0dFnUq27331OwbpOsZdoXF9soOLWm7mQUz5+UUmyec4g==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", "cpu": [ - "arm" + "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "linux" + "darwin" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.60.0.tgz", - "integrity": "sha512-G1Ve7lAa6sFBolVI2LWHfEAqy0YKh4vnioH8uYO9kAEdgM7mR40IksIx9/Zk4+vbYew/sGa4J9Q4tZ3n9gXDHA==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.60.0.tgz", - "integrity": "sha512-LTQdRBf6uzj/h7Xk6lKzbGD2hrF/fK4YI9LIN1c0509tPUn8wRa3mCmrFQpEWJPLYGFrLFFMTYW1Ljj6VqW2Hw==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.60.0.tgz", - "integrity": "sha512-2JMo3XPxMPx3hiqddSZYyaH+fKJm6cz0u8n1naYjP/CdOQOZW34i8lKBUfmbWiuFvd6KoYXLmhAyBuvojsYS7Q==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.60.0.tgz", - "integrity": "sha512-L3C+nBD13lr306tr/PjM3RMll+BVqgFrIgUyoeHuai5oueJrRLgO3j+GO5/Cbhtkf5PSlHYTI1JY7iqBd1qa6A==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", "cpu": [ - "ppc64" + "riscv64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.60.0.tgz", - "integrity": "sha512-M4MsmvqlxFiPtSRGyBYQSZxchEf463AOyd+Dh4/9xDpjWBsRtDUTDMFN5EdHinjVK1/eDJQ8MLpcYjpYayaCnA==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.60.0.tgz", - "integrity": "sha512-OH+9UskYuxRB+GxqdGkVN8f5UpwhqG8YscNo1wl8+KJ62cd7wZdGga6iGLJIf8kibF1WBwvlfDUx3cez/VXwFg==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", "cpu": [ - "riscv64" + "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.60.0.tgz", - "integrity": "sha512-y7AAFutt9wFWBFOAn6+BHaV39usZmcr3YYH2385f+NHgPNpIF9HpqKp0jgUxPaUOCyG3oaX5VhJduL1Nw164rw==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", "cpu": [ - "s390x" + "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.60.0.tgz", - "integrity": "sha512-yKZ9+CXAI+1RO5nH/4Z/9M6DAsfOzd5bw/gtWk81KB4mpalMaRRSXfouc5/tHxazDmBek55HNPepNYBgaCew0Q==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.60.0.tgz", + "integrity": "sha512-1q4q4Jc8FlOMVojEisyFAVyl8h1yawNv6phjgmhGVEDeyeOdsSnSr9x0+D4mOnEKvpO5L4mxKZ/DP9X6U3A/Mw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.60.0.tgz", + "integrity": "sha512-tD41I6nCt9k8SQXft0CSjjU9jg6SwG7uMu7PxodSEHXl+GDW0868oy6tTtoJkyUze8YKFgTpz/k5LuPUnFiGLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.60.0.tgz", + "integrity": "sha512-CnOoWgQ7L+JL/YQaRJ+NyATciSfcftncm7y3kqyte1cGtFEGnStaCd1TAyrinkfQ7nRBfHrTs1/vTwUJr3WF2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.60.0.tgz", + "integrity": "sha512-ychJo7S3hZxdO6eDZ9zM6F2lM9fpJS3EKS5CAUSWyprdLYxTu4gbaUKV/VBPTcMJwQa2Bpo+643y3OJ537pihA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.60.0.tgz", + "integrity": "sha512-36IH5o55T2Fx7E0feDttt+mifxN6yk9pWv4KfhAIsP0dFnUq27331OwbpOsZdoXF9soOLWm7mQUz5+UUmyec4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.60.0.tgz", + "integrity": "sha512-G1Ve7lAa6sFBolVI2LWHfEAqy0YKh4vnioH8uYO9kAEdgM7mR40IksIx9/Zk4+vbYew/sGa4J9Q4tZ3n9gXDHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.60.0.tgz", + "integrity": "sha512-LTQdRBf6uzj/h7Xk6lKzbGD2hrF/fK4YI9LIN1c0509tPUn8wRa3mCmrFQpEWJPLYGFrLFFMTYW1Ljj6VqW2Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.60.0.tgz", + "integrity": "sha512-2JMo3XPxMPx3hiqddSZYyaH+fKJm6cz0u8n1naYjP/CdOQOZW34i8lKBUfmbWiuFvd6KoYXLmhAyBuvojsYS7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.60.0.tgz", + "integrity": "sha512-L3C+nBD13lr306tr/PjM3RMll+BVqgFrIgUyoeHuai5oueJrRLgO3j+GO5/Cbhtkf5PSlHYTI1JY7iqBd1qa6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.60.0.tgz", + "integrity": "sha512-M4MsmvqlxFiPtSRGyBYQSZxchEf463AOyd+Dh4/9xDpjWBsRtDUTDMFN5EdHinjVK1/eDJQ8MLpcYjpYayaCnA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.60.0.tgz", + "integrity": "sha512-OH+9UskYuxRB+GxqdGkVN8f5UpwhqG8YscNo1wl8+KJ62cd7wZdGga6iGLJIf8kibF1WBwvlfDUx3cez/VXwFg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.60.0.tgz", + "integrity": "sha512-y7AAFutt9wFWBFOAn6+BHaV39usZmcr3YYH2385f+NHgPNpIF9HpqKp0jgUxPaUOCyG3oaX5VhJduL1Nw164rw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.60.0.tgz", + "integrity": "sha512-yKZ9+CXAI+1RO5nH/4Z/9M6DAsfOzd5bw/gtWk81KB4mpalMaRRSXfouc5/tHxazDmBek55HNPepNYBgaCew0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@oxfmt/binding-linux-x64-musl": { @@ -3128,6 +3877,35 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "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", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, "node_modules/@profoundlogic/hogan": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz", @@ -3140,6 +3918,26 @@ "hulk": "bin/hulk" } }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -3523,6 +4321,13 @@ "entities": "^7.0.1" } }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, "node_modules/buffer-image-size": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", @@ -3551,6 +4356,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "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", @@ -3594,6 +4423,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "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", @@ -3693,6 +4532,16 @@ "node": ">=12.0.0" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/marked": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", @@ -3706,6 +4555,46 @@ "node": ">= 20" } }, + "node_modules/miniflare": { + "version": "5.20260815.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260815.0-alpha.tgz", + "integrity": "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ==", + "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", + "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/nopt": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", @@ -3822,6 +4711,20 @@ } } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3835,6 +4738,77 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "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", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "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", @@ -3845,6 +4819,14 @@ "node": "^20.0.0 || >=22.0.0" } }, + "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", + "optional": true + }, "node_modules/tsx": { "version": "4.23.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", @@ -3905,6 +4887,16 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "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", @@ -3912,6 +4904,16 @@ "dev": true, "license": "MIT" }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -3922,6 +4924,63 @@ "node": ">=12" } }, + "node_modules/workerd": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260815.1.tgz", + "integrity": "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==", + "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", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.124.0.tgz", + "integrity": "sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog==", + "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", @@ -3943,6 +5002,31 @@ } } }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "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", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, "packages/pi-agent-team": { "name": "@geminixiang/pi-agent-team", "version": "0.3.0", @@ -4179,6 +5263,20 @@ "typebox": "*" } }, + "packages/pi-texas-holdem-worker": { + "name": "@geminixiang/pi-texas-holdem-worker", + "version": "0.1.0", + "dependencies": { + "@geminixiang/pi-texas-holdem": "0.1.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260819.1", + "@types/node": "^24.10.1", + "tsx": "^4.20.6", + "typescript": "7.0.2", + "wrangler": "^4.124.0" + } + }, "packages/pi-verification": { "name": "@geminixiang/pi-verification", "version": "0.1.0", diff --git a/packages/pi-texas-holdem-worker/README.md b/packages/pi-texas-holdem-worker/README.md new file mode 100644 index 0000000..e0583b5 --- /dev/null +++ b/packages/pi-texas-holdem-worker/README.md @@ -0,0 +1,97 @@ +# pi-texas-holdem-worker + +A self-hosted Cloudflare Worker + Durable Object backend for private `pi-texas-holdem` rooms. Each room creator deploys this package 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-worker/wrangler.example.jsonc \ + packages/pi-texas-holdem-worker/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-worker/wrangler.jsonc +npx wrangler dev --config packages/pi-texas-holdem-worker/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-worker/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 --workspace @geminixiang/pi-texas-holdem-worker +npm test --workspace @geminixiang/pi-texas-holdem-worker +``` diff --git a/packages/pi-texas-holdem-worker/package.json b/packages/pi-texas-holdem-worker/package.json new file mode 100644 index 0000000..849c185 --- /dev/null +++ b/packages/pi-texas-holdem-worker/package.json @@ -0,0 +1,21 @@ +{ + "name": "@geminixiang/pi-texas-holdem-worker", + "version": "0.1.0", + "private": true, + "description": "Self-hosted Cloudflare Worker and Durable Object rooms for pi-texas-holdem", + "type": "module", + "scripts": { + "check": "tsc --noEmit", + "test": "node --import tsx --test test/*.test.ts" + }, + "dependencies": { + "@geminixiang/pi-texas-holdem": "0.1.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260819.1", + "@types/node": "^24.10.1", + "tsx": "^4.20.6", + "typescript": "7.0.2", + "wrangler": "^4.124.0" + } +} diff --git a/packages/pi-texas-holdem-worker/src/config.ts b/packages/pi-texas-holdem-worker/src/config.ts new file mode 100644 index 0000000..d70fc8f --- /dev/null +++ b/packages/pi-texas-holdem-worker/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-worker/src/index.ts b/packages/pi-texas-holdem-worker/src/index.ts new file mode 100644 index 0000000..0e7d2fb --- /dev/null +++ b/packages/pi-texas-holdem-worker/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-worker/src/poker-room.ts b/packages/pi-texas-holdem-worker/src/poker-room.ts new file mode 100644 index 0000000..bca840d --- /dev/null +++ b/packages/pi-texas-holdem-worker/src/poker-room.ts @@ -0,0 +1,355 @@ +import { applyAction, createTable, forceFold, leaveSeat, seatPlayer, startHand } from "@geminixiang/pi-texas-holdem/src/engine/table.ts"; +import type { Action, TableState } from "@geminixiang/pi-texas-holdem/src/engine/types.ts"; +import { redactStateFor } from "@geminixiang/pi-texas-holdem/src/engine/view.ts"; +import { + encode, + PROTOCOL_VERSION, + type ClientMessage, + type ServerMessage, +} from "@geminixiang/pi-texas-holdem/src/net/protocol.ts"; +import { sanitizeChatText } from "@geminixiang/pi-texas-holdem/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-worker/src/router.ts b/packages/pi-texas-holdem-worker/src/router.ts new file mode 100644 index 0000000..ce2a6a4 --- /dev/null +++ b/packages/pi-texas-holdem-worker/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-worker/src/types.ts b/packages/pi-texas-holdem-worker/src/types.ts new file mode 100644 index 0000000..2bc1a68 --- /dev/null +++ b/packages/pi-texas-holdem-worker/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-worker/test/config.test.ts b/packages/pi-texas-holdem-worker/test/config.test.ts new file mode 100644 index 0000000..953883b --- /dev/null +++ b/packages/pi-texas-holdem-worker/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-worker/test/integration.test.ts b/packages/pi-texas-holdem-worker/test/integration.test.ts new file mode 100644 index 0000000..b58685c --- /dev/null +++ b/packages/pi-texas-holdem-worker/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 "@geminixiang/pi-texas-holdem/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-worker/test/router.test.ts b/packages/pi-texas-holdem-worker/test/router.test.ts new file mode 100644 index 0000000..408ad42 --- /dev/null +++ b/packages/pi-texas-holdem-worker/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-worker/tsconfig.json b/packages/pi-texas-holdem-worker/tsconfig.json new file mode 100644 index 0000000..949ac12 --- /dev/null +++ b/packages/pi-texas-holdem-worker/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"] +} diff --git a/packages/pi-texas-holdem-worker/wrangler.example.jsonc b/packages/pi-texas-holdem-worker/wrangler.example.jsonc new file mode 100644 index 0000000..69c622a --- /dev/null +++ b/packages/pi-texas-holdem-worker/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/README.md b/packages/pi-texas-holdem/README.md index e44e789..14ef831 100644 --- a/packages/pi-texas-holdem/README.md +++ b/packages/pi-texas-holdem/README.md @@ -8,7 +8,8 @@ A [pi](https://github.com/earendil-works/pi) extension that plays Texas Hold'em - A persistent status widget keeps showing whose turn it is and the pot while you're back to working - No accounts: your seat name is your machine's hostname - Today's cumulative token usage is shown in the table header and the widget -- Play solo against simple bots, or host/join a private room over the LAN +- Three explicit entry paths: play locally, create a room on your personal Cloudflare Worker, or join a room URL +- Legacy LAN hosting remains available for trusted friends and existing workflows - Full no-limit Hold'em rules: blinds, betting rounds, side pots, showdown - Table chat: talk to the other seats and see fold/call/raise/showdown events in the same feed @@ -28,16 +29,80 @@ npm install pi -e ./extensions/poker.ts ``` -## Usage +## Choose how to play + +### 1. Play locally + +```text +/poker local [seats] start immediately against bots (2-6 seats, default 6) +``` + +Everything stays in this pi process. This is the fastest path and needs no account or network service. + +### 2. Create a room on your personal Cloudflare Worker + +The repository includes `@geminixiang/pi-texas-holdem-worker`, a Worker + Durable Object backend that you deploy to **your own Cloudflare account**. Follow [`../pi-texas-holdem-worker/README.md`](../pi-texas-holdem-worker/README.md) to deploy it; pi never logs into Cloudflare or deploys infrastructure for you. + +Configure pi before launching it: + +```sh +export PI_POKER_WORKER_URL="https://YOUR-WORKER.workers.dev" +# Only when your deployment protects POST /rooms: +export PI_POKER_CREATE_SECRET="your-creation-secret" +pi +``` + +Then create and enter a room: + +```text +/poker create +``` + +The extension posts private defaults to `POST /rooms` with telemetry disabled, joins the creator URL returned by the Worker, and displays the shareable invite URL after the WebSocket connection succeeds. The creation secret is sent only as an `Authorization: Bearer …` header: it is never placed in a URL, notification, or room invite. Prefer setting it through your shell's secret manager rather than saving it in a checked-in file. + +If `PI_POKER_WORKER_URL` is missing or invalid, `/poker create` makes no request and shows setup guidance. The endpoint must use HTTPS, except loopback HTTP for local development. + +The creator session can request the first and later hands with `Enter`. A normal invite URL does not grant creator controls; authorization is carried only by the unshareable `?creator=…` capability in `creatorUrl`. + +For a trusted LAN, VPN, or manually forwarded port, the advanced fallback remains: ```text -/poker bots [seats] start a solo game against bots (2-6 seats, default 6) -/poker host [port] host a private room (default port 4551) and wait for others -/poker join join someone else's room -/poker reopen the table for the session already in progress -/poker leave close the current session +/poker lan-host [port] --i-know ``` +The explicit confirmation is required because this machine runs the game and can technically inspect every hand. Do not use it for money. + +### 3. Join a room URL + +```text +/poker join +``` + +Paste a `wss://`/`https://` share URL, or connect directly with `host:port` on a trusted LAN. HTTPS share links are converted to secure WebSocket URLs. Only join room operators you trust. + +### Session and roadmap commands + +```text +/poker reopen the active table +/poker leave leave and close the active game +/poker rooms public directory status (coming soon; makes no request) +/poker privacy telemetry status and policy +/poker help show the command summary +``` + +Public rooms and a searchable directory are intentionally reserved for a later backend. `/poker rooms` does not present fake data. Telemetry is currently off; any future analytics will be clearly opt-in, never enabled merely by creating or joining a room, and will include an obvious way to disable it. + +### Compatibility + +Existing commands remain supported: + +- `/poker bots [seats]` → `/poker local [seats]` +- `/poker host [port] --i-know` → `/poker lan-host [port] --i-know` +- `/poker quit` → `/poker leave` +- `/poker join ` continues to work + +The old aliases show a short migration hint but retain their behavior. + While the table is open: - `F` fold, `C` check/call, `R` raise, `A` all-in @@ -56,7 +121,8 @@ npm run check ## Notes / current limitations -- Hosting binds a plain WebSocket server on your machine; players connect with `/poker join :`, so this only works when reachable (LAN, VPN, or a manually forwarded port). A relay/matchmaking server for public games without port-forwarding is a possible follow-up, not built here yet. +- Legacy LAN hosting binds a plain WebSocket server on your machine. It only works when reachable (LAN, VPN, or a manually forwarded port), and the host is trusted rather than cheat-proof. +- Personal internet rooms use the separately deployed Worker package and remain private, unlisted bearer URLs. Public discovery is not implemented. - Reconnecting to a room after a dropped connection isn't handled — a disconnect auto-folds the seat's current hand and frees the seat. - Bots use a simple fold/call/raise heuristic, not real hand-strength evaluation. - Chat is sanitized (ANSI/control characters stripped, length-capped, rate-limited) before it's ever broadcast, since it's rendered straight into other players' terminals. diff --git a/packages/pi-texas-holdem/extensions/poker.ts b/packages/pi-texas-holdem/extensions/poker.ts index f3b07ba..376c168 100644 --- a/packages/pi-texas-holdem/extensions/poker.ts +++ b/packages/pi-texas-holdem/extensions/poker.ts @@ -3,17 +3,17 @@ 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 { formatCard } from "../src/engine/cards.ts"; -import { applyAction, createTable, legalActions as computeLegalActions, seatPlayer, startHand as engineStartHand } from "../src/engine/table.ts"; -import type { Action, LegalActions, Street, TableState } from "../src/engine/types.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 { redactStateFor } from "../src/engine/view.ts"; -import { HostRoom } from "../src/net/host.ts"; -import { RoomClient } from "../src/net/client.ts"; import type { ChatMessage } from "../src/net/protocol.ts"; -import { sanitizeChatText } from "../src/net/sanitize.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_LOG_LIMIT = 200; const CHAT_PANEL_LINES = 4; const RING_WIDTH = 75; @@ -21,7 +21,6 @@ const RING_HEIGHT = 15; const SEAT_HALF_WIDTH = 10; const SEAT_HALF_HEIGHT = 3; const BOX_INNER = 17; -const DEFAULT_PORT = 4551; const DEFAULT_STARTING_STACK = 2000; const DEFAULT_SMALL_BLIND = 10; const DEFAULT_BIG_BLIND = 20; @@ -74,272 +73,6 @@ function formatTokenCount(n: number): string { return String(n); } -// ---------- bot AI ---------- - -function botDecision(state: TableState, seatIndex: number): Action { - const legal = computeLegalActions(state, seatIndex); - const roll = Math.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" }; -} - -// ---------- game session abstraction ---------- - -interface SessionInfo { - roomLabel: string; - myId: string; - mySeatIndex: number | null; -} - -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(fn: () => void): () => void; - close(): void; -} - -function makeEmitter() { - const listeners = new Set<() => void>(); - return { - subscribe(fn: () => void) { - listeners.add(fn); - return () => listeners.delete(fn); - }, - emit() { - for (const fn of listeners) fn(); - }, - }; -} - -interface LocalOptions { - myId: string; - displayName: string; - seatCount: number; - smallBlind: number; - bigBlind: number; - startingStack: number; -} - -/** Solo play against simple local bots. No networking. */ -function createBotsSession(opts: LocalOptions): GameSession { - 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 = makeEmitter(); - let botTimer: NodeJS.Timeout | undefined; - let chatLog: ChatMessage[] = []; - let announcedLogLength = 0; - let chatCounter = 0; - - const pushChat = (seatIndex: number | null, displayName: string, text: string) => { - chatLog = [...chatLog, { id: `local-${++chatCounter}`, seatIndex, displayName, text, ts: Date.now() }]; - if (chatLog.length > CHAT_LOG_LIMIT) chatLog = chatLog.slice(-CHAT_LOG_LIMIT); - }; - - const announceNewLog = () => { - while (announcedLogLength < state.log.length) { - pushChat(null, "table", state.log[announcedLogLength] as string); - announcedLogLength++; - } - }; - - const scheduleBotTurn = () => { - if (botTimer) return; - const toAct = state.toActIndex; - if (toAct === null || toAct === 0 || state.street === "showdown") return; - botTimer = setTimeout(() => { - botTimer = undefined; - const idx = state.toActIndex; - if (idx === null || idx === 0 || state.street === "showdown") return; - try { - state = applyAction(state, idx, botDecision(state, idx)); - } catch { - state = applyAction(state, idx, { type: "fold" }); - } - announceNewLog(); - emitter.emit(); - scheduleBotTurn(); - }, 450); - }; - - return { - info: { roomLabel: "Local vs bots", myId: opts.myId, mySeatIndex: 0 }, - getState: () => redactStateFor(state, opts.myId), - legalActionsForMe: () => computeLegalActions(state, 0), - canStartHand: () => state.street === "showdown" && state.seats.filter((s) => s && s.stack > 0).length >= 2, - startHand: () => { - state = engineStartHand(state); - 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: () => { - if (botTimer) clearTimeout(botTimer); - }, - }; -} - -interface HostOptions extends LocalOptions { - port: number; - onLog?: (line: string) => void; -} - -function createHostSession(opts: HostOptions, onReady: (port: number) => void): GameSession { - const emitter = makeEmitter(); - 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((port) => onReady(port)) - .catch(() => {}); - - return { - info: { roomLabel: "Hosting", myId: opts.myId, mySeatIndex: 0 }, - getState: () => redactStateFor(room.state, opts.myId), - legalActionsForMe: () => computeLegalActions(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: () => room.close(), - }; -} - -interface JoinOptions { - url: string; - myId: string; - displayName: string; -} - -function createJoinSession(opts: JoinOptions, onEvent: (kind: "welcome" | "rejected" | "hostDisconnected", message?: string) => void): GameSession { - const emitter = makeEmitter(); - let lastState: PublicTableState | undefined; - let mySeatIndex: number | null = null; - let chatLog: ChatMessage[] = []; - let welcomed = false; - - const client = new RoomClient({ - url: opts.url, - playerId: opts.myId, - displayName: opts.displayName, - onWelcome: (info) => { - welcomed = true; - mySeatIndex = info.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), - // ponytail: welcomed==false means we never connected; welcomed==true means host went away mid-game. - onClose: () => onEvent(welcomed ? "hostDisconnected" : "rejected", welcomed ? undefined : "Connection closed"), - }); - - const emptyState: PublicTableState = { - seats: [], - dealerIndex: -1, - smallBlind: 0, - bigBlind: 0, - street: "showdown", - communityCards: [], - toActIndex: null, - currentBet: 0, - minRaise: 0, - handNumber: 0, - pots: [], - winners: [], - awards: [], - log: [], - }; - - return { - info: { roomLabel: `Connected to ${opts.url}`, myId: opts.myId, mySeatIndex }, - getState: () => lastState ?? emptyState, - legalActionsForMe: () => { - if (mySeatIndex === null || !lastState) return null; - // Clients don't run the engine locally; derive a minimal legality check - // from the public state so the UI can grey out actions consistently. - const seat = lastState.seats[mySeatIndex]; - if (!seat || lastState.toActIndex !== mySeatIndex || seat.status !== "active") { - return { canFold: false, canCheck: false, canCall: false, callAmount: 0, canBetOrRaise: false, minRaiseTo: 0, maxRaiseTo: 0 }; - } - const toCall = Math.max(0, lastState.currentBet - seat.committed); - const callAmount = Math.min(toCall, seat.stack); - const maxRaiseTo = seat.committed + seat.stack; - const minRaiseTo = Math.min(lastState.currentBet + lastState.minRaise, maxRaiseTo); - return { - canFold: true, - canCheck: toCall === 0, - canCall: toCall > 0 && seat.stack > 0, - callAmount, - canBetOrRaise: seat.stack > callAmount, - minRaiseTo, - maxRaiseTo, - }; - }, - canStartHand: () => false, - startHand: () => {}, - act: (action) => client.sendAction(action), - getChatLog: () => chatLog, - sendChat: (text) => client.sendChat(text), - subscribe: emitter.subscribe, - close: () => client.close(), - }; -} - // ---------- rendering ---------- function padTo(text: string, width: number): string { @@ -556,7 +289,7 @@ export default function pokerExtension(pi: ExtensionAPI) { const openTable = async (ctx: ExtensionCommandContext) => { if (!session) { - ctx.ui.notify("No poker session active. Try /poker bots, /poker host, or /poker join
.", "warning"); + 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; @@ -660,49 +393,91 @@ export default function pokerExtension(pi: ExtensionAPI) { }; pi.registerCommand("poker", { - description: "Play Texas Hold'em: /poker bots | host [port] | join | (reopen table)", + 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 args = rawArgs.trim().split(/\s+/).filter(Boolean); - const sub = args[0]; + const command = parsePokerCommand(rawArgs); const hostname = os.hostname(); const myId = `${hostname}-${crypto.randomBytes(3).toString("hex")}`; - if (!sub) { + if (command.kind === "open") { await openTable(ctx); return; } - if (sub === "bots") { + if (command.kind === "local") { closeSession(ctx); - const seatCount = Math.min(6, Math.max(2, Number(args[1]) || 6)); session = createBotsSession({ myId, displayName: hostname, - seatCount, + 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 (sub === "host") { - // ponytail: no native y/n prompt in this TUI; require an explicit --i-know flag instead of building a modal. - if (!args.includes("--i-know")) { + if (command.kind === "create") { + const rawEndpoint = process.env.PI_POKER_WORKER_URL; + if (!rawEndpoint) { ctx.ui.notify( - "🎴 Friends-only poker. YOU (the host) can technically see everyone's hole cards — this is not cheat-proof. Do not use for money. Re-run with `/poker host [port] --i-know` to confirm.", + "Personal Worker is not configured. Deploy packages/pi-texas-holdem-worker 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); - const port = Number(args[1] && !args[1].startsWith("--") ? args[1] : "") || DEFAULT_PORT; session = createHostSession( { myId, @@ -711,31 +486,40 @@ export default function pokerExtension(pi: ExtensionAPI) { smallBlind: DEFAULT_SMALL_BLIND, bigBlind: DEFAULT_BIG_BLIND, startingStack: DEFAULT_STARTING_STACK, - port, + port: command.port, onLog: (line) => ctx.ui.notify(line, "info"), }, - (boundPort) => ctx.ui.notify(`Hosting on port ${boundPort}. Share :${boundPort} for others to join.`, "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 (sub === "join") { - const address = args[1]; - if (!address) { - ctx.ui.notify("Usage: /poker join ", "warning"); + 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); - const url = address.startsWith("ws://") || address.startsWith("wss://") ? address : `ws://${address}`; - ctx.ui.notify(`Joining ${address} — the host's machine runs the game and can see your hole cards. Only join hosts you trust.`, "info"); - session = createJoinSession({ url, myId, displayName: hostname }, (kind, message) => { - if (kind === "rejected") { + 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("Host went offline — this hand ended and any bets are returned. Room closed.", "warning"); + ctx.ui.notify("Room went offline — this hand ended and any bets are returned. Room closed.", "warning"); closeSession(ctx); } }); @@ -744,13 +528,24 @@ export default function pokerExtension(pi: ExtensionAPI) { return; } - if (sub === "leave" || sub === "quit") { + 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; } - ctx.ui.notify("Usage: /poker bots [seats] | host [port] | join | leave", "warning"); + 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"); }, }); diff --git a/packages/pi-texas-holdem/src/engine/cards.ts b/packages/pi-texas-holdem/src/engine/cards.ts index 687f0be..5437faa 100644 --- a/packages/pi-texas-holdem/src/engine/cards.ts +++ b/packages/pi-texas-holdem/src/engine/cards.ts @@ -1,3 +1,4 @@ +import { secureRandom } from "./random.ts"; import type { Card, Rank, Suit } from "./types.ts"; export const SUITS: Suit[] = ["s", "h", "d", "c"]; @@ -51,10 +52,14 @@ export function createDeck(): Card[] { } /** Fisher-Yates shuffle. Returns a new array; does not mutate the input. */ -export function shuffleDeck(deck: Card[], rng: () => number = Math.random): Card[] { +export function shuffleDeck(deck: Card[], rng: () => number = secureRandom): Card[] { const result = deck.slice(); for (let i = result.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); + 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/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/table.ts b/packages/pi-texas-holdem/src/engine/table.ts index 707dda4..bb65f47 100644 --- a/packages/pi-texas-holdem/src/engine/table.ts +++ b/packages/pi-texas-holdem/src/engine/table.ts @@ -1,4 +1,5 @@ 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"; @@ -55,6 +56,32 @@ export function leaveSeat(state: TableState, seatIndex: number): TableState { 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; } @@ -88,7 +115,7 @@ function contenders(state: TableState): { index: number; seat: Seat }[] { .filter((entry): entry is { index: number; seat: Seat } => !!entry.seat && entry.seat.status !== "folded"); } -export function startHand(state: TableState, rng: () => number = Math.random): TableState { +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 = @@ -192,6 +219,9 @@ function computeSidePots(seats: Seat[]): Pot[] { 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; diff --git a/packages/pi-texas-holdem/src/net/client.ts b/packages/pi-texas-holdem/src/net/client.ts index 6828074..70ea800 100644 --- a/packages/pi-texas-holdem/src/net/client.ts +++ b/packages/pi-texas-holdem/src/net/client.ts @@ -12,6 +12,8 @@ export interface RoomClientOptions { 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; } @@ -20,7 +22,10 @@ export class RoomClient { seatIndex: number | null = null; constructor(private readonly opts: RoomClientOptions) { - this.ws = new WebSocket(opts.url); + 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({ @@ -28,18 +33,23 @@ export class RoomClient { 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", () => opts.onRejected("error", "Connection failed")); + 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 })); } @@ -76,10 +86,18 @@ export class RoomClient { this.opts.onRejected("protocolMismatch", `Host is running protocol v${message.hostVersion}`); break; case "error": - this.opts.onRejected("error", message.message); + 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 index b05b8a3..93d3d32 100644 --- a/packages/pi-texas-holdem/src/net/host.ts +++ b/packages/pi-texas-holdem/src/net/host.ts @@ -1,6 +1,5 @@ -import crypto from "node:crypto"; import { WebSocket, WebSocketServer } from "ws"; -import { applyAction, createTable, leaveSeat, seatPlayer, startHand } from "../engine/table.ts"; +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"; @@ -19,6 +18,8 @@ export interface HostRoomOptions { onStateChange: (state: TableState) => void; onChatMessage?: (message: ChatMessage) => void; onLog?: (line: string) => void; + createMessageId?: () => string; + now?: () => number; } interface Connection { @@ -125,7 +126,13 @@ export class HostRoom { } private pushChat(seatIndex: number | null, displayName: string, text: string): void { - const message: ChatMessage = { id: crypto.randomUUID(), seatIndex, displayName, text, ts: Date.now() }; + 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); @@ -221,15 +228,10 @@ export class HostRoom { this.connections.delete(conn); const seat = this.state.seats[conn.seatIndex]; if (seat) { - // A player who disconnects mid-hand auto-folds if it was their turn; - // re-joining under the same connection isn't handled yet (v1 limitation). - if (this.state.toActIndex === conn.seatIndex && seat.status === "active") { - try { - this.applyLocalAction(conn.seatIndex, { type: "fold" }); - } catch { - // hand may have already ended between the disconnect and this fold - } - } + // 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`); diff --git a/packages/pi-texas-holdem/src/net/protocol.ts b/packages/pi-texas-holdem/src/net/protocol.ts index cc0b670..a772921 100644 --- a/packages/pi-texas-holdem/src/net/protocol.ts +++ b/packages/pi-texas-holdem/src/net/protocol.ts @@ -13,7 +13,7 @@ export interface ChatMessage { } export type ClientMessage = - | { type: "hello"; protocolVersion: number; playerId: string; displayName: string } + | { type: "hello"; protocolVersion: number; playerId: string; displayName: string; creatorCapability?: string } | { type: "action"; action: Action } | { type: "startHand" } | { type: "chat"; text: string } diff --git a/packages/pi-texas-holdem/src/net/sanitize.ts b/packages/pi-texas-holdem/src/net/sanitize.ts index c98aa84..7d86d77 100644 --- a/packages/pi-texas-holdem/src/net/sanitize.ts +++ b/packages/pi-texas-holdem/src/net/sanitize.ts @@ -9,9 +9,10 @@ // 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 range except the newlines we collapse separately. +// 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]/g; +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, ""); 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 index f40a33d..50c898b 100644 --- a/packages/pi-texas-holdem/test/cards.test.ts +++ b/packages/pi-texas-holdem/test/cards.test.ts @@ -19,6 +19,11 @@ test("shuffle is a pure permutation and does not mutate the input", () => { ); }); +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♥"); diff --git a/packages/pi-texas-holdem/test/chat.test.ts b/packages/pi-texas-holdem/test/chat.test.ts index c3af80e..f181d22 100644 --- a/packages/pi-texas-holdem/test/chat.test.ts +++ b/packages/pi-texas-holdem/test/chat.test.ts @@ -123,8 +123,10 @@ test("a newly joined client receives recent chat history", async () => { } }); -test("rapid chat messages from the same client are rate-limited", async () => { +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, @@ -139,7 +141,10 @@ test("rapid chat messages from the same client are rate-limited", async () => { let guest: RoomClient | undefined; try { - guest = await connect(port, "guest-id", "guest-machine"); + 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)); @@ -147,12 +152,38 @@ test("rapid chat messages from the same client are rate-limited", async () => { 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({ 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/net.test.ts b/packages/pi-texas-holdem/test/net.test.ts index 75a553d..bdbb80c 100644 --- a/packages/pi-texas-holdem/test/net.test.ts +++ b/packages/pi-texas-holdem/test/net.test.ts @@ -1,12 +1,61 @@ 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, @@ -44,6 +93,130 @@ test("a remote client can join a hosted room and see redacted state", async () = } }); +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({ diff --git a/packages/pi-texas-holdem/test/sanitize.test.ts b/packages/pi-texas-holdem/test/sanitize.test.ts index fab6b90..fc5a319 100644 --- a/packages/pi-texas-holdem/test/sanitize.test.ts +++ b/packages/pi-texas-holdem/test/sanitize.test.ts @@ -11,8 +11,8 @@ test("strips OSC sequences (e.g. terminal title injection)", () => { assert.equal(sanitizeChatText("\x1B]0;evil title\x07hi"), "hi"); }); -test("strips other control characters but keeps normal punctuation", () => { - assert.equal(sanitizeChatText("go\x07od nice hand!"), "good nice hand!"); +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", () => { 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 index d71f4bc..4f64943 100644 --- a/packages/pi-texas-holdem/test/table.test.ts +++ b/packages/pi-texas-holdem/test/table.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { applyAction, computeSidePots, createTable, legalActions, seatPlayer, startHand } from "../src/engine/table.ts"; +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 { @@ -88,6 +88,35 @@ test("calling all-in for less than the current bet does not reopen betting", () 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 From f00b0260901c5bb7a5328056553ac75f1586073f Mon Sep 17 00:00:00 2001 From: Ying Xiang Date: Wed, 19 Aug 2026 20:26:20 +0800 Subject: [PATCH 5/8] docs(pi-texas-holdem): simplify quick start --- packages/pi-texas-holdem/README.md | 113 +++++++---------------------- 1 file changed, 27 insertions(+), 86 deletions(-) diff --git a/packages/pi-texas-holdem/README.md b/packages/pi-texas-holdem/README.md index 14ef831..0bf0ef4 100644 --- a/packages/pi-texas-holdem/README.md +++ b/packages/pi-texas-holdem/README.md @@ -1,129 +1,70 @@ # pi-texas-holdem -A [pi](https://github.com/earendil-works/pi) extension that plays Texas Hold'em on an oval table drawn with box-drawing characters, inside the same terminal you're coding in. +Play Texas Hold'em inside [pi](https://github.com/earendil-works/pi). -## Features - -- `/poker` opens the table as a focused overlay; `Esc` drops back to your normal session -- A persistent status widget keeps showing whose turn it is and the pot while you're back to working -- No accounts: your seat name is your machine's hostname -- Today's cumulative token usage is shown in the table header and the widget -- Three explicit entry paths: play locally, create a room on your personal Cloudflare Worker, or join a room URL -- Legacy LAN hosting remains available for trusted friends and existing workflows -- Full no-limit Hold'em rules: blinds, betting rounds, side pots, showdown -- Table chat: talk to the other seats and see fold/call/raise/showdown events in the same feed - -## Install +## Quick start From this checkout: ```sh npm install -pi install . -``` - -For development without installing the package: - -```sh -npm install -pi -e ./extensions/poker.ts +pi -e ./packages/pi-texas-holdem/extensions/poker.ts ``` -## Choose how to play - -### 1. Play locally +Play locally against bots: ```text -/poker local [seats] start immediately against bots (2-6 seats, default 6) +/poker local ``` -Everything stays in this pi process. This is the fastest path and needs no account or network service. +## Online rooms -### 2. Create a room on your personal Cloudflare Worker - -The repository includes `@geminixiang/pi-texas-holdem-worker`, a Worker + Durable Object backend that you deploy to **your own Cloudflare account**. Follow [`../pi-texas-holdem-worker/README.md`](../pi-texas-holdem-worker/README.md) to deploy it; pi never logs into Cloudflare or deploys infrastructure for you. - -Configure pi before launching it: +To create internet rooms, first deploy [`pi-texas-holdem-worker`](../pi-texas-holdem-worker/README.md) to your personal Cloudflare account. Then start pi with: ```sh export PI_POKER_WORKER_URL="https://YOUR-WORKER.workers.dev" -# Only when your deployment protects POST /rooms: export PI_POKER_CREATE_SECRET="your-creation-secret" pi ``` -Then create and enter a room: +Create a room and share the displayed invite URL: ```text /poker create ``` -The extension posts private defaults to `POST /rooms` with telemetry disabled, joins the creator URL returned by the Worker, and displays the shareable invite URL after the WebSocket connection succeeds. The creation secret is sent only as an `Authorization: Bearer …` header: it is never placed in a URL, notification, or room invite. Prefer setting it through your shell's secret manager rather than saving it in a checked-in file. - -If `PI_POKER_WORKER_URL` is missing or invalid, `/poker create` makes no request and shows setup guidance. The endpoint must use HTTPS, except loopback HTTP for local development. - -The creator session can request the first and later hands with `Enter`. A normal invite URL does not grant creator controls; authorization is carried only by the unshareable `?creator=…` capability in `creatorUrl`. - -For a trusted LAN, VPN, or manually forwarded port, the advanced fallback remains: - -```text -/poker lan-host [port] --i-know -``` - -The explicit confirmation is required because this machine runs the game and can technically inspect every hand. Do not use it for money. - -### 3. Join a room URL +Join a room: ```text /poker join ``` -Paste a `wss://`/`https://` share URL, or connect directly with `host:port` on a trusted LAN. HTTPS share links are converted to secure WebSocket URLs. Only join room operators you trust. - -### Session and roadmap commands +## 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 and close the active game -/poker rooms public directory status (coming soon; makes no request) -/poker privacy telemetry status and policy -/poker help show the command summary +/poker leave leave the current game +/poker help show command help ``` -Public rooms and a searchable directory are intentionally reserved for a later backend. `/poker rooms` does not present fake data. Telemetry is currently off; any future analytics will be clearly opt-in, never enabled merely by creating or joining a room, and will include an obvious way to disable it. - -### Compatibility - -Existing commands remain supported: - -- `/poker bots [seats]` → `/poker local [seats]` -- `/poker host [port] --i-know` → `/poker lan-host [port] --i-know` -- `/poker quit` → `/poker leave` -- `/poker join ` continues to work +## Controls -The old aliases show a short migration hint but retain their behavior. - -While the table is open: - -- `F` fold, `C` check/call, `R` raise, `A` all-in -- While raising: `+`/`-` adjust the amount, `Enter` confirms, `Esc` cancels -- `/` opens a chat line, `Enter` sends it, `Esc` cancels the draft -- `Enter` starts the next hand once at least two seats have chips -- `Esc` closes the table overlay without leaving the game; the widget keeps tracking it +```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 install -npm test -npm run check +npm test --workspace @geminixiang/pi-texas-holdem +npm run check --workspace @geminixiang/pi-texas-holdem ``` - -## Notes / current limitations - -- Legacy LAN hosting binds a plain WebSocket server on your machine. It only works when reachable (LAN, VPN, or a manually forwarded port), and the host is trusted rather than cheat-proof. -- Personal internet rooms use the separately deployed Worker package and remain private, unlisted bearer URLs. Public discovery is not implemented. -- Reconnecting to a room after a dropped connection isn't handled — a disconnect auto-folds the seat's current hand and frees the seat. -- Bots use a simple fold/call/raise heuristic, not real hand-strength evaluation. -- Chat is sanitized (ANSI/control characters stripped, length-capped, rate-limited) before it's ever broadcast, since it's rendered straight into other players' terminals. -- "Today's token usage" sums the current pi session's entries for the local calendar day; it doesn't merge usage from other session files. From ed0702bcf3af089668dec1633dbe4cc405ec148a Mon Sep 17 00:00:00 2001 From: Ying Xiang Date: Wed, 19 Aug 2026 20:32:30 +0800 Subject: [PATCH 6/8] refactor(pi-texas-holdem): bundle cloud backend --- README.md | 3 +-- package-lock.json | 22 +++---------------- packages/pi-texas-holdem-worker/package.json | 21 ------------------ packages/pi-texas-holdem/README.md | 2 +- .../cloudflare}/README.md | 18 +++++++-------- .../cloudflare}/src/config.ts | 0 .../cloudflare}/src/index.ts | 0 .../cloudflare}/src/poker-room.ts | 10 ++++----- .../cloudflare}/src/router.ts | 0 .../cloudflare}/src/types.ts | 0 .../cloudflare}/test/config.test.ts | 0 .../cloudflare}/test/integration.test.ts | 2 +- .../cloudflare}/test/router.test.ts | 0 .../cloudflare}/tsconfig.json | 2 +- .../cloudflare}/wrangler.example.jsonc | 0 packages/pi-texas-holdem/extensions/poker.ts | 2 +- packages/pi-texas-holdem/package.json | 14 +++++++++--- 17 files changed, 33 insertions(+), 63 deletions(-) delete mode 100644 packages/pi-texas-holdem-worker/package.json rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/README.md (89%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/src/config.ts (100%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/src/index.ts (100%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/src/poker-room.ts (97%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/src/router.ts (100%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/src/types.ts (100%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/test/config.test.ts (100%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/test/integration.test.ts (96%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/test/router.test.ts (100%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/tsconfig.json (69%) rename packages/{pi-texas-holdem-worker => pi-texas-holdem/cloudflare}/wrangler.example.jsonc (100%) diff --git a/README.md b/README.md index 1326aba..45dd891 100644 --- a/README.md +++ b/README.md @@ -20,8 +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. | -| [`@geminixiang/pi-texas-holdem-worker`](packages/pi-texas-holdem-worker) | 0.1.0 | Self-hosted Cloudflare Worker and Durable Object backend for private poker rooms. | +| [`@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 d7361ce..ab89594 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1177,10 +1177,6 @@ "resolved": "packages/pi-texas-holdem", "link": true }, - "node_modules/@geminixiang/pi-texas-holdem-worker": { - "resolved": "packages/pi-texas-holdem-worker", - "link": true - }, "node_modules/@geminixiang/pi-verification": { "resolved": "packages/pi-verification", "link": true @@ -3579,13 +3575,15 @@ "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" + "typescript": "7.0.2", + "wrangler": "^4.124.0" }, "engines": { "node": ">=22.19.0" @@ -3596,20 +3594,6 @@ "typebox": "*" } }, - "packages/pi-texas-holdem-worker": { - "name": "@geminixiang/pi-texas-holdem-worker", - "version": "0.1.0", - "dependencies": { - "@geminixiang/pi-texas-holdem": "0.1.0" - }, - "devDependencies": { - "@cloudflare/workers-types": "^5.20260819.1", - "@types/node": "^24.10.1", - "tsx": "^4.20.6", - "typescript": "7.0.2", - "wrangler": "^4.124.0" - } - }, "packages/pi-verification": { "name": "@geminixiang/pi-verification", "version": "0.1.0", diff --git a/packages/pi-texas-holdem-worker/package.json b/packages/pi-texas-holdem-worker/package.json deleted file mode 100644 index 849c185..0000000 --- a/packages/pi-texas-holdem-worker/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@geminixiang/pi-texas-holdem-worker", - "version": "0.1.0", - "private": true, - "description": "Self-hosted Cloudflare Worker and Durable Object rooms for pi-texas-holdem", - "type": "module", - "scripts": { - "check": "tsc --noEmit", - "test": "node --import tsx --test test/*.test.ts" - }, - "dependencies": { - "@geminixiang/pi-texas-holdem": "0.1.0" - }, - "devDependencies": { - "@cloudflare/workers-types": "^5.20260819.1", - "@types/node": "^24.10.1", - "tsx": "^4.20.6", - "typescript": "7.0.2", - "wrangler": "^4.124.0" - } -} diff --git a/packages/pi-texas-holdem/README.md b/packages/pi-texas-holdem/README.md index 0bf0ef4..8b19ae7 100644 --- a/packages/pi-texas-holdem/README.md +++ b/packages/pi-texas-holdem/README.md @@ -19,7 +19,7 @@ Play locally against bots: ## Online rooms -To create internet rooms, first deploy [`pi-texas-holdem-worker`](../pi-texas-holdem-worker/README.md) to your personal Cloudflare account. Then start pi with: +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" diff --git a/packages/pi-texas-holdem-worker/README.md b/packages/pi-texas-holdem/cloudflare/README.md similarity index 89% rename from packages/pi-texas-holdem-worker/README.md rename to packages/pi-texas-holdem/cloudflare/README.md index e0583b5..dfb7099 100644 --- a/packages/pi-texas-holdem-worker/README.md +++ b/packages/pi-texas-holdem/cloudflare/README.md @@ -1,6 +1,6 @@ -# pi-texas-holdem-worker +# Cloudflare backend -A self-hosted Cloudflare Worker + Durable Object backend for private `pi-texas-holdem` rooms. Each room creator deploys this package to **their own Cloudflare account**; there is no shared service, account, credential, or domain in this repository. +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 @@ -19,20 +19,20 @@ Requirements: Node.js 22+, a personal Cloudflare account, and Durable Objects av ```sh npm install -cp packages/pi-texas-holdem-worker/wrangler.example.jsonc \ - packages/pi-texas-holdem-worker/wrangler.jsonc +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-worker/wrangler.jsonc -npx wrangler dev --config packages/pi-texas-holdem-worker/wrangler.jsonc + --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-worker/wrangler.jsonc +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. @@ -92,6 +92,6 @@ No authentication or reconnect token exists in protocol v2. Duplicate live `play ## Verification ```sh -npm run check --workspace @geminixiang/pi-texas-holdem-worker -npm test --workspace @geminixiang/pi-texas-holdem-worker +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-worker/src/config.ts b/packages/pi-texas-holdem/cloudflare/src/config.ts similarity index 100% rename from packages/pi-texas-holdem-worker/src/config.ts rename to packages/pi-texas-holdem/cloudflare/src/config.ts diff --git a/packages/pi-texas-holdem-worker/src/index.ts b/packages/pi-texas-holdem/cloudflare/src/index.ts similarity index 100% rename from packages/pi-texas-holdem-worker/src/index.ts rename to packages/pi-texas-holdem/cloudflare/src/index.ts diff --git a/packages/pi-texas-holdem-worker/src/poker-room.ts b/packages/pi-texas-holdem/cloudflare/src/poker-room.ts similarity index 97% rename from packages/pi-texas-holdem-worker/src/poker-room.ts rename to packages/pi-texas-holdem/cloudflare/src/poker-room.ts index bca840d..125698e 100644 --- a/packages/pi-texas-holdem-worker/src/poker-room.ts +++ b/packages/pi-texas-holdem/cloudflare/src/poker-room.ts @@ -1,13 +1,13 @@ -import { applyAction, createTable, forceFold, leaveSeat, seatPlayer, startHand } from "@geminixiang/pi-texas-holdem/src/engine/table.ts"; -import type { Action, TableState } from "@geminixiang/pi-texas-holdem/src/engine/types.ts"; -import { redactStateFor } from "@geminixiang/pi-texas-holdem/src/engine/view.ts"; +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 "@geminixiang/pi-texas-holdem/src/net/protocol.ts"; -import { sanitizeChatText } from "@geminixiang/pi-texas-holdem/src/net/sanitize.ts"; +} 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"; diff --git a/packages/pi-texas-holdem-worker/src/router.ts b/packages/pi-texas-holdem/cloudflare/src/router.ts similarity index 100% rename from packages/pi-texas-holdem-worker/src/router.ts rename to packages/pi-texas-holdem/cloudflare/src/router.ts diff --git a/packages/pi-texas-holdem-worker/src/types.ts b/packages/pi-texas-holdem/cloudflare/src/types.ts similarity index 100% rename from packages/pi-texas-holdem-worker/src/types.ts rename to packages/pi-texas-holdem/cloudflare/src/types.ts diff --git a/packages/pi-texas-holdem-worker/test/config.test.ts b/packages/pi-texas-holdem/cloudflare/test/config.test.ts similarity index 100% rename from packages/pi-texas-holdem-worker/test/config.test.ts rename to packages/pi-texas-holdem/cloudflare/test/config.test.ts diff --git a/packages/pi-texas-holdem-worker/test/integration.test.ts b/packages/pi-texas-holdem/cloudflare/test/integration.test.ts similarity index 96% rename from packages/pi-texas-holdem-worker/test/integration.test.ts rename to packages/pi-texas-holdem/cloudflare/test/integration.test.ts index b58685c..4dd11d8 100644 --- a/packages/pi-texas-holdem-worker/test/integration.test.ts +++ b/packages/pi-texas-holdem/cloudflare/test/integration.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { forceFold, seatPlayer, startHand, createTable } from "@geminixiang/pi-texas-holdem/src/engine/table.ts"; +import { forceFold, seatPlayer, startHand, createTable } from "../../src/engine/table.ts"; import { assertCreatorCanStartHand, canAcceptConnection, diff --git a/packages/pi-texas-holdem-worker/test/router.test.ts b/packages/pi-texas-holdem/cloudflare/test/router.test.ts similarity index 100% rename from packages/pi-texas-holdem-worker/test/router.test.ts rename to packages/pi-texas-holdem/cloudflare/test/router.test.ts diff --git a/packages/pi-texas-holdem-worker/tsconfig.json b/packages/pi-texas-holdem/cloudflare/tsconfig.json similarity index 69% rename from packages/pi-texas-holdem-worker/tsconfig.json rename to packages/pi-texas-holdem/cloudflare/tsconfig.json index 949ac12..d4b7048 100644 --- a/packages/pi-texas-holdem-worker/tsconfig.json +++ b/packages/pi-texas-holdem/cloudflare/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "types": ["@cloudflare/workers-types", "node"] }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "../src/engine/**/*.ts", "../src/net/protocol.ts", "../src/net/sanitize.ts"] } diff --git a/packages/pi-texas-holdem-worker/wrangler.example.jsonc b/packages/pi-texas-holdem/cloudflare/wrangler.example.jsonc similarity index 100% rename from packages/pi-texas-holdem-worker/wrangler.example.jsonc rename to packages/pi-texas-holdem/cloudflare/wrangler.example.jsonc diff --git a/packages/pi-texas-holdem/extensions/poker.ts b/packages/pi-texas-holdem/extensions/poker.ts index 376c168..fa7c2d7 100644 --- a/packages/pi-texas-holdem/extensions/poker.ts +++ b/packages/pi-texas-holdem/extensions/poker.ts @@ -428,7 +428,7 @@ export default function pokerExtension(pi: ExtensionAPI) { const rawEndpoint = process.env.PI_POKER_WORKER_URL; if (!rawEndpoint) { ctx.ui.notify( - "Personal Worker is not configured. Deploy packages/pi-texas-holdem-worker 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.", + "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; diff --git a/packages/pi-texas-holdem/package.json b/packages/pi-texas-holdem/package.json index 7c8fbfe..192cdf4 100644 --- a/packages/pi-texas-holdem/package.json +++ b/packages/pi-texas-holdem/package.json @@ -21,6 +21,10 @@ "files": [ "extensions", "src", + "cloudflare/README.md", + "cloudflare/src", + "cloudflare/tsconfig.json", + "cloudflare/wrangler.example.jsonc", "README.md" ], "pi": { @@ -29,20 +33,24 @@ ] }, "scripts": { - "check": "tsc --noEmit", - "test": "node --import tsx --test test/*.test.ts" + "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" + "typescript": "7.0.2", + "wrangler": "^4.124.0" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*", From b518a4b891b4e073effd142949e594827f926b73 Mon Sep 17 00:00:00 2001 From: Ying Xiang Date: Wed, 19 Aug 2026 20:55:32 +0800 Subject: [PATCH 7/8] fix(pi-texas-holdem): connect table rail --- packages/pi-texas-holdem/src/engine/ring.ts | 102 ++++++++++++++------ packages/pi-texas-holdem/test/ring.test.ts | 20 ++++ 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/packages/pi-texas-holdem/src/engine/ring.ts b/packages/pi-texas-holdem/src/engine/ring.ts index 0c4b522..b945597 100644 --- a/packages/pi-texas-holdem/src/engine/ring.ts +++ b/packages/pi-texas-holdem/src/engine/ring.ts @@ -27,52 +27,90 @@ export interface TableLayout { padLeft: number; } -const BORDER_GLYPHS = ["│", "╮", "─", "╭", "│", "╰", "─", "╯"] as const; - -function borderGlyph(dx: number, dy: number): string { - const angle = (Math.atan2(-dy, dx) * 180) / Math.PI; - const normalized = ((angle % 360) + 360) % 360; - const idx = Math.floor(((normalized + 22.5) % 360) / 45); - return BORDER_GLYPHS[idx] as string; -} +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, b } = railRadii(width, height); - const grid: string[][] = Array.from({ length: height }, () => Array.from({ length: width }, () => " ")); - - const setCell = (x: number, y: number) => { - const xi = Math.round(x); - const yi = Math.round(y); - if (xi < 0 || xi >= width || yi < 0 || yi >= height) return; - grid[yi]![xi] = borderGlyph(xi - cx, yi - cy); + 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] = "─"; }; - // Round the radius offset once, then apply it symmetrically to both sides of the - // integer center. Rounding cx-d and cx+d independently instead can pick different - // directions for a .5 offset and skew the oval by a column. - for (let xi = 0; xi < width; xi++) { - const ndx = (xi - cx) / a; - if (Math.abs(ndx) > 1) continue; - const dy = Math.round(b * Math.sqrt(Math.max(0, 1 - ndx * ndx))); - setCell(xi, cy - dy); - setCell(xi, cy + dy); + 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] = "│"; + } } - for (let yi = 0; yi < height; yi++) { - const ndy = (yi - cy) / b; - if (Math.abs(ndy) > 1) continue; - const dx = Math.round(a * Math.sqrt(Math.max(0, 1 - ndy * ndy))); - setCell(cx - dx, yi); - setCell(cx + dx, yi); + + // 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: grid.map((row) => row.join("")) }; + return { width, height, rows: rows.map((row) => row.join("")) }; } /** diff --git a/packages/pi-texas-holdem/test/ring.test.ts b/packages/pi-texas-holdem/test/ring.test.ts index 47ee5a7..1217ed4 100644 --- a/packages/pi-texas-holdem/test/ring.test.ts +++ b/packages/pi-texas-holdem/test/ring.test.ts @@ -27,6 +27,26 @@ test("the ring is left-right symmetric", () => { } }); +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); From ae1191bd780121515baca17e4d8addfebce16c61 Mon Sep 17 00:00:00 2001 From: Ying Xiang Date: Thu, 20 Aug 2026 11:10:16 +0800 Subject: [PATCH 8/8] feat(pi-texas-holdem): show current hand --- packages/pi-texas-holdem/extensions/poker.ts | 11 ++++++ .../pi-texas-holdem/src/engine/evaluator.ts | 39 +++++++++++++++++++ .../pi-texas-holdem/test/evaluator.test.ts | 10 ++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/pi-texas-holdem/extensions/poker.ts b/packages/pi-texas-holdem/extensions/poker.ts index fa7c2d7..486780d 100644 --- a/packages/pi-texas-holdem/extensions/poker.ts +++ b/packages/pi-texas-holdem/extensions/poker.ts @@ -2,6 +2,7 @@ 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"; @@ -119,8 +120,18 @@ function buildTableGrid(state: PublicTableState, mySeatIndex: number | null): st 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]; diff --git a/packages/pi-texas-holdem/src/engine/evaluator.ts b/packages/pi-texas-holdem/src/engine/evaluator.ts index d12f5f4..882d126 100644 --- a/packages/pi-texas-holdem/src/engine/evaluator.ts +++ b/packages/pi-texas-holdem/src/engine/evaluator.ts @@ -114,6 +114,45 @@ export function categoryLabel(category: HandCategory): string { } } +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/test/evaluator.test.ts b/packages/pi-texas-holdem/test/evaluator.test.ts index 629898d..abe6006 100644 --- a/packages/pi-texas-holdem/test/evaluator.test.ts +++ b/packages/pi-texas-holdem/test/evaluator.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { Card } from "../src/engine/types.ts"; -import { compareEvaluations, evaluateBestHand } from "../src/engine/evaluator.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 @@ -62,6 +62,14 @@ test("two pair compares the higher pair first, then the lower pair, then kicker" 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"));