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
96 changes: 75 additions & 21 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ today.
| 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 a bot. 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. |
Expand Down Expand Up @@ -75,17 +75,31 @@ Match: only-rock vs only-paper (Rock Paper Scissors)
(At the real default of 300 rounds, the per-round lines are replaced with a single
`(300 rounds played)` line instead — see [the note below](#a-note-on-ties-and-non-terminating-matches).)

For a game with more than 2 participants, like Hearts (which requires **exactly 4**, not "2 or
more"), pass one bot id per seat:

```bash
yarn thunderdome match run random-hearts lowest-card-hearts point-dodger-hearts <your-bot-id> \
--config '{"pointLimit":100}'
```

- `--config` is whatever JSON the shared game's own `parseConfig` expects — for
Rock-Paper-Scissors, `{"totalRounds": <positive int>}` (defaults to 300 hands if omitted; see
`games/rock-paper-scissors/src/types.ts`'s `RpsConfigSchema`). Every configured hand is played
regardless of who's ahead — the tally at the end decides the winner, or a genuine tie if it's
exactly even. Defaults to `{}`, which is valid on its own for Rock-Paper-Scissors.
`games/rock-paper-scissors/src/types.ts`'s `RpsConfigSchema`); for Hearts,
`{"pointLimit": <positive int>}` (defaults to 100 if omitted; see
`games/card-game-hearts/src/types.ts`'s `HeartsConfigSchema`) — the match ends once any
player's cumulative score crosses it, lowest score wins. Defaults to `{}`, which is valid on its
own for both games.
- Bot ids are whatever a bot's own `manifest.json` declares as `id` — see
[`/bots/rock-paper-scissors/`](../../bots/rock-paper-scissors/) for what's available today, or
[`docs/guides/rps-bot-author-guide.md`](../../docs/guides/rps-bot-author-guide.md) for writing
your own.
[`/bots/rock-paper-scissors/`](../../bots/rock-paper-scissors/) or
[`/bots/card-game-hearts/`](../../bots/card-game-hearts/) for what's available today, or
[`docs/guides/rps-bot-author-guide.md`](../../docs/guides/rps-bot-author-guide.md) /
[`docs/guides/hearts-bot-author-guide.md`](../../docs/guides/hearts-bot-author-guide.md) for
writing your own.
- All bots in one `match run` must share the same `game` (checked against each bot's own
manifest) — you'll get a clear error, not a confusing crash, if they don't.
manifest) — you'll get a clear error, not a confusing crash, if they don't. Passing the wrong
number of bot ids for that game (e.g. 3 for Hearts) gets a clear error from the game's own
`initialize()`, not a hang or a crash.
- Exits 0 on a completed, forfeited, or timed-out match (see below), 1 on any resolution error
(unknown bot/game id, mismatched games, invalid `--config`, or a bot failing to initialize).

Expand Down Expand Up @@ -116,13 +130,15 @@ for the full explanation. If you still see this, it's a new issue, not the old o
## `play`

```bash
yarn thunderdome play <botId> [--as <yourParticipantId>] [--game-config '<json>']
yarn thunderdome play <botId> [...moreBotIds] [--as <yourParticipantId>] [--game-config '<json>']
```

Resolves the one given bot id (and its game) through the registry exactly like `match run`, builds
its Docker image on demand, then drives a real match through the same `@thunderdome/engine`
`runMatch()` loop — except this time, one side of the match is you: every round, the game's own
prompt is printed right here in this terminal, and whatever you type becomes your action.
Resolves the given bot id(s) (and their shared game) through the registry exactly like
`match run`, builds each Docker image on demand, then drives a real match through the same
`@thunderdome/engine` `runMatch()` loop — except this time, one seat is you: every round it's
your turn, the game's own prompt is printed right here in this terminal, and whatever you type
becomes your action. One bot id per remaining seat, in order after you — a 2-player game (Rock
Paper Scissors) takes exactly one; a 4-player game (Hearts) takes three.

```bash
yarn thunderdome play only-rock --game-config '{"totalRounds":3}'
Expand All @@ -144,26 +160,63 @@ rock, paper, or scissors? (r/p/s) paper
2. only-rock (loss, score=0)
```

For a 4-player game like Hearts, pass 3 bot ids to fill the other 3 seats:

```bash
yarn thunderdome play random-hearts lowest-card-hearts point-dodger-hearts \
--game-config '{"pointLimit":100}'
```

```
Building 3 bot image(s)...

You (you) vs random-hearts, lowest-card-hearts, point-dodger-hearts (Hearts). Type "quit" anytime to resign.

Hearts — Hand 1 (passing) — passing direction: left
Your hand: 9C QC 2D 4D 9D TD QD KD TH KH 5S JS QS
Hearts broken: no
Scores — you: 0, random-hearts: 0, lowest-card-hearts: 0, point-dodger-hearts: 0 (lowest wins at 100)
Cards: rank+suit — 2-9, T=10, J, Q, K, A; C=clubs, D=diamonds, H=hearts, S=spades (e.g. QS = queen of spades, TH = ten of hearts).
Pass 3 cards left. Type: PASS <card> <card> <card> (example: PASS 2C 5C TH)
> PASS 9C QC 2D
Passed: 9C QC 2D
...

1. point-dodger-hearts (win, score=31)
2. lowest-card-hearts (loss, score=53)
3. random-hearts (loss, score=99)
4. you (loss, score=103)
```

Every prompt includes the card-notation legend and a format example with a real, currently-legal
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
explicit "Sorry, I didn't understand that — try again." rather than silently repeating the same
block.

- Requires the shared game to opt in via `GameDefinition.humanInterface` (an optional hook next to
`onMissingAction` on the same interface, `packages/engine/src/types.ts`) — it owns rendering each
round's prompt and parsing your reply into that game's own action shape, the same way every other
piece of a game's rules lives in the game package, not the CLI. Rock-Paper-Scissors implements
it (`games/rock-paper-scissors/src/game.ts`); a game that hasn't yet gets a clear error here
rather than a garbled prompt.
piece of a game's rules lives in the game package, not the CLI. Rock-Paper-Scissors
(`games/rock-paper-scissors/src/game.ts`) and Hearts (`games/card-game-hearts/src/game.ts`)
both implement it; a game that hasn't yet gets a clear error here rather than a garbled prompt.
- Type "quit" (or "resign") at any prompt to forfeit the match early instead of playing out every
configured round — this is the same generic missing-action path a bot's crash or timeout takes,
not a special case.
- An unparseable line (a typo, an empty line, anything the game's `humanInterface.parseInput`
doesn't recognize) just reprompts — it never counts as a forfeit or an illegal move.
- `--as` names your own participant id (defaults to `you`); it can't collide with the bot's own id.
- `--as` names your own participant id (defaults to `you`); it can't collide with any of the given
bots' ids.
- Unlike a bot's per-turn deadline, nothing times out your own replies — you set the pace. The
match as a whole is still capped (a generous 1 hour, not `match run`'s 120 seconds) as pure
defense-in-depth against a genuinely abandoned session, matching
[the note on ties and non-terminating matches](#a-note-on-ties-and-non-terminating-matches)
above.
- Subject to the same
[interrupt handling](#interrupting-a-running-match-run--play--tournament-run) as
`match run`/`tournament run` — Ctrl+C tears down the bot's container before exiting.
`match run`/`tournament run` — Ctrl+C tears down every bot's container before exiting.

## `tournament run`

Expand Down Expand Up @@ -359,6 +412,7 @@ Any tournament format beyond round robin and single elimination (Swiss, pool-the
for the design that still-aspirational piece is meant to fit.

`play` only works against a game that implements `GameDefinition.humanInterface` — today, that's
Rock-Paper-Scissors only. Connect Four hasn't implemented it yet (`play leftmost-connect-four`
gives a clear error, not a crash); doing so is just a matter of `games/connect-four` adding its
own `describeObservation`/`parseInput` pair, no CLI or engine change required.
Rock-Paper-Scissors and Hearts. Connect Four hasn't implemented it yet (`play
leftmost-connect-four` gives a clear error, not a crash); doing so is just a matter of
`games/connect-four` adding its own `describeObservation`/`parseInput` pair, no CLI or engine
change required.
28 changes: 13 additions & 15 deletions apps/cli/src/commands/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import {
import { printRoundEvents } from './match.js';

const USAGE =
'Usage: thunderdome play <botId> [--as <yourParticipantId>] ' +
'Usage: thunderdome play <botId> [...moreBotIds] [--as <yourParticipantId>] ' +
'[--game-config \'{"totalRounds":300}\']\n' +
' Type your move at each prompt; type "quit" (or "resign") anytime to end the match early.';
' One bot id per remaining seat, in order after you — a 2-player game takes exactly one; a ' +
'4-player game like Hearts takes three. Type your move at each prompt; type "quit" (or ' +
'"resign") anytime to end the match early.';

export interface PlayOptions {
/** Repo root to scan games/ and bots/ under. */
Expand All @@ -42,18 +44,18 @@ export async function runPlayCommand(
allowPositionals: true,
});

const [botId, ...rest] = positionals;
if (botId === undefined || rest.length > 0) {
const botIds = positionals;
if (botIds.length === 0) {
console.error(USAGE);
return 1;
}
const humanParticipantId = values.as;
if (humanParticipantId === botId) {
console.error(`--as "${humanParticipantId}" can't be the same id as the bot you're playing.`);
if (botIds.includes(humanParticipantId)) {
console.error(`--as "${humanParticipantId}" can't be the same id as a bot you're playing.`);
return 1;
}

const resolved = await resolveBotsAndGame(options.rootDir, [botId]);
const resolved = await resolveBotsAndGame(options.rootDir, botIds);
if (!resolved.ok) {
console.error(resolved.message);
return 1;
Expand Down Expand Up @@ -86,18 +88,14 @@ export async function runPlayCommand(
}
const config = configResult.value;

console.log(`Building 1 bot image...`);
console.log(`Building ${String(entries.length)} bot image(s)...`);
const imageTagsByBotId = await buildBotImages(entries);
const imageTag = imageTagsByBotId.get(botId);
if (imageTag === undefined) {
throw new Error(`unreachable: no image built for bot "${botId}"`);
}

const matchId = `play-${String(Date.now())}`;
const tournamentSeed = generateTournamentSeed(); // the one entropy boundary (ADR-0004)

console.log(
`\nYou (${humanParticipantId}) vs ${botId} (${gameEntry.manifest.name}). Type "quit" anytime to resign.\n`,
`\nYou (${humanParticipantId}) vs ${botIds.join(', ')} (${gameEntry.manifest.name}). Type "quit" anytime to resign.\n`,
);

let outcome;
Expand All @@ -108,8 +106,8 @@ export async function runPlayCommand(
config,
matchId,
humanParticipantId,
botParticipantId: botId,
botImageTag: imageTag,
botParticipantIds: botIds,
botImageTagsByBotId: imageTagsByBotId,
tournamentSeed,
// Omitted entirely when absent, rather than set to `undefined` — required by
// tsconfig.base.json's `exactOptionalPropertyTypes`.
Expand Down
30 changes: 25 additions & 5 deletions apps/cli/src/lib/human-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,19 @@ 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 silently past an unparseable line (typing
// "roc" is a typo, not a forfeit) rather than ever handing that on to validateAction/resolve.
// 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;
for (;;) {
this.output.write(humanInterface.describeObservation(args.observation));
if (isRetry) {
this.output.write("Sorry, I didn't understand that — try again.\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
// prompt from where you type, so your answer lands on its own line instead of running on
// right after the prompt text.
this.output.write(`${humanInterface.describeObservation(args.observation)}\n> `);
const next = await this.lines.next();
if (next.done) {
return { ok: false, reason: 'disconnected' }; // stdin ended — nobody left to answer
Expand All @@ -59,9 +68,20 @@ export class TerminalHumanCollector implements ActionCollector {
return { ok: false, reason: 'disconnected' };
}
const action = humanInterface.parseInput(raw);
if (action !== undefined) {
return { ok: true, action };
if (action === undefined) {
isRetry = true;
continue;
}
// Confirms exactly how the input was understood — not just that something was accepted —
// so a valid-but-unintended parse (e.g. a typo that still happens to name a real card)
// doesn't slip past unnoticed. Optional: a game with nothing useful to add here just omits it.
const confirmation = humanInterface.describeAction?.(action);
if (confirmation !== undefined) {
// No leading newline: pressing Enter to submit already moved the terminal to a new
// line, so one here would just add a stray blank line before the confirmation.
this.output.write(`${confirmation}\n\n`);
}
return { ok: true, action };
}
}

Expand Down
Loading
Loading