Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
22 changes: 11 additions & 11 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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.

Expand Down
9 changes: 7 additions & 2 deletions apps/cli/src/commands/tournament.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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 ` +
Expand Down
18 changes: 13 additions & 5 deletions apps/cli/src/lib/human-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 —
Expand Down
10 changes: 10 additions & 0 deletions apps/cli/src/lib/match-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export async function runHumanMatch(args: RunHumanMatchArgs): Promise<SingleMatc
} = args;
const roster = [humanParticipantId, ...botParticipantIds];
const matchRng = createRng(deriveSeed(tournamentSeed, 'match', matchId));
const writeStream = output ?? process.stdout;

const lifecycles = new Map<string, BotLifecycle>();
const collector = new TerminalHumanCollector({
Expand Down Expand Up @@ -361,6 +362,15 @@ export async function runHumanMatch(args: RunHumanMatchArgs): Promise<SingleMatc
collector,
defaultDeadlineMs: 5_000,
matchDeadlineMs: HUMAN_MATCH_DEADLINE_MS,
// Narrates every round live — including ones where it isn't the human's turn — so a
// human still learns what happened even once they're no longer ever asked to act again
// (e.g. busted out of the hand they just went all-in on).
onRoundResolved: (events) => {
const line = game.humanInterface?.describeRoundEvents?.(events, humanParticipantId);
if (line !== undefined) {
writeStream.write(`${line}\n`);
}
},
});

const matchEndPayload =
Expand Down
60 changes: 57 additions & 3 deletions apps/cli/test/human-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)}` }
: {}),
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/test/tournament.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading