diff --git a/README.md b/README.md index 416b0ac..8f99d7e 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ bias toward any language or any game, sits the **Engine** — judge, executioner in the wasteland every contender obeys without question. **The state of the empire, as chronicled this cycle:** the wire protocol, the Docker-forged cage -every bot fights inside, the one true game engine, three blood-sworn arenas -(Rock-Paper-Scissors, Connect Four, and the 4-seat trick-taking gauntlet of Hearts), the registry +every bot fights inside, the one true game engine, four blood-sworn arenas +(Rock-Paper-Scissors, Connect Four, the 4-seat trick-taking gauntlet of Hearts, and the no-limit +felt of Texas Hold'em), the registry that remembers every name ever entered, a command to summon a single match between registered champions (`yarn thunderdome match run`), a full tournament ringmaster — round robin's gauntlet, single elimination's guillotine, and Swiss league's cumulative-score tables all now law @@ -170,8 +171,9 @@ yarn thunderdome --help yarn thunderdome match run only-rock only-paper --config '{"totalRounds":3}' # one real match yarn thunderdome tournament run only-rock only-paper only-scissors --game-config '{"totalRounds":3}' # a real round-robin tournament, see apps/cli/README.md yarn thunderdome match run leftmost-connect-four random-connect-four # the second game, Connect Four -yarn thunderdome match run random-hearts lowest-card-hearts point-dodger-hearts tominator-t2 # the third game, Hearts (exactly 4 seats) -yarn thunderdome play tx --game-config '{"totalRounds":10}' # step into the ring yourself, see apps/cli/README.md +yarn thunderdome match run random-hearts lowest-card-hearts point-dodger-hearts tominator-t101 # the third game, Hearts (exactly 4 seats) +yarn thunderdome match run random-poker calling-station-poker --config '{"startingStack":500,"totalHands":5,"matchFormat":"fixedHands"}' # the fourth game, Texas Hold'em +yarn thunderdome play tominator-tx --game-config '{"totalRounds":10}' # step into the ring yourself, see apps/cli/README.md yarn thunderdome cleanup # force-remove any leftover bot containers, if you ever need to ``` diff --git a/apps/cli/README.md b/apps/cli/README.md index 8e7430e..6032d29 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -30,17 +30,17 @@ today. ## Commands -| Command | Status | -| -------------------- | ------------------------------------------------------------------------------------------------ | -| `match run` | Real — runs one match between two or more bots. See below. | +| Command | Status | +| -------------------- | ----------------------------------------------------------------------------------------------------------- | +| `match run` | Real — runs one match between two or more bots. See below. | | `play` | Real — a human, typing into this terminal, plays one interactive match against one or more bots. See below. | -| `tournament run` | Real — runs a round-robin or single-elimination tournament among two or more bots. See below. | -| `tournament list` | Real — lists persisted tournament records. See below. | -| `tournament inspect` | Real — prints one persisted tournament's details and final standings. See below. | -| `tournament replay` | Real — replays a persisted tournament's matches purely from its record, no Docker. See below. | -| `cleanup` | Real — force-removes any leftover Thunderdome bot containers. See below. | -| `games` | Stub — prints "not yet implemented" | -| `bots` | Stub — prints "not yet implemented" | +| `tournament run` | Real — runs a round-robin or single-elimination tournament among two or more bots. See below. | +| `tournament list` | Real — lists persisted tournament records. See below. | +| `tournament inspect` | Real — prints one persisted tournament's details and final standings. See below. | +| `tournament replay` | Real — replays a persisted tournament's matches purely from its record, no Docker. See below. | +| `cleanup` | Real — force-removes any leftover Thunderdome bot containers. See below. | +| `games` | Stub — prints "not yet implemented" | +| `bots` | Stub — prints "not yet implemented" | ## `match run` @@ -190,7 +190,7 @@ Every prompt includes the card-notation legend and a format example with a real, card substituted in, and every accepted action is echoed back immediately (`Passed: 9C QC 2D`, `Played: 4D`, ...) — via the optional `humanInterface.describeAction` hook (`packages/engine/src/types.ts`) — so you can confirm your input was understood as intended before -the match moves on, not just that *something* was accepted. An unparseable line reprompts with an +the match moves on, not just that _something_ was accepted. An unparseable line reprompts with an explicit "Sorry, I didn't understand that — try again." rather than silently repeating the same block. diff --git a/apps/cli/src/commands/tournament.ts b/apps/cli/src/commands/tournament.ts index 9db0499..ee2cb24 100644 --- a/apps/cli/src/commands/tournament.ts +++ b/apps/cli/src/commands/tournament.ts @@ -110,7 +110,10 @@ function withAllBotsDefaults(formatConfigRaw: unknown): unknown { * participant count, as Hearts has). A variable-count game, or an already-explicit `tableSize`, * is left alone — the latter case falls through to the format's own required-field error. */ -function withSwissLeagueTableSizeDefault(formatConfigRaw: unknown, gameEntry: GameRegistryEntry): unknown { +function withSwissLeagueTableSizeDefault( + formatConfigRaw: unknown, + gameEntry: GameRegistryEntry, +): unknown { if ( typeof formatConfigRaw !== 'object' || formatConfigRaw === null || @@ -206,7 +209,9 @@ function printSingleEliminationStandingsEntries( }); } -function printSwissLeagueStandingsEntries(entries: readonly SwissLeaguePublicStandingsEntry[]): void { +function printSwissLeagueStandingsEntries( + entries: readonly SwissLeaguePublicStandingsEntry[], +): void { entries.forEach((entry, index) => { console.log( `${String(index + 1)}. ${entry.participantId} — ${entry.averageScore.toFixed(2)} avg pts ` + diff --git a/apps/cli/src/lib/human-collector.ts b/apps/cli/src/lib/human-collector.ts index 0bb7390..91c4c35 100644 --- a/apps/cli/src/lib/human-collector.ts +++ b/apps/cli/src/lib/human-collector.ts @@ -47,11 +47,14 @@ export class TerminalHumanCollector implements ActionCollector { // No deadline enforced here, unlike a bot's own requestAction path — a human, not a // container, sets this match's pace. Loops past an unparseable line (typing "roc" is a typo, // not a forfeit) rather than ever handing that on to validateAction/resolve — but says so - // explicitly on a retry, rather than just silently reprinting the same prompt. - let isRetry = false; + // explicitly on a retry, rather than just silently reprinting the same prompt. Same treatment + // for a well-formed but out-of-range action (e.g. a raise below the min or above the max) — + // see `validateInput` on `humanInterface` — since otherwise it would reach `validateAction`, + // fail there, and forfeit the match instead of just reprompting. + let retryMessage: string | undefined; for (;;) { - if (isRetry) { - this.output.write("Sorry, I didn't understand that — try again.\n\n"); + if (retryMessage !== undefined) { + this.output.write(`${retryMessage}\n\n`); } // `describeObservation` deliberately ends with no trailing newline (a game's own choice — // see packages/engine/src/types.ts) — the `\n> ` here is what actually separates the @@ -69,7 +72,12 @@ export class TerminalHumanCollector implements ActionCollector { } const action = humanInterface.parseInput(raw); if (action === undefined) { - isRetry = true; + retryMessage = "Sorry, I didn't understand that — try again."; + continue; + } + const invalidReason = humanInterface.validateInput?.(action, args.observation); + if (invalidReason !== undefined) { + retryMessage = `${invalidReason} — try again.`; continue; } // Confirms exactly how the input was understood — not just that something was accepted — diff --git a/apps/cli/src/lib/match-execution.ts b/apps/cli/src/lib/match-execution.ts index 172d114..b888a38 100644 --- a/apps/cli/src/lib/match-execution.ts +++ b/apps/cli/src/lib/match-execution.ts @@ -304,6 +304,7 @@ export async function runHumanMatch(args: RunHumanMatchArgs): Promise(); const collector = new TerminalHumanCollector({ @@ -361,6 +362,15 @@ export async function runHumanMatch(args: RunHumanMatchArgs): Promise { + const line = game.humanInterface?.describeRoundEvents?.(events, humanParticipantId); + if (line !== undefined) { + writeStream.write(`${line}\n`); + } + }, }); const matchEndPayload = diff --git a/apps/cli/test/human-collector.test.ts b/apps/cli/test/human-collector.test.ts index ceb0e34..80ae1b3 100644 --- a/apps/cli/test/human-collector.test.ts +++ b/apps/cli/test/human-collector.test.ts @@ -6,9 +6,17 @@ import type { AnyGameDefinition } from '../src/lib/match-execution.js'; /** A minimal fake `GameDefinition` — only `humanInterface` and `id` matter to this collector. */ function fakeGame( - options: { withHumanInterface?: boolean; withDescribeAction?: boolean } = {}, + options: { + withHumanInterface?: boolean; + withDescribeAction?: boolean; + withValidateInput?: boolean; + } = {}, ): AnyGameDefinition { - const { withHumanInterface = true, withDescribeAction = false } = options; + const { + withHumanInterface = true, + withDescribeAction = false, + withValidateInput = false, + } = options; return { id: 'fake-game', version: '1.0.0', @@ -27,7 +35,19 @@ function fakeGame( humanInterface: { describeObservation: (observation: unknown) => `prompt:${JSON.stringify(observation)}> `, - parseInput: (raw: string) => (raw === 'valid' ? { picked: raw } : undefined), + // "valid" always parses; "out-of-range" also parses (syntactically well-formed) but + // is rejected downstream by validateInput below — mirrors a raise amount that's a + // fine positive integer but outside the observation's min/max. + parseInput: (raw: string) => + raw === 'valid' || raw === 'out-of-range' ? { picked: raw } : undefined, + ...(withValidateInput + ? { + validateInput: (action: unknown) => + (action as { picked: string }).picked === 'out-of-range' + ? 'that amount is outside the legal range' + : undefined, + } + : {}), ...(withDescribeAction ? { describeAction: (action: unknown) => `confirmed:${JSON.stringify(action)}` } : {}), @@ -116,6 +136,40 @@ describe('TerminalHumanCollector', () => { expect(text().indexOf(notice)).toBeLessThan(text().lastIndexOf('prompt:')); }); + it('reprompts with the reason on a well-formed but out-of-range action, instead of returning it', async () => { + const { output, text } = outputSink(); + const collector = new TerminalHumanCollector({ + humanParticipantId: 'you', + game: fakeGame({ withValidateInput: true }), + fallback: new FakeFallback(), + input: Readable.from(['out-of-range\n', 'valid\n']), + output, + }); + + const result = await collector.requestAction(BASE_ARGS); + + expect(result).toEqual({ ok: true, action: { picked: 'valid' } }); + expect(text()).toContain('that amount is outside the legal range — try again.'); + // prompted twice: once before the out-of-range line, once more before the valid one + expect(text().split('prompt:').length - 1).toBe(2); + }); + + it('never calls validateInput when the game does not declare it', async () => { + const collector = new TerminalHumanCollector({ + humanParticipantId: 'you', + game: fakeGame({ withValidateInput: false }), + fallback: new FakeFallback(), + input: Readable.from(['out-of-range\n']), + output: outputSink().output, + }); + + // With no validateInput declared, "out-of-range" parses successfully and is returned as-is — + // the fake game's own validateInput logic never runs to reject it. + const result = await collector.requestAction(BASE_ARGS); + + expect(result).toEqual({ ok: true, action: { picked: 'out-of-range' } }); + }); + it("prints the game's describeAction confirmation immediately after a valid parse, with no stray blank line before it", async () => { const { output, text } = outputSink(); const collector = new TerminalHumanCollector({ diff --git a/apps/cli/test/index.test.ts b/apps/cli/test/index.test.ts index 8641cd6..a8b0fcd 100644 --- a/apps/cli/test/index.test.ts +++ b/apps/cli/test/index.test.ts @@ -88,7 +88,7 @@ describe('thunderdome CLI', () => { error.mockRestore(); }); - it("exits 1 when --as collides with any of the given bot ids", async () => { + it('exits 1 when --as collides with any of the given bot ids', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); const code = await run(['play', 'only-rock', 'only-paper', '--as', 'only-paper']); expect(code).toBe(1); diff --git a/apps/cli/test/tournament.integration.test.ts b/apps/cli/test/tournament.integration.test.ts index 3930120..5cfa5b6 100644 --- a/apps/cli/test/tournament.integration.test.ts +++ b/apps/cli/test/tournament.integration.test.ts @@ -167,7 +167,7 @@ describe.runIf(dockerAvailable)('runTournamentCommand (real Docker, real registr expect( loggedLines.some((line) => line.includes( - 'copycat-rps, only-paper, only-rock, only-scissors, random-rps, t1000, t800, tx', + 'copycat-rps, only-paper, only-rock, only-scissors, random-rps, tominator-t1000, tominator-t800, tominator-tx', ), ), ).toBe(true); diff --git a/bots/README.md b/bots/README.md index 48c30b2..738760a 100644 --- a/bots/README.md +++ b/bots/README.md @@ -16,7 +16,7 @@ point (see the doc comment at the top of [`scripts/scaffold-bot.mjs`](../scripts/scaffold-bot.mjs) for every flag), then follow [`docs/guides/bot-author-guide.md`](../docs/guides/bot-author-guide.md) for the full contract, manifest, Dockerfile, and testing steps — game-agnostic throughout, with a dedicated section for -each game's own specifics (Rock-Paper-Scissors, Hearts). +each game's own specifics (Rock-Paper-Scissors, Hearts, Texas Hold'em). Two different kinds of bot live under `bots/`, and the distinction matters: **reference bots** exist to teach the wire protocol and SDK — deliberately simple, sometimes deliberately bad, so @@ -37,45 +37,50 @@ strategies actually trying to win — read them for strategy ideas, not API plum | [`card-game-hearts/random-hearts`](card-game-hearts/random-hearts/) | Hearts | JavaScript | Uniformly random pass/play every turn, seeded from the match's `rngSeed`. | | [`card-game-hearts/lowest-card-hearts`](card-game-hearts/lowest-card-hearts/) | Hearts | JavaScript | Always plays/passes by raw rank, ignoring the trick, hearts, or scores entirely. | | [`card-game-hearts/point-dodger-hearts`](card-game-hearts/point-dodger-hearts/) | Hearts | JavaScript | Sheds dangerous cards when passing, leads safe non-point cards, ducks under the trick's current winner when it can, and dumps its most dangerous card when void in the led suit. | +| [`poker-texas-hold-em/random-poker`](poker-texas-hold-em/random-poker/) | Texas Hold Em | JavaScript | Uniformly random choice among whatever's currently legal (fold/check/call/raise/allIn), with a uniformly random raise amount, PRNG seeded from the match's `rngSeed`. | +| [`poker-texas-hold-em/calling-station-poker`](poker-texas-hold-em/calling-station-poker/) | Texas Hold Em | JavaScript | Never folds or raises: checks when possible, otherwise calls (capped at its own stack). No randomness at all. | +| [`poker-texas-hold-em/tight-poker`](poker-texas-hold-em/tight-poker/) | Texas Hold Em | TypeScript | Bets/raises only with a good hand (a standard tight preflop range, or a made pair-or-better postflop) and never bluffs; calls a bet only when it's no more than the big blind. | ## Competitors Real strategies, entered to actually win — not written to demonstrate anything about the protocol. -| Bot | Game | Language | Strategy | -| -------------------------------------------------------------------------------- | ---------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------ | -| [`rock-paper-scissors/t800`](rock-paper-scissors/t800/) | Rock Paper Scissors | TypeScript | Three fixed phases: early-game deck exploration, mid-game exploitation of what it learned, late-game coast-or-reevaluate. | -| [`rock-paper-scissors/t1000`](rock-paper-scissors/t1000/) | Rock Paper Scissors | TypeScript | A red-herring/research/exploit/reevaluate/defense state machine that adapts through the match instead of t800's fixed phases. | -| [`rock-paper-scissors/tx`](rock-paper-scissors/tx/) | Rock Paper Scissors | TypeScript | Defaults to a self-correcting balancing deck; exploits an opponent pattern only past a strict z-score bar, retreating on any real recent lead. | -| [`card-game-hearts/tominator-t1`](card-game-hearts/tominator-t1/) | Hearts | TypeScript | Plays the highest card in hand that still loses to the trick's current highest card. | -| [`card-game-hearts/tominator-t2`](card-game-hearts/tominator-t2/) | Hearts | TypeScript | Tracks played cards and which suits opponents have shown out of to inform play; defaults to defensive point-avoidance but switches to an aggressive shoot-the-moon attempt once a hand-strength signal crosses a threshold while leading. | +| Bot | Game | Language | Strategy | +|-------------------------------------------------------------------------------| ---------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------ | +| [`rock-paper-scissors/tominator-t800`](rock-paper-scissors/tominator-t800/) | Rock Paper Scissors | TypeScript | Three fixed phases: early-game deck exploration, mid-game exploitation of what it learned, late-game coast-or-reevaluate. | +| [`rock-paper-scissors/tominator-t1000`](rock-paper-scissors/tominator-t1000/) | Rock Paper Scissors | TypeScript | A red-herring/research/exploit/reevaluate/defense state machine that adapts through the match instead of tominator-t800's fixed phases. | +| [`rock-paper-scissors/tominator-tx`](rock-paper-scissors/tominator-tx/) | Rock Paper Scissors | TypeScript | Defaults to a self-correcting balancing deck; exploits an opponent pattern only past a strict z-score bar, retreating on any real recent lead. | +| [`card-game-hearts/tominator-t1`](card-game-hearts/tominator-t1/) | Hearts | TypeScript | Plays the highest card in hand that still loses to the trick's current highest card. | +| [`card-game-hearts/tominator-t101`](card-game-hearts/tominator-t101/) | Hearts | TypeScript | Tracks played cards and which suits opponents have shown out of to inform play; defaults to defensive point-avoidance but switches to an aggressive shoot-the-moon attempt once a hand-strength signal crosses a threshold while leading. | Both tables are one roster as far as the platform is concerned — nothing about `match run`, `tournament run`, or the registry distinguishes a "reference" bot from a "competitor" one; the split above is purely for a reader trying to figure out which bots to imitate versus which to actually try to beat. -Each bot is verified against the real Docker runtime by its own `smoke-test.mjs` — see -[`docs/guides/examples/counter-bot/README.md`](../docs/guides/examples/counter-bot/README.md) for -the pattern they follow (build the image, then `node /smoke-test.mjs` from the repo root). +Each bot is verified against the real Docker runtime by its own `smoke-test.mjs` — build the +image, then `node /smoke-test.mjs` from the repo root; any bot's own `smoke-test.mjs` is a +working template for the pattern (e.g. +[`rock-paper-scissors/only-rock/smoke-test.mjs`](rock-paper-scissors/only-rock/smoke-test.mjs)). -All fifteen depend on `@thunderdome/bot-sdk`'s `runBot()` for the NDJSON wire-protocol handling +All eighteen depend on `@thunderdome/bot-sdk`'s `runBot()` for the NDJSON wire-protocol handling (replying to `init`, reading `observation`, exiting on `match-end`) — each bot's own file is just a -`decideAction()` (and, for `random-rps`/`t800`/`t1000`/`tx`/`random-connect-four`/`random-hearts`, +`decideAction()` (and, for `random-rps`/`tominator-t800`/`tominator-t1000`/`tominator-tx`/ +`random-connect-four`/`random-hearts`/`random-poker`, an `onInit` hook to seed its PRNG from the match's `rngSeed`). Since `bots/**` isn't a Yarn workspace member, that dependency is a vendored tarball rather than a live workspace link: each bot has its own `package.json`, `package-lock.json`, and `vendor/thunderdome-bot-sdk.tgz`, produced by -[`scripts/pack-bot-sdk.sh`](../scripts/README.md#pack-bot-sdksh). The `only-*` bots, `t800`, -`t1000`, `tx`, and both `tominator-*` bots additionally show the shape of a TypeScript bot: their -own `tsconfig.json` and a multi-stage `Dockerfile` that compiles TS in a build stage and ships only -the resulting JS plus production `node_modules` — no build tooling ends up in the runtime image. +[`scripts/pack-bot-sdk.sh`](../scripts/README.md#pack-bot-sdksh). The `only-*` bots, every +`tominator-*` bot, and `tight-poker` additionally show the shape of a TypeScript bot: their own +`tsconfig.json` and a multi-stage `Dockerfile` that compiles TS in a build stage and ships only the +resulting JS plus production `node_modules` — no build tooling ends up in the runtime image. Every other bot listed above is plain JS with no build step at all — just an `index.mjs` (or `index.js`) shipped directly into the image. Want to watch them actually play each other? `yarn thunderdome match run [...moreBotIds]` runs a real match between registry-resolved bots through the real engine and runtime (building each bot's Docker image on demand) — 2 bot ids for Rock Paper Scissors or -Connect Four, exactly 4 for Hearts. See -[`docs/guides/bot-author-guide.md`](../docs/guides/bot-author-guide.md) §9/§10 for details. +Connect Four, exactly 4 for Hearts, 2-10 for Texas Hold'em. See +[`docs/guides/bot-author-guide.md`](../docs/guides/bot-author-guide.md) §9/§10/§11 for details. To play against 3 Hearts bots yourself instead of watching, see `yarn thunderdome play` in [`apps/cli/README.md`](../apps/cli/README.md#play). diff --git a/bots/card-game-hearts/tominator-t1/manifest.json b/bots/card-game-hearts/tominator-t1/manifest.json index f2fbd27..31e4d54 100644 --- a/bots/card-game-hearts/tominator-t1/manifest.json +++ b/bots/card-game-hearts/tominator-t1/manifest.json @@ -1,6 +1,6 @@ { "id": "tominator-t1", - "name": "Tominator T1", + "name": "T-1", "version": "0.1.0", "game": "card-game-hearts", "author": { diff --git a/bots/card-game-hearts/tominator-t1/src/index.ts b/bots/card-game-hearts/tominator-t1/src/index.ts index a7a41da..f08a4d0 100644 --- a/bots/card-game-hearts/tominator-t1/src/index.ts +++ b/bots/card-game-hearts/tominator-t1/src/index.ts @@ -1,7 +1,7 @@ import { runBot } from '@thunderdome/bot-sdk'; /** - * Tominator T1 — a bot for the "card-game-hearts" game. + * The T-1 — a standard bot for the "card-game-hearts" game. */ type Suit = 'clubs' | 'diamonds' | 'hearts' | 'spades'; diff --git a/bots/card-game-hearts/tominator-t2/Dockerfile b/bots/card-game-hearts/tominator-t101/Dockerfile similarity index 100% rename from bots/card-game-hearts/tominator-t2/Dockerfile rename to bots/card-game-hearts/tominator-t101/Dockerfile diff --git a/bots/card-game-hearts/tominator-t2/manifest.json b/bots/card-game-hearts/tominator-t101/manifest.json similarity index 90% rename from bots/card-game-hearts/tominator-t2/manifest.json rename to bots/card-game-hearts/tominator-t101/manifest.json index c29dd98..f8a0306 100644 --- a/bots/card-game-hearts/tominator-t2/manifest.json +++ b/bots/card-game-hearts/tominator-t101/manifest.json @@ -1,6 +1,6 @@ { - "id": "tominator-t2", - "name": "Tominator T2", + "id": "tominator-t101", + "name": "T-101", "version": "0.1.0", "game": "card-game-hearts", "author": { diff --git a/bots/card-game-hearts/tominator-t2/package-lock.json b/bots/card-game-hearts/tominator-t101/package-lock.json similarity index 97% rename from bots/card-game-hearts/tominator-t2/package-lock.json rename to bots/card-game-hearts/tominator-t101/package-lock.json index e812374..4cbc066 100644 --- a/bots/card-game-hearts/tominator-t2/package-lock.json +++ b/bots/card-game-hearts/tominator-t101/package-lock.json @@ -1,11 +1,11 @@ { - "name": "tominator-t2", + "name": "tominator-t101", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tominator-t2", + "name": "tominator-t101", "version": "0.1.0", "dependencies": { "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" diff --git a/bots/card-game-hearts/tominator-t101/package.json b/bots/card-game-hearts/tominator-t101/package.json new file mode 100644 index 0000000..c1b228d --- /dev/null +++ b/bots/card-game-hearts/tominator-t101/package.json @@ -0,0 +1,16 @@ +{ + "name": "tominator-t101", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "typescript": "^5.6.3" + } +} diff --git a/bots/card-game-hearts/tominator-t2/src/deck.ts b/bots/card-game-hearts/tominator-t101/src/deck.ts similarity index 100% rename from bots/card-game-hearts/tominator-t2/src/deck.ts rename to bots/card-game-hearts/tominator-t101/src/deck.ts diff --git a/bots/card-game-hearts/tominator-t2/src/index.ts b/bots/card-game-hearts/tominator-t101/src/index.ts similarity index 100% rename from bots/card-game-hearts/tominator-t2/src/index.ts rename to bots/card-game-hearts/tominator-t101/src/index.ts diff --git a/bots/card-game-hearts/tominator-t2/src/pass.ts b/bots/card-game-hearts/tominator-t101/src/pass.ts similarity index 100% rename from bots/card-game-hearts/tominator-t2/src/pass.ts rename to bots/card-game-hearts/tominator-t101/src/pass.ts diff --git a/bots/card-game-hearts/tominator-t2/src/play.ts b/bots/card-game-hearts/tominator-t101/src/play.ts similarity index 100% rename from bots/card-game-hearts/tominator-t2/src/play.ts rename to bots/card-game-hearts/tominator-t101/src/play.ts diff --git a/bots/card-game-hearts/tominator-t2/src/state.ts b/bots/card-game-hearts/tominator-t101/src/state.ts similarity index 100% rename from bots/card-game-hearts/tominator-t2/src/state.ts rename to bots/card-game-hearts/tominator-t101/src/state.ts diff --git a/bots/card-game-hearts/tominator-t2/src/types.ts b/bots/card-game-hearts/tominator-t101/src/types.ts similarity index 100% rename from bots/card-game-hearts/tominator-t2/src/types.ts rename to bots/card-game-hearts/tominator-t101/src/types.ts diff --git a/bots/card-game-hearts/tominator-t2/tsconfig.json b/bots/card-game-hearts/tominator-t101/tsconfig.json similarity index 100% rename from bots/card-game-hearts/tominator-t2/tsconfig.json rename to bots/card-game-hearts/tominator-t101/tsconfig.json diff --git a/bots/card-game-hearts/tominator-t2/vendor/.gitkeep b/bots/card-game-hearts/tominator-t101/vendor/.gitkeep similarity index 100% rename from bots/card-game-hearts/tominator-t2/vendor/.gitkeep rename to bots/card-game-hearts/tominator-t101/vendor/.gitkeep diff --git a/bots/card-game-hearts/tominator-t2/vendor/thunderdome-bot-sdk.tgz b/bots/card-game-hearts/tominator-t101/vendor/thunderdome-bot-sdk.tgz similarity index 100% rename from bots/card-game-hearts/tominator-t2/vendor/thunderdome-bot-sdk.tgz rename to bots/card-game-hearts/tominator-t101/vendor/thunderdome-bot-sdk.tgz diff --git a/bots/poker-texas-hold-em/calling-station-poker/Dockerfile b/bots/poker-texas-hold-em/calling-station-poker/Dockerfile new file mode 100644 index 0000000..c6a28d2 --- /dev/null +++ b/bots/poker-texas-hold-em/calling-station-poker/Dockerfile @@ -0,0 +1,7 @@ +FROM node:25-alpine +WORKDIR /app +COPY package.json package-lock.json ./ +COPY vendor ./vendor +RUN npm ci --omit=dev +COPY index.mjs ./ +ENTRYPOINT ["node", "index.mjs"] diff --git a/bots/poker-texas-hold-em/calling-station-poker/index.mjs b/bots/poker-texas-hold-em/calling-station-poker/index.mjs new file mode 100644 index 0000000..1aa5f56 --- /dev/null +++ b/bots/poker-texas-hold-em/calling-station-poker/index.mjs @@ -0,0 +1,21 @@ +/** + * Calling Station Poker — never folds, never raises: checks when it can, otherwise calls (or, if + * short-stacked, goes all-in rather than folding). No randomness, no hand-strength evaluation at + * all — the simplest possible deterministic baseline, the poker equivalent of + * bots/rock-paper-scissors/only-rock: something any real strategy should be able to beat, and a + * fixed target to measure other poker bots against. + * + * All of the NDJSON wire-protocol handling (replying to "init", reading "observation", exiting + * on "match-end") lives in @thunderdome/bot-sdk's runBot() — see + * docs/guides/bot-author-guide.md for the full protocol walkthrough. + */ +import { runBot } from '@thunderdome/bot-sdk'; + +function decideAction(observation) { + // `call` already caps at your remaining stack (a short call is a legal all-in call in this + // game's rules — see games/poker-texas-hold-em/src/game.ts), so check/call is genuinely all + // this bot ever needs; there's no case where it must reach for `allIn` or `fold`. + return observation.toCall === 0 ? { type: 'check' } : { type: 'call' }; +} + +runBot({ decideAction }); diff --git a/bots/poker-texas-hold-em/calling-station-poker/manifest.json b/bots/poker-texas-hold-em/calling-station-poker/manifest.json new file mode 100644 index 0000000..44bd5de --- /dev/null +++ b/bots/poker-texas-hold-em/calling-station-poker/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "calling-station-poker", + "name": "Calling Station Poker", + "version": "0.1.0", + "game": "poker-texas-hold-em", + "author": { + "name": "Tom Sarver", + "contact": "tom@tomsarver.dev" + }, + "runtime": { + "language": "node" + }, + "interface": { + "transport": "stdio" + }, + "protocolVersion": "^1.0", + "description": "Never folds or raises: checks when possible, otherwise calls (capped at its own stack). A fixed, deterministic baseline to measure other poker bots against." +} diff --git a/bots/poker-texas-hold-em/calling-station-poker/package-lock.json b/bots/poker-texas-hold-em/calling-station-poker/package-lock.json new file mode 100644 index 0000000..750ca16 --- /dev/null +++ b/bots/poker-texas-hold-em/calling-station-poker/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "calling-station-poker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "calling-station-poker", + "version": "0.1.0", + "dependencies": { + "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" + } + }, + "node_modules/@thunderdome/bot-sdk": { + "version": "0.1.0", + "resolved": "file:vendor/thunderdome-bot-sdk.tgz", + "integrity": "sha512-vNJ9I8N3x7R4w3qAFXCeTEhvL9zlHouw9Y8MeSOOoGbPELTAu5Ya618UyKMv8GdbP0QEuTroILMZAQ3qRuLd9g==", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/bots/poker-texas-hold-em/calling-station-poker/package.json b/bots/poker-texas-hold-em/calling-station-poker/package.json new file mode 100644 index 0000000..8d06bde --- /dev/null +++ b/bots/poker-texas-hold-em/calling-station-poker/package.json @@ -0,0 +1,9 @@ +{ + "name": "calling-station-poker", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" + } +} diff --git a/bots/poker-texas-hold-em/calling-station-poker/smoke-test.mjs b/bots/poker-texas-hold-em/calling-station-poker/smoke-test.mjs new file mode 100644 index 0000000..3fbe1c2 --- /dev/null +++ b/bots/poker-texas-hold-em/calling-station-poker/smoke-test.mjs @@ -0,0 +1,107 @@ +// Verifies calling-station-poker against the real Docker runtime: it checks when it can, and +// calls (capped at its stack) otherwise — never folds, never raises. Requires: +// docker build -t thunderdome-calling-station-poker . +import { DockerBotProcess, BotLifecycle, DEFAULT_RESOURCE_LIMITS } from '@thunderdome/runtime'; + +const IMAGE_TAG = 'thunderdome-calling-station-poker'; +const ROSTER = ['calling-station-poker', 'opponent']; + +function assertEqual(actual, expected, label) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) { + throw new Error(`${label}: expected ${e}, got ${a}`); + } + console.log(`ok - ${label}`); +} + +async function withLifecycle(matchId, run) { + const botProcess = new DockerBotProcess({ + imageRef: IMAGE_TAG, + matchId, + participantId: 'calling-station-poker', + resourceLimits: DEFAULT_RESOURCE_LIMITS, + }); + await botProcess.start(); + const lifecycle = new BotLifecycle({ process: botProcess, matchId }); + + const initOutcome = await lifecycle.initialize( + { + gameId: 'poker-texas-hold-em', + gameVersion: '0.1.0', + participantId: 'calling-station-poker', + roster: ROSTER, + rngSeed: 'deadbeef', + config: { startingStack: 500, smallBlind: 10, bigBlind: 20 }, + }, + { initTimeoutMs: 10_000 }, + ); + assertEqual(initOutcome, { ok: true }, 'bot completes init/ready handshake'); + + const result = await run(lifecycle); + + await lifecycle.finish({ + result: { + participantIds: ROSTER, + stacks: { 'calling-station-poker': 500, opponent: 500 }, + bustedOut: [], + handsPlayed: 1, + }, + reason: 'completed', + }); + return result; +} + +function observationFacing(toCall, overrides = {}) { + return { + you: 'calling-station-poker', + handNumber: 0, + street: 'flop', + board: [ + { suit: 'clubs', rank: 9 }, + { suit: 'diamonds', rank: 4 }, + { suit: 'hearts', rank: 2 }, + ], + holeCards: [ + { suit: 'spades', rank: 7 }, + { suit: 'spades', rank: 6 }, + ], + pot: 100 + toCall, + yourStack: 360, + yourCommittedThisStreet: 0, + toCall, + minRaiseTo: toCall === 0 ? null : toCall + 20, + maxRaiseTo: 360, + smallBlind: 10, + bigBlind: 20, + buttonParticipantId: 'opponent', + opponents: [ + { + participantId: 'opponent', + stack: 340, + committed: toCall, + committedThisStreet: toCall, + folded: false, + allIn: false, + isButton: true, + }, + ], + legalActions: toCall === 0 ? ['fold', 'check', 'raise', 'allIn'] : ['fold', 'call', 'raise', 'allIn'], + lastHandSummary: null, + ...overrides, + }; +} + +const checked = await withLifecycle('calling-station-check', async (lifecycle) => { + lifecycle.sendObservation(0, { state: observationFacing(0), awaitingAction: true }); + return lifecycle.awaitAction(0, 10_000); +}); +assertEqual(checked, { ok: true, action: { type: 'check' } }, 'checks when there is nothing to call'); + +const called = await withLifecycle('calling-station-call', async (lifecycle) => { + lifecycle.sendObservation(0, { state: observationFacing(40), awaitingAction: true }); + return lifecycle.awaitAction(0, 10_000); +}); +assertEqual(called, { ok: true, action: { type: 'call' } }, 'calls when facing a bet — never folds or raises'); + +console.log('\nAll checks passed.'); diff --git a/bots/poker-texas-hold-em/calling-station-poker/vendor/.gitkeep b/bots/poker-texas-hold-em/calling-station-poker/vendor/.gitkeep new file mode 100644 index 0000000..62ca1f3 --- /dev/null +++ b/bots/poker-texas-hold-em/calling-station-poker/vendor/.gitkeep @@ -0,0 +1,6 @@ +This directory holds a vendored @thunderdome/bot-sdk tarball, generated by +scripts/pack-bot-sdk.sh — see that script's own comments for why bots/** vendors a tarball +instead of depending on the workspace package directly. Run it (from the repo root) to populate +this directory and generate a matching package-lock.json: + + ./scripts/pack-bot-sdk.sh diff --git a/bots/poker-texas-hold-em/calling-station-poker/vendor/thunderdome-bot-sdk.tgz b/bots/poker-texas-hold-em/calling-station-poker/vendor/thunderdome-bot-sdk.tgz new file mode 100644 index 0000000..3baf66f Binary files /dev/null and b/bots/poker-texas-hold-em/calling-station-poker/vendor/thunderdome-bot-sdk.tgz differ diff --git a/bots/poker-texas-hold-em/random-poker/Dockerfile b/bots/poker-texas-hold-em/random-poker/Dockerfile new file mode 100644 index 0000000..c6a28d2 --- /dev/null +++ b/bots/poker-texas-hold-em/random-poker/Dockerfile @@ -0,0 +1,7 @@ +FROM node:25-alpine +WORKDIR /app +COPY package.json package-lock.json ./ +COPY vendor ./vendor +RUN npm ci --omit=dev +COPY index.mjs ./ +ENTRYPOINT ["node", "index.mjs"] diff --git a/bots/poker-texas-hold-em/random-poker/index.mjs b/bots/poker-texas-hold-em/random-poker/index.mjs new file mode 100644 index 0000000..b1e20d0 --- /dev/null +++ b/bots/poker-texas-hold-em/random-poker/index.mjs @@ -0,0 +1,66 @@ +/** + * Random Poker — picks uniformly among whatever `observation.legalActions` currently allows + * (fold/check/call/raise/allIn), with a uniformly random raise-to amount when it raises. It has + * no notion of hand strength or pot odds at all — the simplest possible reference bot, meant as a + * baseline other poker bots can be measured against, exactly like random-hearts and + * random-connect-four are for their own games. + * + * All of the NDJSON wire-protocol handling (replying to "init", reading "observation", exiting + * on "match-end") lives in @thunderdome/bot-sdk's runBot() — see + * docs/guides/bot-author-guide.md for the full protocol walkthrough. This file only needs to + * decide each turn's action, and seed its own PRNG once the match's rngSeed arrives via `onInit`. + */ +import { runBot } from '@thunderdome/bot-sdk'; + +// --------------------------------------------------------------------------- +// Strategy — the only part that makes this bot different from any other bot in this repo. +// --------------------------------------------------------------------------- + +function decideAction(observation) { + const { legalActions } = observation; + const action = legalActions[Math.floor(random() * legalActions.length)]; + + if (action === 'raise') { + const min = observation.minRaiseTo ?? observation.maxRaiseTo; + const max = observation.maxRaiseTo; + const amount = min + Math.floor(random() * (max - min + 1)); + return { type: 'raise', amount }; + } + return { type: action }; +} + +// --------------------------------------------------------------------------- +// Seeded PRNG — deliberately NOT Math.random(). Identical to bots/card-game-hearts/ +// random-hearts's own copy (docs/adr/0004-deterministic-randomness.md): a bot's own strategy +// randomness must be reproducible given the same seed, so a tournament can be replayed exactly. +// This bot never needs to match the platform's own PRNG bit-for-bit — only "same code + same +// seed => same output," which any seeded PRNG trivially satisfies. +// --------------------------------------------------------------------------- + +let random; // seeded once `init` arrives — never falls back to Math.random() + +function hashSeed(hex) { + let hash = 0; + for (let i = 0; i < hex.length; i += 1) { + hash = (Math.imul(hash, 31) + hex.charCodeAt(i)) | 0; + } + return hash; +} + +/** mulberry32 — a small, well-known deterministic PRNG. No dependency needed. */ +function mulberry32(seed) { + let a = seed; + return function next() { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +runBot({ + decideAction, + onInit: ({ rngSeed }) => { + random = mulberry32(hashSeed(rngSeed)); + }, +}); diff --git a/bots/poker-texas-hold-em/random-poker/manifest.json b/bots/poker-texas-hold-em/random-poker/manifest.json new file mode 100644 index 0000000..f838ef6 --- /dev/null +++ b/bots/poker-texas-hold-em/random-poker/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "random-poker", + "name": "Random Poker", + "version": "0.1.0", + "game": "poker-texas-hold-em", + "author": { + "name": "Tom Sarver", + "contact": "tom@tomsarver.dev" + }, + "runtime": { + "language": "node" + }, + "interface": { + "transport": "stdio" + }, + "protocolVersion": "^1.0", + "description": "Picks uniformly at random among whatever actions are currently legal (fold/check/call/raise/allIn), with a uniformly random raise amount, seeded from the match's rngSeed." +} diff --git a/bots/poker-texas-hold-em/random-poker/package-lock.json b/bots/poker-texas-hold-em/random-poker/package-lock.json new file mode 100644 index 0000000..2271757 --- /dev/null +++ b/bots/poker-texas-hold-em/random-poker/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "random-poker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "random-poker", + "version": "0.1.0", + "dependencies": { + "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" + } + }, + "node_modules/@thunderdome/bot-sdk": { + "version": "0.1.0", + "resolved": "file:vendor/thunderdome-bot-sdk.tgz", + "integrity": "sha512-vNJ9I8N3x7R4w3qAFXCeTEhvL9zlHouw9Y8MeSOOoGbPELTAu5Ya618UyKMv8GdbP0QEuTroILMZAQ3qRuLd9g==", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/bots/poker-texas-hold-em/random-poker/package.json b/bots/poker-texas-hold-em/random-poker/package.json new file mode 100644 index 0000000..f6228dc --- /dev/null +++ b/bots/poker-texas-hold-em/random-poker/package.json @@ -0,0 +1,9 @@ +{ + "name": "random-poker", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" + } +} diff --git a/bots/poker-texas-hold-em/random-poker/smoke-test.mjs b/bots/poker-texas-hold-em/random-poker/smoke-test.mjs new file mode 100644 index 0000000..1459122 --- /dev/null +++ b/bots/poker-texas-hold-em/random-poker/smoke-test.mjs @@ -0,0 +1,125 @@ +// Verifies random-poker against the real Docker runtime, and specifically that its choices are a +// deterministic function of rngSeed (docs/adr/0004-deterministic-randomness.md) — not +// uncontrolled Math.random(). Requires: +// docker build -t thunderdome-random-poker . +import { DockerBotProcess, BotLifecycle, DEFAULT_RESOURCE_LIMITS } from '@thunderdome/runtime'; + +const IMAGE_TAG = 'thunderdome-random-poker'; +const ROSTER = ['random-poker', 'opponent']; + +function assertEqual(actual, expected, label) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) { + throw new Error(`${label}: expected ${e}, got ${a}`); + } + console.log(`ok - ${label}`); +} + +async function withLifecycle(matchId, rngSeed, run) { + const botProcess = new DockerBotProcess({ + imageRef: IMAGE_TAG, + matchId, + participantId: 'random-poker', + resourceLimits: DEFAULT_RESOURCE_LIMITS, + }); + await botProcess.start(); + const lifecycle = new BotLifecycle({ process: botProcess, matchId }); + + const initOutcome = await lifecycle.initialize( + { + gameId: 'poker-texas-hold-em', + gameVersion: '0.1.0', + participantId: 'random-poker', + roster: ROSTER, + rngSeed, + config: { startingStack: 500, smallBlind: 10, bigBlind: 20 }, + }, + { initTimeoutMs: 10_000 }, + ); + assertEqual(initOutcome, { ok: true }, `bot completes init/ready handshake (seed ${rngSeed})`); + + const result = await run(lifecycle); + + await lifecycle.finish({ + result: { participantIds: ROSTER, stacks: { 'random-poker': 500, opponent: 500 }, bustedOut: [], handsPlayed: 1 }, + reason: 'completed', + }); + return result; +} + +/** An observation facing a bet with a wide legal range — exercises fold/check/call/raise/allIn + * all at once, since every one of them is legal here. */ +function facingABetObservation() { + return { + you: 'random-poker', + handNumber: 0, + street: 'flop', + board: [ + { suit: 'clubs', rank: 9 }, + { suit: 'diamonds', rank: 4 }, + { suit: 'hearts', rank: 2 }, + ], + holeCards: [ + { suit: 'spades', rank: 14 }, + { suit: 'spades', rank: 13 }, + ], + pot: 140, + yourStack: 400, + yourCommittedThisStreet: 0, + toCall: 40, + minRaiseTo: 80, + maxRaiseTo: 440, + smallBlind: 10, + bigBlind: 20, + buttonParticipantId: 'opponent', + opponents: [ + { + participantId: 'opponent', + stack: 360, + committed: 40, + committedThisStreet: 40, + folded: false, + allIn: false, + isButton: true, + }, + ], + legalActions: ['fold', 'call', 'raise', 'allIn'], + lastHandSummary: null, + }; +} + +async function decide(matchId, rngSeed) { + return withLifecycle(matchId, rngSeed, async (lifecycle) => { + lifecycle.sendObservation(0, { state: facingABetObservation(), awaitingAction: true }); + return lifecycle.awaitAction(0, 10_000); + }); +} + +const decision1 = await decide('random-poker-1', 'deadbeef'); +if (!decision1.ok) { + throw new Error(`expected a successful decision, got ${JSON.stringify(decision1)}`); +} +const legal = ['fold', 'call', 'raise', 'allIn']; +if (!legal.includes(decision1.action.type)) { + throw new Error(`action type "${decision1.action.type}" is not one of the observation's legalActions`); +} +if (decision1.action.type === 'raise') { + const { amount } = decision1.action; + if (!Number.isInteger(amount) || amount < 80 || amount > 440) { + throw new Error(`raise amount ${String(amount)} is outside [minRaiseTo, maxRaiseTo] = [80, 440]`); + } +} +console.log(`ok - picks a legal action facing a bet: ${JSON.stringify(decision1.action)}`); + +const decision2 = await decide('random-poker-2', 'deadbeef'); +assertEqual(decision2, decision1, 'same rngSeed produces the same decision (deterministic, not Math.random())'); + +const decision3 = await decide('random-poker-3', 'cafebabe'); +console.log( + decision3.ok && JSON.stringify(decision3) !== JSON.stringify(decision1) + ? 'ok - a different rngSeed can produce a different decision' + : 'note - different seed happened to land on the same decision (not a failure, just bad luck of the draw)', +); + +console.log('\nAll checks passed.'); diff --git a/bots/poker-texas-hold-em/random-poker/vendor/.gitkeep b/bots/poker-texas-hold-em/random-poker/vendor/.gitkeep new file mode 100644 index 0000000..62ca1f3 --- /dev/null +++ b/bots/poker-texas-hold-em/random-poker/vendor/.gitkeep @@ -0,0 +1,6 @@ +This directory holds a vendored @thunderdome/bot-sdk tarball, generated by +scripts/pack-bot-sdk.sh — see that script's own comments for why bots/** vendors a tarball +instead of depending on the workspace package directly. Run it (from the repo root) to populate +this directory and generate a matching package-lock.json: + + ./scripts/pack-bot-sdk.sh diff --git a/bots/poker-texas-hold-em/random-poker/vendor/thunderdome-bot-sdk.tgz b/bots/poker-texas-hold-em/random-poker/vendor/thunderdome-bot-sdk.tgz new file mode 100644 index 0000000..3baf66f Binary files /dev/null and b/bots/poker-texas-hold-em/random-poker/vendor/thunderdome-bot-sdk.tgz differ diff --git a/bots/poker-texas-hold-em/tight-poker/Dockerfile b/bots/poker-texas-hold-em/tight-poker/Dockerfile new file mode 100644 index 0000000..8372b93 --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/Dockerfile @@ -0,0 +1,22 @@ +FROM node:25-alpine AS build +WORKDIR /app +COPY package.json package-lock.json tsconfig.json ./ +COPY vendor ./vendor +RUN npm ci +COPY src ./src +RUN npm run build + +# @thunderdome/bot-sdk is a real runtime dependency (not just a type-checking-time one), so the +# final image needs node_modules too — installed separately here with --omit=dev so +# typescript/@types/node never end up in the runtime image. +FROM node:25-alpine AS prod-deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY vendor ./vendor +RUN npm ci --omit=dev + +FROM node:25-alpine +WORKDIR /app +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +ENTRYPOINT ["node", "dist/index.js"] diff --git a/bots/poker-texas-hold-em/tight-poker/manifest.json b/bots/poker-texas-hold-em/tight-poker/manifest.json new file mode 100644 index 0000000..eb29a6a --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "tight-poker", + "name": "Tight Poker", + "version": "0.1.0", + "game": "poker-texas-hold-em", + "author": { + "name": "Tom Sarver", + "contact": "tom@tomsarver.dev" + }, + "runtime": { + "language": "node" + }, + "interface": { + "transport": "stdio" + }, + "protocolVersion": "^1.0", + "description": "Bets and raises only with a good hand (a standard tight preflop range, or a made pair-or-better postflop) and never bluffs; calls a bet only when it's cheap relative to the big blind." +} diff --git a/bots/poker-texas-hold-em/tight-poker/package-lock.json b/bots/poker-texas-hold-em/tight-poker/package-lock.json new file mode 100644 index 0000000..2e4a98f --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/package-lock.json @@ -0,0 +1,67 @@ +{ + "name": "tight-poker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tight-poker", + "version": "0.1.0", + "dependencies": { + "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "typescript": "^5.6.3" + } + }, + "node_modules/@thunderdome/bot-sdk": { + "version": "0.1.0", + "resolved": "file:vendor/thunderdome-bot-sdk.tgz", + "integrity": "sha512-vNJ9I8N3x7R4w3qAFXCeTEhvL9zlHouw9Y8MeSOOoGbPELTAu5Ya618UyKMv8GdbP0QEuTroILMZAQ3qRuLd9g==", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/bots/card-game-hearts/tominator-t2/package.json b/bots/poker-texas-hold-em/tight-poker/package.json similarity index 91% rename from bots/card-game-hearts/tominator-t2/package.json rename to bots/poker-texas-hold-em/tight-poker/package.json index 3491e85..9e1695f 100644 --- a/bots/card-game-hearts/tominator-t2/package.json +++ b/bots/poker-texas-hold-em/tight-poker/package.json @@ -1,5 +1,5 @@ { - "name": "tominator-t2", + "name": "tight-poker", "version": "0.1.0", "private": true, "type": "module", diff --git a/bots/poker-texas-hold-em/tight-poker/smoke-test.mjs b/bots/poker-texas-hold-em/tight-poker/smoke-test.mjs new file mode 100644 index 0000000..5377b60 --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/smoke-test.mjs @@ -0,0 +1,120 @@ +// Verifies tight-poker against the real Docker runtime: it raises with a premium preflop hand, +// folds a weak hand facing an expensive bet, and calls a weak hand facing a cheap one. Requires: +// docker build -t thunderdome-tight-poker . +import { DockerBotProcess, BotLifecycle, DEFAULT_RESOURCE_LIMITS } from '@thunderdome/runtime'; + +const IMAGE_TAG = 'thunderdome-tight-poker'; +const ROSTER = ['tight-poker', 'opponent']; + +function assertEqual(actual, expected, label) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) { + throw new Error(`${label}: expected ${e}, got ${a}`); + } + console.log(`ok - ${label}`); +} + +async function withLifecycle(matchId, run) { + const botProcess = new DockerBotProcess({ + imageRef: IMAGE_TAG, + matchId, + participantId: 'tight-poker', + resourceLimits: DEFAULT_RESOURCE_LIMITS, + }); + await botProcess.start(); + const lifecycle = new BotLifecycle({ process: botProcess, matchId }); + + const initOutcome = await lifecycle.initialize( + { + gameId: 'poker-texas-hold-em', + gameVersion: '0.1.0', + participantId: 'tight-poker', + roster: ROSTER, + rngSeed: 'deadbeef', + config: { startingStack: 500, smallBlind: 10, bigBlind: 20 }, + }, + { initTimeoutMs: 10_000 }, + ); + assertEqual(initOutcome, { ok: true }, 'bot completes init/ready handshake'); + + const result = await run(lifecycle); + + await lifecycle.finish({ + result: { participantIds: ROSTER, stacks: { 'tight-poker': 500, opponent: 500 }, bustedOut: [], handsPlayed: 1 }, + reason: 'completed', + }); + return result; +} + +function baseObservation(overrides) { + return { + you: 'tight-poker', + handNumber: 0, + street: 'preflop', + board: [], + holeCards: [ + { suit: 'clubs', rank: 2 }, + { suit: 'diamonds', rank: 7 }, + ], + pot: 30, + yourStack: 480, + yourCommittedThisStreet: 0, + toCall: 20, + minRaiseTo: 40, + maxRaiseTo: 500, + smallBlind: 10, + bigBlind: 20, + buttonParticipantId: 'opponent', + opponents: [ + { + participantId: 'opponent', + stack: 480, + committed: 20, + committedThisStreet: 20, + folded: false, + allIn: false, + isButton: true, + }, + ], + legalActions: ['fold', 'call', 'raise', 'allIn'], + lastHandSummary: null, + ...overrides, + }; +} + +const premium = await withLifecycle('tight-poker-premium', async (lifecycle) => { + lifecycle.sendObservation(0, { + state: baseObservation({ holeCards: [{ suit: 'spades', rank: 14 }, { suit: 'hearts', rank: 14 }] }), + awaitingAction: true, + }); + return lifecycle.awaitAction(0, 10_000); +}); +assertEqual( + premium, + { ok: true, action: { type: 'raise', amount: 40 } }, + 'raises to the minimum legal amount with pocket aces', +); + +const weakFacingBigBet = await withLifecycle('tight-poker-fold', async (lifecycle) => { + lifecycle.sendObservation(0, { state: baseObservation({ toCall: 40 }), awaitingAction: true }); + return lifecycle.awaitAction(0, 10_000); +}); +assertEqual(weakFacingBigBet, { ok: true, action: { type: 'fold' } }, 'folds a weak hand facing a bet bigger than the big blind'); + +const weakFacingCheapBet = await withLifecycle('tight-poker-call', async (lifecycle) => { + lifecycle.sendObservation(0, { state: baseObservation({ toCall: 20, bigBlind: 20 }), awaitingAction: true }); + return lifecycle.awaitAction(0, 10_000); +}); +assertEqual(weakFacingCheapBet, { ok: true, action: { type: 'call' } }, 'calls a weak hand when toCall is no more than the big blind'); + +const weakCanCheck = await withLifecycle('tight-poker-check', async (lifecycle) => { + lifecycle.sendObservation(0, { + state: baseObservation({ toCall: 0, legalActions: ['fold', 'check', 'raise', 'allIn'] }), + awaitingAction: true, + }); + return lifecycle.awaitAction(0, 10_000); +}); +assertEqual(weakCanCheck, { ok: true, action: { type: 'check' } }, 'checks a weak hand when nothing is owed'); + +console.log('\nAll checks passed.'); diff --git a/bots/poker-texas-hold-em/tight-poker/src/index.ts b/bots/poker-texas-hold-em/tight-poker/src/index.ts new file mode 100644 index 0000000..f11767a --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/src/index.ts @@ -0,0 +1,10 @@ +/** + * Tight Poker — bets and raises only with a good hand (a standard tight preflop range, or a made + * pair-or-better postflop), never bluffs, and only calls a bet cheaply with a weak hand. See + * strategy.ts for the hand-strength heuristic and decision logic. + */ +import { runBot } from '@thunderdome/bot-sdk'; +import { decideAction } from './strategy.js'; +import type { Action, Observation } from './types.js'; + +runBot({ decideAction }); diff --git a/bots/poker-texas-hold-em/tight-poker/src/strategy.ts b/bots/poker-texas-hold-em/tight-poker/src/strategy.ts new file mode 100644 index 0000000..13cf272 --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/src/strategy.ts @@ -0,0 +1,72 @@ +import type { Action, Card, Observation, Rank } from './types.js'; + +const PREMIUM_PAIR_MIN_RANK = 10; // pocket TT or better + +/** + * Preflop hand strength, from hole cards alone: pocket tens or better, an ace with a jack-or- + * better kicker, or king-queen suited — a standard tight opening range, nothing fancy. + */ +function isPremiumHoleCards(holeCards: [Card, Card]): boolean { + const [a, b] = holeCards; + const high = Math.max(a.rank, b.rank); + const low = Math.min(a.rank, b.rank); + const suited = a.suit === b.suit; + if (high === low) { + return high >= PREMIUM_PAIR_MIN_RANK; + } + if (high === 14) { + return low >= 11; + } + return high === 13 && low === 12 && suited; +} + +/** + * Postflop hand strength: at least a pair that one of your own hole cards is actually part of + * (an unimproved pocket pair counts too) — a deliberately simple made-hand check. It never + * recognizes a straight or flush that doesn't also pair up, which only ever makes this bot too + * cautious, never reckless — exactly the direction a "no bluffing" bot should err. + */ +function hasMadeHand(holeCards: [Card, Card], board: Card[]): boolean { + const holeRanks = new Set(holeCards.map((card) => card.rank)); + const countByRank = new Map(); + for (const card of [...holeCards, ...board]) { + countByRank.set(card.rank, (countByRank.get(card.rank) ?? 0) + 1); + } + for (const [rank, count] of countByRank) { + if (count >= 2 && holeRanks.has(rank)) { + return true; + } + } + return false; +} + +function hasGoodHand(observation: Observation): boolean { + return observation.board.length === 0 + ? isPremiumHoleCards(observation.holeCards) + : hasMadeHand(observation.holeCards, observation.board); +} + +/** + * Only bets or raises with a good hand, and only calls a bet cheaply with a weak one — never + * bluffs, never chases a big bet. `legalActions` still gates every choice below, since having a + * "good" hand doesn't make check/call/raise legal on its own (e.g. `check` is illegal whenever + * there's a bet to call). + */ +export function decideAction(observation: Observation): Action { + const { legalActions, toCall, minRaiseTo, bigBlind } = observation; + const goodHand = hasGoodHand(observation); + + if (goodHand && legalActions.includes('raise') && minRaiseTo !== null) { + return { type: 'raise', amount: minRaiseTo }; + } + if (legalActions.includes('check')) { + return { type: 'check' }; + } + if (goodHand && legalActions.includes('call')) { + return { type: 'call' }; + } + if (legalActions.includes('call') && toCall <= bigBlind) { + return { type: 'call' }; + } + return { type: 'fold' }; +} diff --git a/bots/poker-texas-hold-em/tight-poker/src/types.ts b/bots/poker-texas-hold-em/tight-poker/src/types.ts new file mode 100644 index 0000000..1060626 --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/src/types.ts @@ -0,0 +1,54 @@ +// Mirrors games/poker-texas-hold-em/src/types.ts's Observation/Action shapes — bots/** never +// depends on games/** (docs/adr/0001-monorepo-and-boundary.md), so this is a hand-copied subset, +// not an import. `lastHandSummary` is typed loosely since this bot's strategy never reads it. + +export type Suit = 'clubs' | 'diamonds' | 'hearts' | 'spades'; +/** 2..10, then 11=J, 12=Q, 13=K, 14=A — numeric so "highest card" is a plain `>`. */ +export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; + +export interface Card { + suit: Suit; + rank: Rank; +} + +export type LegalActionType = 'fold' | 'check' | 'call' | 'raise' | 'allIn'; + +export interface Opponent { + participantId: string; + stack: number; + committed: number; + committedThisStreet: number; + folded: boolean; + allIn: boolean; + isButton: boolean; +} + +export interface Observation { + you: string; + handNumber: number; + street: 'preflop' | 'flop' | 'turn' | 'river'; + board: Card[]; + holeCards: [Card, Card]; + pot: number; + yourStack: number; + yourCommittedThisStreet: number; + /** 0 means you can check. */ + toCall: number; + /** The minimum legal `raise` amount, or `null` if you have no chips left to raise with. */ + minRaiseTo: number | null; + /** Your all-in amount — the max legal `raise` amount. */ + maxRaiseTo: number; + smallBlind: number; + bigBlind: number; + buttonParticipantId: string; + opponents: Opponent[]; + legalActions: LegalActionType[]; + lastHandSummary: unknown; +} + +export type Action = + | { type: 'fold' } + | { type: 'check' } + | { type: 'call' } + | { type: 'raise'; amount: number } + | { type: 'allIn' }; diff --git a/bots/poker-texas-hold-em/tight-poker/tsconfig.json b/bots/poker-texas-hold-em/tight-poker/tsconfig.json new file mode 100644 index 0000000..ee87989 --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src" + ] +} diff --git a/bots/poker-texas-hold-em/tight-poker/vendor/.gitkeep b/bots/poker-texas-hold-em/tight-poker/vendor/.gitkeep new file mode 100644 index 0000000..62ca1f3 --- /dev/null +++ b/bots/poker-texas-hold-em/tight-poker/vendor/.gitkeep @@ -0,0 +1,6 @@ +This directory holds a vendored @thunderdome/bot-sdk tarball, generated by +scripts/pack-bot-sdk.sh — see that script's own comments for why bots/** vendors a tarball +instead of depending on the workspace package directly. Run it (from the repo root) to populate +this directory and generate a matching package-lock.json: + + ./scripts/pack-bot-sdk.sh diff --git a/bots/poker-texas-hold-em/tight-poker/vendor/thunderdome-bot-sdk.tgz b/bots/poker-texas-hold-em/tight-poker/vendor/thunderdome-bot-sdk.tgz new file mode 100644 index 0000000..3baf66f Binary files /dev/null and b/bots/poker-texas-hold-em/tight-poker/vendor/thunderdome-bot-sdk.tgz differ diff --git a/bots/rock-paper-scissors/t1000/Dockerfile b/bots/rock-paper-scissors/tominator-t1000/Dockerfile similarity index 100% rename from bots/rock-paper-scissors/t1000/Dockerfile rename to bots/rock-paper-scissors/tominator-t1000/Dockerfile diff --git a/bots/rock-paper-scissors/t1000/manifest.json b/bots/rock-paper-scissors/tominator-t1000/manifest.json similarity index 92% rename from bots/rock-paper-scissors/t1000/manifest.json rename to bots/rock-paper-scissors/tominator-t1000/manifest.json index 5a742c0..371ee65 100644 --- a/bots/rock-paper-scissors/t1000/manifest.json +++ b/bots/rock-paper-scissors/tominator-t1000/manifest.json @@ -1,6 +1,6 @@ { - "id": "t1000", - "name": "t1000", + "id": "tominator-t1000", + "name": "T-1000", "version": "1.0.0", "game": "rock-paper-scissors", "author": { diff --git a/bots/rock-paper-scissors/t800/package-lock.json b/bots/rock-paper-scissors/tominator-t1000/package-lock.json similarity index 97% rename from bots/rock-paper-scissors/t800/package-lock.json rename to bots/rock-paper-scissors/tominator-t1000/package-lock.json index c352c15..8863ea4 100644 --- a/bots/rock-paper-scissors/t800/package-lock.json +++ b/bots/rock-paper-scissors/tominator-t1000/package-lock.json @@ -1,11 +1,11 @@ { - "name": "t800", + "name": "tominator-t1000", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "t800", + "name": "tominator-t1000", "version": "1.0.0", "dependencies": { "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" diff --git a/bots/rock-paper-scissors/tx/package.json b/bots/rock-paper-scissors/tominator-t1000/package.json similarity index 90% rename from bots/rock-paper-scissors/tx/package.json rename to bots/rock-paper-scissors/tominator-t1000/package.json index 8c2c0ff..279a289 100644 --- a/bots/rock-paper-scissors/tx/package.json +++ b/bots/rock-paper-scissors/tominator-t1000/package.json @@ -1,5 +1,5 @@ { - "name": "tx", + "name": "tominator-t1000", "version": "1.0.0", "private": true, "type": "module", diff --git a/bots/rock-paper-scissors/t1000/smoke-test.mjs b/bots/rock-paper-scissors/tominator-t1000/smoke-test.mjs similarity index 82% rename from bots/rock-paper-scissors/t1000/smoke-test.mjs rename to bots/rock-paper-scissors/tominator-t1000/smoke-test.mjs index 012d3f3..00f518e 100644 --- a/bots/rock-paper-scissors/t1000/smoke-test.mjs +++ b/bots/rock-paper-scissors/tominator-t1000/smoke-test.mjs @@ -1,10 +1,11 @@ -// Verifies t1000 against the real Docker runtime: completes the handshake, always answers with a -// valid choice, is a deterministic function of rngSeed (docs/adr/0004-deterministic-randomness.md -// — not uncontrolled Math.random()), and shuts down cleanly. Requires: -// docker build -t thunderdome-t1000 . +// Verifies tominator-t1000 against the real Docker runtime: completes the handshake, always +// answers with a valid choice, is a deterministic function of rngSeed +// (docs/adr/0004-deterministic-randomness.md — not uncontrolled Math.random()), and shuts down +// cleanly. Requires: +// docker build -t thunderdome-tominator-t1000 . import { DockerBotProcess, BotLifecycle, DEFAULT_RESOURCE_LIMITS } from '@thunderdome/runtime'; -const IMAGE_TAG = 'thunderdome-t1000'; +const IMAGE_TAG = 'thunderdome-tominator-t1000'; function assertEqual(actual, expected, label) { const a = JSON.stringify(actual); @@ -19,7 +20,7 @@ async function playOneRound(matchId, rngSeed) { const botProcess = new DockerBotProcess({ imageRef: IMAGE_TAG, matchId, - participantId: 't1000', + participantId: 'tominator-t1000', resourceLimits: DEFAULT_RESOURCE_LIMITS, }); await botProcess.start(); @@ -29,8 +30,8 @@ async function playOneRound(matchId, rngSeed) { { gameId: 'rock-paper-scissors', gameVersion: '1.0.0', - participantId: 't1000', - roster: ['t1000', 'opponent'], + participantId: 'tominator-t1000', + roster: ['tominator-t1000', 'opponent'], rngSeed, config: { totalRounds: 3, onMissingAction: 'forfeitMatch' }, }, diff --git a/bots/rock-paper-scissors/t1000/src/index.ts b/bots/rock-paper-scissors/tominator-t1000/src/index.ts similarity index 96% rename from bots/rock-paper-scissors/t1000/src/index.ts rename to bots/rock-paper-scissors/tominator-t1000/src/index.ts index b4dfaa8..db13171 100644 --- a/bots/rock-paper-scissors/t1000/src/index.ts +++ b/bots/rock-paper-scissors/tominator-t1000/src/index.ts @@ -1,10 +1,9 @@ /** - * t1000 — ported from a standalone RPS-bot-tournament template ("T2"). Builds on the same - * primitives as t800 ("T1") but adds a real state machine over the match (RED_HERRING, RESEARCH, - * EXPLOIT, REEVALUATE, DEFENSE): a short repeated bait move at the top of every 100-turn block to - * see whether a naive opponent detector locks on; exploiting whenever a signal or recent win rate - * says it's working; falling back to RESEARCH (or, late and confidently ahead, DEFENSE) once a - * losing streak says it isn't. + * The T-1000 — Builds on the same primitives as the T-800 but adds a real state machine over the + * match (RED_HERRING, RESEARCH, EXPLOIT, REEVALUATE, DEFENSE): a short repeated bait move at the + * top of every 100-turn block to see whether a naive opponent detector locks on; exploiting + * whenever a signal or recent win rate says it's working; falling back to RESEARCH (or, late and + * confidently ahead, DEFENSE) once a losing streak says it isn't. * * All NDJSON wire-protocol handling lives in @thunderdome/bot-sdk's runBot() — see * docs/guides/bot-author-guide.md. diff --git a/bots/rock-paper-scissors/t1000/tsconfig.json b/bots/rock-paper-scissors/tominator-t1000/tsconfig.json similarity index 100% rename from bots/rock-paper-scissors/t1000/tsconfig.json rename to bots/rock-paper-scissors/tominator-t1000/tsconfig.json diff --git a/bots/rock-paper-scissors/t1000/vendor/thunderdome-bot-sdk.tgz b/bots/rock-paper-scissors/tominator-t1000/vendor/thunderdome-bot-sdk.tgz similarity index 100% rename from bots/rock-paper-scissors/t1000/vendor/thunderdome-bot-sdk.tgz rename to bots/rock-paper-scissors/tominator-t1000/vendor/thunderdome-bot-sdk.tgz diff --git a/bots/rock-paper-scissors/t800/Dockerfile b/bots/rock-paper-scissors/tominator-t800/Dockerfile similarity index 100% rename from bots/rock-paper-scissors/t800/Dockerfile rename to bots/rock-paper-scissors/tominator-t800/Dockerfile diff --git a/bots/rock-paper-scissors/t800/manifest.json b/bots/rock-paper-scissors/tominator-t800/manifest.json similarity index 92% rename from bots/rock-paper-scissors/t800/manifest.json rename to bots/rock-paper-scissors/tominator-t800/manifest.json index 39edb36..e78d1c7 100644 --- a/bots/rock-paper-scissors/t800/manifest.json +++ b/bots/rock-paper-scissors/tominator-t800/manifest.json @@ -1,6 +1,6 @@ { - "id": "t800", - "name": "t800", + "id": "tominator-t800", + "name": "T-800", "version": "1.0.0", "game": "rock-paper-scissors", "author": { diff --git a/bots/rock-paper-scissors/tx/package-lock.json b/bots/rock-paper-scissors/tominator-t800/package-lock.json similarity index 97% rename from bots/rock-paper-scissors/tx/package-lock.json rename to bots/rock-paper-scissors/tominator-t800/package-lock.json index 2c267fc..1132780 100644 --- a/bots/rock-paper-scissors/tx/package-lock.json +++ b/bots/rock-paper-scissors/tominator-t800/package-lock.json @@ -1,11 +1,11 @@ { - "name": "tx", + "name": "tominator-t800", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tx", + "name": "tominator-t800", "version": "1.0.0", "dependencies": { "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" diff --git a/bots/rock-paper-scissors/t1000/package.json b/bots/rock-paper-scissors/tominator-t800/package.json similarity index 90% rename from bots/rock-paper-scissors/t1000/package.json rename to bots/rock-paper-scissors/tominator-t800/package.json index ec6b43d..0266665 100644 --- a/bots/rock-paper-scissors/t1000/package.json +++ b/bots/rock-paper-scissors/tominator-t800/package.json @@ -1,5 +1,5 @@ { - "name": "t1000", + "name": "tominator-t800", "version": "1.0.0", "private": true, "type": "module", diff --git a/bots/rock-paper-scissors/tx/smoke-test.mjs b/bots/rock-paper-scissors/tominator-t800/smoke-test.mjs similarity index 82% rename from bots/rock-paper-scissors/tx/smoke-test.mjs rename to bots/rock-paper-scissors/tominator-t800/smoke-test.mjs index 1fa22ac..593367c 100644 --- a/bots/rock-paper-scissors/tx/smoke-test.mjs +++ b/bots/rock-paper-scissors/tominator-t800/smoke-test.mjs @@ -1,10 +1,11 @@ -// Verifies tx against the real Docker runtime: completes the handshake, always answers with a -// valid choice, is a deterministic function of rngSeed (docs/adr/0004-deterministic-randomness.md -// — not uncontrolled Math.random()), and shuts down cleanly. Requires: -// docker build -t thunderdome-tx . +// Verifies tominator-t800 against the real Docker runtime: completes the handshake, always +// answers with a valid choice, is a deterministic function of rngSeed +// (docs/adr/0004-deterministic-randomness.md — not uncontrolled Math.random()), and shuts down +// cleanly. Requires: +// docker build -t thunderdome-tominator-t800 . import { DockerBotProcess, BotLifecycle, DEFAULT_RESOURCE_LIMITS } from '@thunderdome/runtime'; -const IMAGE_TAG = 'thunderdome-tx'; +const IMAGE_TAG = 'thunderdome-tominator-t800'; function assertEqual(actual, expected, label) { const a = JSON.stringify(actual); @@ -19,7 +20,7 @@ async function playOneRound(matchId, rngSeed) { const botProcess = new DockerBotProcess({ imageRef: IMAGE_TAG, matchId, - participantId: 'tx', + participantId: 'tominator-t800', resourceLimits: DEFAULT_RESOURCE_LIMITS, }); await botProcess.start(); @@ -29,8 +30,8 @@ async function playOneRound(matchId, rngSeed) { { gameId: 'rock-paper-scissors', gameVersion: '1.0.0', - participantId: 'tx', - roster: ['tx', 'opponent'], + participantId: 'tominator-t800', + roster: ['tominator-t800', 'opponent'], rngSeed, config: { totalRounds: 3, onMissingAction: 'forfeitMatch' }, }, diff --git a/bots/rock-paper-scissors/t800/src/index.ts b/bots/rock-paper-scissors/tominator-t800/src/index.ts similarity index 96% rename from bots/rock-paper-scissors/t800/src/index.ts rename to bots/rock-paper-scissors/tominator-t800/src/index.ts index 770fdca..ca1a6ba 100644 --- a/bots/rock-paper-scissors/t800/src/index.ts +++ b/bots/rock-paper-scissors/tominator-t800/src/index.ts @@ -1,10 +1,9 @@ /** - * t800 — ported from a standalone RPS-bot-tournament template ("T1"). Three fixed phases over a - * 300-round match: early game (turns 0-98) explores via a no-replacement 33/33/33 deck while - * watching for an obvious opponent self-pattern worth exploiting immediately; mid game exploits - * whatever pattern the early-game window taught it; late game either keeps coasting on a - * still-winning read or re-evaluates (win-stay/lose-shift, then the general pattern signal) once - * its edge erodes. + * The T-800 — Three fixed phases over a 300-round match: early game (turns 0-98) explores via a + * no-replacement 33/33/33 deck while watching for an obvious opponent self-pattern worth exploiting + * immediately; mid game exploits whatever pattern the early-game window taught it; late game either + * keeps coasting on a still-winning read or re-evaluates (win-stay/lose-shift, then the general + * pattern signal) once its edge erodes. * * All NDJSON wire-protocol handling lives in @thunderdome/bot-sdk's runBot() — see * docs/guides/bot-author-guide.md. diff --git a/bots/rock-paper-scissors/t800/tsconfig.json b/bots/rock-paper-scissors/tominator-t800/tsconfig.json similarity index 100% rename from bots/rock-paper-scissors/t800/tsconfig.json rename to bots/rock-paper-scissors/tominator-t800/tsconfig.json diff --git a/bots/rock-paper-scissors/t800/vendor/thunderdome-bot-sdk.tgz b/bots/rock-paper-scissors/tominator-t800/vendor/thunderdome-bot-sdk.tgz similarity index 100% rename from bots/rock-paper-scissors/t800/vendor/thunderdome-bot-sdk.tgz rename to bots/rock-paper-scissors/tominator-t800/vendor/thunderdome-bot-sdk.tgz diff --git a/bots/rock-paper-scissors/tx/Dockerfile b/bots/rock-paper-scissors/tominator-tx/Dockerfile similarity index 100% rename from bots/rock-paper-scissors/tx/Dockerfile rename to bots/rock-paper-scissors/tominator-tx/Dockerfile diff --git a/bots/rock-paper-scissors/tx/manifest.json b/bots/rock-paper-scissors/tominator-tx/manifest.json similarity index 92% rename from bots/rock-paper-scissors/tx/manifest.json rename to bots/rock-paper-scissors/tominator-tx/manifest.json index aaf2068..e850182 100644 --- a/bots/rock-paper-scissors/tx/manifest.json +++ b/bots/rock-paper-scissors/tominator-tx/manifest.json @@ -1,6 +1,6 @@ { - "id": "tx", - "name": "tx", + "id": "tominator-tx", + "name": "T-X", "version": "1.0.0", "game": "rock-paper-scissors", "author": { diff --git a/bots/rock-paper-scissors/t1000/package-lock.json b/bots/rock-paper-scissors/tominator-tx/package-lock.json similarity index 97% rename from bots/rock-paper-scissors/t1000/package-lock.json rename to bots/rock-paper-scissors/tominator-tx/package-lock.json index d224736..b0379b1 100644 --- a/bots/rock-paper-scissors/t1000/package-lock.json +++ b/bots/rock-paper-scissors/tominator-tx/package-lock.json @@ -1,11 +1,11 @@ { - "name": "t1000", + "name": "tominator-tx", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "t1000", + "name": "tominator-tx", "version": "1.0.0", "dependencies": { "@thunderdome/bot-sdk": "file:./vendor/thunderdome-bot-sdk.tgz" diff --git a/bots/rock-paper-scissors/t800/package.json b/bots/rock-paper-scissors/tominator-tx/package.json similarity index 91% rename from bots/rock-paper-scissors/t800/package.json rename to bots/rock-paper-scissors/tominator-tx/package.json index 8b02eeb..46f541e 100644 --- a/bots/rock-paper-scissors/t800/package.json +++ b/bots/rock-paper-scissors/tominator-tx/package.json @@ -1,5 +1,5 @@ { - "name": "t800", + "name": "tominator-tx", "version": "1.0.0", "private": true, "type": "module", diff --git a/bots/rock-paper-scissors/t800/smoke-test.mjs b/bots/rock-paper-scissors/tominator-tx/smoke-test.mjs similarity index 82% rename from bots/rock-paper-scissors/t800/smoke-test.mjs rename to bots/rock-paper-scissors/tominator-tx/smoke-test.mjs index d4fbbc4..f9992e8 100644 --- a/bots/rock-paper-scissors/t800/smoke-test.mjs +++ b/bots/rock-paper-scissors/tominator-tx/smoke-test.mjs @@ -1,10 +1,11 @@ -// Verifies t800 against the real Docker runtime: completes the handshake, always answers with a -// valid choice, is a deterministic function of rngSeed (docs/adr/0004-deterministic-randomness.md -// — not uncontrolled Math.random()), and shuts down cleanly. Requires: -// docker build -t thunderdome-t800 . +// Verifies tominator-tx against the real Docker runtime: completes the handshake, always +// answers with a valid choice, is a deterministic function of rngSeed +// (docs/adr/0004-deterministic-randomness.md — not uncontrolled Math.random()), and shuts down +// cleanly. Requires: +// docker build -t thunderdome-tominator-tx . import { DockerBotProcess, BotLifecycle, DEFAULT_RESOURCE_LIMITS } from '@thunderdome/runtime'; -const IMAGE_TAG = 'thunderdome-t800'; +const IMAGE_TAG = 'thunderdome-tominator-tx'; function assertEqual(actual, expected, label) { const a = JSON.stringify(actual); @@ -19,7 +20,7 @@ async function playOneRound(matchId, rngSeed) { const botProcess = new DockerBotProcess({ imageRef: IMAGE_TAG, matchId, - participantId: 't800', + participantId: 'tominator-tx', resourceLimits: DEFAULT_RESOURCE_LIMITS, }); await botProcess.start(); @@ -29,8 +30,8 @@ async function playOneRound(matchId, rngSeed) { { gameId: 'rock-paper-scissors', gameVersion: '1.0.0', - participantId: 't800', - roster: ['t800', 'opponent'], + participantId: 'tominator-tx', + roster: ['tominator-tx', 'opponent'], rngSeed, config: { totalRounds: 3, onMissingAction: 'forfeitMatch' }, }, diff --git a/bots/rock-paper-scissors/tx/src/index.ts b/bots/rock-paper-scissors/tominator-tx/src/index.ts similarity index 97% rename from bots/rock-paper-scissors/tx/src/index.ts rename to bots/rock-paper-scissors/tominator-tx/src/index.ts index 9c2253e..871c899 100644 --- a/bots/rock-paper-scissors/tx/src/index.ts +++ b/bots/rock-paper-scissors/tominator-tx/src/index.ts @@ -1,3 +1,8 @@ +/** + * The T-X — Uses randomness as a means to keep the score even, all while learning about any + * potential opponent patterns. Make educated guesses about 10% of the time, and if we get a lead, + * then we go back to random as a way to continue to keep our score ahead. + */ import { runBot } from '@thunderdome/bot-sdk'; type RpsChoice = 'rock' | 'paper' | 'scissors'; diff --git a/bots/rock-paper-scissors/tx/tsconfig.json b/bots/rock-paper-scissors/tominator-tx/tsconfig.json similarity index 100% rename from bots/rock-paper-scissors/tx/tsconfig.json rename to bots/rock-paper-scissors/tominator-tx/tsconfig.json diff --git a/bots/rock-paper-scissors/tx/vendor/thunderdome-bot-sdk.tgz b/bots/rock-paper-scissors/tominator-tx/vendor/thunderdome-bot-sdk.tgz similarity index 100% rename from bots/rock-paper-scissors/tx/vendor/thunderdome-bot-sdk.tgz rename to bots/rock-paper-scissors/tominator-tx/vendor/thunderdome-bot-sdk.tgz diff --git a/docs/guides/README.md b/docs/guides/README.md index 34d64d0..2a28193 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -10,23 +10,23 @@ environment" actually are in plain language, then points you at Every guide below assumes you already have a working dev environment and know what a test is; neither is re-explained per-guide. -| Guide | For | Status | -| ---------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| [`getting-started.md`](getting-started.md) | Total beginners: Node/Yarn/Docker/dev-env, plain language | Real — a translation layer over the root README's setup steps, not a substitute for them | -| [`testing-guide.md`](testing-guide.md) | What unit/integration tests are, and writing your first one | Real — walks through this repo's actual test suites, no toy examples | -| [`bot-author-guide.md`](bot-author-guide.md) | Writing a bot for any game — one guide, game-agnostic throughout, with a dedicated section per game's own specifics | Real and testable today — protocol, runtime, and Rock-Paper-Scissors/Hearts (§9/§10) are all implemented | -| [`game-authoring-guide.md`](game-authoring-guide.md) | Implementing a new `GameDefinition` | Real — walks through Rock-Paper-Scissors and Connect Four in depth; Hearts is a third real, tested implementation of the same interface, described in [`bot-author-guide.md`](bot-author-guide.md) §10 and `games/card-game-hearts/src/types.ts` instead | -| [`human-friendly-games-guide.md`](human-friendly-games-guide.md) | Making a game pleasant to play by hand, and other ways to develop it further | Real — Rock-Paper-Scissors and Hearts both implement `humanInterface`; Connect Four doesn't yet, and is this guide's own worked exercise | -| [`tournament-author-guide.md`](tournament-author-guide.md) | Configuring and persisting a tournament | Real for round robin, single elimination, Swiss league, and persistence | -| [`tournament-format-authoring-guide.md`](tournament-format-authoring-guide.md) | Building a brand new `TournamentFormat` | Real — three real formats (round robin, single elimination, Swiss league) as worked references; pool-then-elimination still not built | -| [`protocol-reference.md`](protocol-reference.md) | The wire protocol, precisely | Real — every message type, with real examples from `packages/protocol/fixtures` | -| [`security-model.md`](security-model.md) | Isolation, timeouts, and cleanup guarantees | Real — summarizes ADR-0003 for bot authors and operators, plus resource-cleanup detail ADR-0003 itself doesn't spell out in one place | +| Guide | For | Status | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`getting-started.md`](getting-started.md) | Total beginners: Node/Yarn/Docker/dev-env, plain language | Real — a translation layer over the root README's setup steps, not a substitute for them | +| [`testing-guide.md`](testing-guide.md) | What unit/integration tests are, and writing your first one | Real — walks through this repo's actual test suites, no toy examples | +| [`bot-author-guide.md`](bot-author-guide.md) | Writing a bot for any game — one guide, game-agnostic throughout, with a dedicated section per game's own specifics | Real and testable today — protocol, runtime, and Rock-Paper-Scissors/Hearts/Texas Hold'em (§9/§10/§11) are all implemented; Connect Four doesn't have its own section yet but is fully playable | +| [`game-authoring-guide.md`](game-authoring-guide.md) | Implementing a new `GameDefinition` | Real — walks through Rock-Paper-Scissors and Connect Four in depth; Hearts and Texas Hold'em are two more real, tested implementations of the same interface, described in [`bot-author-guide.md`](bot-author-guide.md) §10/§11 and `games/card-game-hearts/src/types.ts` / `games/poker-texas-hold-em/src/types.ts` instead | +| [`human-friendly-games-guide.md`](human-friendly-games-guide.md) | Making a game pleasant to play by hand, and other ways to develop it further | Real — Rock-Paper-Scissors, Hearts, and Texas Hold'em all implement `humanInterface`; Connect Four doesn't yet, and is this guide's own worked exercise | +| [`tournament-author-guide.md`](tournament-author-guide.md) | Configuring and persisting a tournament | Real for round robin, single elimination, Swiss league, and persistence | +| [`tournament-format-authoring-guide.md`](tournament-format-authoring-guide.md) | Building a brand new `TournamentFormat` | Real — three real formats (round robin, single elimination, Swiss league) as worked references; pool-then-elimination still not built | +| [`protocol-reference.md`](protocol-reference.md) | The wire protocol, precisely | Real — every message type, with real examples from `packages/protocol/fixtures` | +| [`security-model.md`](security-model.md) | Isolation, timeouts, and cleanup guarantees | Real — summarizes ADR-0003 for bot authors and operators, plus resource-cleanup detail ADR-0003 itself doesn't spell out in one place | Connect Four (`games/connect-four`) is the platform's second real game — sequential and fully observable, unlike Rock-Paper-Scissors' simultaneous reveal. It doesn't have its own dedicated -section in [`bot-author-guide.md`](bot-author-guide.md) yet (only Rock-Paper-Scissors and Hearts -do, §9/§10) — its wire shapes (`ConnectFourObservation`/`ConnectFourAction`) are documented -directly in `games/connect-four/src/types.ts` and in +section in [`bot-author-guide.md`](bot-author-guide.md) yet (Rock-Paper-Scissors, Hearts, and +Texas Hold'em do, §9/§10/§11) — its wire shapes (`ConnectFourObservation`/`ConnectFourAction`) +are documented directly in `games/connect-four/src/types.ts` and in [`protocol-reference.md`](protocol-reference.md), and `bots/connect-four/` has two working reference bots (`leftmost-connect-four`, `random-connect-four`) to read for a concrete starting point. @@ -36,21 +36,27 @@ information and more than 2 players — 4 seats, your own hand private, a simult phase before each hand's sequential trick play. It **does** have its own section in [`bot-author-guide.md`](bot-author-guide.md) (§10), and `bots/card-game-hearts/` has five working bots (`random-hearts`, `lowest-card-hearts`, `point-dodger-hearts`, `tominator-t1`, -`tominator-t2` — see `bots/README.md` for which are reference bots versus real competitors). +`tominator-t101` — see `bots/README.md` for which are reference bots versus real competitors). [`game-authoring-guide.md`](game-authoring-guide.md) still only walks through Rock-Paper-Scissors and Connect Four from the _implementer's_ side — Hearts' own state machine, hidden-information handling, and rules are documented in `games/card-game-hearts/src/types.ts` and [`bot-author-guide.md`](bot-author-guide.md) §10 instead. -`examples/` holds complete, working example code referenced by the guides above: - -- [`examples/counter-bot/`](examples/counter-bot/) — a full Rock-Paper-Scissors bot, with its own - README. +Texas Hold'em (`games/poker-texas-hold-em`) is the platform's fourth real game and its first with +a variable-size table (2-10 players, rather than a fixed seat count) and real betting — no-limit +wagering with side pots across four streets per hand. It **does** have its own section in +[`bot-author-guide.md`](bot-author-guide.md) (§11), implements `humanInterface` +(`yarn thunderdome play`), and `bots/poker-texas-hold-em/` has three working reference bots +(`random-poker`, `calling-station-poker`, `tight-poker`). Like Hearts, [`game-authoring-guide.md`](game-authoring-guide.md) +doesn't walk through it from the _implementer's_ side — its own state machine, betting rules, and +side-pot math are documented in `games/poker-texas-hold-em/src/types.ts` and +[`bot-author-guide.md`](bot-author-guide.md) §11 instead. See [`/bots`](../../bots/) for the reference bots, and run `yarn thunderdome match run [...moreBotIds]` to play several of them against each other through the real registry, engine, and runtime end to end (`apps/cli/src/commands/match.ts`) — see [`bot-author-guide.md`](bot-author-guide.md) §7 for details (the registry/CLI/protocol mechanics -are the same for any game — Hearts just needs exactly 4 bot ids where Rock-Paper-Scissors needs -2). `yarn thunderdome play [...moreBotIds]` does the same thing with you filling one seat -instead of a bot — see [`apps/cli/README.md`](../../apps/cli/README.md#play). +are the same for any game — Hearts needs exactly 4 bot ids, Texas Hold'em takes 2-10, and +Rock-Paper-Scissors/Connect Four each need 2). `yarn thunderdome play [...moreBotIds]` +does the same thing with you filling one seat instead of a bot — see +[`apps/cli/README.md`](../../apps/cli/README.md#play). diff --git a/docs/guides/bot-author-guide.md b/docs/guides/bot-author-guide.md index 3a49f73..d531c3f 100644 --- a/docs/guides/bot-author-guide.md +++ b/docs/guides/bot-author-guide.md @@ -5,10 +5,11 @@ must speak, the manifest that describes it, the Docker image it ships in, and ho before submitting it. The process is identical no matter which game you're targeting — §1 through §8 are the same for every game on the platform. The handful of things that genuinely differ by game (the exact shape of what you receive and what you send back) live in their own section at the -end: [§9 Rock-Paper-Scissors specifics](#9-rock-paper-scissors-specifics) and -[§10 Hearts specifics](#10-hearts-specifics). Building a bot for a game without its own section -here (Connect Four, or one you're authoring yourself)? That game's own `src/types.ts` plus -[`protocol-reference.md`](protocol-reference.md) document the same three things (config, +end: [§9 Rock-Paper-Scissors specifics](#9-rock-paper-scissors-specifics), +[§10 Hearts specifics](#10-hearts-specifics), and +[§11 Texas Hold'em specifics](#11-texas-holdem-specifics). Building a bot for a game without its +own section here (Connect Four, or one you're authoring yourself)? That game's own `src/types.ts` +plus [`protocol-reference.md`](protocol-reference.md) document the same three things (config, observation, action) a dedicated section would. **Status check first:** the protocol (`docs/adr/0002-universal-bot-protocol.md`), the Docker @@ -17,11 +18,12 @@ schema and the developer-facing `runBot()` protocol client) are all implemented and identical regardless of which game you're writing for. So is the bot registry and the `match run`/`play`/`tournament run` CLI — see [`tournament-author-guide.md`](tournament-author-guide.md) for round robin, single elimination, -and Swiss league. Three games have real, playable bots today: Rock-Paper-Scissors (2 players, +and Swiss league. Four games have real, playable bots today: Rock-Paper-Scissors (2 players, simultaneous, fully observable), Connect Four (2 players, sequential, fully observable — no -dedicated bot-author section yet, see above), and Hearts (4 players, hidden information, two -action shapes). Where this guide describes something that doesn't exist yet, it says so -explicitly rather than pretending otherwise. +dedicated bot-author section yet, see above), Hearts (4 players, hidden information, two action +shapes), and Texas Hold'em (2-10 players, hidden information, no-limit betting). Where this guide +describes something that doesn't exist yet, it says so explicitly rather than pretending +otherwise. **New to Node, Docker, or dev environments in general?** [`getting-started.md`](getting-started.md) explains those from first principles before this guide @@ -32,11 +34,12 @@ assumes you already have them working. 1. Run `yarn scaffold:bot ` for a working starting point (already using `@thunderdome/bot-sdk`'s `runBot()`, §4) rather than writing the protocol plumbing by hand. 2. Replace the scaffolded `decideAction()` with your actual strategy — §2 explains what you always - receive and return conceptually; §9/§10 give the exact types for Rock-Paper-Scissors/Hearts. + receive and return conceptually; §9/§10/§11 give the exact types for Rock-Paper-Scissors/ + Hearts/Texas Hold'em. 3. Fill in `manifest.json` (§5) and make sure your `Dockerfile` builds a self-contained image (§6). 4. Test it — first via a scripted smoke test against your own container (§7, [`testing-guide.md`](testing-guide.md) if "integration test" is a new term to you), then for - real: `yarn thunderdome match run ` (§9/§10 give the exact + real: `yarn thunderdome match run ` (§9/§10/§11 give the exact number of opponents your game needs). 5. Open a PR touching only `bots///` (§8). @@ -88,7 +91,12 @@ what follows): ```ts interface Envelope { protocolVersion: string; // "1.0" today - type: 'init' | 'observation' | 'action' | 'match-end' | /* a few more, rarely needed — see protocol-reference.md */ string; + type: + | 'init' + | 'observation' + | 'action' + | 'match-end' + | /* a few more, rarely needed — see protocol-reference.md */ string; matchId: string; roundId?: number; // present on round-scoped messages only seq: number; // per-direction monotonic counter, starting at 0 @@ -127,8 +135,9 @@ A few rules apply no matter which game you're playing: - **Exit promptly (exit code 0) once you receive `match-end`.** If you don't, the runtime closes your stdin, then escalates to `SIGTERM`, then `SIGKILL` (`docs/adr/0003-docker-bot-isolation.md`). -§9 and §10 show the full, concrete message-by-message trace for a real Rock-Paper-Scissors match -and a real Hearts hand respectively — the shapes above, with real numbers filled in. +§9, §10, and §11 show the full, concrete message-by-message trace for a real Rock-Paper-Scissors +match, a real Hearts hand, and a real Texas Hold'em hand respectively — the shapes above, with +real numbers filled in. ## 4. Writing your bot: a complete example @@ -140,7 +149,7 @@ used by every real bot on the platform. With it, your entire bot is just: import { runBot } from '@thunderdome/bot-sdk'; function decideAction(observation) { - return { choice: 'rock' }; // whatever your game's Action shape is — see §9/§10 + return { choice: 'rock' }; // whatever your game's Action shape is — see §9/§10/§11 } runBot({ decideAction }); @@ -156,10 +165,8 @@ zero dependencies — useful if you're writing a bot in a language other than TS want to understand what `runBot()` is doing under the hood. It splits cleanly into two pieces: generic protocol plumbing (framing, the init/ready handshake, match-end shutdown) that has zero knowledge of any particular game, and your actual strategy (one function: given this round's -observation, what's your action?). The working example — "Counter Bot," a Rock-Paper-Scissors bot -that plays whatever beats the opponent's most recently revealed choice, or `rock` on the first -round — is at [`docs/guides/examples/counter-bot/`](examples/counter-bot/); see its own -[README](examples/counter-bot/README.md) for how to run it. The generic half: +observation, what's your action?). The worked example below plays whatever beats the opponent's +most recently revealed choice, or `rock` on the first round. The generic half: ```js // harness.mjs — copy this file as-is for a bot for a different game; nothing here is @@ -258,7 +265,7 @@ Notes, true for any game: - `id` must be kebab-case and must match the bot's directory name once it's submitted. - `game` must match an existing game's id — today, one of `rock-paper-scissors`, `connect-four`, - or `card-game-hearts`. + `card-game-hearts`, or `poker-texas-hold-em`. - `interface.transport` must be `"stdio"` — that's the only transport the protocol supports today. - `resources` is optional and, if present, is a _request_, not an authority — the runtime clamps to platform-enforced hard caps regardless of what you ask for. @@ -277,7 +284,7 @@ under those constraints (small memory footprint, no network calls, no writes out Three real shapes exist on the platform today, depending on your bot: -**Zero-dependency, hand-rolled protocol (§4's Counter Bot):** +**Zero-dependency, hand-rolled protocol (§4's worked example):** ```dockerfile FROM node:25-alpine @@ -332,18 +339,19 @@ ENTRYPOINT ["node", "dist/index.js"] ## 7. Testing your bot locally Three levels, from narrowest to broadest — identical regardless of game, apart from how many -opponent bot ids a real match needs (§9/§10 give the exact number and real bot ids to use). +opponent bot ids a real match needs (§9/§10/§11 give the exact number and real bot ids to use). **In isolation, against a scripted observation sequence.** Exercise your bot's container directly with the same runtime primitives the platform itself uses (`@thunderdome/runtime`'s `DockerBotProcess`/`BotLifecycle`) — see -[`examples/counter-bot/README.md`](examples/counter-bot/README.md) for the generic steps, or [`bots/card-game-hearts/point-dodger-hearts/smoke-test.mjs`](../../bots/card-game-hearts/point-dodger-hearts/smoke-test.mjs) for a worked example that scripts several distinct observations (leading, ducking, voiding, -forced-to-win) and asserts the exact action back. Good template for your own bot regardless of -game: swap in your image tag and whatever observation sequence exercises your strategy's branches. -[`testing-guide.md`](testing-guide.md) explains what "integration test" means generally, if this -is new to you. +forced-to-win) and asserts the exact action back, or +[`bots/poker-texas-hold-em/random-poker/smoke-test.mjs`](../../bots/poker-texas-hold-em/random-poker/smoke-test.mjs) +for one that also checks the bot's own randomness is deterministic given the same `rngSeed`. Good +template for your own bot regardless of game: swap in your image tag and whatever observation +sequence exercises your strategy's branches. [`testing-guide.md`](testing-guide.md) explains what +"integration test" means generally, if this is new to you. **Against real opponents, end to end.** `yarn thunderdome match run [...moreBotIds]` resolves every bot id and the game they share through the real bot/game registry @@ -351,7 +359,7 @@ is new to you. manual `docker build` step first — and drives a real match through the generic engine (`@thunderdome/engine`'s `runMatch()`) and runtime, printing round results and final standings. How many bot ids you need depends entirely on the game (2 for Rock-Paper-Scissors or Connect Four, -exactly 4 for Hearts) — §9/§10 give concrete commands. +exactly 4 for Hearts, 2-10 for Texas Hold'em) — §9/§10/§11 give concrete commands. **Against yourself, by hand.** `yarn thunderdome play [...moreBotIds]` puts you in the ring instead of one of the bots — same registry resolution and real Docker match, but each @@ -535,7 +543,7 @@ hands are played — a real, honest outcome of actual gameplay, not a cop-out. `bots/rock-paper-scissors/only-rock/src/index.ts` (and its siblings `only-paper`, `only-scissors`) are the simplest real, `runBot()`-based bots to read; see [`bots/README.md`](../../bots/README.md) for the full roster, from trivial reference bots through -`t800`/`t1000`/`tx`'s real adaptive strategies. +`tominator-t800`/`tominator-t1000`/`tominator-tx`'s real adaptive strategies. ### Testing it for real @@ -586,7 +594,7 @@ interface HeartsConfig { ``` Handed to you (opaque, informational) in `init`'s `payload.config`. Lower is better in Hearts — -`pointLimit` is the *ceiling* a score has to cross to end the match, not a target to race toward. +`pointLimit` is the _ceiling_ a score has to cross to end the match, not a target to race toward. **Cards, on the wire** — a plain object, never a compact string like `"QS"`: @@ -598,7 +606,7 @@ interface Card { ``` (The compact `"QS"`/`"TH"` string form only exists in `thunderdome play`'s terminal text UI for a -*human* typing commands. A bot's `Card` objects on the wire are never strings.) +_human_ typing commands. A bot's `Card` objects on the wire are never strings.) **Observation** — what you receive: @@ -716,7 +724,10 @@ round (the other 3 don't hear anything until it's their turn): "phase": "playing", "handNumber": 0, "passDirection": "left", - "hand": [{ "suit": "clubs", "rank": 9 }, { "suit": "diamonds", "rank": 4 }], + "hand": [ + { "suit": "clubs", "rank": 9 }, + { "suit": "diamonds", "rank": 4 } + ], "handSizes": { "your-bot-id": 2, "p2": 3, "p3": 3, "p4": 3 }, "heartsBroken": true, "tricksCompleted": 11, @@ -828,3 +839,281 @@ again." instead of silently repeating the prompt. Type `quit` (or `resign`) any early instead of playing the whole match out. See [`apps/cli/README.md`](../../apps/cli/README.md#play) for the full CLI reference, including `--as` (your own participant id) and Ctrl+C behavior. + +## 11. Texas Hold'em specifics + +### What makes Texas Hold'em different + +Texas Hold'em is **2-10 players, hidden information, no-limit betting**, the richest action +surface of any game on the platform so far: + +- The roster size is variable, not fixed like every other game (RPS/Connect Four are always 2, + Hearts is always 4) — your bot has to work correctly whether it's facing 1 opponent or 9. +- Like Hearts, you only ever see your own 2 hole cards — everyone else's are hidden until a + showdown reveals them (and never at all if the hand ends by everyone-but-one folding). +- Unlike Hearts' two action shapes, there are **five**: `fold`, `check`, `call`, `raise` (with an + `amount`), and `allIn`. Which ones are actually legal right now is state-dependent (you can't + `check` into a bet, you can't `call` with nothing to call) — `observation.legalActions` tells you + exactly which of the five apply this turn, advisory only (`validateAction` is the real + authority, same as `legalPlays` in Hearts). +- A hand plays out over up to 4 **streets** (`preflop`, `flop`, `turn`, `river`) with a full round + of betting on each — `observation.street` and `observation.board` (0, 3, 4, or 5 community + cards) tell you where you are. +- A match is many hands, like Hearts, but ends one of two ways depending on + `config.matchFormat`: `'elimination'` (hands repeat until only one participant still has chips — + the default) or `'fixedHands'` (play exactly `config.totalHands` hands, then rank by final chip + count). Either way, a hand can bust a participant down to 0 chips along the way; the match also + ends early if that ever leaves fewer than 2 players with chips to deal another hand to. +- **A missing/invalid/timed-out action forfeits the whole match** — unlike Hearts, which + substitutes a legal card on your behalf. Texas Hold'em has no sensible stand-in for "what would + you have bet," the same reasoning Connect Four uses for having no sensible stand-in move. + +Everything else — the NDJSON wire framing, the init/ready handshake, `stdout` being +protocol-only, `seq` strictly increasing, using `@thunderdome/bot-sdk`'s `runBot()` — is identical +to every other game on the platform (§1-§8 above). + +### The contract + +**Config**: + +```ts +interface PokerTexasHoldEmConfig { + matchFormat: 'elimination' | 'fixedHands'; // default: 'elimination' + totalHands: number; // only consulted when matchFormat === 'fixedHands'; default 10 + startingStack: number; // chips every participant starts a match with; default 1000 + smallBlind: number; // default 10 + bigBlind: number; // must exceed smallBlind; default 20 +} +``` + +Handed to you (opaque, informational) in `init`'s `payload.config`. + +**Cards, on the wire** — the same plain object as Hearts, never a compact string like `"AS"`: + +```ts +interface Card { + suit: 'clubs' | 'diamonds' | 'hearts' | 'spades'; + rank: number; // 2..10, then 11=J, 12=Q, 13=K, 14=A +} +``` + +**Observation** — what you receive, every time it's your turn to act: + +```ts +interface PokerTexasHoldEmObservation { + you: string; + handNumber: number; // 0-based + street: 'preflop' | 'flop' | 'turn' | 'river'; + board: Card[]; // community cards revealed so far — 0, 3, 4, or 5 of them + holeCards: [Card, Card]; // YOUR 2 hole cards — nobody else's ever appear here + pot: number; // everyone's total chips committed this hand so far, across all streets + yourStack: number; // chips NOT yet committed to this hand's pot + yourCommittedThisStreet: number; + toCall: number; // 0 means you can check + minRaiseTo: number | null; // the minimum legal `raise` amount; null if you have no chips left to raise with + maxRaiseTo: number; // your all-in amount — the max legal `raise` amount + smallBlind: number; + bigBlind: number; + buttonParticipantId: string; + opponents: { + participantId: string; + stack: number; + committed: number; // this opponent's total committed this hand so far + committedThisStreet: number; + folded: boolean; + allIn: boolean; + isButton: boolean; + }[]; // every other still-in-the-match participant, in seat order starting after you + legalActions: ('fold' | 'check' | 'call' | 'raise' | 'allIn')[]; // advisory — see above + lastHandSummary: { + handNumber: number; + winners: { participantId: string; amount: number }[]; + reason: 'fold' | 'showdown'; + showdown?: { participantId: string; holeCards: [Card, Card]; category: string }[]; // only present for reason === 'showdown' + board: Card[]; + } | null; // null before the first hand's result exists +} +``` + +`lastHandSummary` exists for the same reason Hearts' `lastTrick` does — a stateless bot process +otherwise has no way to know what just happened before this turn's observation arrived. + +**Action** — a discriminated union; `legalActions` tells you which are legal this turn: + +```ts +type PokerTexasHoldEmAction = + | { type: 'fold' } + | { type: 'check' } + | { type: 'call' } + | { type: 'raise'; amount: number } // "raise TO", not "raise BY" — see below + | { type: 'allIn' }; +``` + +Two things worth knowing about `raise`: `amount` is the **total** you want +`yourCommittedThisStreet` to become after the raise, not how much more you're adding — so opening +the first bet of a street and reraising an existing bet use the exact same shape, just a bigger +`amount`. And `allIn` is a convenience over `raise` with `amount` set to your entire stack — it +saves you from having to know your own exact `yourStack`/`yourCommittedThisStreet` arithmetic just +to shove; use it instead of computing `maxRaiseTo` yourself. + +### The wire protocol, concretely + +Here's an **observation** partway through a hand — the flop, facing a bet — and the **action** +reply, from your bot's point of view (the concrete version of §3's generic envelope; `init`/`ready` +are identical in shape to every other game, just with `"gameId": "poker-texas-hold-em"`): + +**Engine → you**: + +```json +{ + "protocolVersion": "1.0", + "type": "observation", + "matchId": "match-001", + "roundId": 6, + "seq": 7, + "sentAt": "2026-01-01T00:00:03.000Z", + "payload": { + "state": { + "you": "your-bot-id", + "handNumber": 0, + "street": "flop", + "board": [ + { "suit": "clubs", "rank": 9 }, + { "suit": "diamonds", "rank": 4 }, + { "suit": "hearts", "rank": 2 } + ], + "holeCards": [ + { "suit": "spades", "rank": 14 }, + { "suit": "spades", "rank": 13 } + ], + "pot": 140, + "yourStack": 400, + "yourCommittedThisStreet": 0, + "toCall": 40, + "minRaiseTo": 80, + "maxRaiseTo": 440, + "smallBlind": 10, + "bigBlind": 20, + "buttonParticipantId": "opponent-id", + "opponents": [ + { + "participantId": "opponent-id", + "stack": 360, + "committed": 40, + "committedThisStreet": 40, + "folded": false, + "allIn": false, + "isButton": true + } + ], + "legalActions": ["fold", "call", "raise", "allIn"], + "lastHandSummary": null + }, + "awaitingAction": true, + "deadlineAt": "2026-01-01T00:00:08.000Z" + } +} +``` + +`check` isn't in `legalActions` here — there's a bet to call (`toCall: 40`), so it's `call` or +nothing. **You → engine**, calling it: + +```json +{ + "protocolVersion": "1.0", + "type": "action", + "matchId": "match-001", + "roundId": 6, + "seq": 4, + "sentAt": "2026-01-01T00:00:03.400Z", + "payload": { "action": { "type": "call" } } +} +``` + +This repeats for every street of every hand — however many it takes for the match to end (see +`config.matchFormat` above) — then ends with `match-end` exactly like every other game: + +```json +{ + "protocolVersion": "1.0", + "type": "match-end", + "matchId": "match-001", + "seq": 41, + "sentAt": "2026-01-01T00:02:12.000Z", + "payload": { + "result": { + "participantIds": ["your-bot-id", "opponent-id"], + "stacks": { "your-bot-id": 800, "opponent-id": 0 }, + "bustedOut": [["opponent-id"]], + "handsPlayed": 7 + }, + "reason": "completed" + } +} +``` + +`bustedOut` groups participants by which hand they busted in — here, `opponent-id` ran out of +chips during the 7th and final hand, ending the match (`elimination` format's default). + +### Reference bots + +`bots/poker-texas-hold-em/random-poker/index.mjs` picks uniformly at random among whatever +`legalActions` allows, with a uniformly random `raise` amount — the simplest real strategy to +read. `bots/poker-texas-hold-em/calling-station-poker/index.mjs` is even simpler: it never folds +or raises, just checks when it can and calls otherwise — a fixed, deterministic baseline to +measure other poker bots against. `bots/poker-texas-hold-em/tight-poker/` is a real TypeScript +example: it bets/raises only with a good hand (a standard tight preflop range in +`src/strategy.ts`'s `isPremiumHoleCards`, or a made pair-or-better postflop in `hasMadeHand`) and +never bluffs, folding a weak hand to anything pricier than the big blind. See +[`bots/README.md`](../../bots/README.md) for all three. + +### Testing it for real + +Texas Hold'em takes **2 to 10** participants, not a fixed count like Hearts' exactly-4 — any +number of bot ids in that range works: + +```bash +yarn thunderdome match run random-poker calling-station-poker \ + --config '{"startingStack":500,"totalHands":5,"matchFormat":"fixedHands"}' +``` + +`--config` is whatever JSON `parseConfig` expects (see the `PokerTexasHoldEmConfig` shape above, +or `games/poker-texas-hold-em/src/types.ts`'s `PokerTexasHoldEmConfigSchema`). Any bot in +`bots/poker-texas-hold-em/` is playable this way, by its manifest `id` — including your own, once +it's merged. + +```bash +yarn thunderdome play random-poker --game-config '{"startingStack":300,"smallBlind":10,"bigBlind":20,"totalHands":3,"matchFormat":"fixedHands"}' +``` + +puts you in the ring against `random-poker` instead. Every time it's your turn, your prompt is +printed right here in the terminal — the street and board, the pot, your hole cards, every +opponent's stack/status, and the exact legal actions with their min/max amounts: + +``` +---------- + +Texas Hold'em — Hand 1 (preflop) +Blinds: 10/20 — button: you +Board: (none) +Pot: 30 + +You [button]: stack 290, committed 10 this street, hole cards: 9D 5H +Opponents: + random-poker: stack 280, committed 20 this hand (in) + +To call: 10 +Cards: rank+suit — 2-9, T=10, J, Q, K, A; C=clubs, D=diamonds, H=hearts, S=spades. +Type: FOLD | CALL (10) | RAISE (min 40, max 300) | ALLIN (300) +> call +You called. +``` + +Type `FOLD`, `CHECK`, `CALL`, `RAISE `, or `ALLIN` (case-insensitive; `F`/`X`/`C`/`R`/`A` +and a few other aliases work too — `games/poker-texas-hold-em/src/human.ts`'s `parseInput` has the +full list). Right after you submit, the CLI echoes back exactly how it understood your input +(`You called.`, `You raised to 80.`) via the same `humanInterface.describeAction` hook Hearts uses, +for the same reason: a well-formed-but-unintended amount is still a real, submittable action, not +a typo the CLI can catch for you. Type `quit` (or `resign`) any time to forfeit early instead of +playing the whole match out. See [`apps/cli/README.md`](../../apps/cli/README.md#play) for the +full CLI reference. diff --git a/docs/guides/examples/counter-bot/Dockerfile b/docs/guides/examples/counter-bot/Dockerfile deleted file mode 100644 index 39295d2..0000000 --- a/docs/guides/examples/counter-bot/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM node:25-alpine -WORKDIR /app -COPY harness.mjs strategy.mjs index.mjs ./ -ENTRYPOINT ["node", "index.mjs"] diff --git a/docs/guides/examples/counter-bot/README.md b/docs/guides/examples/counter-bot/README.md deleted file mode 100644 index cd389b4..0000000 --- a/docs/guides/examples/counter-bot/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Counter Bot (example) - -A complete, working Rock-Paper-Scissors bot for -[the bot author guide](../../bot-author-guide.md). Plays whatever beats the opponent's most -recently revealed choice, or `rock` on the first round when there's no history yet. - -## Files - -| File | What it is | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `harness.mjs` | Generic NDJSON protocol plumbing — no Rock-Paper-Scissors knowledge at all. Reusable as-is for a bot for a different game. | -| `strategy.mjs` | The one Rock-Paper-Scissors-specific function: `decideAction(observation) -> action`. | -| `index.mjs` | Three-line entrypoint wiring `harness.mjs` and `strategy.mjs` together. | -| `manifest.json` | The bot manifest (`docs/guides/bot-author-guide.md` §5). | -| `Dockerfile` | Packages the three `.mjs` files into a self-contained image. | -| `smoke-test.mjs` | Drives a real container through a scripted exchange using `@thunderdome/runtime`, and asserts the bot replies correctly. | - -## Steps - -1. **Build the image:** - - ```bash - cd docs/guides/examples/counter-bot - docker build -t thunderdome-counter-bot-example . - ``` - -2. **Sanity-check it by hand** (optional) — feed it one `init` message directly and confirm it - replies with `ready`: - - ```bash - echo '{"protocolVersion":"1.0","type":"init","matchId":"m1","seq":0,"sentAt":"2026-01-01T00:00:00.000Z","payload":{"gameId":"rock-paper-scissors","gameVersion":"1.0.0","participantId":"p1","roster":["p1","p2"],"rngSeed":"abc","config":{}}}' \ - | docker run --rm -i thunderdome-counter-bot-example - ``` - -3. **Run the smoke test** — from the repo root (it needs `@thunderdome/runtime`, resolved via the - monorepo's `node_modules`): - - ```bash - cd /path/to/thunderdome - node docs/guides/examples/counter-bot/smoke-test.mjs - ``` - - Expected output: - - ``` - ok - bot completes init/ready handshake - ok - round 0: plays rock with no history - ok - round 1: counters opponent's last "paper" with "scissors" - ok - shuts down cleanly on match-end - ok - no fault recorded - - All checks passed. - ``` - -4. **Adapt it for your own bot** — copy this directory, replace `strategy.mjs`'s `decideAction` - with your own logic, update `manifest.json`'s `id`/`name`/`author`/`description`, and point - `smoke-test.mjs`'s `IMAGE_TAG` at your image and its assertions at your strategy's actual - choices. - -## A note on flakiness - -Docker's `attach` used to occasionally race container startup and the smoke test would time out -waiting for `ready` even though the bot and image were fine. This was root-caused (a dropped -first write into a freshly attached container's stdin) and fixed by a retry in -`@thunderdome/runtime` (`packages/runtime/src/first-write-retry.ts`) — see -[`scripts/README.md`](../../../../scripts/README.md#a-known-docker-reliability-issue-root-caused-and-fixed) -for the full story. If you still see this, it's worth investigating as a new issue rather than -assuming it's the old one. - -## What this doesn't cover - -This example only proves your bot speaks the protocol correctly in isolation, against a scripted -opponent view — not a real second bot. This directory is a doc illustration, not itself a -registry entry (it lives under `docs/guides/examples/`, not `bots/`) — see the real bots in -[`/bots/rock-paper-scissors/`](../../../../bots/rock-paper-scissors/) for that. Once your own bot -is submitted there (`docs/guides/bot-author-guide.md` §8), `yarn thunderdome match run - ` resolves both through the real bot registry and drives an actual -match via `@thunderdome/engine` and `@thunderdome/runtime` — or -`yarn thunderdome tournament run [...]` for a real round-robin -tournament among more than two bots. See -[`docs/guides/tournament-author-guide.md`](../../tournament-author-guide.md) for what's still -missing beyond round robin (other formats, persistence). diff --git a/docs/guides/examples/counter-bot/harness.mjs b/docs/guides/examples/counter-bot/harness.mjs deleted file mode 100644 index b2ebf7c..0000000 --- a/docs/guides/examples/counter-bot/harness.mjs +++ /dev/null @@ -1,66 +0,0 @@ -// A minimal, generic NDJSON protocol harness — reusable for any Thunderdome bot, for any game, -// in any Node script. It handles the mechanical parts of the wire protocol (message framing, -// envelope fields, the init/ready handshake, and match-end shutdown) and calls out to your -// strategy for exactly one decision per round: given this round's observation, what action do -// you take? It has zero knowledge of Rock-Paper-Scissors or any other game — copy this file -// as-is into a bot for a different game and only `strategy.mjs` needs to change. -// -// See docs/guides/bot-author-guide.md for the protocol this implements. -import { createInterface } from 'node:readline'; - -/** - * @param {{ decideAction: (observation: unknown) => unknown }} strategy - * `decideAction` receives `observation.payload.state` for a round with `awaitingAction: true` - * and must return the action payload's `action` value — for Rock-Paper-Scissors that's - * `{ choice: 'rock' | 'paper' | 'scissors' }`, but the harness never looks inside it. - */ -export function runBot({ decideAction }) { - let seq = 0; - - function send(matchId, type, fields) { - process.stdout.write( - `${JSON.stringify({ - protocolVersion: '1.0', - type, - matchId, - seq: seq++, - sentAt: new Date().toISOString(), - ...fields, - })}\n`, - ); - } - - const rl = createInterface({ input: process.stdin }); - - rl.on('line', (line) => { - let message; - try { - message = JSON.parse(line); - } catch { - return; // malformed input isn't something a bot can usefully react to - } - - switch (message.type) { - case 'init': { - // message.payload has { gameId, gameVersion, participantId, roster, rngSeed, config } — - // this generic harness doesn't need any of it, but your strategy module can import it - // separately and read message.payload.config if your game needs it. - send(message.matchId, 'ready', { payload: { protocolVersion: '1.0' } }); - break; - } - case 'observation': { - if (!message.payload.awaitingAction) { - break; // an informational observation this round doesn't require a reply - } - send(message.matchId, 'action', { - roundId: message.roundId, - payload: { action: decideAction(message.payload.state) }, - }); - break; - } - case 'match-end': { - process.exit(0); - } - } - }); -} diff --git a/docs/guides/examples/counter-bot/index.mjs b/docs/guides/examples/counter-bot/index.mjs deleted file mode 100644 index d19bdac..0000000 --- a/docs/guides/examples/counter-bot/index.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { runBot } from './harness.mjs'; -import { decideAction } from './strategy.mjs'; - -runBot({ decideAction }); diff --git a/docs/guides/examples/counter-bot/manifest.json b/docs/guides/examples/counter-bot/manifest.json deleted file mode 100644 index c34ca4d..0000000 --- a/docs/guides/examples/counter-bot/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "counter-bot", - "name": "Counter Bot", - "version": "1.0.0", - "game": "rock-paper-scissors", - "author": { - "name": "Thunderdome Docs", - "contact": "docs@example.com" - }, - "runtime": { - "language": "node" - }, - "interface": { - "transport": "stdio" - }, - "protocolVersion": "^1.0", - "description": "Plays whatever beats the opponent's most recently revealed choice; rock on the first round." -} diff --git a/docs/guides/examples/counter-bot/smoke-test.mjs b/docs/guides/examples/counter-bot/smoke-test.mjs deleted file mode 100644 index 7fa0eb3..0000000 --- a/docs/guides/examples/counter-bot/smoke-test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -// Drives this bot's real Docker container through a scripted exchange using the platform's own -// runtime primitives (docs/guides/bot-author-guide.md, "Testing your bot locally"). There's -// no CLI wrapper for this specific kind of single-bot, scripted-opponent smoke test — -// `yarn thunderdome match run` is for real two-bot matches through the registry instead — so -// this script exercises the same DockerBotProcess + BotLifecycle pieces the platform itself uses. -// -// Usage: -// docker build -t thunderdome-counter-bot-example . -// node smoke-test.mjs -import { DockerBotProcess, BotLifecycle, DEFAULT_RESOURCE_LIMITS } from '@thunderdome/runtime'; - -const IMAGE_TAG = 'thunderdome-counter-bot-example'; -const MATCH_ID = 'smoke-test-match'; - -function assertEqual(actual, expected, label) { - const a = JSON.stringify(actual); - const e = JSON.stringify(expected); - if (a !== e) { - throw new Error(`${label}: expected ${e}, got ${a}`); - } - console.log(`ok - ${label}`); -} - -const botProcess = new DockerBotProcess({ - imageRef: IMAGE_TAG, - matchId: MATCH_ID, - participantId: 'counter-bot', - resourceLimits: DEFAULT_RESOURCE_LIMITS, -}); -await botProcess.start(); - -const lifecycle = new BotLifecycle({ process: botProcess, matchId: MATCH_ID }); - -const initOutcome = await lifecycle.initialize( - { - gameId: 'rock-paper-scissors', - gameVersion: '1.0.0', - participantId: 'counter-bot', - roster: ['counter-bot', 'opponent'], - rngSeed: 'deadbeef', - config: { totalRounds: 3, onMissingAction: 'forfeitMatch' }, - }, - { initTimeoutMs: 10_000 }, -); -assertEqual(initOutcome, { ok: true }, 'bot completes init/ready handshake'); - -// Round 0: no history yet -> expect "rock". -lifecycle.sendObservation(0, { - state: { - round: 0, - totalRounds: 3, - yourWins: 0, - opponentWins: 0, - opponentId: 'opponent', - history: [], - }, - awaitingAction: true, -}); -const round0 = await lifecycle.awaitAction(0, 10_000); -assertEqual( - round0, - { ok: true, action: { choice: 'rock' } }, - 'round 0: plays rock with no history', -); - -// Round 1: opponent played "paper" last round (and won) -> expect "scissors" (beats paper). -lifecycle.sendObservation(1, { - state: { - round: 1, - totalRounds: 3, - yourWins: 0, - opponentWins: 1, - opponentId: 'opponent', - history: [{ round: 0, you: 'rock', opponent: 'paper', winner: 'opponent' }], - }, - awaitingAction: true, -}); -const round1 = await lifecycle.awaitAction(1, 10_000); -assertEqual( - round1, - { ok: true, action: { choice: 'scissors' } }, - 'round 1: counters opponent\'s last "paper" with "scissors"', -); - -await lifecycle.finish({ result: { winnerId: 'opponent' }, reason: 'completed' }); -assertEqual(lifecycle.state, 'terminated', 'shuts down cleanly on match-end'); -assertEqual(lifecycle.getTerminalFailure(), undefined, 'no fault recorded'); - -console.log('\nAll checks passed.'); diff --git a/docs/guides/examples/counter-bot/strategy.mjs b/docs/guides/examples/counter-bot/strategy.mjs deleted file mode 100644 index ed11a1d..0000000 --- a/docs/guides/examples/counter-bot/strategy.mjs +++ /dev/null @@ -1,11 +0,0 @@ -// The only Rock-Paper-Scissors-specific part of this bot. Everything else (harness.mjs) is -// generic protocol plumbing that knows nothing about this game. -const BEATS = { rock: 'paper', paper: 'scissors', scissors: 'rock' }; - -// Receives the RpsObservation ({ round, totalRounds, yourWins, opponentWins, opponentId, history }) -// described in docs/guides/bot-author-guide.md §9, and returns an RPS action -// ({ choice: 'rock' | 'paper' | 'scissors' }). -export function decideAction(observation) { - const lastRound = observation.history.at(-1); - return { choice: lastRound ? BEATS[lastRound.opponent] : 'rock' }; -} diff --git a/docs/guides/game-authoring-guide.md b/docs/guides/game-authoring-guide.md index 0015e85..b09eebc 100644 --- a/docs/guides/game-authoring-guide.md +++ b/docs/guides/game-authoring-guide.md @@ -6,12 +6,16 @@ different job from the other two guides: a **bot author** author** ([`tournament-author-guide.md`](tournament-author-guide.md)) configures matchups between bots; a **game author** defines the rules those bots and tournaments run on top of. -**Status check first.** Two real games exist today — Rock-Paper-Scissors -(`games/rock-paper-scissors`, simultaneous, no hidden information) and Connect Four -(`games/connect-four`, sequential single-mover turns, also no hidden information). Both implement -exactly the interface this guide describes; neither needed an engine change to exist. A -hidden-information game (each participant sees something different) is the one shape neither -exercises yet — ADR-0005 covers how that would work, referenced below. +**Status check first.** Four real games exist today; this guide deep-dives two of them — +Rock-Paper-Scissors (`games/rock-paper-scissors`, simultaneous, no hidden information) and Connect +Four (`games/connect-four`, sequential single-mover turns, also no hidden information) — since +together they cover the two basic turn-taking shapes (§2) with nothing hidden to complicate the +walkthrough. Both implement exactly the interface this guide describes; neither needed an engine +change to exist. A hidden-information game (each participant sees something different, ADR-0005) +is exercised by the other two instead: Hearts (`games/card-game-hearts`, 4 fixed seats) and Texas +Hold'em (`games/poker-texas-hold-em`, 2-10 seats, real no-limit betting with side pots) — both real +`GameDefinition`s too, just documented in [`bot-author-guide.md`](bot-author-guide.md) §10/§11 and +their own `src/types.ts` instead of here (see [`README.md`](README.md) for the full roster). **New to Node/Docker/dev environments, or to this codebase generally?** [`getting-started.md`](getting-started.md) explains the tooling this guide assumes you already diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index f52cea2..23f301a 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -15,7 +15,7 @@ A **repository** ("repo") is just a folder of files — code, docs, config — t is ever truly lost and everyone working on the project can merge their changes together safely. "Cloning" a repo means downloading your own copy of it, history and all. -This particular repo is a **monorepo**: instead of one project living in one repo, it's *many* +This particular repo is a **monorepo**: instead of one project living in one repo, it's _many_ related projects (the game engine, the CLI, each individual game, each individual bot) living together in one repo, so they can share code and be tested together. `packages/README.md`, `games/README.md`, and `bots/README.md` each describe one slice of it. @@ -33,7 +33,7 @@ on one version can break on another. This repo pins an exact version (`.node-ver `25`) so that "works on my machine" actually means "works on everyone's machine." A **version manager** like [`fnm`](https://github.com/Schniz/fnm) lets you have several Node versions installed side by side and switch between them per-project automatically — that's why the root -README recommends installing Node *through* fnm rather than downloading it directly from +README recommends installing Node _through_ fnm rather than downloading it directly from nodejs.org. ## 3. What is Yarn, and how is it different from `npm`? @@ -75,7 +75,7 @@ That sandboxing is exactly why this repo needs it: every bot match actually runs Docker container, with no exceptions and no "trust me, it's fine" shortcut — see [`docs/adr/0003-docker-bot-isolation.md`](../adr/0003-docker-bot-isolation.md) for the full reasoning. This also means Docker isn't optional tooling you can skip — anything that runs a real -match (`yarn thunderdome match run`, the runtime's own tests) needs Docker actually *running* +match (`yarn thunderdome match run`, the runtime's own tests) needs Docker actually _running_ (not just installed) in the background, the same way a database needs to be running before an app that talks to it will work. `docker ps` (root README §3) is how you check that it is. @@ -113,14 +113,14 @@ Once your dev environment is working (`yarn build && yarn lint && yarn typecheck pass, and `yarn thunderdome --help` prints usage), pick the guide that matches what you actually want to build: -| I want to... | Start here | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| Write a bot that plays an existing game | [`bot-author-guide.md`](bot-author-guide.md) | -| Understand automated testing, or write my first test | [`testing-guide.md`](testing-guide.md) | -| Build a brand new game from scratch | [`game-authoring-guide.md`](game-authoring-guide.md) | -| Make a game nice to actually play as a human, or improve one already built | [`human-friendly-games-guide.md`](human-friendly-games-guide.md) | -| Build a brand new tournament format (like Swiss) | [`tournament-format-authoring-guide.md`](tournament-format-authoring-guide.md) | -| Configure and run an existing tournament | [`tournament-author-guide.md`](tournament-author-guide.md) | +| I want to... | Start here | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Write a bot that plays an existing game | [`bot-author-guide.md`](bot-author-guide.md) | +| Understand automated testing, or write my first test | [`testing-guide.md`](testing-guide.md) | +| Build a brand new game from scratch | [`game-authoring-guide.md`](game-authoring-guide.md) | +| Make a game nice to actually play as a human, or improve one already built | [`human-friendly-games-guide.md`](human-friendly-games-guide.md) | +| Build a brand new tournament format (like Swiss) | [`tournament-format-authoring-guide.md`](tournament-format-authoring-guide.md) | +| Configure and run an existing tournament | [`tournament-author-guide.md`](tournament-author-guide.md) | Every one of those guides assumes you've read this page (or already know its contents) — none of them re-explain what Node, Docker, or a dev environment are. diff --git a/docs/guides/human-friendly-games-guide.md b/docs/guides/human-friendly-games-guide.md index 3118806..8665ef6 100644 --- a/docs/guides/human-friendly-games-guide.md +++ b/docs/guides/human-friendly-games-guide.md @@ -1,18 +1,20 @@ # Making a Game Human-Friendly (and Other Ways to Develop It Further) This guide is for taking a game that already works — its `GameDefinition` is implemented, bots can -play it, `match run` and `tournament run` both function — and making it *better*: pleasant for an +play it, `match run` and `tournament run` both function — and making it _better_: pleasant for an actual human to sit down and play, and easier to keep improving over time. If you haven't implemented a `GameDefinition` yet, start with [`game-authoring-guide.md`](game-authoring-guide.md) first; this guide assumes that part is done. -**Status check first.** Two of the three real games support human play today — Rock-Paper-Scissors -and Hearts both implement `GameDefinition.humanInterface`, so `yarn thunderdome play` works against -either. **Connect Four does not yet** — `yarn thunderdome play leftmost-connect-four` gives a -clear "this game doesn't support human play yet" error rather than a crash or a garbled prompt. -That gap is real, self-contained, and doesn't require touching the CLI or engine at all — §2 below -walks through closing it as a concrete, worked exercise, using Rock-Paper-Scissors and Hearts as -the two existing references. +**Status check first.** Three of the four real games support human play today — +Rock-Paper-Scissors, Hearts, and Texas Hold'em all implement `GameDefinition.humanInterface`, so +`yarn thunderdome play` works against any of them. **Connect Four does not yet** — +`yarn thunderdome play leftmost-connect-four` gives a clear "this game doesn't support human play +yet" error rather than a crash or a garbled prompt. That gap is real, self-contained, and doesn't +require touching the CLI or engine at all — §2 below walks through closing it as a concrete, +worked exercise, using Rock-Paper-Scissors and Hearts as the two existing references (Texas +Hold'em's `src/human.ts` is a third, if you want a betting/multi-street example instead of a +trick-taking one). ## 1. Why bother making a game human-playable at all? @@ -118,7 +120,7 @@ humanInterface: { describeObservation, parseInput, describeAction }, Printed immediately after a valid input is accepted, before the next prompt — Hearts uses this for `Passed: 9C QC 2D` and `Played: 4D`. This matters most when your notation is dense enough that a -typo could silently parse into a *different, still-valid* action rather than failing to parse at +typo could silently parse into a _different, still-valid_ action rather than failing to parse at all (e.g. typing `9D` instead of `9C` — both are legal card tokens, just not the one you meant). Rock-Paper-Scissors skips `describeAction` entirely — its next prompt already restates your last choice as part of the round recap, so a separate confirmation would be redundant. Omit it when your @@ -138,7 +140,7 @@ Here's the exact path, following the pattern above: see `game-authoring-guide.md` §4), plus whose turn it is and the column-number legend, ending in a prompt like `Drop in which column? (0-6)`. 3. **Write `parseInput`**: parse a bare integer, trim whitespace, return `undefined` for anything - that isn't a valid integer in range — don't re-check whether the column is *currently* full + that isn't a valid integer in range — don't re-check whether the column is _currently_ full here; that's `validateAction`'s job, and returning a well-formed-but-illegal action is fine (the CLI/engine already handles a rejected action the same way it handles a bot's illegal move). 4. **Wire it into `GameDefinition`**: add `humanInterface: { describeObservation, parseInput }` next @@ -163,17 +165,17 @@ roughly in order of how self-contained they are: - **Add more reference bots.** A game with only one or two reference bots (`bots//`) is hard for a newcomer to learn from by comparison, and hides "what does a genuinely different strategy look like" questions your game design might not have considered. `yarn scaffold:bot - ` (see `bots/README.md`) gets you a working starting point in seconds — start + ` (see `bots/README.md`) gets you a working starting point in seconds — start from `random-` and `lowest-card-hearts`-style "obviously not optimal but a real baseline" strategies before attempting something sophisticated. - **Add or tune `resourceLimits`.** If your game's bots need meaningfully more (or less) time per - turn than the `5000`ms/`128`MB/`0.5`-CPU convention both real games currently use + turn than the `5000`ms/`128`MB/`0.5`-CPU convention every real game currently uses (`game-authoring-guide.md` §8), that's a legitimate, game-specific decision — a card game with a larger search space than Rock-Paper-Scissors' single-choice-per-round might reasonably need - longer. Document *why* in a comment next to the value, the same way you'd document any other + longer. Document _why_ in a comment next to the value, the same way you'd document any other non-obvious constant. - **Tune your config's defaults.** `parseConfig`'s defaults (`game-authoring-guide.md` §9) are - often the single biggest lever on how a game *feels* without changing a single rule — + often the single biggest lever on how a game _feels_ without changing a single rule — Rock-Paper-Scissors' `totalRounds: 300` default exists specifically so a match has enough hands for an adaptive bot's strategy to actually show up in the result, rather than being decided by an early lucky streak. If playtesting (by hand, via `yarn thunderdome play`, or by watching several diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index b79f53d..4ea3cc7 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -22,7 +22,7 @@ file, which is the faster loop to use while actively writing code. ## 2. Unit tests vs. integration tests -These two terms describe *how much* of the system a test exercises at once — think of it as a +These two terms describe _how much_ of the system a test exercises at once — think of it as a dial, not a strict binary, but the two ends of that dial behave differently enough to be worth naming separately. @@ -37,7 +37,7 @@ For example, `rockPaperScissors.parseConfig({ totalRounds: 4 })` is a plain func an input, get back a `Result`, check it's what you expected. No Docker, no network, no other part of the codebase involved — a unit test. -**An integration test** checks that several pieces actually work correctly *together* — the thing +**An integration test** checks that several pieces actually work correctly _together_ — the thing a unit test, by design, never proves. Two well-tested functions can each work perfectly in isolation and still fail the moment they're wired together (a mismatched assumption about what one hands the other, a real network call that behaves differently than a fake one did). Integration @@ -45,16 +45,16 @@ tests are slower (they touch real infrastructure — here, that's Docker) and a wider area to investigate — but they're the only kind of test that can catch a wiring mistake between two otherwise-correct pieces. -Neither kind replaces the other. A healthy test suite has *many* fast unit tests covering every -function's own logic in detail, plus a *smaller* number of integration tests confirming the real +Neither kind replaces the other. A healthy test suite has _many_ fast unit tests covering every +function's own logic in detail, plus a _smaller_ number of integration tests confirming the real pieces genuinely fit together — this repo's own test suite is shaped exactly that way (§3). ## 3. This repo's two testing tiers -| Tier | What it exercises | How fast | Real example in this repo | -| --------------- | --------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | -| Unit test | One `GameDefinition` method, or one pure function, called directly | Instant | `games/rock-paper-scissors/test/game.test.ts` — calls `rockPaperScissors.parseConfig()`/`.resolve()`/etc. directly against hand-built state, with **zero** Docker involved | -| Integration test | A real bot's Docker container, driven through the real wire protocol | Seconds | `bots/rock-paper-scissors/*/smoke-test.mjs` and [`docs/guides/examples/counter-bot/smoke-test.mjs`](examples/counter-bot/smoke-test.mjs) — builds a real image, starts a real container via `@thunderdome/runtime`, and drives it through a scripted `init`/`observation`/`action` exchange | +| Tier | What it exercises | How fast | Real example in this repo | +| ---------------- | -------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unit test | One `GameDefinition` method, or one pure function, called directly | Instant | `games/rock-paper-scissors/test/game.test.ts` — calls `rockPaperScissors.parseConfig()`/`.resolve()`/etc. directly against hand-built state, with **zero** Docker involved | +| Integration test | A real bot's Docker container, driven through the real wire protocol | Seconds | `bots/rock-paper-scissors/*/smoke-test.mjs` — builds a real image, starts a real container via `@thunderdome/runtime`, and drives it through a scripted `init`/`observation`/`action` exchange | Every package under `packages/` and `games/` has a `test/` directory of unit tests, run by `yarn test` (Vitest) — these are what CI runs on every change and what you should be running @@ -131,20 +131,34 @@ target. That's the entire loop this repo's whole unit-test suite is built from — every `test/*.test.ts` file in `packages/` and `games/` is the same three ingredients (`describe`, `it`, `expect`) -repeated against different functions. [`game-authoring-guide.md`](game-authoring-guide.md) §11 +repeated against different functions. [`game-authoring-guide.md`](game-authoring-guide.md) §11 lists, concretely, everything a new game's own test suite should cover. ## 5. Writing (or extending) an integration test -Integration tests in this repo follow [`docs/guides/examples/counter-bot/smoke-test.mjs`](examples/counter-bot/README.md)'s -pattern: build the bot's Docker image, then drive it through a real container using -`@thunderdome/runtime`'s primitives (`DockerBotProcess`/`BotLifecycle`) with a scripted sequence of -observations, asserting the bot's replies match what you expect. Its own README spells out the -exact steps (build → sanity-check by hand → run the smoke test) and is the right template to copy -when your own bot needs one — you're not expected to write this kind of test from scratch. +Integration tests in this repo all follow the same pattern: build the bot's Docker image, then +drive it through a real container using `@thunderdome/runtime`'s primitives +(`DockerBotProcess`/`BotLifecycle`) with a scripted sequence of observations, asserting the bot's +replies match what you expect. Any bot's own `smoke-test.mjs` (e.g. +[`bots/rock-paper-scissors/only-rock/smoke-test.mjs`](../../bots/rock-paper-scissors/only-rock/smoke-test.mjs)) +is the right template to copy when your own bot needs one — you're not expected to write this kind +of test from scratch. + +To actually run one, from the repo root, with Docker running (§3): build the bot's image (the tag +its own `smoke-test.mjs` expects is named in a comment at the top of that file), then run the +script with plain `node` — it isn't wired into `yarn test`, since it needs Docker rather than +just Vitest: + +```bash +docker build -t thunderdome-only-rock bots/rock-paper-scissors/only-rock +node bots/rock-paper-scissors/only-rock/smoke-test.mjs +``` + +Every bot under `bots/` follows this same two-command pattern — swap in that bot's own directory +(and the image tag its `smoke-test.mjs` names) to run any other one, including your own. You generally only need to write a new integration test when you're building something that -*talks to Docker directly* — a new bot, or a change to `@thunderdome/runtime` itself. A new game or +_talks to Docker directly_ — a new bot, or a change to `@thunderdome/runtime` itself. A new game or tournament format's correctness is proven by its unit tests (§4) plus actually running it for real (`yarn thunderdome match run`/`tournament run`) — see [`game-authoring-guide.md`](game-authoring-guide.md) §12. @@ -155,5 +169,5 @@ This repo doesn't chase a coverage percentage. Instead, for any new behavior, as distinct cases, and does a test exist for each one?** For `GameDefinition.parseConfig`, that's "valid input," "invalid input," and "input relying on a default." For `resolve()`, that's one test per distinct outcome the game can reach (Connect Four's suite has one test per win direction, plus -a draw — `game-authoring-guide.md` §11). A single happy-path test proves the feature *can* work; it -takes the edge cases and error cases to prove it reliably *does*. +a draw — `game-authoring-guide.md` §11). A single happy-path test proves the feature _can_ work; it +takes the edge cases and error cases to prove it reliably _does_. diff --git a/docs/guides/tournament-author-guide.md b/docs/guides/tournament-author-guide.md index e287e0b..4d6e91c 100644 --- a/docs/guides/tournament-author-guide.md +++ b/docs/guides/tournament-author-guide.md @@ -16,20 +16,21 @@ see §3 for how it fits the same `TournamentFormat` contract as the other two. W yet: anything beyond these three (pool-then-elimination, ...) — see [`tournament-format-authoring-guide.md`](tournament-format-authoring-guide.md) if you want to build one. This guide documents what's real, using Rock-Paper-Scissors (still the running example -throughout) — Connect Four is the platform's other real game (`docs/guides/README.md`) and fits -every piece below identically, just without its own worked example here yet. +throughout) — Connect Four and Texas Hold'em are the platform's two other real games without a +proven tournament format yet (`docs/guides/README.md`), and fit every piece below identically, +just without their own worked example here. ## 1. The five pieces of a tournament A tournament is the combination of: -| Piece | What it is | Status | -| ------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| **Game** | Which game, which version, and that game's own config | Real — `rock-paper-scissors`, `{ totalRounds, onMissingAction }` | -| **Roster** | Which bots (from the bot registry) are competing | Real — `@thunderdome/registry`'s `scanBots`/`scanGames` | +| Piece | What it is | Status | +| ------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **Game** | Which game, which version, and that game's own config | Real — `rock-paper-scissors`, `{ totalRounds, onMissingAction }` | +| **Roster** | Which bots (from the bot registry) are competing | Real — `@thunderdome/registry`'s `scanBots`/`scanGames` | | **Format** | How matchups are generated and standings computed | Real for round robin, single elimination, and Swiss league — `@thunderdome/tournament-formats`; pool-then-elimination not built | -| **Seed** | The one entropy boundary this tournament's reproducibility traces back to | Real — `@thunderdome/rng` | -| **Resource limits** | Per-game CPU/memory/timeout ceilings | Partially real — `GameDefinition.resourceLimits` exists; runtime enforcement is per-match, not tournament-level yet | +| **Seed** | The one entropy boundary this tournament's reproducibility traces back to | Real — `@thunderdome/rng` | +| **Resource limits** | Per-game CPU/memory/timeout ceilings | Partially real — `GameDefinition.resourceLimits` exists; runtime enforcement is per-match, not tournament-level yet | None of these are new concepts invented for this guide — they're exactly `docs/architecture.md`'s mental model: **Games + Tournaments + Bots → Matches → Match diff --git a/docs/guides/tournament-format-authoring-guide.md b/docs/guides/tournament-format-authoring-guide.md index 68c1320..9e5e5a3 100644 --- a/docs/guides/tournament-format-authoring-guide.md +++ b/docs/guides/tournament-format-authoring-guide.md @@ -1,8 +1,8 @@ # Tournament Format Authoring Guide This is a different guide from [`tournament-author-guide.md`](tournament-author-guide.md): that -one is for *configuring and running* a tournament in a format that already exists. This one is for -*building a brand new format* — the step-by-step path from "I want a Swiss-style or +one is for _configuring and running_ a tournament in a format that already exists. This one is for +_building a brand new format_ — the step-by-step path from "I want a Swiss-style or pool-then-elimination bracket" to a real, tested, runnable `--tournament-config '{"format":"my-new-format"}'`. @@ -38,7 +38,12 @@ interface TournamentFormat { standings: TStandings; match: MatchDescriptor; record: MatchRecord; - }): { formatState: TFormatState; standings: TStandings; readyMatches: MatchDescriptor[]; notices?: string[] }; + }): { + formatState: TFormatState; + standings: TStandings; + readyMatches: MatchDescriptor[]; + notices?: string[]; + }; isComplete(args: { formatState: TFormatState; standings: TStandings }): boolean; getPublicStandings(standings: TStandings): unknown; } @@ -48,8 +53,8 @@ Read [`tournament-author-guide.md`](tournament-author-guide.md) §3 before writi walks through exactly how round robin and single elimination implement this **incremental pull model** (unlock the matches that are ready to play now; unlock more once results come back), which is the one genuinely non-obvious idea here. Your new format needs to fit the same shape: it's never -handed a full schedule up front, and it never runs a match itself — it only ever decides *which* -matches are ready and *how* results roll up into standings. +handed a full schedule up front, and it never runs a match itself — it only ever decides _which_ +matches are ready and _how_ results roll up into standings. `TFormatConfig`/`TFormatState`/`TStandings` are entirely yours, exactly like a `GameDefinition`'s type parameters (`game-authoring-guide.md` §1) — the engine only ever touches them through these @@ -58,13 +63,12 @@ five methods. ## 2. Decide what your format actually needs to be different Before writing anything, be concrete about what round robin, single elimination, and Swiss league -*don't* already do that your format needs. This determines almost everything else: +_don't_ already do that your format needs. This determines almost everything else: - **Do matches happen between exactly 2 participants, or N?** Round robin and single elimination are both hardcoded to 2-participant pairings; Swiss league plays N-participant tables (Hearts' 4-player hands) — see `swiss-league.ts`'s own top comment for why that generalization was - needed, and copy its `MatchDescriptor` construction if your format also needs tables larger than - 2. + needed, and copy its `MatchDescriptor` construction if your format also needs tables larger than 2. - **Does round N depend on round N-1's results, or is the whole schedule knowable up front?** Round robin's `initialize` computes every pairing immediately (a schedule known in full from the roster alone); single elimination and Swiss league both only know their first round up front and @@ -84,7 +88,7 @@ Before writing anything, be concrete about what round robin, single elimination, inventing a new one. If the answer to all of the above matches an existing format exactly, you don't need a new format -— you need a new *config option* on an existing one (e.g. `bestOf`). +— you need a new _config option_ on an existing one (e.g. `bestOf`). ## 3. Scaffold the file diff --git a/games/README.md b/games/README.md index e74d77a..bd4e117 100644 --- a/games/README.md +++ b/games/README.md @@ -18,7 +18,8 @@ principles before either guide above assumes you already know them. | --------------------------------------------- | --------------------------------------- | ------- | ------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | [`rock-paper-scissors`](rock-paper-scissors/) | `@thunderdome/game-rock-paper-scissors` | 2 | No | `round-robin` | Best-of-N Rock Paper Scissors — the platform's first vertical-slice game, simultaneous reveal each round. | | [`connect-four`](connect-four/) | `@thunderdome/game-connect-four` | 2 | Yes | `round-robin`, `single-elimination` | Classic 7x6 Connect Four — the first sequential, no-hidden-information game. | -| [`card-game-hearts`](card-game-hearts/) | `@thunderdome/game-card-game-hearts` | 4 | No | — | The classic 4-player trick-taking card game of Hearts — hidden information (each player's hand) and passing, unlike the other two games. | +| [`card-game-hearts`](card-game-hearts/) | `@thunderdome/game-card-game-hearts` | 4 | No | `swiss-league` | The classic 4-player trick-taking card game of Hearts — hidden information (each player's hand) and passing, unlike the other two games. | +| [`poker-texas-hold-em`](poker-texas-hold-em/) | `@thunderdome/game-poker-texas-hold-em` | 2-10 | No | — | No-limit Texas Hold'em — the first variable-size table, hidden hole cards, and side-pot betting across multiple streets per hand. | Each game directory is a standard workspace package shape: `src/` for the `GameDefinition` implementation, `test/` for its unit tests, `manifest.json` for the metadata @@ -26,8 +27,8 @@ implementation, `test/` for its unit tests, `manifest.json` for the metadata formats, maintainers), and a `dist/` produced by `yarn build`. Every game depends on `@thunderdome/engine` for the `GameDefinition` contract it implements, plus -whatever else its rules need — `card-game-hearts` also pulls in -[`@thunderdome/card-kit`](../packages/game-dev-toolkit/card-kit/) for shared card/deck/trick +whatever else its rules need — `card-game-hearts` and `poker-texas-hold-em` also pull in +[`@thunderdome/card-kit`](../packages/game-dev-toolkit/card-kit/) for shared card/deck primitives. See [`packages/README.md`](../packages/README.md) for what each of those packages actually provides. diff --git a/games/card-game-hearts/src/game.ts b/games/card-game-hearts/src/game.ts index f9e6eca..9109114 100644 --- a/games/card-game-hearts/src/game.ts +++ b/games/card-game-hearts/src/game.ts @@ -93,7 +93,11 @@ function resolvePassing( // Deliberately omits which cards were passed — card contents aren't legitimately public // until played to a trick, so the event channel stays as conservative as getObservation. events: [ - { type: 'cards-passed', participantIds: [...state.participantIds], data: { direction, passedTo } }, + { + type: 'cards-passed', + participantIds: [...state.participantIds], + data: { direction, passedTo }, + }, ], }; } @@ -120,7 +124,11 @@ function resolvePlay( const plays = [...state.currentTrick.plays, { participantId, card }]; const events: RoundEvent[] = [ - { type: 'card-played', participantIds: [participantId], data: { card, trickPosition: plays.length } }, + { + type: 'card-played', + participantIds: [participantId], + data: { card, trickPosition: plays.length }, + }, ]; if (plays.length < 4) { @@ -175,7 +183,12 @@ function resolvePlay( events.push({ type: 'hand-scored', - data: { handNumber: state.handNumber, handPoints: finalHandPoints, shotTheMoon: shooterId, scores }, + data: { + handNumber: state.handNumber, + handPoints: finalHandPoints, + shotTheMoon: shooterId, + scores, + }, }); if (matchComplete) { @@ -235,7 +248,13 @@ export const hearts: GameDefinition< initialize({ participantIds, config, rng }) { const [a, b, c, d] = participantIds; - if (a === undefined || b === undefined || c === undefined || d === undefined || participantIds.length !== 4) { + if ( + a === undefined || + b === undefined || + c === undefined || + d === undefined || + participantIds.length !== 4 + ) { throw new Error( `card-game-hearts requires exactly 4 participants, got ${String(participantIds.length)}`, ); @@ -246,7 +265,13 @@ export const hearts: GameDefinition< scores[id] = 0; }); const setup = dealNewHand(ids, 0, rng); - const state: HeartsState = { participantIds: ids, config, scores, matchComplete: false, ...setup }; + const state: HeartsState = { + participantIds: ids, + config, + scores, + matchComplete: false, + ...setup, + }; return state; }, @@ -339,7 +364,10 @@ export const hearts: GameDefinition< const hand = state.hands[participantId] ?? []; return { policy: 'substitute', action: { type: 'pass', cards: deterministicPass(hand) } }; } - return { policy: 'substitute', action: { type: 'play', card: deterministicPlay(state, participantId) } }; + return { + policy: 'substitute', + action: { type: 'play', card: deterministicPlay(state, participantId) }, + }; }, isTerminal(state) { @@ -347,7 +375,11 @@ export const hearts: GameDefinition< }, getResult(state) { - return { participantIds: state.participantIds, scores: state.scores, handsPlayed: state.handNumber + 1 }; + return { + participantIds: state.participantIds, + scores: state.scores, + handsPlayed: state.handNumber + 1, + }; }, getStandingOutcomes(result) { @@ -359,7 +391,13 @@ export const hearts: GameDefinition< return ids.map((id) => { const rank = 1 + ids.filter((other) => scoreOf(other) < scoreOf(id)).length; const outcome: NonNullable = - bestIds.length > 1 ? (bestIds.includes(id) ? 'draw' : 'loss') : id === bestIds[0] ? 'win' : 'loss'; + bestIds.length > 1 + ? bestIds.includes(id) + ? 'draw' + : 'loss' + : id === bestIds[0] + ? 'win' + : 'loss'; return { participantId: id, rank, score: scoreOf(id), outcome }; }); }, diff --git a/games/card-game-hearts/src/human.ts b/games/card-game-hearts/src/human.ts index 240a0f5..de34fb5 100644 --- a/games/card-game-hearts/src/human.ts +++ b/games/card-game-hearts/src/human.ts @@ -62,7 +62,9 @@ export function describeObservation(observation: HeartsObservation): string { const scoresLineWithYou = ` — you: ${String(observation.scores[observation.you] ?? 0)} (${handPointsOf(observation.you)} this hand)`; const scoresLinesForOthers = observation.participantIds .filter((id) => id !== observation.you) - .map((id) => ` — ${id}: ${String(observation.scores[id] ?? 0)} (${handPointsOf(id)} this hand)`); + .map( + (id) => ` — ${id}: ${String(observation.scores[id] ?? 0)} (${handPointsOf(id)} this hand)`, + ); const trickLine = observation.currentTrick === null ? undefined diff --git a/games/card-game-hearts/src/rules.ts b/games/card-game-hearts/src/rules.ts index f46b22a..2a82557 100644 --- a/games/card-game-hearts/src/rules.ts +++ b/games/card-game-hearts/src/rules.ts @@ -58,7 +58,10 @@ export function passTargetIndex( * `state.currentPlayerIndex`). */ export function legalPlaysFor(state: HeartsState, participantId: string): Card[] { - if (state.phase !== 'playing' || state.participantIds[state.currentPlayerIndex] !== participantId) { + if ( + state.phase !== 'playing' || + state.participantIds[state.currentPlayerIndex] !== participantId + ) { return []; } const hand = state.hands[participantId] ?? []; diff --git a/games/card-game-hearts/test/game.test.ts b/games/card-game-hearts/test/game.test.ts index 589d52c..26706ea 100644 --- a/games/card-game-hearts/test/game.test.ts +++ b/games/card-game-hearts/test/game.test.ts @@ -1,10 +1,4 @@ -import { - type Card, - cardId, - containsCard, - parseCardId, - standardDeck, -} from '@thunderdome/card-kit'; +import { type Card, cardId, containsCard, parseCardId, standardDeck } from '@thunderdome/card-kit'; import { createRng } from '@thunderdome/rng'; import { describe, expect, it } from 'vitest'; import { hearts } from '../src/game.js'; @@ -469,13 +463,17 @@ describe('hearts.validateAction', () => { }); it('rejects a pass with a duplicate card', () => { - const state = passingState({ hands: { alice: cards('2C', '5H', '9D'), bob: [], carol: [], dave: [] } }); + const state = passingState({ + hands: { alice: cards('2C', '5H', '9D'), bob: [], carol: [], dave: [] }, + }); const action = { type: 'pass', cards: [card('2C'), card('2C'), card('5H')] }; expect(hearts.validateAction(state, 'alice', action).ok).toBe(false); }); it('rejects a pass of a card not in hand', () => { - const state = passingState({ hands: { alice: cards('2C', '5H', '9D'), bob: [], carol: [], dave: [] } }); + const state = passingState({ + hands: { alice: cards('2C', '5H', '9D'), bob: [], carol: [], dave: [] }, + }); expect( hearts.validateAction(state, 'alice', { type: 'pass', cards: cards('2C', '5H', 'AS') }).ok, ).toBe(false); @@ -494,7 +492,9 @@ describe('hearts.validateAction', () => { it('rejects a play submitted during the passing phase', () => { const state = passingState({ hands: { alice: cards('2C'), bob: [], carol: [], dave: [] } }); - expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('2C') }).ok).toBe(false); + expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('2C') }).ok).toBe( + false, + ); }); it('rejects playing a card not in hand', () => { @@ -503,7 +503,9 @@ describe('hearts.validateAction', () => { currentTrick: { leaderId: 'alice', plays: [] }, currentPlayerIndex: 0, }); - expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('5H') }).ok).toBe(false); + expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('5H') }).ok).toBe( + false, + ); }); it('rejects an off-suit play when able to follow', () => { @@ -524,7 +526,9 @@ describe('hearts.validateAction', () => { currentPlayerIndex: 0, tricksCompleted: 1, }); - expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('5H') }).ok).toBe(false); + expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('5H') }).ok).toBe( + false, + ); expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('KS') }).ok).toBe(true); }); @@ -565,7 +569,9 @@ describe('hearts.validateAction', () => { currentPlayerIndex: 0, tricksCompleted: 0, }); - expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('5H') }).ok).toBe(false); + expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('5H') }).ok).toBe( + false, + ); expect(hearts.validateAction(state, 'alice', { type: 'play', card: card('2C') }).ok).toBe(true); }); }); @@ -628,7 +634,11 @@ describe('hearts.resolve', () => { expect(rightResult.nextState.hands.alice?.map(cardId).sort()).toEqual(['2C', '7C', '8C']); // from bob const acrossState = makeState(2); // hand 2 => 'across' - const acrossResult = hearts.resolve({ state: acrossState, actions: actionsFor(acrossState), rng }); + const acrossResult = hearts.resolve({ + state: acrossState, + actions: actionsFor(acrossState), + rng, + }); expect(acrossResult.nextState.hands.alice?.map(cardId).sort()).toEqual(['9C', 'JC', 'TC']); // from carol }); @@ -832,7 +842,11 @@ describe('hearts.getResult / hearts.getStandingOutcomes', () => { it('ranks a fully decisive result 1/2/3/4 with a single winner and the rest losses', () => { const state = { ...initialState(), scores: { alice: 40, bob: 60, carol: 20, dave: 80 } }; const result = hearts.getResult(state); - expect(result).toEqual({ participantIds: PARTICIPANT_IDS, scores: state.scores, handsPlayed: 1 }); + expect(result).toEqual({ + participantIds: PARTICIPANT_IDS, + scores: state.scores, + handsPlayed: 1, + }); expect(hearts.getStandingOutcomes(result)).toEqual([ { participantId: 'alice', rank: 2, score: 40, outcome: 'loss' }, { participantId: 'bob', rank: 3, score: 60, outcome: 'loss' }, @@ -848,9 +862,18 @@ describe('hearts.getResult / hearts.getStandingOutcomes', () => { handsPlayed: 1, }; const outcomes = hearts.getStandingOutcomes(result); - expect(outcomes.find((o) => o.participantId === 'alice')).toMatchObject({ rank: 1, outcome: 'draw' }); - expect(outcomes.find((o) => o.participantId === 'bob')).toMatchObject({ rank: 1, outcome: 'draw' }); - expect(outcomes.find((o) => o.participantId === 'carol')).toMatchObject({ rank: 3, outcome: 'loss' }); + expect(outcomes.find((o) => o.participantId === 'alice')).toMatchObject({ + rank: 1, + outcome: 'draw', + }); + expect(outcomes.find((o) => o.participantId === 'bob')).toMatchObject({ + rank: 1, + outcome: 'draw', + }); + expect(outcomes.find((o) => o.participantId === 'carol')).toMatchObject({ + rank: 3, + outcome: 'loss', + }); }); it('reports a 4-way draw when every score ties', () => { @@ -870,10 +893,22 @@ describe('hearts.getResult / hearts.getStandingOutcomes', () => { handsPlayed: 1, }; const outcomes = hearts.getStandingOutcomes(result); - expect(outcomes.find((o) => o.participantId === 'alice')).toMatchObject({ rank: 1, outcome: 'win' }); - expect(outcomes.find((o) => o.participantId === 'bob')).toMatchObject({ rank: 2, outcome: 'loss' }); - expect(outcomes.find((o) => o.participantId === 'carol')).toMatchObject({ rank: 2, outcome: 'loss' }); - expect(outcomes.find((o) => o.participantId === 'dave')).toMatchObject({ rank: 4, outcome: 'loss' }); + expect(outcomes.find((o) => o.participantId === 'alice')).toMatchObject({ + rank: 1, + outcome: 'win', + }); + expect(outcomes.find((o) => o.participantId === 'bob')).toMatchObject({ + rank: 2, + outcome: 'loss', + }); + expect(outcomes.find((o) => o.participantId === 'carol')).toMatchObject({ + rank: 2, + outcome: 'loss', + }); + expect(outcomes.find((o) => o.participantId === 'dave')).toMatchObject({ + rank: 4, + outcome: 'loss', + }); }); }); diff --git a/games/card-game-hearts/tsconfig.json b/games/card-game-hearts/tsconfig.json index a47cede..5a24989 100644 --- a/games/card-game-hearts/tsconfig.json +++ b/games/card-game-hearts/tsconfig.json @@ -4,7 +4,5 @@ "outDir": "dist", "rootDir": "src" }, - "include": [ - "src" - ] + "include": ["src"] } diff --git a/games/card-game-hearts/tsconfig.test.json b/games/card-game-hearts/tsconfig.test.json index 14bb39e..45bbe8f 100644 --- a/games/card-game-hearts/tsconfig.test.json +++ b/games/card-game-hearts/tsconfig.test.json @@ -4,8 +4,5 @@ "noEmit": true, "rootDir": "." }, - "include": [ - "src", - "test" - ] + "include": ["src", "test"] } diff --git a/games/poker-texas-hold-em/manifest.json b/games/poker-texas-hold-em/manifest.json new file mode 100644 index 0000000..3e2695e --- /dev/null +++ b/games/poker-texas-hold-em/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "poker-texas-hold-em", + "name": "Texas Hold Em", + "version": "0.1.0", + "entryPackage": "@thunderdome/game-poker-texas-hold-em", + "protocolVersion": "^1.0", + "minParticipants": 2, + "maxParticipants": 10, + "deterministic": false, + "maintainers": [ + { + "name": "Tom Sarver", + "contact": "tom@tomsarver.dev" + } + ], + "description": "Good Ol' Fashioned Texas Hold Em." +} diff --git a/games/poker-texas-hold-em/package.json b/games/poker-texas-hold-em/package.json new file mode 100644 index 0000000..49d3a16 --- /dev/null +++ b/games/poker-texas-hold-em/package.json @@ -0,0 +1,18 @@ +{ + "name": "@thunderdome/game-poker-texas-hold-em", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.test.json", + "test": "vitest run" + }, + "dependencies": { + "@thunderdome/card-kit": "^0.1.0", + "@thunderdome/engine": "^0.1.0", + "zod": "^3.23.8" + } +} diff --git a/games/poker-texas-hold-em/src/advance.ts b/games/poker-texas-hold-em/src/advance.ts new file mode 100644 index 0000000..9bcfb62 --- /dev/null +++ b/games/poker-texas-hold-em/src/advance.ts @@ -0,0 +1,181 @@ +import type { Rng, RoundEvent } from '@thunderdome/engine'; +import { dealNewHand } from './deal.js'; +import { awardPotToSoleWinner, doShowdown } from './showdown.js'; +import { postflopFirstToActIndex } from './table.js'; +import type { BettingStreet, PokerTexasHoldEmState } from './types.js'; + +function mapValues( + record: Readonly>, + fn: (value: T) => U, +): Record { + return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, fn(value)])); +} + +function nextStreet(street: BettingStreet): BettingStreet { + if (street === 'preflop') return 'flop'; + if (street === 'flop') return 'turn'; + if (street === 'turn') return 'river'; + throw new Error('unreachable: there is no street after the river'); +} + +function dealNextStreetWithBetting( + state: PokerTexasHoldEmState, + events: RoundEvent[], +): PokerTexasHoldEmState { + const street = nextStreet(state.street); + const cardsToReveal = street === 'flop' ? 3 : 1; + const revealed = state.remainingBoardCards.slice(0, cardsToReveal); + const remainingBoardCards = state.remainingBoardCards.slice(cardsToReveal); + const board = [...state.board, ...revealed]; + events.push({ type: 'street-dealt', data: { street, board } }); + + const players = mapValues(state.players, (player) => ({ ...player, committedThisStreet: 0 })); + const n = state.seatOrder.length; + const liveNonAllIn = state.seatOrder.filter( + (id) => players[id]?.folded !== true && players[id]?.allIn !== true, + ); + + return { + ...state, + board, + remainingBoardCards, + street, + players, + currentBet: 0, + minRaise: state.config.bigBlind, + playersToAct: liveNonAllIn, + actingIndex: (postflopFirstToActIndex(n) - 1 + n) % n, + }; +} + +/** No more betting is possible this hand (at most one live player isn't all-in) — deal every + * remaining street's cards straight through to the river with no betting in between. */ +function runOutRemainingStreets( + state: PokerTexasHoldEmState, + events: RoundEvent[], +): PokerTexasHoldEmState { + let working = state; + while (working.street !== 'river') { + working = dealNextStreetWithBetting(working, events); + } + return { ...working, playersToAct: [] }; +} + +/** A hand just ended (fold-win or showdown, chips already paid out) — busts anyone left at 0 + * chips, then either ends the match (too few players left, or the configured hand count is + * reached) or deals the next hand. */ +function settleHandEnd( + state: PokerTexasHoldEmState, + rng: Rng, + events: RoundEvent[], +): PokerTexasHoldEmState { + const newlyBusted = state.seatOrder.filter((id) => (state.stacks[id] ?? 0) === 0); + const bustedOut = newlyBusted.length > 0 ? [...state.bustedOut, newlyBusted] : state.bustedOut; + if (newlyBusted.length > 0) { + events.push({ type: 'busted', participantIds: newlyBusted, data: {} }); + } + + const bustedSet = new Set(bustedOut.flat()); + const activeCount = state.participantIds.filter((id) => !bustedSet.has(id)).length; + const handsPlayed = state.handNumber + 1; + const reachedFixedHandsLimit = + state.config.matchFormat === 'fixedHands' && handsPlayed >= state.config.totalHands; + + if (activeCount <= 1 || reachedFixedHandsLimit) { + events.push({ type: 'match-complete', data: { stacks: state.stacks } }); + return { ...state, bustedOut, matchComplete: true }; + } + + const dealt = dealNewHand( + state.participantIds, + state.stacks, + bustedSet, + state.buttonParticipantId, + handsPlayed, + state.config, + rng, + ); + events.push(...dealt.events); + return { + ...state, + bustedOut, + seatOrder: dealt.seatOrder, + players: dealt.players, + board: dealt.board, + remainingBoardCards: dealt.remainingBoardCards, + street: dealt.street, + currentBet: dealt.currentBet, + minRaise: dealt.minRaise, + playersToAct: dealt.playersToAct, + actingIndex: dealt.actingIndex, + buttonParticipantId: dealt.buttonParticipantId, + handNumber: dealt.handNumber, + stacks: dealt.stacks, + }; +} + +function advanceToNextActor(state: PokerTexasHoldEmState): PokerTexasHoldEmState { + const n = state.seatOrder.length; + const playersToActSet = new Set(state.playersToAct); + for (let step = 1; step <= n; step += 1) { + const index = (state.actingIndex + step) % n; + const candidate = state.seatOrder[index]; + if (candidate !== undefined && playersToActSet.has(candidate)) { + return { ...state, actingIndex: index }; + } + } + throw new Error('unreachable: advanceToNextActor requires a non-empty playersToAct'); +} + +/** + * The single driver behind every state transition that doesn't require waiting on a human/bot + * decision: closing a betting round, dealing the next street (or running out every remaining + * street at once when no one left can act), settling a showdown or fold-win, busting players, + * and dealing the next hand — repeating for as long as the resulting state still needs no + * decision, and stopping the instant either an actor is found or the match ends. Used both right + * after a real action is applied (`resolve`) and right after a fresh deal (`initialize`, + * `settleHandEnd`), since a fresh deal can itself already be un-actionable (e.g. a short-stacked + * blind posts all-in against another short-stacked blind). + */ +export function advance( + state: PokerTexasHoldEmState, + rng: Rng, + events: RoundEvent[], +): PokerTexasHoldEmState { + let working = state; + for (;;) { + const nonFolded = working.seatOrder.filter((id) => working.players[id]?.folded !== true); + if (nonFolded.length === 1) { + const winnerId = nonFolded[0]; + if (winnerId === undefined) { + throw new Error('unreachable: nonFolded.length === 1 was just checked'); + } + working = awardPotToSoleWinner(working, winnerId, events); + working = settleHandEnd(working, rng, events); + if (working.matchComplete) { + return working; + } + continue; + } + + if (working.playersToAct.length === 0) { + if (working.street === 'river') { + working = doShowdown(working, events); + working = settleHandEnd(working, rng, events); + if (working.matchComplete) { + return working; + } + continue; + } + const liveNonAllIn = nonFolded.filter((id) => working.players[id]?.allIn !== true); + if (liveNonAllIn.length <= 1) { + working = runOutRemainingStreets(working, events); + continue; + } + working = dealNextStreetWithBetting(working, events); + continue; + } + + return advanceToNextActor(working); + } +} diff --git a/games/poker-texas-hold-em/src/betting.ts b/games/poker-texas-hold-em/src/betting.ts new file mode 100644 index 0000000..6d53fc3 --- /dev/null +++ b/games/poker-texas-hold-em/src/betting.ts @@ -0,0 +1,121 @@ +import type { RoundEvent } from '@thunderdome/engine'; +import type { + PokerPlayerHandState, + PokerTexasHoldEmAction, + PokerTexasHoldEmState, +} from './types.js'; + +export interface AppliedAction { + state: PokerTexasHoldEmState; + events: RoundEvent[]; +} + +/** Applies one already-validated player action to `state`: updates their chips/committed + * amounts, and the street's `currentBet`/`minRaise`/`playersToAct` bookkeeping. Doesn't decide + * anything beyond that single action — closing the betting round, dealing the next street, or + * settling the hand is `advance()`'s job (advance.ts), run right after this by `resolve()`. */ +export function applyPlayerAction( + state: PokerTexasHoldEmState, + participantId: string, + action: PokerTexasHoldEmAction, +): AppliedAction { + const player = state.players[participantId]; + if (player === undefined) { + throw new Error(`unreachable: "${participantId}" is not seated in the current hand`); + } + const stack = state.stacks[participantId] ?? 0; + const players = { ...state.players }; + const stacks = { ...state.stacks }; + let currentBet = state.currentBet; + let minRaise = state.minRaise; + let playersToAct = state.playersToAct; + const events: RoundEvent[] = []; + + // Takes `basePlayer` explicitly (rather than closing over the outer `player`) so TS's + // undefined-narrowing above actually applies at every call site — narrowing a `const` doesn't + // carry into a nested function declaration's body. + function commit(basePlayer: PokerPlayerHandState, delta: number): PokerPlayerHandState { + stacks[participantId] = stack - delta; + const updated: PokerPlayerHandState = { + ...basePlayer, + committed: basePlayer.committed + delta, + committedThisStreet: basePlayer.committedThisStreet + delta, + allIn: stacks[participantId] === 0, + }; + players[participantId] = updated; + return updated; + } + + // A raise (full or a short all-in) reopens the action for everyone else still live. This + // deliberately simplifies the cardroom rule that a short (under-min-raise) all-in does NOT + // reopen betting for players who already acted at the current level — a real but rare-in-play + // edge case; every raise here is treated uniformly for simplicity. + function reopenActionAfterRaise(): string[] { + return state.seatOrder.filter( + (id) => id !== participantId && players[id]?.folded !== true && players[id]?.allIn !== true, + ); + } + + switch (action.type) { + case 'fold': { + players[participantId] = { ...player, folded: true }; + playersToAct = playersToAct.filter((id) => id !== participantId); + events.push({ type: 'action', participantIds: [participantId], data: { action: 'fold' } }); + break; + } + case 'check': { + playersToAct = playersToAct.filter((id) => id !== participantId); + events.push({ type: 'action', participantIds: [participantId], data: { action: 'check' } }); + break; + } + case 'call': { + const toCall = Math.min(currentBet - player.committedThisStreet, stack); + commit(player, toCall); + playersToAct = playersToAct.filter((id) => id !== participantId); + events.push({ + type: 'action', + participantIds: [participantId], + data: { action: 'call', amount: toCall }, + }); + break; + } + case 'raise': { + const delta = action.amount - player.committedThisStreet; + commit(player, delta); + const isFullRaise = action.amount - currentBet >= minRaise; + if (isFullRaise) { + minRaise = action.amount - currentBet; + } + currentBet = action.amount; + playersToAct = reopenActionAfterRaise(); + events.push({ + type: 'action', + participantIds: [participantId], + data: { action: 'raise', amount: action.amount }, + }); + break; + } + case 'allIn': { + const updated = commit(player, stack); + const amount = updated.committedThisStreet; + if (amount > currentBet) { + const isFullRaise = amount - currentBet >= minRaise; + if (isFullRaise) { + minRaise = amount - currentBet; + } + currentBet = amount; + playersToAct = reopenActionAfterRaise(); + } else { + playersToAct = playersToAct.filter((id) => id !== participantId); + } + events.push({ + type: 'action', + participantIds: [participantId], + data: { action: 'allIn', amount }, + }); + break; + } + } + + return { state: { ...state, players, stacks, currentBet, minRaise, playersToAct }, events }; +} diff --git a/games/poker-texas-hold-em/src/deal.ts b/games/poker-texas-hold-em/src/deal.ts new file mode 100644 index 0000000..ba44198 --- /dev/null +++ b/games/poker-texas-hold-em/src/deal.ts @@ -0,0 +1,131 @@ +import { shuffle, standardDeck, type Card } from '@thunderdome/card-kit'; +import type { Rng, RoundEvent } from '@thunderdome/engine'; +import { + bigBlindSeatIndex, + buildSeatOrder, + nextButton, + preflopFirstToActIndex, + smallBlindSeatIndex, +} from './table.js'; +import type { BettingStreet, PokerPlayerHandState, PokerTexasHoldEmConfig } from './types.js'; + +export interface DealtHand { + seatOrder: string[]; + players: Record; + board: Card[]; + remainingBoardCards: Card[]; + street: BettingStreet; + currentBet: number; + minRaise: number; + playersToAct: string[]; + /** A pointer to the seat BEFORE the real first-to-act seat, not the seat itself — `advance()` + * (advance.ts) always resolves "who's actually next" by walking forward from here to the first + * live member of `playersToAct`, which is the one thing that's correct whether or not the + * formula seat happens to have folded or been dealt out all-in (impossible preflop, but the + * same dealing path also feeds `dealNextStreetWithBetting` in advance.ts, where it's common). */ + actingIndex: number; + buttonParticipantId: string; + handNumber: number; + stacks: Record; + events: RoundEvent[]; +} + +/** + * Deals a fresh hand: rotates the button, shuffles, deals hole cards, and posts blinds. Does NOT + * decide whether any further betting is actually possible this hand (e.g. both blinds posting + * all-in) — `advance()` (advance.ts) is what settles that, uniformly for every state transition. + * + * The deck is shuffled once and sliced into contiguous chunks (2 cards per seat, then 5 reserved + * for the board) rather than dealt round-robin or with burn cards between streets — a single + * uniform shuffle makes contiguous slicing statistically identical to either of those (see + * @thunderdome/card-kit's `dealHands` doc comment for the same reasoning), so both are omitted. + */ +export function dealNewHand( + participantIds: readonly string[], + stacks: Readonly>, + bustedOut: ReadonlySet, + previousButtonParticipantId: string | null, + handNumber: number, + config: PokerTexasHoldEmConfig, + rng: Rng, +): DealtHand { + const activeParticipantIds = participantIds.filter((id) => !bustedOut.has(id)); + const buttonParticipantId = + previousButtonParticipantId === null + ? rng.pick(activeParticipantIds) + : nextButton(participantIds, previousButtonParticipantId, bustedOut); + const seatOrder = buildSeatOrder(participantIds, buttonParticipantId, bustedOut); + const n = seatOrder.length; + + const deck = shuffle(standardDeck(), rng); + const players: Record = {}; + seatOrder.forEach((id, index) => { + const first = deck[index * 2]; + const second = deck[index * 2 + 1]; + if (first === undefined || second === undefined) { + throw new Error('unreachable: standardDeck() has 52 cards, far more than 2 per seat needs'); + } + players[id] = { + holeCards: [first, second], + folded: false, + allIn: false, + committed: 0, + committedThisStreet: 0, + }; + }); + const remainingBoardCards = deck.slice(n * 2, n * 2 + 5); + + const nextStacks = { ...stacks }; + const events: RoundEvent[] = [ + { type: 'hand-started', data: { handNumber, buttonParticipantId, seatOrder } }, + ]; + + function postBlind(participantId: string, desired: number): number { + const stack = nextStacks[participantId] ?? 0; + const posted = Math.min(desired, stack); + nextStacks[participantId] = stack - posted; + const player = players[participantId]; + if (player === undefined) { + throw new Error(`unreachable: "${participantId}" was just dealt into players above`); + } + players[participantId] = { + ...player, + committed: player.committed + posted, + committedThisStreet: player.committedThisStreet + posted, + allIn: nextStacks[participantId] === 0, + }; + return posted; + } + + const sbId = seatOrder[smallBlindSeatIndex(n)]; + const bbId = seatOrder[bigBlindSeatIndex(n)]; + if (sbId === undefined || bbId === undefined) { + throw new Error('unreachable: seatOrder always has at least 2 seats'); + } + const sbPosted = postBlind(sbId, config.smallBlind); + const bbPosted = postBlind(bbId, config.bigBlind); + events.push({ + type: 'blinds-posted', + participantIds: [sbId, bbId], + data: { + smallBlind: { participantId: sbId, amount: sbPosted }, + bigBlind: { participantId: bbId, amount: bbPosted }, + }, + }); + + return { + seatOrder, + players, + board: [], + remainingBoardCards, + street: 'preflop', + currentBet: bbPosted, + minRaise: config.bigBlind, + playersToAct: seatOrder.filter((id) => players[id]?.allIn !== true), + actingIndex: (preflopFirstToActIndex(n) - 1 + n) % n, + buttonParticipantId, + handNumber, + stacks: nextStacks, + events, + }; +} diff --git a/games/poker-texas-hold-em/src/game.ts b/games/poker-texas-hold-em/src/game.ts new file mode 100644 index 0000000..186f5a1 --- /dev/null +++ b/games/poker-texas-hold-em/src/game.ts @@ -0,0 +1,315 @@ +import { err, ok, type GameDefinition, type StandingOutcome } from '@thunderdome/engine'; +import { advance } from './advance.js'; +import { applyPlayerAction } from './betting.js'; +import { dealNewHand } from './deal.js'; +import { + describeAction, + describeObservation, + describeRoundEvents, + parseInput, + validateInput, +} from './human.js'; +import { + PokerTexasHoldEmActionSchema, + PokerTexasHoldEmConfigSchema, + type PokerLegalActionType, + type PokerOpponentView, + type PokerTexasHoldEmAction, + type PokerTexasHoldEmConfig, + type PokerTexasHoldEmObservation, + type PokerTexasHoldEmResult, + type PokerTexasHoldEmState, +} from './types.js'; + +const MIN_PARTICIPANTS = 2; +const MAX_PARTICIPANTS = 10; + +/** + * Texas Hold Em — no-limit, 2-10 players. Each hand: hole cards and blinds are dealt + * (deal.ts), players act in turn (betting.ts applies each action), and `advance()` (advance.ts) + * drives every automatic transition in between — closing a betting round, dealing the next + * street, running out an all-in, and settling a showdown or fold-win (showdown.ts) — until either + * a real decision is needed or the match ends. See `config.matchFormat` (types.ts) for the two + * supported ways a match ends: `elimination` (last stack standing) or `fixedHands` (play exactly + * `totalHands` hands, then rank by chip count). + * + * `onMissingAction` is deliberately omitted, same as Connect Four: there's no sensible substitute + * action for a no-show mid-hand (unlike Hearts, where "play the lowest legal card" is a + * reasonable stand-in), so a missing required action just forfeits the match, the engine's + * default. + */ +export const pokerTexasHoldEm: GameDefinition< + PokerTexasHoldEmConfig, + PokerTexasHoldEmState, + PokerTexasHoldEmObservation, + PokerTexasHoldEmAction, + PokerTexasHoldEmResult +> = { + id: 'poker-texas-hold-em', + version: '0.1.0', + + parseConfig(raw) { + const result = PokerTexasHoldEmConfigSchema.safeParse(raw); + return result.success + ? ok(result.data) + : err(result.error.issues.map((issue) => issue.message).join('; ')); + }, + + // Deals the opening hand, then runs it through `advance()` in case it's already un-actionable + // (e.g. a short-stacked blind posting all-in). `initialize` has no events channel (unlike + // `resolve`), so the deal's own events are deliberately dropped — only `advance`'s state-shaping + // matters here. + initialize({ participantIds, config, rng }) { + if (participantIds.length < MIN_PARTICIPANTS || participantIds.length > MAX_PARTICIPANTS) { + throw new Error( + `poker-texas-hold-em requires between ${String(MIN_PARTICIPANTS)} and ${String(MAX_PARTICIPANTS)} participants, got ${String(participantIds.length)}`, + ); + } + const stacks: Record = {}; + participantIds.forEach((id) => { + stacks[id] = config.startingStack; + }); + + const dealt = dealNewHand(participantIds, stacks, new Set(), null, 0, config, rng); + const initial: PokerTexasHoldEmState = { + participantIds: [...participantIds], + config, + stacks: dealt.stacks, + bustedOut: [], + handNumber: dealt.handNumber, + buttonParticipantId: dealt.buttonParticipantId, + seatOrder: dealt.seatOrder, + players: dealt.players, + board: dealt.board, + remainingBoardCards: dealt.remainingBoardCards, + street: dealt.street, + currentBet: dealt.currentBet, + minRaise: dealt.minRaise, + playersToAct: dealt.playersToAct, + actingIndex: dealt.actingIndex, + lastHandSummary: null, + matchComplete: false, + }; + return advance(initial, rng, []); + }, + + // The engine only ever calls this for a participant `getPendingActions` just named + // (match-runner.ts), and a busted-out participant is never in `seatOrder` / never pending — so + // `player === undefined` below is unreachable via normal engine use, guarded only defensively. + getObservation(state, participantId) { + if (!state.participantIds.includes(participantId)) { + throw new Error(`unknown participant "${participantId}"`); + } + const player = state.players[participantId]; + if (player === undefined) { + throw new Error(`"${participantId}" is not seated in the current hand (likely busted out)`); + } + const stack = state.stacks[participantId] ?? 0; + const toCall = Math.max(0, state.currentBet - player.committedThisStreet); + const maxRaiseTo = stack + player.committedThisStreet; + const minRaiseTo = stack === 0 ? null : Math.min(state.currentBet + state.minRaise, maxRaiseTo); + + const legalActions: PokerLegalActionType[] = []; + if (stack > 0) { + legalActions.push('fold'); + } + if (toCall === 0) { + legalActions.push('check'); + } else { + legalActions.push('call'); + } + if (stack > 0 && maxRaiseTo > state.currentBet) { + legalActions.push('raise'); + } + if (stack > 0) { + legalActions.push('allIn'); + } + + const selfIndex = state.seatOrder.indexOf(participantId); + const n = state.seatOrder.length; + const opponents: PokerOpponentView[] = state.seatOrder + .filter((id) => id !== participantId) + .map((id) => { + const opponent = state.players[id]; + if (opponent === undefined) { + throw new Error(`unreachable: "${id}" is in seatOrder`); + } + return { + participantId: id, + stack: state.stacks[id] ?? 0, + committed: opponent.committed, + committedThisStreet: opponent.committedThisStreet, + folded: opponent.folded, + allIn: opponent.allIn, + isButton: id === state.buttonParticipantId, + }; + }) + .sort((a, b) => { + const offset = (id: string) => (state.seatOrder.indexOf(id) - selfIndex - 1 + n) % n; + return offset(a.participantId) - offset(b.participantId); + }); + + const pot = state.seatOrder.reduce((sum, id) => sum + (state.players[id]?.committed ?? 0), 0); + + return { + you: participantId, + handNumber: state.handNumber, + street: state.street, + board: state.board, + holeCards: player.holeCards, + pot, + yourStack: stack, + yourCommittedThisStreet: player.committedThisStreet, + toCall, + minRaiseTo, + maxRaiseTo, + smallBlind: state.config.smallBlind, + bigBlind: state.config.bigBlind, + buttonParticipantId: state.buttonParticipantId, + opponents, + legalActions, + lastHandSummary: state.lastHandSummary, + }; + }, + + getPendingActions(state) { + if (state.matchComplete) { + return []; + } + const participantId = state.seatOrder[state.actingIndex]; + if (participantId === undefined) { + throw new Error( + 'unreachable: actingIndex is a valid seatOrder index while the match is in progress', + ); + } + return [{ participantId, required: true }]; + }, + + validateAction(state, participantId, raw) { + const parsed = PokerTexasHoldEmActionSchema.safeParse(raw); + if (!parsed.success) { + return err( + 'action must be { type: "fold" }, { type: "check" }, { type: "call" }, { type: "raise", amount } (total street commitment), or { type: "allIn" }', + ); + } + const action = parsed.data; + const player = state.players[participantId]; + if (player === undefined) { + return err(`"${participantId}" is not seated in the current hand`); + } + const stack = state.stacks[participantId] ?? 0; + const toCall = state.currentBet - player.committedThisStreet; + + switch (action.type) { + case 'fold': + return ok(action); + case 'check': + return toCall === 0 ? ok(action) : err('cannot check: there is a bet to call'); + case 'call': + return toCall > 0 ? ok(action) : err('nothing to call — use check'); + case 'allIn': + return stack > 0 ? ok(action) : err('no chips left to move all-in with'); + case 'raise': { + if (stack <= 0) { + return err('no chips left to raise with'); + } + const maxAmount = player.committedThisStreet + stack; + if (action.amount > maxAmount) { + return err(`cannot commit more than your stack (max ${String(maxAmount)})`); + } + if (action.amount <= state.currentBet) { + return err('raise amount must exceed the current bet'); + } + const isAllIn = action.amount === maxAmount; + if (!isAllIn && action.amount < state.currentBet + state.minRaise) { + return err( + `raise must add at least ${String(state.minRaise)} to the current bet of ${String(state.currentBet)} (i.e. reach ${String(state.currentBet + state.minRaise)}), or go all-in for less`, + ); + } + return ok(action); + } + } + }, + + resolve({ state, actions, rng }) { + const participantId = state.seatOrder[state.actingIndex]; + if (participantId === undefined) { + throw new Error( + 'unreachable: actingIndex is a valid seatOrder index while the match is in progress', + ); + } + const action = actions.get(participantId); + if (action === undefined) { + throw new Error( + `unreachable: resolve() called without a validated action for "${participantId}"`, + ); + } + const applied = applyPlayerAction(state, participantId, action); + const events = [...applied.events]; + const nextState = advance(applied.state, rng, events); + return { nextState, events }; + }, + + isTerminal(state) { + return state.matchComplete; + }, + + getResult(state) { + return { + participantIds: state.participantIds, + stacks: state.stacks, + bustedOut: state.bustedOut, + handsPlayed: state.handNumber + 1, + }; + }, + + // Whoever busted later (or never busted) placed better than whoever busted earlier; players who + // bust in the same hand tie. `tierOf` is that group's index in `bustedOut` for a busted + // participant, or `bustedOut.length` (better than every busted tier) for anyone who still holds + // chips at match end. + getStandingOutcomes(result) { + const { participantIds, stacks, bustedOut } = result; + const tierOf = (id: string): number => { + if ((stacks[id] ?? 0) > 0) { + return bustedOut.length; + } + const index = bustedOut.findIndex((group) => group.includes(id)); + return index === -1 ? bustedOut.length : index; + }; + const isBetter = (a: string, b: string): boolean => { + const stackA = stacks[a] ?? 0; + const stackB = stacks[b] ?? 0; + return stackA !== stackB ? stackA > stackB : tierOf(a) > tierOf(b); + }; + const isEqual = (a: string, b: string): boolean => + (stacks[a] ?? 0) === (stacks[b] ?? 0) && tierOf(a) === tierOf(b); + + const bestIds = participantIds.filter((id) => + participantIds.every((other) => !isBetter(other, id)), + ); + const soleBestId = bestIds[0]; + + return participantIds.map((id) => { + const rank = 1 + participantIds.filter((other) => isBetter(other, id)).length; + const outcome: NonNullable = + bestIds.length > 1 + ? bestIds.some((other) => isEqual(other, id)) + ? 'draw' + : 'loss' + : id === soleBestId + ? 'win' + : 'loss'; + return { participantId: id, rank, score: stacks[id] ?? 0, outcome }; + }); + }, + + resourceLimits: { cpus: 0.5, memoryMb: 128, turnTimeoutMs: 5000 }, + + humanInterface: { + describeObservation, + parseInput, + validateInput, + describeAction, + describeRoundEvents, + }, +}; diff --git a/games/poker-texas-hold-em/src/hand-evaluator.ts b/games/poker-texas-hold-em/src/hand-evaluator.ts new file mode 100644 index 0000000..584b2ff --- /dev/null +++ b/games/poker-texas-hold-em/src/hand-evaluator.ts @@ -0,0 +1,169 @@ +import type { Card, Rank } from '@thunderdome/card-kit'; + +export type HandCategory = + | 'high-card' + | 'pair' + | 'two-pair' + | 'three-of-a-kind' + | 'straight' + | 'flush' + | 'full-house' + | 'four-of-a-kind' + | 'straight-flush'; + +/** Ordinal strength of each category — higher wins, compared before either hand's `tiebreakers`. */ +const CATEGORY_STRENGTH: Record = { + 'high-card': 0, + pair: 1, + 'two-pair': 2, + 'three-of-a-kind': 3, + straight: 4, + flush: 5, + 'full-house': 6, + 'four-of-a-kind': 7, + 'straight-flush': 8, +}; + +export interface HandRank { + category: HandCategory; + /** Ranks that break a tie within the same category, most significant first — e.g. `[quadRank, + * kicker]` for four-of-a-kind, all 5 ranks descending for high-card/flush. Compared + * lexicographically after `category`. */ + tiebreakers: number[]; +} + +/** `-1`/`0`/`1` per the usual comparator contract: positive means `a` beats `b`. */ +export function compareHandRank(a: HandRank, b: HandRank): number { + const strengthDiff = CATEGORY_STRENGTH[a.category] - CATEGORY_STRENGTH[b.category]; + if (strengthDiff !== 0) { + return strengthDiff; + } + const length = Math.max(a.tiebreakers.length, b.tiebreakers.length); + for (let i = 0; i < length; i += 1) { + const diff = (a.tiebreakers[i] ?? 0) - (b.tiebreakers[i] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +/** `null` unless `ranks` (deduplicated, descending) forms 5 consecutive ranks — including the + * wheel (A-2-3-4-5, which plays as a 5-high straight, the one case where an Ace counts low). */ +function straightHighCard(uniqueRanksDesc: readonly Rank[]): number | null { + if (uniqueRanksDesc.length < 5) { + return null; + } + for (let start = 0; start + 4 < uniqueRanksDesc.length; start += 1) { + const high = uniqueRanksDesc[start]; + const low = uniqueRanksDesc[start + 4]; + if (high === undefined || low === undefined) { + throw new Error('unreachable: index bounds checked above'); + } + if (high - low === 4) { + return high; + } + } + const wheel = [14, 5, 4, 3, 2]; + if (wheel.every((rank) => uniqueRanksDesc.includes(rank as Rank))) { + return 5; + } + return null; +} + +/** + * Ranks the best 5-card poker hand out of exactly 5 cards. Same-rank cards are grouped into + * `groups`, largest group first and same-size groups broken by rank descending, so every category + * below can just read `groups[0]`/`groups[1]` for "the pair/trips/quads rank" without re-deriving + * it. + */ +export function evaluateFiveCardHand(cards: readonly Card[]): HandRank { + if (cards.length !== 5) { + throw new Error(`evaluateFiveCardHand requires exactly 5 cards, got ${String(cards.length)}`); + } + const ranksDesc = [...cards].map((card) => card.rank).sort((a, b) => b - a); + const isFlush = cards.every((card) => card.suit === cards[0]?.suit); + const uniqueRanksDesc = [...new Set(ranksDesc)]; + const straightHigh = straightHighCard(uniqueRanksDesc); + + const countByRank = new Map(); + ranksDesc.forEach((rank) => countByRank.set(rank, (countByRank.get(rank) ?? 0) + 1)); + const groups = [...countByRank.entries()].sort(([rankA, countA], [rankB, countB]) => + countA === countB ? rankB - rankA : countB - countA, + ); + const [topGroup, secondGroup, thirdGroup] = groups; + if (topGroup === undefined) { + throw new Error('unreachable: 5 cards always produce at least one rank group'); + } + + if (straightHigh !== null && isFlush) { + return { category: 'straight-flush', tiebreakers: [straightHigh] }; + } + if (topGroup[1] === 4) { + const kicker = secondGroup?.[0] ?? 0; + return { category: 'four-of-a-kind', tiebreakers: [topGroup[0], kicker] }; + } + if (topGroup[1] === 3 && secondGroup?.[1] === 2) { + return { category: 'full-house', tiebreakers: [topGroup[0], secondGroup[0]] }; + } + if (isFlush) { + return { category: 'flush', tiebreakers: ranksDesc }; + } + if (straightHigh !== null) { + return { category: 'straight', tiebreakers: [straightHigh] }; + } + if (topGroup[1] === 3) { + const kickers = groups.slice(1).map(([rank]) => rank); + return { category: 'three-of-a-kind', tiebreakers: [topGroup[0], ...kickers] }; + } + if (topGroup[1] === 2 && secondGroup?.[1] === 2) { + const kicker = thirdGroup?.[0] ?? 0; + return { category: 'two-pair', tiebreakers: [topGroup[0], secondGroup[0], kicker] }; + } + if (topGroup[1] === 2) { + const kickers = groups.slice(1).map(([rank]) => rank); + return { category: 'pair', tiebreakers: [topGroup[0], ...kickers] }; + } + return { category: 'high-card', tiebreakers: ranksDesc }; +} + +/** Every k-combination of `items`, order-preserving within each combination. */ +function combinations(items: readonly T[], k: number): T[][] { + if (k === 0) { + return [[]]; + } + if (items.length < k) { + return []; + } + const [first, ...rest] = items; + if (first === undefined) { + throw new Error('unreachable: items.length >= k >= 1 checked above'); + } + const withFirst = combinations(rest, k - 1).map((combo) => [first, ...combo]); + const withoutFirst = combinations(rest, k); + return [...withFirst, ...withoutFirst]; +} + +export interface BestHand extends HandRank { + /** The specific 5 cards (a subset of the input) that produced this ranking. */ + cards: Card[]; +} + +/** The best 5-card hand reachable from `cards` (typically 7: 2 hole + 5 board). Requires at + * least 5 cards. */ +export function bestHand(cards: readonly Card[]): BestHand { + if (cards.length < 5) { + throw new Error(`bestHand requires at least 5 cards, got ${String(cards.length)}`); + } + let best: BestHand | null = null; + for (const combo of combinations(cards, 5)) { + const rank = evaluateFiveCardHand(combo); + if (best === null || compareHandRank(rank, best) > 0) { + best = { ...rank, cards: combo }; + } + } + if (best === null) { + throw new Error('unreachable: combinations(cards, 5) is non-empty whenever cards.length >= 5'); + } + return best; +} diff --git a/games/poker-texas-hold-em/src/human.ts b/games/poker-texas-hold-em/src/human.ts new file mode 100644 index 0000000..87adb4e --- /dev/null +++ b/games/poker-texas-hold-em/src/human.ts @@ -0,0 +1,268 @@ +import { cardId, type Card } from '@thunderdome/card-kit'; +import type { RoundEvent } from '@thunderdome/engine'; +import type { + BettingStreet, + PokerHandSummary, + PokerLegalActionType, + PokerTexasHoldEmAction, + PokerTexasHoldEmObservation, +} from './types.js'; + +// Same convention as card-game-hearts/src/human.ts: an empty string element contributes exactly +// one blank line once `.join('\n')` runs — a literal '\n' element would double it up. +const BLANK_LINE = ''; +const SEPARATOR = '----------'; + +function describeCards(cards: readonly Card[]): string { + return cards.length === 0 ? '(none)' : cards.map(cardId).join(' '); +} + +const STREET_LABELS: Record = { + preflop: 'preflop', + flop: 'flop', + turn: 'turn', + river: 'river', +}; + +function describeLegalActions(observation: PokerTexasHoldEmObservation): string { + const parts: string[] = []; + const has = (action: PokerLegalActionType) => observation.legalActions.includes(action); + if (has('fold')) parts.push('FOLD'); + if (has('check')) parts.push('CHECK'); + if (has('call')) parts.push(`CALL (${String(observation.toCall)})`); + if (has('raise') && observation.minRaiseTo !== null) { + parts.push( + `RAISE (min ${String(observation.minRaiseTo)}, max ${String(observation.maxRaiseTo)})`, + ); + } + if (has('allIn')) parts.push(`ALLIN (${String(observation.maxRaiseTo)})`); + return parts.join(' | '); +} + +export function describeObservation(observation: PokerTexasHoldEmObservation): string { + const opponentLines = observation.opponents.map((opponent) => { + const status = opponent.folded ? 'folded' : opponent.allIn ? 'all-in' : 'in'; + const buttonTag = opponent.isButton ? ' [button]' : ''; + return ` ${opponent.participantId}${buttonTag}: stack ${String(opponent.stack)}, committed ${String(opponent.committed)} this hand (${status})`; + }); + + const youAreButton = observation.buttonParticipantId === observation.you ? ' [button]' : ''; + + return [ + SEPARATOR, + BLANK_LINE, + `Texas Hold'em — Hand #${String(observation.handNumber + 1)} (${STREET_LABELS[observation.street]})`, + ` - Blinds: ${String(observation.smallBlind)}/${String(observation.bigBlind)} — button: ${observation.buttonParticipantId === observation.you ? 'you' : observation.buttonParticipantId}`, + BLANK_LINE, + `Board: ${describeCards(observation.board)}`, + `Pot: ${String(observation.pot)} (to call: ${String(observation.toCall)})`, + BLANK_LINE, + 'Opponents:', + ...opponentLines, + BLANK_LINE, + `You${youAreButton}:`, + ` Your stack: ${String(observation.yourStack)} (committed ${String(observation.yourCommittedThisStreet)} this street)`, + ` Your cards: ${describeCards(observation.holeCards)}`, + BLANK_LINE, + `Cards: rank+suit — 2-9, T=10, J, Q, K, A; C=clubs, D=diamonds, H=hearts, S=spades.`, + `Type: ${describeLegalActions(observation)}`, + ].join('\n'); +} + +/** + * Confirms exactly how `parseInput` understood the human's last input, printed right before the + * next prompt — matters most for RAISE, where a typo'd amount is still a well-formed, just + * different, legal-or-illegal action (card-game-hearts/src/human.ts's `describeAction` exists for + * the same reason). + */ +export function describeAction(action: PokerTexasHoldEmAction): string { + switch (action.type) { + case 'fold': + return 'You folded.'; + case 'check': + return 'You checked.'; + case 'call': + return 'You called.'; + case 'raise': + return `You raised to ${String(action.amount)}.`; + case 'allIn': + return 'You went all-in.'; + } +} + +/** + * Syntactically parses the command, with no access to state — `validateAction` is the sole + * authority on whether it's actually legal right now (e.g. checking when there's a bet to call). + */ +export function parseInput(raw: string): PokerTexasHoldEmAction | undefined { + const tokens = raw.trim().toUpperCase().split(/\s+/); + const [command, ...rest] = tokens; + + switch (command) { + case 'FOLD': + case 'F': + return rest.length === 0 ? { type: 'fold' } : undefined; + case 'CHECK': + case 'CHK': + case 'X': + return rest.length === 0 ? { type: 'check' } : undefined; + case 'CALL': + case 'C': + return rest.length === 0 ? { type: 'call' } : undefined; + case 'ALLIN': + case 'ALL-IN': + case 'SHOVE': + case 'A': + return rest.length === 0 ? { type: 'allIn' } : undefined; + case 'RAISE': + case 'BET': + case 'R': { + if (rest.length !== 1) { + return undefined; + } + const amount = Number(rest[0]); + return Number.isInteger(amount) && amount > 0 ? { type: 'raise', amount } : undefined; + } + default: + return undefined; + } +} + +/** + * Catches a raise whose amount `parseInput` accepted as a well-formed positive integer but which + * falls outside what this observation says is actually raiseable right now — the same min/max + * bounds `game.ts`'s `validateAction` enforces against the authoritative state, computed here from + * the observation's own `minRaiseTo`/`maxRaiseTo` (see `getObservation` in game.ts, which derives + * both from the identical `stack`/`committedThisStreet`/`currentBet`/`minRaise` this mirrors) so a + * human gets an immediate reprompt instead of the collector forwarding an illegal action that + * `validateAction` would then reject as a missing/invalid required action (forfeiting the match — + * see `onMissingAction`'s comment in game.ts). + */ +export function validateInput( + action: PokerTexasHoldEmAction, + observation: PokerTexasHoldEmObservation, +): string | undefined { + if (action.type !== 'raise') { + return undefined; + } + if (observation.minRaiseTo === null) { + return 'You have no chips left to raise with'; + } + if (action.amount > observation.maxRaiseTo) { + return `You can't commit more than your stack (max ${String(observation.maxRaiseTo)})`; + } + const currentBet = observation.yourCommittedThisStreet + observation.toCall; + if (action.amount <= currentBet) { + return 'Your raise must exceed the current bet'; + } + if (action.amount < observation.minRaiseTo) { + return `Your raise must reach at least ${String(observation.minRaiseTo)} (or go all-in for less)`; + } + return undefined; +} + +// ---------- live round narration (see GameDefinition.humanInterface.describeRoundEvents) ---------- + +/** The `data` shape betting.ts's `applyPlayerAction` puts on every `'action'` event. */ +interface ActionEventData { + action: 'fold' | 'check' | 'call' | 'raise' | 'allIn'; + amount?: number; +} + +/** The `data` shape advance.ts's `dealNextStreetWithBetting` puts on every `'street-dealt'` event. */ +interface StreetDealtEventData { + street: BettingStreet; + board: Card[]; +} + +const STREET_HEADERS: Record, string> = { + flop: 'Flop', + turn: 'Turn', + river: 'River', +}; + +function describeActionEvent(participantId: string, data: ActionEventData): string { + switch (data.action) { + case 'fold': + return `${participantId} folds.`; + case 'check': + return `${participantId} checks.`; + case 'call': + return `${participantId} calls${data.amount === undefined ? '' : ` ${String(data.amount)}`}.`; + case 'raise': + return `${participantId} raises to ${String(data.amount)}.`; + case 'allIn': + return `${participantId} goes all-in${data.amount === undefined ? '' : ` (${String(data.amount)})`}.`; + } +} + +/** Lines announcing a completed hand's outcome — a dedicated "winner:" line always leads, + * whether the hand ended by fold or showdown, since `summary.winners` can list more than one + * participant on a split pot or a multi-way side pot. */ +function describeHandSummary(summary: PokerHandSummary, youParticipantId: string): string[] { + const who = (id: string) => (id === youParticipantId ? 'you' : id); + const winnerLine = summary.winners + .map((w) => `${who(w.participantId)} +${String(w.amount)}`) + .join(', '); + + if (summary.reason === 'fold') { + return [`Hand result: everyone else folded — winner: ${winnerLine}`]; + } + const reveals = (summary.showdown ?? []).map( + (reveal) => + ` ${who(reveal.participantId)}: ${describeCards(reveal.holeCards)} (${reveal.category})`, + ); + return [`Hand result (showdown) — winner: ${winnerLine}`, ...reveals]; +} + +/** + * Narrates a round's events live for a human bystander — called by the CLI even on rounds where + * it isn't the human's turn (see `GameDefinition.humanInterface.describeRoundEvents`, + * `packages/engine/src/types.ts`), which is the only way a human still learns what happened in + * the hand that busts them out: `describeObservation` never fires again once they have no more + * turns coming. Skips narrating the human's own action, since `describeAction` already confirmed + * it the moment they submitted it. + */ +export function describeRoundEvents( + events: RoundEvent[], + youParticipantId: string, +): string | undefined { + const lines: string[] = []; + for (const event of events) { + switch (event.type) { + case 'action': { + const participantId = event.participantIds?.[0]; + if (participantId === undefined || participantId === youParticipantId) { + break; + } + lines.push(describeActionEvent(participantId, event.data as ActionEventData)); + break; + } + case 'street-dealt': { + const { street, board } = event.data as StreetDealtEventData; + if (street === 'preflop') { + break; // never actually emitted for preflop — deal.ts's dealNewHand starts there directly + } + lines.push(`${STREET_HEADERS[street]}: ${describeCards(board)}`); + break; + } + case 'hand-complete': + case 'showdown': + lines.push(...describeHandSummary(event.data as PokerHandSummary, youParticipantId)); + break; + case 'busted': { + for (const participantId of event.participantIds ?? []) { + lines.push( + participantId === youParticipantId + ? 'You are out of chips!' + : `${participantId} is out of chips!`, + ); + } + break; + } + default: + break; + } + } + return lines.length === 0 ? undefined : lines.join('\n'); +} diff --git a/games/poker-texas-hold-em/src/index.ts b/games/poker-texas-hold-em/src/index.ts new file mode 100644 index 0000000..cbff844 --- /dev/null +++ b/games/poker-texas-hold-em/src/index.ts @@ -0,0 +1,8 @@ +export * from './types.js'; +export * from './game.js'; + +// The registry-driven CLI (@thunderdome/registry + apps/cli) resolves a game manifest's +// `entryPackage` and dynamically imports it — it can't statically know each game's own export +// name (`pokerTexasHoldEm` here), so every game's entrypoint must also export its GameDefinition under +// this fixed name (games/rock-paper-scissors/src/index.ts established the convention). +export { pokerTexasHoldEm as game } from './game.js'; diff --git a/games/poker-texas-hold-em/src/showdown.ts b/games/poker-texas-hold-em/src/showdown.ts new file mode 100644 index 0000000..5d57be3 --- /dev/null +++ b/games/poker-texas-hold-em/src/showdown.ts @@ -0,0 +1,116 @@ +import type { RoundEvent } from '@thunderdome/engine'; +import { bestHand, compareHandRank, type BestHand } from './hand-evaluator.js'; +import { computeSidePots, oddChipOrder } from './table.js'; +import type { PokerHandSummary, PokerShowdownReveal, PokerTexasHoldEmState } from './types.js'; + +/** Everyone but one player folded — award them the whole pot, no showdown needed (and no side-pot + * math either: with only one eligible player, every layer's chips go to the same winner). */ +export function awardPotToSoleWinner( + state: PokerTexasHoldEmState, + winnerId: string, + events: RoundEvent[], +): PokerTexasHoldEmState { + const potTotal = state.seatOrder.reduce( + (sum, id) => sum + (state.players[id]?.committed ?? 0), + 0, + ); + const stacks = { ...state.stacks, [winnerId]: (state.stacks[winnerId] ?? 0) + potTotal }; + const summary: PokerHandSummary = { + handNumber: state.handNumber, + winners: [{ participantId: winnerId, amount: potTotal }], + reason: 'fold', + board: state.board, + }; + events.push({ type: 'hand-complete', participantIds: [winnerId], data: summary }); + return { ...state, stacks, lastHandSummary: summary }; +} + +/** + * The river's betting closed with 2+ players still in — reveal hands, split every side-pot layer + * among whoever's still eligible for it, and pay out. A pot layer with zero eligible contenders + * (everyone who reached it folded) is skipped as a defensive no-op rather than treated as + * unreachable — real play can't actually produce one (the last aggressor at any layer with real + * chips in it is never the one who folds into their own bet), but nothing here depends on that. + */ +export function doShowdown( + state: PokerTexasHoldEmState, + events: RoundEvent[], +): PokerTexasHoldEmState { + const committed = Object.fromEntries( + state.seatOrder.map((id) => [id, state.players[id]?.committed ?? 0]), + ); + const foldedSet = new Set(state.seatOrder.filter((id) => state.players[id]?.folded === true)); + const pots = computeSidePots(committed, foldedSet); + + const handsByParticipant = new Map(); + const showdownReveals: PokerShowdownReveal[] = []; + for (const id of state.seatOrder) { + if (foldedSet.has(id)) { + continue; + } + const player = state.players[id]; + if (player === undefined) { + throw new Error(`unreachable: "${id}" is seated this hand`); + } + const rank = bestHand([...player.holeCards, ...state.board]); + handsByParticipant.set(id, rank); + showdownReveals.push({ + participantId: id, + holeCards: player.holeCards, + category: rank.category, + }); + } + + const winningsByParticipant = new Map(); + for (const pot of pots) { + const contenders = pot.eligibleParticipantIds.filter((id) => handsByParticipant.has(id)); + if (contenders.length === 0) { + continue; + } + let bestRank: BestHand | null = null; + for (const id of contenders) { + const rank = handsByParticipant.get(id); + if (rank !== undefined && (bestRank === null || compareHandRank(rank, bestRank) > 0)) { + bestRank = rank; + } + } + if (bestRank === null) { + continue; + } + const winningRank = bestRank; + const winners = contenders.filter((id) => { + const rank = handsByParticipant.get(id); + return rank !== undefined && compareHandRank(rank, winningRank) === 0; + }); + const ordered = oddChipOrder(winners, state.seatOrder); + const base = Math.floor(pot.amount / winners.length); + let remainder = pot.amount - base * winners.length; + for (const id of ordered) { + const extra = remainder > 0 ? 1 : 0; + remainder -= extra; + winningsByParticipant.set(id, (winningsByParticipant.get(id) ?? 0) + base + extra); + } + } + + const stacks = { ...state.stacks }; + for (const [id, amount] of winningsByParticipant) { + stacks[id] = (stacks[id] ?? 0) + amount; + } + + const summary: PokerHandSummary = { + handNumber: state.handNumber, + winners: [...winningsByParticipant.entries()].map(([participantId, amount]) => ({ + participantId, + amount, + })), + reason: 'showdown', + showdown: showdownReveals, + board: state.board, + }; + events.push({ + type: 'showdown', + participantIds: showdownReveals.map((reveal) => reveal.participantId), + data: summary, + }); + return { ...state, stacks, lastHandSummary: summary }; +} diff --git a/games/poker-texas-hold-em/src/table.ts b/games/poker-texas-hold-em/src/table.ts new file mode 100644 index 0000000..2cd9aef --- /dev/null +++ b/games/poker-texas-hold-em/src/table.ts @@ -0,0 +1,115 @@ +/** + * Pure helpers for seating order, blind/action-order positions, and side-pot math. Split out from + * `game.ts` because heads-up (2 players) genuinely inverts several of these rules relative to + * 3+-handed play — keeping the formulas named and tested in isolation is worth it. + */ + +/** Table order rotated to start at the button, filtered down to players still in the match + * (i.e. not busted out). `seatOrder[0]` is always the button. */ +export function buildSeatOrder( + participantIds: readonly string[], + buttonParticipantId: string, + bustedOut: ReadonlySet, +): string[] { + const buttonIndex = participantIds.indexOf(buttonParticipantId); + if (buttonIndex === -1) { + throw new Error(`unreachable: "${buttonParticipantId}" is not in participantIds`); + } + const rotated = [...participantIds.slice(buttonIndex), ...participantIds.slice(0, buttonIndex)]; + return rotated.filter((id) => !bustedOut.has(id)); +} + +/** The next player (in fixed table order, wrapping) after `currentButtonId` who hasn't busted + * out — i.e. where the button moves for the next hand. */ +export function nextButton( + participantIds: readonly string[], + currentButtonId: string, + bustedOut: ReadonlySet, +): string { + const n = participantIds.length; + const startIndex = participantIds.indexOf(currentButtonId); + if (startIndex === -1) { + throw new Error(`unreachable: "${currentButtonId}" is not in participantIds`); + } + for (let step = 1; step <= n; step += 1) { + const candidate = participantIds[(startIndex + step) % n]; + if (candidate !== undefined && !bustedOut.has(candidate)) { + return candidate; + } + } + throw new Error('unreachable: nextButton requires at least 2 active (non-busted) players'); +} + +/** + * Heads-up inverts two rules relative to 3+-handed play: the button (who is also the small + * blind) acts FIRST preflop but LAST on every later street, whereas at 3+ players the button + * always acts last. `seatOrder` is button-relative (`seatOrder[0]` = button), so these return + * indices into it. + */ +export function smallBlindSeatIndex(seatCount: number): number { + return seatCount === 2 ? 0 : 1; +} +export function bigBlindSeatIndex(seatCount: number): number { + return seatCount === 2 ? 1 : 2; +} +export function preflopFirstToActIndex(seatCount: number): number { + return seatCount === 2 ? 0 : 3 % seatCount; +} +export function postflopFirstToActIndex(seatCount: number): number { + return 1 % seatCount; +} + +export interface PotShare { + amount: number; + /** Participants whose contribution reached this pot layer and who haven't folded — the only + * ones who can win it. A side pot can have zero eligible winners (everyone who reached that + * layer folded); such a pot is still awarded, to whichever earlier/lower layer's winners + * remain, by the caller working pot-by-pot. */ + eligibleParticipantIds: string[]; +} + +/** + * Splits total contributions into main pot + side pots. Standard layered algorithm: repeatedly + * take the smallest remaining contribution among everyone who still has chips left to allocate, + * multiply by the number of contributors at that layer, and peel that layer off everyone. + * Folded players still contribute chips to whatever layers their commitment reaches — they just + * aren't eligible to win any of them. + */ +export function computeSidePots( + committed: Readonly>, + folded: ReadonlySet, +): PotShare[] { + const remaining = new Map(Object.entries(committed).filter(([, amount]) => amount > 0)); + const pots: PotShare[] = []; + while (remaining.size > 0) { + const minContribution = Math.min(...remaining.values()); + const contributorIds = [...remaining.keys()]; + pots.push({ + amount: minContribution * contributorIds.length, + eligibleParticipantIds: contributorIds.filter((id) => !folded.has(id)), + }); + for (const id of contributorIds) { + const left = (remaining.get(id) ?? 0) - minContribution; + if (left > 0) { + remaining.set(id, left); + } else { + remaining.delete(id); + } + } + } + return pots; +} + +/** Orders `winnerIds` starting from the seat immediately after the button (wrapping), so an + * odd remainder chip from a split pot can be handed out one at a time in that order — the + * standard convention (closest to acting first postflop gets it before the button would). */ +export function oddChipOrder(winnerIds: readonly string[], seatOrder: readonly string[]): string[] { + const n = seatOrder.length; + return [...winnerIds].sort((a, b) => { + const rankOf = (id: string) => { + const index = seatOrder.indexOf(id); + return (index - 1 + n) % n; + }; + return rankOf(a) - rankOf(b); + }); +} diff --git a/games/poker-texas-hold-em/src/types.ts b/games/poker-texas-hold-em/src/types.ts new file mode 100644 index 0000000..ca0c3d8 --- /dev/null +++ b/games/poker-texas-hold-em/src/types.ts @@ -0,0 +1,177 @@ +import type { Card } from '@thunderdome/card-kit'; +import { z } from 'zod'; +import type { HandCategory } from './hand-evaluator.js'; + +export const PokerTexasHoldEmConfigSchema = z + .object({ + /** `'elimination'`: hands repeat until only one participant still has chips (tournament + * style) — `totalHands` is ignored. `'fixedHands'`: play exactly `totalHands` hands, then + * rank by final chip count; a hand can still bust participants along the way, and if that + * ever leaves fewer than 2 with chips the match ends early regardless of `totalHands` (you + * can't deal a hold'em hand to one player). */ + matchFormat: z.enum(['elimination', 'fixedHands']).default('elimination'), + totalHands: z.number().int().positive().default(10), + startingStack: z.number().int().positive().default(1000), + smallBlind: z.number().int().positive().default(10), + bigBlind: z.number().int().positive().default(20), + }) + .refine((config) => config.bigBlind > config.smallBlind, { + message: 'bigBlind must be greater than smallBlind', + path: ['bigBlind'], + }) + .refine((config) => config.startingStack >= config.bigBlind * 2, { + message: + 'startingStack must be at least 2x bigBlind so every seat can post a blind and still have chips left to play', + path: ['startingStack'], + }); +export type PokerTexasHoldEmConfig = z.infer; + +// Unlike card-game-hearts, no action here ever carries a `Card` (you bet on your hand, you never +// name a specific card), so there's no per-card zod schema to define — just the discriminated +// action shapes below. +export const PokerTexasHoldEmActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('fold') }), + z.object({ type: z.literal('check') }), + z.object({ type: z.literal('call') }), + // `amount` is a "raise to" target — your total committed-this-street chip count after the + // raise, not the increment — since that's unambiguous regardless of what's already in front of + // you. Also used for the very first bet of a street (currentBet === 0). + z.object({ type: z.literal('raise'), amount: z.number().int().positive() }), + // Convenience over `raise` with `amount` set to your whole stack — spares a bot from having to + // know its own exact stack size just to shove. + z.object({ type: z.literal('allIn') }), +]); +export type PokerTexasHoldEmAction = z.infer; + +export type BettingStreet = 'preflop' | 'flop' | 'turn' | 'river'; + +export interface PokerPlayerHandState { + holeCards: [Card, Card]; + folded: boolean; + /** True once this player has committed their entire stack this hand — never asked to act + * again for the rest of the hand. */ + allIn: boolean; + /** Total chips committed to the pot this HAND, across every street so far. Drives side-pot + * math at showdown/fold-win. */ + committed: number; + /** Chips committed during the CURRENT street only; reset to 0 whenever a new street is dealt. */ + committedThisStreet: number; +} + +export interface PokerHandWinner { + participantId: string; + amount: number; +} + +export interface PokerShowdownReveal { + participantId: string; + holeCards: [Card, Card]; + category: HandCategory; +} + +export interface PokerHandSummary { + handNumber: number; + winners: PokerHandWinner[]; + reason: 'fold' | 'showdown'; + /** Present only when `reason === 'showdown'` — every non-folded player's hand, as real poker + * reveals them at showdown. A hand that ends by everyone-but-one folding never reveals the + * winner's cards, same as the table game. */ + showdown?: PokerShowdownReveal[]; + board: Card[]; +} + +interface PokerTexasHoldEmCommon { + /** Fixed original roster and seating order — stable for the whole match. A given hand's + * button-relative acting order is `seatOrder`, derived from this each time a hand is dealt. */ + participantIds: string[]; + config: PokerTexasHoldEmConfig; + /** Chips each participant currently holds, NOT counting whatever they've committed to the + * pot in the hand in progress (see `players[id].committed` for that). */ + stacks: Record; + /** Participants with `stacks[id] === 0` who are out of the match entirely, grouped by the hand + * in which they busted (participants who bust in the very same hand share a group, and so tie + * in `getStandingOutcomes`) — earlier groups busted earlier, and rank below later ones. */ + bustedOut: string[][]; + /** 0-based; incremented each time a new hand is dealt. */ + handNumber: number; + buttonParticipantId: string; + /** This hand's active participants (excludes anyone in `bustedOut`), rotated so index 0 is + * the button — the frame every other per-hand field below is indexed against. */ + seatOrder: string[]; + players: Record; + board: Card[]; + /** Community cards dealt but not yet revealed onto `board` (flop/turn/river, in reveal order). + * Set aside once at deal time so an all-in runout or a later street reveal never needs `rng` + * again mid-hand. Never exposed via `getObservation`. */ + remainingBoardCards: Card[]; + street: BettingStreet; + /** The highest `committedThisStreet` among this street's active players — what a call needs + * to match. */ + currentBet: number; + /** The minimum size a full raise must add on top of `currentBet` (an all-in for less is still + * legal, see `validateAction`, but doesn't have to clear this). Starts each hand at the big + * blind, and becomes the size of the last full raise. */ + minRaise: number; + /** Participants (from `seatOrder`) who still need to act before this street's betting closes. + * Emptied one at a time as each acts; a raise refills it with everyone else still live. */ + playersToAct: string[]; + /** Index into `seatOrder` of whoever is next to act — meaningful only while `playersToAct` is + * non-empty. */ + actingIndex: number; + lastHandSummary: PokerHandSummary | null; + matchComplete: boolean; +} + +// `getPendingActions`/`resolve` treat `PokerTexasHoldEmState` as a single shape rather than a +// phase-discriminated union (contrast card-game-hearts) — every field here is meaningful in every +// reachable non-terminal state, since a hand is always "in progress" (dealt, mid-street, or right +// at the showdown/fold-win instant that `resolve()` settles synchronously) until `matchComplete`. +export type PokerTexasHoldEmState = PokerTexasHoldEmCommon; + +export interface PokerOpponentView { + participantId: string; + stack: number; + committed: number; + committedThisStreet: number; + folded: boolean; + allIn: boolean; + isButton: boolean; +} + +export type PokerLegalActionType = 'fold' | 'check' | 'call' | 'raise' | 'allIn'; + +export interface PokerTexasHoldEmObservation { + you: string; + handNumber: number; + street: BettingStreet; + board: Card[]; + holeCards: [Card, Card]; + /** Total chips committed by everyone this hand so far, across all streets (before whatever + * you're about to do). */ + pot: number; + yourStack: number; + yourCommittedThisStreet: number; + /** 0 means you can check. */ + toCall: number; + /** The minimum legal `raise` `amount`, or `null` if you have no chips left to raise with. + * Already capped at your all-in amount when your stack is too short to clear a full raise. */ + minRaiseTo: number | null; + /** Your all-in amount (`yourStack + yourCommittedThisStreet`) — the max legal `raise` `amount`. */ + maxRaiseTo: number; + smallBlind: number; + bigBlind: number; + buttonParticipantId: string; + /** Every other still-in-the-match participant, in seat order starting after you. */ + opponents: PokerOpponentView[]; + /** Advisory only, mirroring `PendingAction.actionSchema`'s convention — `validateAction` is the + * real authority. */ + legalActions: PokerLegalActionType[]; + lastHandSummary: PokerHandSummary | null; +} + +export interface PokerTexasHoldEmResult { + participantIds: string[]; + stacks: Record; + bustedOut: string[][]; + handsPlayed: number; +} diff --git a/games/poker-texas-hold-em/test/advance.test.ts b/games/poker-texas-hold-em/test/advance.test.ts new file mode 100644 index 0000000..d3f2185 --- /dev/null +++ b/games/poker-texas-hold-em/test/advance.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { advance } from '../src/advance.js'; +import { postflopFirstToActIndex } from '../src/table.js'; +import { cards, holeCards, player, pokerState, rng, testConfig } from './fixtures.js'; + +describe('advance — passthrough when someone still needs to act', () => { + // actingIndex 1 ("bob") stands in for "bob just acted" — advance() walks forward from there to + // the first live member of playersToAct, landing on carol at index 2. + it('finds the next live actor via the playersToAct/actingIndex pointer and changes nothing else', () => { + const state = pokerState({ + participantIds: ['alice', 'bob', 'carol'], + seatOrder: ['alice', 'bob', 'carol'], + players: { alice: player(), bob: player(), carol: player() }, + playersToAct: ['carol'], + actingIndex: 1, + }); + const result = advance(state, rng(), []); + expect(result.actingIndex).toBe(2); + expect(result.street).toBe(state.street); + expect(result.board).toEqual(state.board); + }); +}); + +describe('advance — closing a betting round', () => { + // remainingBoardCards supplies only the turn card — this test never reaches the river. + it('deals the next street once playersToAct empties, with a fresh betting round', () => { + const config = testConfig({ bigBlind: 20 }); + const state = pokerState({ + config, + street: 'flop', + board: cards(['2C', '5D', '9H']), + remainingBoardCards: cards(['JS']), + players: { + alice: player({ committedThisStreet: 30 }), + bob: player({ committedThisStreet: 30 }), + }, + currentBet: 30, + minRaise: 20, + playersToAct: [], + }); + const result = advance(state, rng(), []); + expect(result.street).toBe('turn'); + expect(result.board).toEqual(cards(['2C', '5D', '9H', 'JS'])); + expect(result.currentBet).toBe(0); + expect(result.minRaise).toBe(20); + expect(Object.values(result.players).every((p) => p.committedThisStreet === 0)).toBe(true); + expect(result.actingIndex).toBe(postflopFirstToActIndex(2)); + }); +}); + +describe('advance — river showdown', () => { + it('settles a showdown and deals the next hand when the match is not over', () => { + const state = pokerState({ + street: 'river', + board: cards(['2C', '5D', '9H', 'QS', 'QC']), + remainingBoardCards: [], + players: { + alice: player({ holeCards: holeCards('AC', 'AD'), committed: 100 }), + bob: player({ holeCards: holeCards('KC', 'KD'), committed: 100 }), + }, + stacks: { alice: 900, bob: 900 }, + playersToAct: [], + }); + const result = advance(state, rng(), []); + expect(result.lastHandSummary?.reason).toBe('showdown'); + expect(result.lastHandSummary?.winners).toEqual([{ participantId: 'alice', amount: 200 }]); + expect(result.matchComplete).toBe(false); + expect(result.handNumber).toBe(1); + expect(result.street).toBe('preflop'); + }); +}); + +describe('advance — fold to one player', () => { + it('awards the pot without a showdown and deals the next hand', () => { + const state = pokerState({ + participantIds: ['alice', 'bob', 'carol'], + seatOrder: ['alice', 'bob', 'carol'], + players: { + alice: player({ folded: true, committed: 20 }), + bob: player({ folded: true, committed: 20 }), + carol: player({ committed: 20 }), + }, + stacks: { alice: 980, bob: 980, carol: 980 }, + playersToAct: [], + }); + const result = advance(state, rng(), []); + expect(result.lastHandSummary?.reason).toBe('fold'); + expect(result.lastHandSummary?.winners).toEqual([{ participantId: 'carol', amount: 60 }]); + expect(result.matchComplete).toBe(false); + expect(result.handNumber).toBe(1); + }); +}); + +describe('advance — all-in runout and match completion', () => { + it('runs the board out to the river, shows down, busts the loser, and ends the match', () => { + const state = pokerState({ + street: 'preflop', + board: [], + remainingBoardCards: cards(['2C', '6D', '9H', 'JS', '2D']), + players: { + alice: player({ holeCards: holeCards('AC', 'AD'), committed: 100, allIn: true }), + bob: player({ holeCards: holeCards('3C', '4D'), committed: 100, allIn: true }), + }, + stacks: { alice: 0, bob: 0 }, + playersToAct: [], + }); + const result = advance(state, rng(), []); + expect(result.matchComplete).toBe(true); + expect(result.stacks.alice).toBe(200); + expect(result.stacks.bob).toBe(0); + expect(result.bustedOut).toEqual([['bob']]); + }); + + // handNumber 0 -> handsPlayed 1, which already meets totalHands: 1, so the match ends here even + // though both players still have plenty of chips. + it('ends the match once the configured hand count is reached, even with 2+ players left', () => { + const state = pokerState({ + config: testConfig({ matchFormat: 'fixedHands', totalHands: 1 }), + handNumber: 0, + players: { + alice: player({ folded: true, committed: 20 }), + bob: player({ committed: 20 }), + }, + stacks: { alice: 980, bob: 980 }, + playersToAct: [], + }); + const result = advance(state, rng(), []); + expect(result.matchComplete).toBe(true); + expect(result.handNumber).toBe(0); + expect(result.stacks.bob).toBe(1020); + }); +}); diff --git a/games/poker-texas-hold-em/test/betting.test.ts b/games/poker-texas-hold-em/test/betting.test.ts new file mode 100644 index 0000000..fdcaea2 --- /dev/null +++ b/games/poker-texas-hold-em/test/betting.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { applyPlayerAction } from '../src/betting.js'; +import { player, pokerState } from './fixtures.js'; + +describe('applyPlayerAction — fold', () => { + it('marks the player folded and drops them from playersToAct without moving chips', () => { + const state = pokerState({ + currentBet: 20, + players: { + alice: player({ committedThisStreet: 0 }), + bob: player({ committedThisStreet: 20 }), + }, + playersToAct: ['alice'], + }); + const result = applyPlayerAction(state, 'alice', { type: 'fold' }); + expect(result.state.players.alice?.folded).toBe(true); + expect(result.state.playersToAct).toEqual([]); + expect(result.state.stacks.alice).toBe(state.stacks.alice); + expect(result.events).toEqual([ + { type: 'action', participantIds: ['alice'], data: { action: 'fold' } }, + ]); + }); +}); + +describe('applyPlayerAction — check', () => { + it('drops the player from playersToAct without moving chips', () => { + const state = pokerState({ currentBet: 0, playersToAct: ['alice'] }); + const result = applyPlayerAction(state, 'alice', { type: 'check' }); + expect(result.state.playersToAct).toEqual([]); + expect(result.state.players.alice).toEqual(state.players.alice); + expect(result.state.stacks.alice).toBe(state.stacks.alice); + }); +}); + +describe('applyPlayerAction — call', () => { + it('commits exactly the amount needed to match currentBet', () => { + const state = pokerState({ currentBet: 20, playersToAct: ['alice'] }); + const result = applyPlayerAction(state, 'alice', { type: 'call' }); + expect(result.state.players.alice?.committed).toBe(20); + expect(result.state.players.alice?.committedThisStreet).toBe(20); + expect(result.state.players.alice?.allIn).toBe(false); + expect(result.state.stacks.alice).toBe(980); + expect(result.state.playersToAct).toEqual([]); + expect(result.events).toEqual([ + { type: 'action', participantIds: ['alice'], data: { action: 'call', amount: 20 } }, + ]); + }); + + it('caps the call at the remaining stack and marks the player all-in on a short call', () => { + const state = pokerState({ + currentBet: 100, + stacks: { alice: 40, bob: 1000 }, + playersToAct: ['alice'], + }); + const result = applyPlayerAction(state, 'alice', { type: 'call' }); + expect(result.state.players.alice?.committed).toBe(40); + expect(result.state.stacks.alice).toBe(0); + expect(result.state.players.alice?.allIn).toBe(true); + }); +}); + +describe('applyPlayerAction — raise', () => { + // 60 - 20 (the old currentBet) >= the old minRaise (20), so this is a full raise: minRaise + // becomes that same 40 increment. + it('a full raise updates currentBet/minRaise and reopens action for other live players', () => { + const state = pokerState({ + participantIds: ['alice', 'bob', 'carol'], + seatOrder: ['alice', 'bob', 'carol'], + players: { alice: player(), bob: player(), carol: player() }, + currentBet: 20, + minRaise: 20, + playersToAct: ['alice'], + }); + const result = applyPlayerAction(state, 'alice', { type: 'raise', amount: 60 }); + expect(result.state.currentBet).toBe(60); + expect(result.state.minRaise).toBe(40); + expect(result.state.players.alice?.committed).toBe(60); + expect(result.state.playersToAct.sort()).toEqual(['bob', 'carol']); + }); + + it('a short (under-minRaise) raise still updates currentBet but leaves minRaise unchanged', () => { + const state = pokerState({ currentBet: 20, minRaise: 20, playersToAct: ['alice'] }); + const result = applyPlayerAction(state, 'alice', { type: 'raise', amount: 30 }); + expect(result.state.currentBet).toBe(30); + expect(result.state.minRaise).toBe(20); + expect(result.state.playersToAct).toEqual(['bob']); + }); + + it('never reopens action for a folded or already-all-in player', () => { + const state = pokerState({ + participantIds: ['alice', 'bob', 'carol'], + seatOrder: ['alice', 'bob', 'carol'], + players: { + alice: player(), + bob: player({ folded: true }), + carol: player({ allIn: true }), + }, + currentBet: 20, + minRaise: 20, + playersToAct: ['alice'], + }); + const result = applyPlayerAction(state, 'alice', { type: 'raise', amount: 100 }); + expect(result.state.playersToAct).toEqual([]); + }); +}); + +describe('applyPlayerAction — allIn', () => { + it('behaves like a call (no reopen) when the all-in amount does not exceed currentBet', () => { + const state = pokerState({ + currentBet: 100, + stacks: { alice: 40, bob: 1000 }, + playersToAct: ['alice'], + }); + const result = applyPlayerAction(state, 'alice', { type: 'allIn' }); + expect(result.state.currentBet).toBe(100); + expect(result.state.players.alice?.committed).toBe(40); + expect(result.state.players.alice?.allIn).toBe(true); + expect(result.state.playersToAct).toEqual([]); + expect(result.events).toEqual([ + { type: 'action', participantIds: ['alice'], data: { action: 'allIn', amount: 40 } }, + ]); + }); + + it('behaves like a raise (reopens action) when the all-in amount exceeds currentBet', () => { + const state = pokerState({ currentBet: 20, minRaise: 20, playersToAct: ['alice'] }); + const result = applyPlayerAction(state, 'alice', { type: 'allIn' }); + expect(result.state.currentBet).toBe(1000); + expect(result.state.minRaise).toBe(980); + expect(result.state.playersToAct).toEqual(['bob']); + }); +}); diff --git a/games/poker-texas-hold-em/test/deal.test.ts b/games/poker-texas-hold-em/test/deal.test.ts new file mode 100644 index 0000000..1254ea5 --- /dev/null +++ b/games/poker-texas-hold-em/test/deal.test.ts @@ -0,0 +1,155 @@ +import { cardId } from '@thunderdome/card-kit'; +import { describe, expect, it } from 'vitest'; +import { dealNewHand } from '../src/deal.js'; +import { nextButton, preflopFirstToActIndex } from '../src/table.js'; +import { rng, testConfig } from './fixtures.js'; + +const TWO_PLAYERS = ['alice', 'bob']; +const FOUR_PLAYERS = ['alice', 'bob', 'carol', 'dave']; + +function stacksOf(participantIds: readonly string[], amount: number): Record { + return Object.fromEntries(participantIds.map((id) => [id, amount])); +} + +describe('dealNewHand', () => { + it('deals 2 unique hole cards per seat with no duplicates across the whole deal', () => { + const dealt = dealNewHand( + FOUR_PLAYERS, + stacksOf(FOUR_PLAYERS, 1000), + new Set(), + null, + 0, + testConfig(), + rng(), + ); + const dealtCardIds = [ + ...Object.values(dealt.players).flatMap((player) => player.holeCards.map(cardId)), + ...dealt.remainingBoardCards.map(cardId), + ]; + expect(new Set(dealtCardIds).size).toBe(dealtCardIds.length); + for (const id of dealt.seatOrder) { + expect(dealt.players[id]?.holeCards).toHaveLength(2); + } + }); + + it('picks a random button from the active roster on the very first hand', () => { + const dealt = dealNewHand( + FOUR_PLAYERS, + stacksOf(FOUR_PLAYERS, 1000), + new Set(), + null, + 0, + testConfig(), + rng(), + ); + expect(FOUR_PLAYERS).toContain(dealt.buttonParticipantId); + }); + + it('rotates the button via table.nextButton on every later hand', () => { + const bustedOut = new Set(['carol']); + const dealt = dealNewHand( + FOUR_PLAYERS, + stacksOf(FOUR_PLAYERS, 1000), + bustedOut, + 'bob', + 1, + testConfig(), + rng(), + ); + expect(dealt.buttonParticipantId).toBe(nextButton(FOUR_PLAYERS, 'bob', bustedOut)); + expect(dealt.seatOrder).not.toContain('carol'); + }); + + it('heads-up: the button posts the small blind, the other seat posts the big blind', () => { + const config = testConfig({ smallBlind: 10, bigBlind: 20 }); + const dealt = dealNewHand( + TWO_PLAYERS, + stacksOf(TWO_PLAYERS, 1000), + new Set(), + 'bob', + 1, + config, + rng(), + ); + const [button, other] = dealt.seatOrder; + expect(button).toBeDefined(); + expect(other).toBeDefined(); + expect(dealt.players[button ?? '']?.committed).toBe(10); + expect(dealt.players[other ?? '']?.committed).toBe(20); + expect(dealt.currentBet).toBe(20); + expect(dealt.minRaise).toBe(20); + }); + + it('3+ handed: seats 1 and 2 (not the button) post the blinds', () => { + const config = testConfig({ smallBlind: 5, bigBlind: 10 }); + const dealt = dealNewHand( + FOUR_PLAYERS, + stacksOf(FOUR_PLAYERS, 1000), + new Set(), + 'alice', + 1, + config, + rng(), + ); + const [button, sb, bb, utg] = dealt.seatOrder; + expect(dealt.players[button ?? '']?.committed).toBe(0); + expect(dealt.players[sb ?? '']?.committed).toBe(5); + expect(dealt.players[bb ?? '']?.committed).toBe(10); + expect(dealt.players[utg ?? '']?.committed).toBe(0); + }); + + // previousButtonParticipantId 'bob' -> button rotates to 'alice' in a 2-seat roster, so alice + // is deterministically the small blind here. + it('posts a short blind (and marks all-in) when a blind seat cannot cover it', () => { + const config = testConfig({ smallBlind: 10, bigBlind: 20 }); + const stacks = { alice: 3, bob: 1000 }; + const dealt = dealNewHand(TWO_PLAYERS, stacks, new Set(), 'bob', 1, config, rng()); + expect(dealt.buttonParticipantId).toBe('alice'); + expect(dealt.players.alice?.committed).toBe(3); + expect(dealt.players.alice?.allIn).toBe(true); + expect(dealt.playersToAct).not.toContain('alice'); + expect(dealt.stacks.alice).toBe(0); + }); + + it('excludes busted participants from the dealt hand entirely', () => { + const bustedOut = new Set(['bob']); + const dealt = dealNewHand( + FOUR_PLAYERS, + stacksOf(FOUR_PLAYERS, 1000), + bustedOut, + 'alice', + 1, + testConfig(), + rng(), + ); + expect(dealt.seatOrder).not.toContain('bob'); + expect(dealt.players.bob).toBeUndefined(); + }); + + it('sets actingIndex as a one-before pointer to the true preflop first-to-act seat', () => { + const dealt = dealNewHand( + FOUR_PLAYERS, + stacksOf(FOUR_PLAYERS, 1000), + new Set(), + 'alice', + 1, + testConfig(), + rng(), + ); + const n = dealt.seatOrder.length; + expect((dealt.actingIndex + 1) % n).toBe(preflopFirstToActIndex(n)); + }); + + it('emits hand-started and blinds-posted events', () => { + const dealt = dealNewHand( + TWO_PLAYERS, + stacksOf(TWO_PLAYERS, 1000), + new Set(), + 'bob', + 1, + testConfig(), + rng(), + ); + expect(dealt.events.map((event) => event.type)).toEqual(['hand-started', 'blinds-posted']); + }); +}); diff --git a/games/poker-texas-hold-em/test/fixtures.ts b/games/poker-texas-hold-em/test/fixtures.ts new file mode 100644 index 0000000..c3ca01d --- /dev/null +++ b/games/poker-texas-hold-em/test/fixtures.ts @@ -0,0 +1,84 @@ +// Shared test-only helpers for constructing poker fixtures by hand — not a test suite itself +// (vitest only picks up *.test.ts), just factored out since table/deal/betting/showdown/advance +// each need small pieces of the same state shape to unit-test their one function in isolation, +// per game-authoring-guide.md §11's "build state directly" convention. +import { parseCardId, type Card } from '@thunderdome/card-kit'; +import { createRng, type Rng } from '@thunderdome/rng'; +import type { + PokerPlayerHandState, + PokerTexasHoldEmConfig, + PokerTexasHoldEmState, +} from '../src/types.js'; + +export function rng(seed = 1): Rng { + return createRng(Buffer.alloc(16, seed)); +} + +export function testConfig(overrides?: Partial): PokerTexasHoldEmConfig { + return { + matchFormat: 'elimination', + totalHands: 10, + startingStack: 1000, + smallBlind: 10, + bigBlind: 20, + ...overrides, + }; +} + +/** Parses card notation (see card-kit's `cardId`/`parseCardId`: rank+suit, e.g. "AS", "TC") — + * throws on anything malformed, since a bad fixture should fail loudly, not silently. */ +export function card(id: string): Card { + const parsed = parseCardId(id); + if (parsed === undefined) { + throw new Error(`invalid card id "${id}" in test fixture`); + } + return parsed; +} + +export function cards(ids: readonly string[]): Card[] { + return ids.map(card); +} + +export function holeCards(a: string, b: string): [Card, Card] { + return [card(a), card(b)]; +} + +export function player(overrides?: Partial): PokerPlayerHandState { + return { + holeCards: holeCards('2C', '7D'), + folded: false, + allIn: false, + committed: 0, + committedThisStreet: 0, + ...overrides, + }; +} + +/** A minimal but fully-shaped `PokerTexasHoldEmState` — every field defaulted to an inert value + * (heads-up, preflop, nobody committed) so a test only needs to override what it actually cares + * about, per game-authoring-guide.md §11's "skip initialize(), build state directly" convention. */ +export function pokerState(overrides?: Partial): PokerTexasHoldEmState { + const participantIds = overrides?.participantIds ?? ['alice', 'bob']; + const seatOrder = overrides?.seatOrder ?? participantIds; + const players = overrides?.players ?? Object.fromEntries(seatOrder.map((id) => [id, player()])); + return { + participantIds, + config: testConfig(), + stacks: Object.fromEntries(participantIds.map((id) => [id, 1000])), + bustedOut: [], + handNumber: 0, + buttonParticipantId: seatOrder[0] ?? participantIds[0] ?? '', + seatOrder, + players, + board: [], + remainingBoardCards: [], + street: 'preflop', + currentBet: 0, + minRaise: 20, + playersToAct: [...seatOrder], + actingIndex: 0, + lastHandSummary: null, + matchComplete: false, + ...overrides, + }; +} diff --git a/games/poker-texas-hold-em/test/game.test.ts b/games/poker-texas-hold-em/test/game.test.ts new file mode 100644 index 0000000..3536456 --- /dev/null +++ b/games/poker-texas-hold-em/test/game.test.ts @@ -0,0 +1,284 @@ +import { createRng } from '@thunderdome/rng'; +import { describe, expect, it } from 'vitest'; +import { pokerTexasHoldEm } from '../src/game.js'; +import type { PokerTexasHoldEmAction, PokerTexasHoldEmState } from '../src/types.js'; + +function rng(seed = 1) { + return createRng(Buffer.alloc(16, seed)); +} + +const TWO_PLAYERS = ['alice', 'bob']; + +function config(overrides?: Partial[0]>) { + const result = pokerTexasHoldEm.parseConfig({ + startingStack: 200, + smallBlind: 5, + bigBlind: 10, + ...overrides, + }); + if (!result.ok) { + throw new Error(result.reason); + } + return result.value; +} + +function initialState( + participantIds: string[], + overrides?: Parameters[0], + seed = 1, +) { + return pokerTexasHoldEm.initialize({ config: config(overrides), participantIds, rng: rng(seed) }); +} + +function actingParticipant(state: PokerTexasHoldEmState): string { + const [entry] = pokerTexasHoldEm.getPendingActions(state); + if (entry === undefined) { + throw new Error('no pending action — state is terminal'); + } + return entry.participantId; +} + +/** Drives one action from whoever's currently up. */ +function act(state: PokerTexasHoldEmState, action: PokerTexasHoldEmAction, seed = 1) { + const participantId = actingParticipant(state); + const validated = pokerTexasHoldEm.validateAction(state, participantId, action); + if (!validated.ok) { + throw new Error(`${participantId}'s ${action.type} rejected: ${validated.reason}`); + } + const actions = new Map([[participantId, validated.value]]); + return { participantId, ...pokerTexasHoldEm.resolve({ state, actions, rng: rng(seed) }) }; +} + +describe('pokerTexasHoldEm.parseConfig', () => { + it('defaults to an elimination match with sensible blinds/stack', () => { + const result = pokerTexasHoldEm.parseConfig({}); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toMatchObject({ + matchFormat: 'elimination', + totalHands: 10, + startingStack: 1000, + smallBlind: 10, + bigBlind: 20, + }); + } + }); + + it('rejects a bigBlind that does not exceed smallBlind', () => { + expect(pokerTexasHoldEm.parseConfig({ smallBlind: 10, bigBlind: 10 }).ok).toBe(false); + }); + + it('rejects a startingStack too small to post blinds twice over', () => { + expect(pokerTexasHoldEm.parseConfig({ bigBlind: 20, startingStack: 30 }).ok).toBe(false); + }); +}); + +describe('pokerTexasHoldEm.initialize', () => { + it('rejects a roster outside [2, 10]', () => { + expect(() => + pokerTexasHoldEm.initialize({ config: config(), participantIds: ['alice'], rng: rng() }), + ).toThrow(); + expect(() => + pokerTexasHoldEm.initialize({ + config: config(), + participantIds: Array.from({ length: 11 }, (_, i) => `p${String(i)}`), + rng: rng(), + }), + ).toThrow(); + }); + + // This config's blinds are smallBlind: 5, bigBlind: 10 (see `config()` above). + it('deals two hole cards to every seated participant and posts blinds', () => { + const state = initialState(TWO_PLAYERS); + expect(state.seatOrder.sort()).toEqual([...TWO_PLAYERS].sort()); + for (const id of TWO_PLAYERS) { + expect(state.players[id]?.holeCards).toHaveLength(2); + } + const totalCommitted = TWO_PLAYERS.reduce( + (sum, id) => sum + (state.players[id]?.committed ?? 0), + 0, + ); + expect(totalCommitted).toBe(15); + expect(state.street).toBe('preflop'); + expect(state.matchComplete).toBe(false); + }); + + it('heads-up: the button posts the small blind and acts first preflop', () => { + const state = initialState(TWO_PLAYERS); + const button = state.buttonParticipantId; + const other = TWO_PLAYERS.find((id) => id !== button); + expect(other).toBeDefined(); + expect(state.players[button]?.committed).toBe(5); + expect(state.players[other ?? '']?.committed).toBe(10); + expect(actingParticipant(state)).toBe(button); + }); +}); + +describe('pokerTexasHoldEm.getObservation', () => { + it("reveals only your own hole cards and exposes opponents' public info", () => { + const state = initialState(TWO_PLAYERS); + const [firstId] = TWO_PLAYERS; + if (firstId === undefined) throw new Error('unreachable'); + const observation = pokerTexasHoldEm.getObservation(state, firstId); + expect(observation.holeCards).toHaveLength(2); + expect(observation.opponents).toHaveLength(1); + expect(observation.opponents[0]).not.toHaveProperty('holeCards'); + expect(observation.pot).toBe(15); + }); + + // `actor` here is the button/small-blind, facing the big blind. + it('reports toCall/legalActions correctly for the player facing the big blind preflop', () => { + const state = initialState(TWO_PLAYERS); + const actor = actingParticipant(state); + const observation = pokerTexasHoldEm.getObservation(state, actor); + expect(observation.toCall).toBe(5); + expect(observation.legalActions).toContain('call'); + expect(observation.legalActions).not.toContain('check'); + }); + + it('throws for a participant id outside the roster', () => { + const state = initialState(TWO_PLAYERS); + expect(() => pokerTexasHoldEm.getObservation(state, 'eve')).toThrow(); + }); +}); + +describe('pokerTexasHoldEm.validateAction', () => { + it('rejects a check when there is a bet to call', () => { + const state = initialState(TWO_PLAYERS); + const actor = actingParticipant(state); + expect(pokerTexasHoldEm.validateAction(state, actor, { type: 'check' }).ok).toBe(false); + }); + + // currentBet=10 (bigBlind), minRaise=10 -> a legal raise must reach >= 20. + it('rejects a raise below the minimum raise size unless it is an all-in', () => { + const state = initialState(TWO_PLAYERS); + const actor = actingParticipant(state); + expect(pokerTexasHoldEm.validateAction(state, actor, { type: 'raise', amount: 15 }).ok).toBe( + false, + ); + expect(pokerTexasHoldEm.validateAction(state, actor, { type: 'raise', amount: 20 }).ok).toBe( + true, + ); + }); + + it('rejects committing more than your stack', () => { + const state = initialState(TWO_PLAYERS, { startingStack: 200 }); + const actor = actingParticipant(state); + expect( + pokerTexasHoldEm.validateAction(state, actor, { type: 'raise', amount: 10_000 }).ok, + ).toBe(false); + }); + + it('rejects malformed actions', () => { + const state = initialState(TWO_PLAYERS); + const actor = actingParticipant(state); + expect(pokerTexasHoldEm.validateAction(state, actor, { type: 'raise' }).ok).toBe(false); + expect(pokerTexasHoldEm.validateAction(state, actor, { noop: true }).ok).toBe(false); + }); +}); + +describe('pokerTexasHoldEm.resolve — betting progression', () => { + it('advances preflop -> flop once both players call/check through', () => { + let state = initialState(TWO_PLAYERS); + ({ nextState: state } = act(state, { type: 'call' })); + ({ nextState: state } = act(state, { type: 'check' })); + expect(state.street).toBe('flop'); + expect(state.board).toHaveLength(3); + expect(Object.values(state.players).every((p) => p.committedThisStreet === 0)).toBe(true); + }); + + // A new hand is already dealt by the time resolve() returns (2 players never bust from a + // single small-blind hand), so the fold-win itself is checked via the *previous* hand's + // recorded lastHandSummary rather than the fresh live state. + it('ends the hand immediately when everyone but one player folds', () => { + let state = initialState(TWO_PLAYERS); + const button = state.buttonParticipantId; + ({ nextState: state } = act(state, { type: 'fold' })); + const winner = TWO_PLAYERS.find((id) => id !== button); + expect(winner).toBeDefined(); + expect(state.lastHandSummary?.reason).toBe('fold'); + expect(state.lastHandSummary?.winners[0]?.participantId).toBe(winner); + expect(state.lastHandSummary?.winners[0]?.amount).toBe(15); + expect(state.handNumber).toBe(1); + }); + + // Everyone just checks through every street, so the final pot is only the two blinds. + it('runs a hand all the way to a river showdown and pays the winner', () => { + let state = initialState(TWO_PLAYERS, { startingStack: 1000 }); + ({ nextState: state } = act(state, { type: 'call' })); + ({ nextState: state } = act(state, { type: 'check' })); + expect(state.street).toBe('flop'); + ({ nextState: state } = act(state, { type: 'check' })); + ({ nextState: state } = act(state, { type: 'check' })); + expect(state.street).toBe('turn'); + ({ nextState: state } = act(state, { type: 'check' })); + ({ nextState: state } = act(state, { type: 'check' })); + expect(state.street).toBe('river'); + ({ nextState: state } = act(state, { type: 'check' })); + ({ nextState: state } = act(state, { type: 'check' })); + + expect(state.lastHandSummary?.reason).toBe('showdown'); + expect(state.lastHandSummary?.showdown).toHaveLength(2); + const totalWon = state.lastHandSummary?.winners.reduce((sum, w) => sum + w.amount, 0) ?? 0; + expect(totalWon).toBe(20); + expect(state.handNumber).toBe(1); + }); + + it('runs out remaining streets automatically once both players are all-in', () => { + let state = initialState(TWO_PLAYERS, { startingStack: 100 }); + ({ nextState: state } = act(state, { type: 'allIn' })); + ({ nextState: state } = act(state, { type: 'allIn' })); + expect(state.lastHandSummary).not.toBeNull(); + expect(state.lastHandSummary?.board).toHaveLength(5); + }); + + it('busts the loser of an all-in and ends the heads-up match', () => { + let state = initialState(TWO_PLAYERS, { startingStack: 100 }); + ({ nextState: state } = act(state, { type: 'allIn' })); + ({ nextState: state } = act(state, { type: 'allIn' })); + expect(pokerTexasHoldEm.isTerminal(state)).toBe(true); + const stacks = Object.values(state.stacks); + expect(stacks.filter((s) => s > 0)).toHaveLength(1); + expect(stacks.reduce((a, b) => a + b, 0)).toBe(200); + }); +}); + +describe('pokerTexasHoldEm — fixedHands format', () => { + // getResult().handsPlayed is 1-indexed; state.handNumber itself stays 0-indexed. + it('ends the match once totalHands hands have been played', () => { + let state = initialState(TWO_PLAYERS, { + matchFormat: 'fixedHands', + totalHands: 2, + startingStack: 5000, + }); + for (let hand = 0; hand < 2 && !pokerTexasHoldEm.isTerminal(state); hand += 1) { + let guard = 0; + while (!pokerTexasHoldEm.isTerminal(state) && guard < 20) { + const actor = actingParticipant(state); + const observation = pokerTexasHoldEm.getObservation(state, actor); + const action: PokerTexasHoldEmAction = + observation.toCall === 0 ? { type: 'check' } : { type: 'call' }; + ({ nextState: state } = act(state, action)); + guard += 1; + if (state.handNumber > hand) break; + } + } + expect(pokerTexasHoldEm.isTerminal(state)).toBe(true); + expect(pokerTexasHoldEm.getResult(state).handsPlayed).toBe(2); + }); +}); + +describe('pokerTexasHoldEm.getResult / getStandingOutcomes', () => { + it('ranks the sole chip leader as the winner once the match ends', () => { + let state = initialState(TWO_PLAYERS, { startingStack: 100 }); + ({ nextState: state } = act(state, { type: 'allIn' })); + ({ nextState: state } = act(state, { type: 'allIn' })); + const result = pokerTexasHoldEm.getResult(state); + const outcomes = pokerTexasHoldEm.getStandingOutcomes(result); + const winner = outcomes.find((o) => o.rank === 1); + const loser = outcomes.find((o) => o.rank === 2); + expect(winner?.outcome).toBe('win'); + expect(loser?.outcome).toBe('loss'); + expect(result.stacks[winner?.participantId ?? '']).toBe(200); + }); +}); diff --git a/games/poker-texas-hold-em/test/hand-evaluator.test.ts b/games/poker-texas-hold-em/test/hand-evaluator.test.ts new file mode 100644 index 0000000..a848eaa --- /dev/null +++ b/games/poker-texas-hold-em/test/hand-evaluator.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { bestHand, compareHandRank, evaluateFiveCardHand } from '../src/hand-evaluator.js'; +import { cards } from './fixtures.js'; + +describe('evaluateFiveCardHand', () => { + it('recognizes a straight flush, including the wheel (A-2-3-4-5) as 5-high', () => { + const broadway = evaluateFiveCardHand(cards(['TC', 'JC', 'QC', 'KC', 'AC'])); + expect(broadway.category).toBe('straight-flush'); + expect(broadway.tiebreakers).toEqual([14]); + + const wheel = evaluateFiveCardHand(cards(['AC', '2D', '3H', '4S', '5C'])); + expect(wheel.category).toBe('straight'); + expect(wheel.tiebreakers).toEqual([5]); + }); + + it('recognizes four of a kind with the correct kicker', () => { + const hand = evaluateFiveCardHand(cards(['AC', 'AD', 'AH', 'AS', '2C'])); + expect(hand.category).toBe('four-of-a-kind'); + expect(hand.tiebreakers).toEqual([14, 2]); + }); + + it('recognizes a full house ordered trips-then-pair', () => { + const hand = evaluateFiveCardHand(cards(['8C', '8D', '8H', '2S', '2C'])); + expect(hand.category).toBe('full-house'); + expect(hand.tiebreakers).toEqual([8, 2]); + }); + + it('recognizes a flush ranked by all 5 cards descending', () => { + const hand = evaluateFiveCardHand(cards(['2C', '5C', '9C', 'JC', 'KC'])); + expect(hand.category).toBe('flush'); + expect(hand.tiebreakers).toEqual([13, 11, 9, 5, 2]); + }); + + it('recognizes a non-flush straight', () => { + const hand = evaluateFiveCardHand(cards(['4C', '5D', '6H', '7S', '8C'])); + expect(hand.category).toBe('straight'); + expect(hand.tiebreakers).toEqual([8]); + }); + + it('recognizes three of a kind with kickers descending', () => { + const hand = evaluateFiveCardHand(cards(['9C', '9D', '9H', 'KC', '3D'])); + expect(hand.category).toBe('three-of-a-kind'); + expect(hand.tiebreakers).toEqual([9, 13, 3]); + }); + + it('recognizes two pair, breaking a tie on the kicker', () => { + const better = evaluateFiveCardHand(cards(['TC', 'TD', '8H', '8S', 'AC'])); + const worse = evaluateFiveCardHand(cards(['TC', 'TD', '8H', '8S', '2C'])); + expect(better.category).toBe('two-pair'); + expect(better.tiebreakers).toEqual([10, 8, 14]); + expect(compareHandRank(better, worse)).toBeGreaterThan(0); + }); + + it('recognizes one pair with kickers descending', () => { + const hand = evaluateFiveCardHand(cards(['6C', '6D', 'KC', '9H', '2S'])); + expect(hand.category).toBe('pair'); + expect(hand.tiebreakers).toEqual([6, 13, 9, 2]); + }); + + it('falls back to high card, ranked by all 5 cards descending', () => { + const hand = evaluateFiveCardHand(cards(['2C', '5D', '9H', 'JC', 'KC'])); + expect(hand.category).toBe('high-card'); + expect(hand.tiebreakers).toEqual([13, 11, 9, 5, 2]); + }); + + it('throws unless given exactly 5 cards', () => { + expect(() => evaluateFiveCardHand(cards(['2C', '5D']))).toThrow(); + expect(() => evaluateFiveCardHand(cards(['2C', '5D', '9H', 'JC', 'KC', 'AC']))).toThrow(); + }); +}); + +describe('compareHandRank', () => { + it('ranks a straight flush above four of a kind', () => { + const straightFlush = evaluateFiveCardHand(cards(['5C', '6C', '7C', '8C', '9C'])); + const quads = evaluateFiveCardHand(cards(['AC', 'AD', 'AH', 'AS', '2C'])); + expect(compareHandRank(straightFlush, quads)).toBeGreaterThan(0); + }); + + it('treats two identically-ranked hands as a tie', () => { + const a = evaluateFiveCardHand(cards(['AC', 'AD', '8H', '8S', '2C'])); + const b = evaluateFiveCardHand(cards(['AS', 'AH', '8C', '8D', '2S'])); + expect(compareHandRank(a, b)).toBe(0); + }); +}); + +describe('bestHand', () => { + // The board alone is already trip nines with 5/4 kickers; the hole cards (2, 3) are worse than + // both board kickers, so the best hand is the board's own 5 cards, untouched. + it('picks the best 5 of 7 cards, ignoring hole cards that do not help', () => { + const hand = bestHand(cards(['2C', '3D', '9C', '9D', '9H', '4S', '5H'])); + expect(hand.category).toBe('three-of-a-kind'); + expect(hand.tiebreakers).toEqual([9, 5, 4]); + }); + + it('uses both hole cards to complete a flush not fully on the board', () => { + const hand = bestHand(cards(['AS', 'KS', '2S', '7S', '9S', 'QC', '3D'])); + expect(hand.category).toBe('flush'); + expect(hand.tiebreakers).toEqual([14, 13, 9, 7, 2]); + }); + + it('throws unless given at least 5 cards', () => { + expect(() => bestHand(cards(['2C', '5D', '9H', 'JC']))).toThrow(); + }); +}); diff --git a/games/poker-texas-hold-em/test/human.test.ts b/games/poker-texas-hold-em/test/human.test.ts new file mode 100644 index 0000000..5d9be11 --- /dev/null +++ b/games/poker-texas-hold-em/test/human.test.ts @@ -0,0 +1,250 @@ +import type { RoundEvent } from '@thunderdome/engine'; +import { describe, expect, it } from 'vitest'; +import { + describeAction, + describeObservation, + describeRoundEvents, + parseInput, + validateInput, +} from '../src/human.js'; +import type { PokerTexasHoldEmObservation } from '../src/types.js'; +import { cards, holeCards } from './fixtures.js'; + +function observation( + overrides?: Partial, +): PokerTexasHoldEmObservation { + return { + you: 'alice', + handNumber: 0, + street: 'preflop', + board: [], + holeCards: holeCards('AS', 'KS'), + pot: 15, + yourStack: 195, + yourCommittedThisStreet: 5, + toCall: 5, + minRaiseTo: 20, + maxRaiseTo: 200, + smallBlind: 5, + bigBlind: 10, + buttonParticipantId: 'alice', + opponents: [ + { + participantId: 'bob', + stack: 190, + committed: 10, + committedThisStreet: 10, + folded: false, + allIn: false, + isButton: false, + }, + ], + legalActions: ['fold', 'call', 'raise', 'allIn'], + lastHandSummary: null, + ...overrides, + }; +} + +describe('parseInput', () => { + it('parses fold/check/call/allIn and their shorthand aliases', () => { + expect(parseInput('fold')).toEqual({ type: 'fold' }); + expect(parseInput('F')).toEqual({ type: 'fold' }); + expect(parseInput('check')).toEqual({ type: 'check' }); + expect(parseInput('x')).toEqual({ type: 'check' }); + expect(parseInput('call')).toEqual({ type: 'call' }); + expect(parseInput('allin')).toEqual({ type: 'allIn' }); + expect(parseInput('shove')).toEqual({ type: 'allIn' }); + }); + + it('parses a raise amount, case-insensitively and with extra whitespace', () => { + expect(parseInput('raise 80')).toEqual({ type: 'raise', amount: 80 }); + expect(parseInput(' Bet 120 ')).toEqual({ type: 'raise', amount: 120 }); + }); + + it('rejects a non-numeric or non-positive raise amount', () => { + expect(parseInput('raise abc')).toBeUndefined(); + expect(parseInput('raise -5')).toBeUndefined(); + expect(parseInput('raise 0')).toBeUndefined(); + expect(parseInput('raise 10.5')).toBeUndefined(); + }); + + it('rejects garbage and extra tokens', () => { + expect(parseInput('')).toBeUndefined(); + expect(parseInput('fold now')).toBeUndefined(); + expect(parseInput('raise')).toBeUndefined(); + expect(parseInput('banana')).toBeUndefined(); + }); +}); + +describe('validateInput', () => { + it('accepts a raise within the observation min/max', () => { + expect(validateInput({ type: 'raise', amount: 20 }, observation())).toBeUndefined(); + expect(validateInput({ type: 'raise', amount: 200 }, observation())).toBeUndefined(); + }); + + it('rejects a raise below the observed minimum instead of letting it through', () => { + expect(validateInput({ type: 'raise', amount: 15 }, observation())).toBe( + 'Your raise must reach at least 20 (or go all-in for less)', + ); + }); + + it('rejects a raise above the observed maximum instead of letting it through', () => { + expect(validateInput({ type: 'raise', amount: 999 }, observation())).toBe( + "You can't commit more than your stack (max 200)", + ); + }); + + it('rejects a raise that does not exceed the current bet', () => { + expect(validateInput({ type: 'raise', amount: 10 }, observation())).toBe( + 'Your raise must exceed the current bet', + ); + }); + + it('rejects any raise once the observation reports no chips left', () => { + expect(validateInput({ type: 'raise', amount: 50 }, observation({ minRaiseTo: null }))).toBe( + 'You have no chips left to raise with', + ); + }); + + it('leaves every non-raise action alone', () => { + expect(validateInput({ type: 'fold' }, observation())).toBeUndefined(); + expect(validateInput({ type: 'check' }, observation())).toBeUndefined(); + expect(validateInput({ type: 'call' }, observation())).toBeUndefined(); + expect(validateInput({ type: 'allIn' }, observation())).toBeUndefined(); + }); +}); + +describe('describeAction', () => { + it('confirms each action type in plain language', () => { + expect(describeAction({ type: 'fold' })).toBe('You folded.'); + expect(describeAction({ type: 'check' })).toBe('You checked.'); + expect(describeAction({ type: 'call' })).toBe('You called.'); + expect(describeAction({ type: 'allIn' })).toBe('You went all-in.'); + }); + + it('confirms a raise with the amount that was actually understood', () => { + expect(describeAction({ type: 'raise', amount: 80 })).toBe('You raised to 80.'); + }); +}); + +describe('describeObservation', () => { + it('renders the pot, call amount, and legal actions with their bounds', () => { + const text = describeObservation(observation()); + expect(text).toContain('Pot: 15'); + expect(text).toContain('(to call: 5)'); + expect(text).toContain('CALL (5)'); + expect(text).toContain('RAISE (min 20, max 200)'); + expect(text).toContain('ALLIN (200)'); + }); + + it('shows CHECK instead of CALL when nothing is owed', () => { + const text = describeObservation( + observation({ toCall: 0, legalActions: ['fold', 'check', 'raise', 'allIn'] }), + ); + expect(text).toContain('CHECK'); + expect(text).not.toContain('CALL ('); + }); + + it('never mentions a hand result — that lives in describeRoundEvents now', () => { + expect(describeObservation(observation())).not.toContain('Hand result'); + }); +}); + +describe('describeRoundEvents', () => { + it('returns undefined for a round with nothing worth narrating', () => { + expect(describeRoundEvents([], 'alice')).toBeUndefined(); + }); + + it('narrates an opponent’s action but skips your own (describeAction already confirmed it)', () => { + const events: RoundEvent[] = [ + { type: 'action', participantIds: ['alice'], data: { action: 'call', amount: 20 } }, + { type: 'action', participantIds: ['bob'], data: { action: 'raise', amount: 60 } }, + ]; + const text = describeRoundEvents(events, 'alice'); + expect(text).not.toContain('alice'); + expect(text).toContain('bob raises to 60.'); + }); + + it('narrates a street being dealt, but never a (nonexistent) preflop deal', () => { + const events: RoundEvent[] = [ + { type: 'street-dealt', data: { street: 'flop', board: cards(['2C', '5D', '9H']) } }, + ]; + expect(describeRoundEvents(events, 'alice')).toBe('Flop: 2C 5D 9H'); + }); + + it('narrates a fold-won hand without revealing any cards', () => { + const events: RoundEvent[] = [ + { + type: 'hand-complete', + participantIds: ['bob'], + data: { + handNumber: 0, + winners: [{ participantId: 'bob', amount: 40 }], + reason: 'fold', + board: [], + }, + }, + ]; + expect(describeRoundEvents(events, 'alice')).toBe( + 'Hand result: everyone else folded — winner: bob +40', + ); + }); + + it('narrates a showdown with every revealed hand, relabeling your own id to "you"', () => { + const events: RoundEvent[] = [ + { + type: 'showdown', + participantIds: ['alice', 'bob'], + data: { + handNumber: 0, + winners: [{ participantId: 'alice', amount: 80 }], + reason: 'showdown', + board: cards(['2C', '5D', '9H', 'QS', 'QC']), + showdown: [ + { participantId: 'alice', holeCards: holeCards('AC', 'AD'), category: 'two-pair' }, + { participantId: 'bob', holeCards: holeCards('KC', 'KD'), category: 'two-pair' }, + ], + }, + }, + ]; + const text = describeRoundEvents(events, 'alice'); + expect(text).toContain('Hand result (showdown) — winner: you +80'); + expect(text).toContain('you: AC AD (two-pair)'); + expect(text).toContain('bob: KC KD (two-pair)'); + }); + + it('lists every winner on a split or side-pot showdown', () => { + const events: RoundEvent[] = [ + { + type: 'showdown', + data: { + handNumber: 0, + winners: [ + { participantId: 'alice', amount: 50 }, + { participantId: 'bob', amount: 30 }, + ], + reason: 'showdown', + board: cards(['2C', '5D', '9H', 'QS', 'QC']), + showdown: [ + { participantId: 'alice', holeCards: holeCards('AC', 'AD'), category: 'two-pair' }, + { participantId: 'bob', holeCards: holeCards('KC', 'KD'), category: 'two-pair' }, + ], + }, + }, + ]; + expect(describeRoundEvents(events, 'alice')).toContain( + 'Hand result (showdown) — winner: you +50, bob +30', + ); + }); + + it('narrates busting out, telling you directly when it is you', () => { + const youBusted = describeRoundEvents([{ type: 'busted', participantIds: ['alice'] }], 'alice'); + expect(youBusted).toBe('You are out of chips!'); + + const opponentBusted = describeRoundEvents( + [{ type: 'busted', participantIds: ['bob'] }], + 'alice', + ); + expect(opponentBusted).toBe('bob is out of chips!'); + }); +}); diff --git a/games/poker-texas-hold-em/test/showdown.test.ts b/games/poker-texas-hold-em/test/showdown.test.ts new file mode 100644 index 0000000..f2366e8 --- /dev/null +++ b/games/poker-texas-hold-em/test/showdown.test.ts @@ -0,0 +1,112 @@ +import type { RoundEvent } from '@thunderdome/engine'; +import { describe, expect, it } from 'vitest'; +import { awardPotToSoleWinner, doShowdown } from '../src/showdown.js'; +import { cards, holeCards, player, pokerState } from './fixtures.js'; + +describe('awardPotToSoleWinner', () => { + it('awards the entire pot, including a folded opponent’s dead chips, to the sole winner', () => { + const state = pokerState({ + players: { + alice: player({ committed: 30 }), + bob: player({ committed: 30, folded: true }), + }, + stacks: { alice: 970, bob: 970 }, + board: [], + }); + const events: RoundEvent[] = []; + const result = awardPotToSoleWinner(state, 'alice', events); + expect(result.stacks.alice).toBe(1030); + expect(result.stacks.bob).toBe(970); + expect(result.lastHandSummary).toEqual({ + handNumber: 0, + winners: [{ participantId: 'alice', amount: 60 }], + reason: 'fold', + board: [], + }); + expect(events).toEqual([ + { type: 'hand-complete', participantIds: ['alice'], data: result.lastHandSummary }, + ]); + }); +}); + +// `board` is a pair of queens, shared by every `doShowdown` test below. +describe('doShowdown', () => { + const board = cards(['2C', '5D', '9H', 'QS', 'QC']); + + // alice (AA) makes two pair, Q+A; bob (KK) makes two pair, Q+K — alice's the better hand and + // takes the full 200 pot on top of her existing 900 stack. + it('awards a single pot to the best hand among equal contributions', () => { + const state = pokerState({ + players: { + alice: player({ holeCards: holeCards('AC', 'AD'), committed: 100 }), + bob: player({ holeCards: holeCards('KC', 'KD'), committed: 100 }), + }, + stacks: { alice: 900, bob: 900 }, + board, + street: 'river', + }); + const result = doShowdown(state, []); + expect(result.stacks.alice).toBe(1100); + expect(result.stacks.bob).toBe(900); + expect(result.lastHandSummary?.reason).toBe('showdown'); + expect(result.lastHandSummary?.winners).toEqual([{ participantId: 'alice', amount: 200 }]); + expect(result.lastHandSummary?.showdown).toHaveLength(2); + }); + + // a is a short all-in (30); b and c each put in 100; d folded after committing 20. a's hand + // (AA) beats everyone, but a is only eligible for the 30-per-player main pot layer(s) — the b/c + // side pot goes to whichever of them has the better hand (b, with KK over c's JJ). Expected + // payouts: main-pot layers (80 + 30, both a/b/c-eligible) go to a = 110; the b/c-only side pot + // (140) goes to b; c and folded d get nothing. Every committed chip is accounted for: 30 + 100 + // + 100 + 20 = 250 = 110 + 140. + it('splits side pots by contribution layer, each layer’s winner decided independently', () => { + const state = pokerState({ + participantIds: ['a', 'b', 'c', 'd'], + seatOrder: ['a', 'b', 'c', 'd'], + players: { + a: player({ holeCards: holeCards('AC', 'AD'), committed: 30, allIn: true }), + b: player({ holeCards: holeCards('KC', 'KD'), committed: 100 }), + c: player({ holeCards: holeCards('JC', 'JD'), committed: 100 }), + d: player({ holeCards: holeCards('7C', '7D'), committed: 20, folded: true }), + }, + stacks: { a: 0, b: 900, c: 900, d: 980 }, + board, + street: 'river', + }); + const result = doShowdown(state, []); + expect(result.stacks.a).toBe(110); + expect(result.stacks.b).toBe(1040); + expect(result.stacks.c).toBe(900); + expect(result.stacks.d).toBe(980); + expect(result.lastHandSummary?.winners).toEqual([ + { participantId: 'a', amount: 110 }, + { participantId: 'b', amount: 140 }, + ]); + expect(result.lastHandSummary?.showdown).toHaveLength(3); + }); + + // The board alone is a royal flush — the unbeatable nuts — so x and y necessarily tie + // regardless of their hole cards (x is the button); z folded after contributing just 1 chip, + // which peels off a tiny first layer (3, eligible x/y) ahead of a larger second layer (98, + // eligible x/y). Both layers split evenly between the tied x/y, so the only remainder is the + // first layer's odd chip (3 / 2 players), which goes to y as the seat right after the button. + it('splits a tied pot evenly, handing any odd remainder chip to the seat after the button first', () => { + const nuts = cards(['TS', 'JS', 'QS', 'KS', 'AS']); + const state = pokerState({ + participantIds: ['x', 'y', 'z'], + seatOrder: ['x', 'y', 'z'], + players: { + x: player({ holeCards: holeCards('2C', '3C'), committed: 50 }), + y: player({ holeCards: holeCards('4D', '5D'), committed: 50 }), + z: player({ holeCards: holeCards('6H', '7H'), committed: 1, folded: true }), + }, + stacks: { x: 950, y: 950, z: 999 }, + board: nuts, + street: 'river', + }); + const result = doShowdown(state, []); + expect(result.stacks.x).toBe(1000); + expect(result.stacks.y).toBe(1001); + expect(result.stacks.z).toBe(999); + }); +}); diff --git a/games/poker-texas-hold-em/test/table.test.ts b/games/poker-texas-hold-em/test/table.test.ts new file mode 100644 index 0000000..d9e9d4e --- /dev/null +++ b/games/poker-texas-hold-em/test/table.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + bigBlindSeatIndex, + buildSeatOrder, + computeSidePots, + nextButton, + oddChipOrder, + postflopFirstToActIndex, + preflopFirstToActIndex, + smallBlindSeatIndex, +} from '../src/table.js'; + +const ALICE_BOB_CAROL_DAVE = ['alice', 'bob', 'carol', 'dave']; + +describe('buildSeatOrder', () => { + it('rotates the roster to start at the button', () => { + expect(buildSeatOrder(ALICE_BOB_CAROL_DAVE, 'carol', new Set())).toEqual([ + 'carol', + 'dave', + 'alice', + 'bob', + ]); + }); + + it('filters out busted participants while preserving relative order', () => { + expect(buildSeatOrder(ALICE_BOB_CAROL_DAVE, 'carol', new Set(['bob']))).toEqual([ + 'carol', + 'dave', + 'alice', + ]); + }); + + it('throws when the button id is not in the roster', () => { + expect(() => buildSeatOrder(ALICE_BOB_CAROL_DAVE, 'eve', new Set())).toThrow(); + }); +}); + +describe('nextButton', () => { + it('moves to the next participant in fixed table order, wrapping', () => { + expect(nextButton(ALICE_BOB_CAROL_DAVE, 'dave', new Set())).toBe('alice'); + expect(nextButton(ALICE_BOB_CAROL_DAVE, 'alice', new Set())).toBe('bob'); + }); + + it('skips busted participants', () => { + expect(nextButton(ALICE_BOB_CAROL_DAVE, 'alice', new Set(['bob', 'carol']))).toBe('dave'); + }); + + it('throws when the current button id is not in the roster', () => { + expect(() => nextButton(ALICE_BOB_CAROL_DAVE, 'eve', new Set())).toThrow(); + }); + + // Busting 3 of 4 still lets the button wrap back around to itself (the one remaining active + // seat) — only busting every seat, the current button included, leaves no candidate at all. + it('throws when every participant, including the current button, has busted', () => { + expect(() => + nextButton(ALICE_BOB_CAROL_DAVE, 'alice', new Set(ALICE_BOB_CAROL_DAVE)), + ).toThrow(); + }); +}); + +describe('blind and first-to-act positions', () => { + it('heads-up: the button is the small blind and acts first preflop, big blind acts first postflop', () => { + expect(smallBlindSeatIndex(2)).toBe(0); + expect(bigBlindSeatIndex(2)).toBe(1); + expect(preflopFirstToActIndex(2)).toBe(0); + expect(postflopFirstToActIndex(2)).toBe(1); + }); + + it('3-handed: seats 1/2 post blinds, and with no UTG seat the button acts first preflop', () => { + expect(smallBlindSeatIndex(3)).toBe(1); + expect(bigBlindSeatIndex(3)).toBe(2); + expect(preflopFirstToActIndex(3)).toBe(0); + expect(postflopFirstToActIndex(3)).toBe(1); + }); + + it('4+-handed: the seat after the big blind (UTG) acts first preflop', () => { + expect(preflopFirstToActIndex(4)).toBe(3); + expect(preflopFirstToActIndex(6)).toBe(3); + expect(postflopFirstToActIndex(4)).toBe(1); + expect(postflopFirstToActIndex(6)).toBe(1); + }); +}); + +describe('computeSidePots', () => { + it('returns a single main pot when every contribution is equal', () => { + expect(computeSidePots({ a: 50, b: 50, c: 50 }, new Set())).toEqual([ + { amount: 150, eligibleParticipantIds: ['a', 'b', 'c'] }, + ]); + }); + + it('layers a side pot for a short all-in', () => { + const pots = computeSidePots({ a: 30, b: 100, c: 100 }, new Set()); + expect(pots).toEqual([ + { amount: 90, eligibleParticipantIds: ['a', 'b', 'c'] }, + { amount: 140, eligibleParticipantIds: ['b', 'c'] }, + ]); + }); + + it('still counts a folded contributor toward a layer amount but excludes them from eligibility', () => { + const pots = computeSidePots({ a: 30, b: 100, c: 100, d: 20 }, new Set(['d'])); + expect(pots).toEqual([ + { amount: 80, eligibleParticipantIds: ['a', 'b', 'c'] }, + { amount: 30, eligibleParticipantIds: ['a', 'b', 'c'] }, + { amount: 140, eligibleParticipantIds: ['b', 'c'] }, + ]); + const totalAwarded = pots.reduce((sum, pot) => sum + pot.amount, 0); + expect(totalAwarded).toBe(30 + 100 + 100 + 20); + }); + + it('returns no pots when nobody committed anything', () => { + expect(computeSidePots({}, new Set())).toEqual([]); + }); +}); + +describe('oddChipOrder', () => { + // seatOrder[0] ("alice") is the button throughout. + it('orders winners starting right after the button, button last', () => { + const seatOrder = ['alice', 'bob', 'carol', 'dave']; + expect(oddChipOrder(['dave', 'bob'], seatOrder)).toEqual(['bob', 'dave']); + expect(oddChipOrder(['alice', 'carol'], seatOrder)).toEqual(['carol', 'alice']); + }); +}); diff --git a/games/poker-texas-hold-em/tsconfig.json b/games/poker-texas-hold-em/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/games/poker-texas-hold-em/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/games/poker-texas-hold-em/tsconfig.test.json b/games/poker-texas-hold-em/tsconfig.test.json new file mode 100644 index 0000000..45bbe8f --- /dev/null +++ b/games/poker-texas-hold-em/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/games/poker-texas-hold-em/vitest.config.ts b/games/poker-texas-hold-em/vitest.config.ts new file mode 100644 index 0000000..cb61456 --- /dev/null +++ b/games/poker-texas-hold-em/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ test: { environment: 'node' } }); diff --git a/packages/engine/src/match-runner.ts b/packages/engine/src/match-runner.ts index 789fdce..a620543 100644 --- a/packages/engine/src/match-runner.ts +++ b/packages/engine/src/match-runner.ts @@ -58,12 +58,28 @@ export interface RunMatchArgs { matchDeadlineMs: number; /** Injectable clock, for deterministic tests of the above; defaults to `Date.now`. */ now?: () => number; + /** + * Optional: called once per round, right after `resolve()` produces it — the seam a caller + * (e.g. the CLI's human-play mode) uses to observe every round's events live, not just the + * final aggregated `MatchOutcome.events` returned once the whole match ends. Never called for + * a round that ends in forfeit (no `resolve()` ever runs for one). + */ + onRoundResolved?: (events: RoundEvent[]) => void; } export async function runMatch( args: RunMatchArgs, ): Promise> { - const { game, config, participantIds, rng, collector, defaultDeadlineMs, matchDeadlineMs } = args; + const { + game, + config, + participantIds, + rng, + collector, + defaultDeadlineMs, + matchDeadlineMs, + onRoundResolved, + } = args; const now = args.now ?? Date.now; const matchStartedAt = now(); @@ -147,6 +163,7 @@ export async function runMatch( const outcome = game.resolve({ state, actions, rng }); events.push(outcome.events); + onRoundResolved?.(outcome.events); state = outcome.nextState; roundId += 1; } diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 500ca44..f1cb5d1 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -119,6 +119,18 @@ export interface GameDefinition humanInterface?: { describeObservation(observation: TObservation): string; parseInput(raw: string): TAction | undefined; + /** + * Optional: catches an action that parsed fine but isn't legal right now — e.g. a raise + * outside the observation's own min/max — using only what `TObservation` already reveals to + * this participant (no access to the full `TState`, same constraint as `parseInput`). Return + * a human-readable reason to have the CLI reprompt with it instead of forwarding the action; + * return `undefined` to let it through. `validateAction` still re-checks against the + * authoritative `TState` afterward regardless — this only exists so a human's out-of-range + * input gets a retry instead of being treated as a no-show and forfeiting the match (a bot's + * own equivalent mistake has no such recovery, by design: see `onMissingAction`). Omit if + * every parseable action for this game is always legal whenever it's offered. + */ + validateInput?(action: TAction, observation: TObservation): string | undefined; /** * Optional: confirms the action `parseInput` just accepted, printed immediately after — * e.g. "Passed: 2C 5C TH" — before the next observation's prompt. Lets a human verify their @@ -127,5 +139,16 @@ export interface GameDefinition * action. Omit if the next prompt already makes the outcome obvious on its own. */ describeAction?(action: TAction): string; + + /** + * Optional: narrates a round's events for a human bystander — called once per resolved + * round with that round's `events` and the human's own participant id, even on a round + * where it isn't their turn (see `RunMatchArgs.onRoundResolved`, match-runner.ts). This is + * the only way a human ever learns what happened in a round they weren't asked to act in — + * `describeObservation` only ever fires on their own turn, which never comes again once + * they're eliminated from the match (e.g. busted out of a poker hand they went all-in on). + * Return `undefined` for a round with nothing worth narrating. + */ + describeRoundEvents?(events: RoundEvent[], youParticipantId: string): string | undefined; }; } diff --git a/packages/engine/test/match-runner.test.ts b/packages/engine/test/match-runner.test.ts index 9e9d8db..8b2c5d8 100644 --- a/packages/engine/test/match-runner.test.ts +++ b/packages/engine/test/match-runner.test.ts @@ -1,5 +1,5 @@ import { createRng } from '@thunderdome/rng'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { runMatch, type ActionCollector, @@ -145,6 +145,41 @@ describe('runMatch: sequential single-actor game', () => { // Only the active participant is ever asked, never both. expect(collector.calls.every((c) => c.required)).toBe(true); }); + + it('calls onRoundResolved once per resolved round, with that round’s own events', async () => { + const collector = new ScriptedCollector(() => ({ ok: true, action: { increment: 2 } })); + const resolvedRounds: unknown[][] = []; + const outcome = await runMatch({ + game: makeRaceGame(4), + config: undefined, + participantIds: ['p1', 'p2'], + rng, + collector, + defaultDeadlineMs: 1000, + matchDeadlineMs: 60_000, + onRoundResolved: (events) => resolvedRounds.push(events), + }); + + expect(resolvedRounds).toEqual(outcome.events); + }); + + it('never calls onRoundResolved for a round that ends in forfeit', async () => { + const collector = new ScriptedCollector(() => ({ ok: false, reason: 'timeout' })); + const onRoundResolved = vi.fn(); + const outcome = await runMatch({ + game: makeRaceGame(4), + config: undefined, + participantIds: ['p1', 'p2'], + rng, + collector, + defaultDeadlineMs: 1000, + matchDeadlineMs: 60_000, + onRoundResolved, + }); + + expect(outcome.status).toBe('forfeit'); + expect(onRoundResolved).not.toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/tournament-formats/src/swiss-league.ts b/packages/tournament-formats/src/swiss-league.ts index 8f88f02..75e3110 100644 --- a/packages/tournament-formats/src/swiss-league.ts +++ b/packages/tournament-formats/src/swiss-league.ts @@ -113,13 +113,17 @@ function buildRoundTables(args: { }): { tables: MatchDescriptor[]; sitOuts: string[] } { const { round, participantIds, standings, tiebreakRank, sitOutCount, tableSize } = args; - const sitOuts = chooseSitOuts(participantIds, sitOutCount, tiebreakRank, participantIds.length % tableSize); + const sitOuts = chooseSitOuts( + participantIds, + sitOutCount, + tiebreakRank, + participantIds.length % tableSize, + ); const sitOutSet = new Set(sitOuts); - const active = participantIds.filter(id => !sitOutSet.has(id)); + const active = participantIds.filter((id) => !sitOutSet.has(id)); const sorted = active.sort((a, b) => { - const scoreDiff = - (standings[a]?.cumulativeScore ?? 0) - (standings[b]?.cumulativeScore ?? 0); + const scoreDiff = (standings[a]?.cumulativeScore ?? 0) - (standings[b]?.cumulativeScore ?? 0); return scoreDiff !== 0 ? scoreDiff : (tiebreakRank[a] ?? 0) - (tiebreakRank[b] ?? 0); }); @@ -134,7 +138,10 @@ function buildRoundTables(args: { return { tables, sitOuts }; } -function nextSitOutCount(current: Record, sitOuts: readonly string[]): Record { +function nextSitOutCount( + current: Record, + sitOuts: readonly string[], +): Record { const next = { ...current }; for (const id of sitOuts) { next[id] = (next[id] ?? 0) + 1; @@ -280,7 +287,8 @@ export const swissLeagueFormat: TournamentFormat< ...entry, // A participant who never got seated (only possible with a very short tournament) sorts // last, not tied-for-best-at-0 — Infinity is never actually "the best average." - averageScore: entry.tablesPlayed > 0 ? entry.cumulativeScore / entry.tablesPlayed : Infinity, + averageScore: + entry.tablesPlayed > 0 ? entry.cumulativeScore / entry.tablesPlayed : Infinity, })) .sort( (a, b) => diff --git a/packages/tournament-formats/test/swiss-league.test.ts b/packages/tournament-formats/test/swiss-league.test.ts index e3be685..43f9c59 100644 --- a/packages/tournament-formats/test/swiss-league.test.ts +++ b/packages/tournament-formats/test/swiss-league.test.ts @@ -1,4 +1,9 @@ -import { runTournament, type MatchDescriptor, type MatchRecord, type RosterEntry } from '@thunderdome/engine'; +import { + runTournament, + type MatchDescriptor, + type MatchRecord, + type RosterEntry, +} from '@thunderdome/engine'; import { createRng } from '@thunderdome/rng'; import { describe, expect, it } from 'vitest'; import { @@ -15,7 +20,10 @@ function roster(...ids: string[]): RosterEntry[] { /** A full table result, Hearts-shaped: lower `score` is better, `rank` derived the same way * `card-game-hearts`'s own `getStandingOutcomes` computes it (1 + how many others scored lower). */ -function tableRecord(match: MatchDescriptor, scoreByParticipant: Record): MatchRecord { +function tableRecord( + match: MatchDescriptor, + scoreByParticipant: Record, +): MatchRecord { const scoreOf = (id: string) => scoreByParticipant[id] ?? 0; return { matchId: match.matchId, @@ -23,8 +31,7 @@ function tableRecord(match: MatchDescriptor, scoreByParticipant: Record scoreOf(other) < scoreOf(participantId)).length, + 1 + match.participantIds.filter((other) => scoreOf(other) < scoreOf(participantId)).length, })), }; } @@ -59,7 +66,11 @@ describe('swissLeagueFormat.parseConfig', () => { describe('swissLeagueFormat.initialize', () => { it('throws when the roster is smaller than tableSize', () => { expect(() => - swissLeagueFormat.initialize({ roster: roster('a', 'b'), config: { tableSize: 4, rounds: 1 }, rng }), + swissLeagueFormat.initialize({ + roster: roster('a', 'b'), + config: { tableSize: 4, rounds: 1 }, + rng, + }), ).toThrow('at least 4 participants'); }); @@ -77,12 +88,12 @@ describe('swissLeagueFormat.initialize', () => { expect(readyMatches).toHaveLength(1); expect(table.participantIds).toHaveLength(4); const seated = new Set(table.participantIds); - const satOut = ['a', 'b', 'c', 'd', 'e'].find(id => !seated.has(id)); + const satOut = ['a', 'b', 'c', 'd', 'e'].find((id) => !seated.has(id)); if (satOut === undefined) { throw new Error('expected exactly one participant to sit out'); } expect(formatState.sitOutCount[satOut]).toBe(1); - expect(notices?.some(notice => notice.includes('sit'))).toBe(true); + expect(notices?.some((notice) => notice.includes('sit'))).toBe(true); }); it('builds one table per tableSize-sized group, covering every participant exactly once', () => { @@ -107,10 +118,17 @@ describe('swissLeagueFormat.initialize', () => { expect(Object.keys(formatState.tiebreakRank).sort()).toEqual( ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].sort(), ); - expect(new Set(Object.values(formatState.tiebreakRank))).toEqual(new Set([0, 1, 2, 3, 4, 5, 6, 7])); + expect(new Set(Object.values(formatState.tiebreakRank))).toEqual( + new Set([0, 1, 2, 3, 4, 5, 6, 7]), + ); for (const id of allSeated) { - expect(standings[id]).toEqual({ participantId: id, cumulativeScore: 0, tablesPlayed: 0, tablesWon: 0 }); + expect(standings[id]).toEqual({ + participantId: id, + cumulativeScore: 0, + tablesPlayed: 0, + tablesWon: 0, + }); } expect(notices).toHaveLength(1); }); @@ -134,7 +152,10 @@ describe('swissLeagueFormat.recordResult', () => { formatState: initial.formatState, standings: initial.standings, match: table1, - record: tableRecord(table1, Object.fromEntries(table1.participantIds.map((id, i) => [id, i]))), + record: tableRecord( + table1, + Object.fromEntries(table1.participantIds.map((id, i) => [id, i])), + ), }); expect(afterTable1.readyMatches).toEqual([]); // table2 hasn't reported yet expect(afterTable1.formatState.tablesRemainingInRound).toBe(1); @@ -144,7 +165,10 @@ describe('swissLeagueFormat.recordResult', () => { formatState: afterTable1.formatState, standings: afterTable1.standings, match: table2, - record: tableRecord(table2, Object.fromEntries(table2.participantIds.map((id, i) => [id, i]))), + record: tableRecord( + table2, + Object.fromEntries(table2.participantIds.map((id, i) => [id, i])), + ), }); expect(afterTable2.readyMatches).toHaveLength(2); // round 2's tables unlocked expect(afterTable2.formatState.roundsCompleted).toBe(1); @@ -291,9 +315,11 @@ describe('swissLeagueFormat.getPublicStandings', () => { b: { participantId: 'b', cumulativeScore: 15, tablesPlayed: 2, tablesWon: 0 }, }; - const result = swissLeagueFormat.getPublicStandings(standings) as SwissLeaguePublicStandingsEntry[]; - expect(result.map(entry => entry.participantId)).toEqual(['a', 'b']); - expect(result.map(entry => entry.averageScore)).toEqual([5, 7.5]); + const result = swissLeagueFormat.getPublicStandings( + standings, + ) as SwissLeaguePublicStandingsEntry[]; + expect(result.map((entry) => entry.participantId)).toEqual(['a', 'b']); + expect(result.map((entry) => entry.averageScore)).toEqual([5, 7.5]); }); it('sorts a participant who never got seated (tablesPlayed 0) last, not tied-for-best', () => { @@ -302,8 +328,10 @@ describe('swissLeagueFormat.getPublicStandings', () => { b: { participantId: 'b', cumulativeScore: 0, tablesPlayed: 0, tablesWon: 0 }, }; - const result = swissLeagueFormat.getPublicStandings(standings) as SwissLeaguePublicStandingsEntry[]; - expect(result.map(entry => entry.participantId)).toEqual(['a', 'b']); + const result = swissLeagueFormat.getPublicStandings( + standings, + ) as SwissLeaguePublicStandingsEntry[]; + expect(result.map((entry) => entry.participantId)).toEqual(['a', 'b']); expect(result[1]?.averageScore).toBe(Infinity); }); }); @@ -365,7 +393,7 @@ describe('swissLeagueFormat + runTournament (end to end)', () => { config: { tableSize: 4, rounds: 6 }, roster: roster(...participantIds), rng, - runMatch: match => { + runMatch: (match) => { matchesRun += 1; return Promise.resolve(tableRecord(match, {})); }, @@ -378,7 +406,7 @@ describe('swissLeagueFormat + runTournament (end to end)', () => { outcome.standings, ) as SwissLeaguePublicStandingsEntry[]; const tablesPlayedByParticipant = new Map( - publicStandings.map(entry => [entry.participantId, entry.tablesPlayed]), + publicStandings.map((entry) => [entry.participantId, entry.tablesPlayed]), ); expect([...tablesPlayedByParticipant.values()].reduce((sum, n) => sum + n, 0)).toBe(24); // Fair rotation: over 6 rounds x 2 sit-outs/round = 12 total sit-outs across 6 participants, diff --git a/scripts/README.md b/scripts/README.md index 2ff75ab..8cf9bab 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,12 +2,12 @@ Dev tooling that isn't a workspace package in its own right. -| Script | What it does | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build.sh` | The root `yarn build`'s implementation — builds every workspace package in dependency order (see its own comments; Yarn Classic's `workspaces run` doesn't guarantee topological order). | -| `pack-bot-sdk.sh` | Builds `@thunderdome/bot-sdk` and vendors it into every bot that depends on it. See below. | -| `scaffold-game.mjs` | Generates a new `games//` workspace package — a minimal but real, working `GameDefinition`. See below. | -| `scaffold-bot.mjs` | Generates a new `bots///` directory — a starter bot on `@thunderdome/bot-sdk`. See below. | +| Script | What it does | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build.sh` | The root `yarn build`'s implementation — builds every workspace package in dependency order (see its own comments; Yarn Classic's `workspaces run` doesn't guarantee topological order). | +| `pack-bot-sdk.sh` | Builds `@thunderdome/bot-sdk` and vendors it into every bot that depends on it. See below. | +| `scaffold-game.mjs` | Generates a new `games//` workspace package — a minimal but real, working `GameDefinition`. See below. | +| `scaffold-bot.mjs` | Generates a new `bots///` directory — a starter bot on `@thunderdome/bot-sdk`. See below. | Running a real match between bots from `/bots` is now `yarn thunderdome match run ` (`apps/cli/src/commands/match.ts`) — the registry-backed successor to what used diff --git a/scripts/build.sh b/scripts/build.sh index ca0ebe8..ddc4798 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -20,6 +20,7 @@ INDEPENDENT_PACKAGES=( "@thunderdome/game-rock-paper-scissors" "@thunderdome/game-connect-four" "@thunderdome/game-card-game-hearts" + "@thunderdome/game-poker-texas-hold-em" ) for pkg in "${INDEPENDENT_PACKAGES[@]}"; do diff --git a/scripts/lib/scaffold-utils.mjs b/scripts/lib/scaffold-utils.mjs index f7fe830..fab5bcb 100644 --- a/scripts/lib/scaffold-utils.mjs +++ b/scripts/lib/scaffold-utils.mjs @@ -33,7 +33,9 @@ export function kebabToTitle(id) { export function gitConfig(key) { try { - return execFileSync('git', ['config', key], { cwd: REPO_ROOT, encoding: 'utf8' }).trim() || undefined; + return ( + execFileSync('git', ['config', key], { cwd: REPO_ROOT, encoding: 'utf8' }).trim() || undefined + ); } catch { return undefined; } diff --git a/scripts/pack-bot-sdk.sh b/scripts/pack-bot-sdk.sh index 57333e5..d4dac19 100755 --- a/scripts/pack-bot-sdk.sh +++ b/scripts/pack-bot-sdk.sh @@ -37,16 +37,19 @@ BOT_DIRS=( "bots/rock-paper-scissors/only-scissors" "bots/rock-paper-scissors/copycat-rps" "bots/rock-paper-scissors/random-rps" - "bots/rock-paper-scissors/t800" - "bots/rock-paper-scissors/t1000" - "bots/rock-paper-scissors/tx" + "bots/rock-paper-scissors/tominator-t800" + "bots/rock-paper-scissors/tominator-t1000" + "bots/rock-paper-scissors/tominator-tx" "bots/connect-four/leftmost-connect-four" "bots/connect-four/random-connect-four" "bots/card-game-hearts/random-hearts" "bots/card-game-hearts/lowest-card-hearts" "bots/card-game-hearts/point-dodger-hearts" "bots/card-game-hearts/tominator-t1" - "bots/card-game-hearts/tominator-t2" + "bots/card-game-hearts/tominator-t101" + "bots/poker-texas-hold-em/random-poker" + "bots/poker-texas-hold-em/calling-station-poker" + "bots/poker-texas-hold-em/tight-poker" ) for dir in "${BOT_DIRS[@]}"; do diff --git a/scripts/scaffold-game.mjs b/scripts/scaffold-game.mjs index 0258340..4226233 100644 --- a/scripts/scaffold-game.mjs +++ b/scripts/scaffold-game.mjs @@ -58,7 +58,8 @@ const testParticipantCount = Math.min(Math.max(minParticipants, 2), NAME_POOL.le const testParticipantIds = NAME_POOL.slice(0, testParticipantCount); const maintainerName = args['maintainer-name'] ?? gitConfig('user.name') ?? 'Your Name'; -const maintainerContact = args['maintainer-contact'] ?? gitConfig('user.email') ?? 'you@example.com'; +const maintainerContact = + args['maintainer-contact'] ?? gitConfig('user.email') ?? 'you@example.com'; const gameDir = join(REPO_ROOT, 'games', id); assertTargetIsFresh(gameDir, 'games/'); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 82751a9..8f459fd 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -1,4 +1,4 @@ import { defineWorkspace } from 'vitest/config'; // Deliberately excludes bots/* — bot code is never built, imported, or tested by the platform. -export default defineWorkspace(['apps/*', 'packages/*', 'games/*', 'tools/*']); +export default defineWorkspace(['apps/*/', 'packages/*/', 'games/*/', 'tools/*/']);