plai_testr 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +20 -194
- data/docs/AGENTS.md +6 -5
- data/docs/getting-started.md +137 -0
- data/docs/ingesting-a-game-manually.md +112 -0
- data/docs/ingesting-a-game-with-an-agent.md +138 -0
- data/docs/running-playtests.md +193 -0
- data/docs/technical/README.md +15 -0
- data/docs/technical/debrief.md +55 -0
- data/docs/technical/personas-and-bots.md +67 -0
- data/docs/technical/reports.md +57 -0
- data/docs/technical/rules-and-components.md +61 -0
- data/docs/technical/runner.md +158 -0
- data/docs/technical/specs.md +62 -0
- data/docs/technical/state.md +60 -0
- data/lib/plai_testr/cli.rb +1 -1
- data/lib/plai_testr/examples.rb +37 -0
- data/lib/plai_testr/generator.rb +3 -2
- data/lib/plai_testr/reporter/botrun_report.rb +1 -1
- data/lib/plai_testr/reporter/duration.rb +12 -0
- data/lib/plai_testr/reporter/report_builder.rb +1 -0
- data/lib/plai_testr/session/runner.rb +3 -1
- data/lib/plai_testr/state/game_state.rb +1 -1
- data/lib/plai_testr/version.rb +1 -1
- data/lib/plai_testr.rb +2 -0
- data/templates/app/AGENTS.md.erb +1 -1
- data/templates/app/README.md.erb +3 -3
- data/templates/examples/gin_rummy/data/components/cards.csv +53 -0
- data/templates/examples/gin_rummy/data/rules.json +1 -64
- metadata +18 -3
- /data/docs/{frame_queue.md → technical/frame-queue.md} +0 -0
- /data/docs/{referee.md → technical/referee.md} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Ingesting a game with an agent
|
|
2
|
+
|
|
3
|
+
*Ingesting* a game means turning your rulebook into a *harness*: code that knows your game's rules well enough to deal the cards, list each player's legal moves, apply the one they pick, and tell when the game is over. Language models are unreliable at tracking a game's state over a long game, so the harness does the bookkeeping and the model only chooses.
|
|
4
|
+
|
|
5
|
+
You can have an AI coding assistant (an *agent*, such as [Claude Code](https://claude.com/claude-code)) write the harness for you. This guide covers what to prepare, how to ask, and how to check the result. It assumes you've finished [Getting started](getting-started.md). To write the harness yourself instead, see [Ingesting a game manually](ingesting-a-game-manually.md). The two mix well: have the agent write the first version, then read and change it yourself.
|
|
6
|
+
|
|
7
|
+
The examples below use Blackjack, whose [rules](https://bicyclecards.com/how-to-play/blackjack) are public domain.
|
|
8
|
+
|
|
9
|
+
## Step 1: Create the game's folder
|
|
10
|
+
|
|
11
|
+
From your app folder:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bundle exec plai_testr generate game blackjack
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Game names are lowercase, with `_` instead of spaces. This creates `games/blackjack/` with a starting file for everything the harness needs:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
games/blackjack/
|
|
21
|
+
data/rules.md The rules, as the model players will read them
|
|
22
|
+
data/components/ Card lists and other components
|
|
23
|
+
data/rules.json The game's name, version, player count, and numbers the code uses
|
|
24
|
+
data/personas.yml Optional: strategies and play styles for the model players
|
|
25
|
+
data/bots.yml Optional: settings for the bots
|
|
26
|
+
lib/ The harness code (the agent writes this)
|
|
27
|
+
spec/ Automated checks (the agent writes these too)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
You fill in the first two; the agent does the rest.
|
|
31
|
+
|
|
32
|
+
## Step 2: Write the rules
|
|
33
|
+
|
|
34
|
+
Put your rules in `games/blackjack/data/rules.md`, in [Markdown](https://www.markdownguide.org/basic-syntax/). There's no required format: write it the way you'd explain the game to a new player. The model players read this file every turn, so:
|
|
35
|
+
|
|
36
|
+
- Describe the procedure: setup, what happens on a turn, how the game ends, how scoring works.
|
|
37
|
+
- Long examples and flavor text can go; they cost the models reading time.
|
|
38
|
+
- Leave out house rules and variants you aren't testing.
|
|
39
|
+
|
|
40
|
+
Don't worry about gaps. The agent is told to find them and ask you.
|
|
41
|
+
|
|
42
|
+
## Step 3: List the components
|
|
43
|
+
|
|
44
|
+
Put every card, tile or token list in `games/blackjack/data/components/`, one file per kind of component. The file name becomes the component's name (`cards.csv` is "cards").
|
|
45
|
+
|
|
46
|
+
A file is a list with one row per component and a column for each thing about it. A spreadsheet saved as CSV works:
|
|
47
|
+
|
|
48
|
+
```csv
|
|
49
|
+
name,suit,rank
|
|
50
|
+
A♣,clubs,1
|
|
51
|
+
2♣,clubs,2
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
YAML works too, and suits cards with longer text:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
- name: Ambush
|
|
58
|
+
cost: 2
|
|
59
|
+
text: Play when an opponent attacks. Cancel the attack.
|
|
60
|
+
copies: 3
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A `copies` column makes that many identical copies. `games/hearts/data/components/cards.csv` is a complete standard deck.
|
|
64
|
+
|
|
65
|
+
Already have your components in another form (a spreadsheet, an export from a card tool, a PDF)? Point the agent at it instead; it will convert it and check the counts with you.
|
|
66
|
+
|
|
67
|
+
## Step 4 (optional): Sketch some personas
|
|
68
|
+
|
|
69
|
+
A *persona* is a play style: a strategy the model players follow, and settings that make the bots play differently. Botruns then compare how each style does.
|
|
70
|
+
|
|
71
|
+
Personas build on a *basic strategy* that every player follows. For Blackjack that might be "never hit on 21" and "always hit under 11". Each persona then adds its own approach: a `by_the_book` player who hits under 16 and stands on 16 or more, a `21_obsessed` player who always tries for exactly 21. It's a fun way to test design hypotheses, and to catch rules regressions later.
|
|
72
|
+
|
|
73
|
+
You don't need to write `personas.yml` yourself. Describing the styles in plain words to the agent is enough.
|
|
74
|
+
|
|
75
|
+
## Step 5: Ask the agent
|
|
76
|
+
|
|
77
|
+
The app comes with instructions for agents. Its `CLAUDE.md` pulls in PlAI Testr's [agent guide](AGENTS.md), which explains how the harness works, the order to build it in, and to ask you before guessing at a rule. Claude Code reads `CLAUDE.md` automatically when started in the app folder. With another agent, tell it to read `AGENTS.md` and `.plai_testr/gem/docs/AGENTS.md` first.
|
|
78
|
+
|
|
79
|
+
Start the agent in the app folder (for example, run `claude`) and give it a prompt like this:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
I'm building a PlAI Testr harness for my game, blackjack. I ran `plai_testr generate game blackjack`, wrote the rules into @./games/blackjack/data/rules.md, and put the components in @./games/blackjack/data/components/.
|
|
83
|
+
|
|
84
|
+
Build out the rest of the harness so I can run botruns and model games:
|
|
85
|
+
|
|
86
|
+
1. Read the rules and components closely. Before writing any code, list
|
|
87
|
+
every gap, ambiguity or rule you'd have to guess at, and ask me about
|
|
88
|
+
them. Record my answers in a Rulings section in rules.md.
|
|
89
|
+
2. Fill in data/rules.json, then build the state, runner and bot following
|
|
90
|
+
the order in the PlAI Testr agent guide, with specs for the rules as you go.
|
|
91
|
+
3. Get `bundle exec plai_testr blackjack botrun --games 100` finishing
|
|
92
|
+
with no crashes and sensible endings, and `bundle exec rspec
|
|
93
|
+
games/blackjack/spec` passing.
|
|
94
|
+
4. Write the player views, debrief facts and debrief questions, and show
|
|
95
|
+
me one turn prompt and one debrief prompt to review.
|
|
96
|
+
|
|
97
|
+
Don't change the rules to make the harness easier. Don't run any model games
|
|
98
|
+
(play, preflight, resume, debrief) — stop and tell me when it's ready.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Add your personas to the prompt if you have them in mind ("Add three personas: …").
|
|
102
|
+
|
|
103
|
+
Expect rules questions. Answering them is often useful in itself: it has turned up ambiguities in my own rules I hadn't noticed. The answers go in a *Rulings* section at the end of `rules.md`, so the model players see them too. A small game like Blackjack typically takes a few minutes; a large one can take much longer.
|
|
104
|
+
|
|
105
|
+
## Step 6: Check the result
|
|
106
|
+
|
|
107
|
+
When the agent says it's ready:
|
|
108
|
+
|
|
109
|
+
1. **Run the checks.** `bundle exec rspec games/blackjack/spec` should pass. These include the standard checks every game gets: games reach an ending, saved games resume exactly, and no player can see another's hand.
|
|
110
|
+
2. **Run a botrun** and read the report:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
bundle exec plai_testr blackjack botrun --games 100
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Do the results make sense? A cautious persona that busts more often than the gambler, or games that all end the same unusual way, point to a bug. Describe what looks wrong to the agent.
|
|
117
|
+
3. **Read the prompts** the agent showed you. Is everything a player needs to decide there? Is anything there that the player shouldn't know, such as the dealer's face-down card? Does any option tell the model what to pick? Options should state facts ("Hit (your total: 14)"), never advice ("Hit (recommended)").
|
|
118
|
+
4. **Run a model game** when you're happy:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
bundle exec plai_testr blackjack preflight
|
|
122
|
+
bundle exec plai_testr blackjack play
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
See [Running playtests](running-playtests.md) for the options and for reading reports.
|
|
126
|
+
|
|
127
|
+
## Asking for more
|
|
128
|
+
|
|
129
|
+
The agent guide stays with your app, so you can come back to the agent at any time. Some requests that work well:
|
|
130
|
+
|
|
131
|
+
- *Write bot rules for blackjack so the gambler persona plays differently, run a 1000-game botrun, and summarize the report.*
|
|
132
|
+
- *Read the newest report in games/blackjack/reports and tell me what it says about balance. Separate what might be the bot's habits from what might be the game.*
|
|
133
|
+
- *The model players in blackjack keep making the same bad choice. Show me the prompt they see and suggest how to make the menu clearer, without telling them what to pick.*
|
|
134
|
+
- *I've ruled that a tie goes to the dealer. Update the engine, add a spec, and record the ruling in data/rules.md.*
|
|
135
|
+
- *The debrief answers mention things that didn't happen. Add debrief facts for what the questions ask about.*
|
|
136
|
+
- *I've changed the rules in data/rules.md to version 0.4. Update the harness to match and tell me what changed.*
|
|
137
|
+
|
|
138
|
+
The agent guide tells the agent to ask before running model games, which can take a long time. Specs and botruns are fast, so it runs those freely.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Running playtests
|
|
2
|
+
|
|
3
|
+
This guide covers running PlAI Testr from the command line: every command and its options, settings, where results go, and how to work through a game efficiently. It assumes your app is set up ([Getting started](getting-started.md)) and you're comfortable with command-line options and a little shell scripting.
|
|
4
|
+
|
|
5
|
+
Run every command from the app folder, with `bundle exec`.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
plai_testr <game> play [--seed N] [--model M] [--think] [--mode llm|hybrid|bot] [--players N] [--personas A,B]
|
|
11
|
+
[--referee M] [--debrief-model M] [--debrief-think] [--skip-preflight] [--quiet | --verbose]
|
|
12
|
+
plai_testr <game> botrun [--games N | --seeds 1,2,3] [--players N] [--personas all|A,B] [--quiet | --verbose]
|
|
13
|
+
plai_testr <game> resume <session_id> [--skip-preflight] [--quiet | --verbose]
|
|
14
|
+
plai_testr <game> debrief <session_id> [--model M] [--think] [--skip-preflight] [--verbose]
|
|
15
|
+
plai_testr <game> preflight [--seed N] [--model M] [--think] [--referee M] [--players N] [--personas A,B]
|
|
16
|
+
plai_testr <game> compare [--since ID]
|
|
17
|
+
plai_testr <game> list
|
|
18
|
+
plai_testr new <dir>
|
|
19
|
+
plai_testr generate game <name>
|
|
20
|
+
plai_testr games
|
|
21
|
+
plai_testr help [<command>]
|
|
22
|
+
plai_testr --version
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Every command takes `--help` to list its options (`plai_testr hearts botrun --help`, or `plai_testr help botrun`). A command refuses options it doesn't take.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
$ bundle exec plai_testr hearts botrun --help
|
|
29
|
+
Usage: plai_testr hearts botrun [options]
|
|
30
|
+
|
|
31
|
+
Play many games with bot rules only (no model), then report
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
--games N Number of games, seeds 1..N (default 100)
|
|
35
|
+
--seeds LIST Comma-separated seeds, instead of --games
|
|
36
|
+
--players N Number of players, within the game's player_count (default: the game's default)
|
|
37
|
+
--personas LIST One persona per player, comma-separated; * for random, "all" to cycle every arrangement (default: random)
|
|
38
|
+
--quiet Show only a progress line and the result
|
|
39
|
+
--verbose Show every game
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### botrun
|
|
43
|
+
|
|
44
|
+
Plays many games with bots only, no model, and writes one report:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bundle exec plai_testr hearts botrun --games 1000
|
|
48
|
+
bundle exec plai_testr hearts botrun --seeds 4,17,52
|
|
49
|
+
bundle exec plai_testr hearts botrun --games 500 --personas all
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`--games N` plays seeds 1 to N. Nothing is saved but the report, `games/<game>/reports/botrun-<time>.md`:
|
|
53
|
+
|
|
54
|
+
- how games ended, with the share of each ending
|
|
55
|
+
- wins by persona and by seat, and persona matchups, each with a 95% range
|
|
56
|
+
- game length, and how often each bot rule was used
|
|
57
|
+
- warnings, crashes, and seeds worth replaying
|
|
58
|
+
|
|
59
|
+
A *95% range* is how far the true win rate could plausibly be from this sample. Two ranges that overlap are not a real difference; play more games to narrow them.
|
|
60
|
+
|
|
61
|
+
Bot results describe how the bots play, not how people play. Confirm a surprising result with a few model games before changing your rules.
|
|
62
|
+
|
|
63
|
+
### play
|
|
64
|
+
|
|
65
|
+
Plays one game with model players:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
bundle exec plai_testr hearts play
|
|
69
|
+
bundle exec plai_testr hearts play --seed 12 --model qwen3:30b --think
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Before playing, `play` runs preflight: it checks Ollama, downloads missing models, warms them up, checks the first prompt fits the model's context, and plays 5 bot games to catch bugs. `--skip-preflight` skips it.
|
|
73
|
+
|
|
74
|
+
| Option | Effect |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `--seed N` | The seed for shuffles and dice. Without it, a random seed from 1 to 100 is picked and printed. |
|
|
77
|
+
| `--model M` | The Ollama model for the players (default `qwen3:8b`). |
|
|
78
|
+
| `--think` | The model reasons before each choice. Stronger play, several times slower. |
|
|
79
|
+
| `--mode hybrid` | The bot plays moves its rules mark *obvious*; the model plays the rest. Faster. |
|
|
80
|
+
| `--mode bot` | No model at all; one bot game saved as a session. |
|
|
81
|
+
| `--players N` | Number of players, within the game's player count. |
|
|
82
|
+
| `--personas A,B` | One persona per player, in seat order; `*` for a random one (`--personas moon_shooter,*,*,*`). Default: random. |
|
|
83
|
+
| `--referee M` | A second model for yes/no rulings, for games that ask for them. |
|
|
84
|
+
| `--debrief-model M`, `--debrief-think` | A different model, or thinking, for the end-of-game debrief only. |
|
|
85
|
+
|
|
86
|
+
When the game ends, each player answers the game's debrief questions, and the report goes in `games/<game>/reports/<session_id>.md`:
|
|
87
|
+
|
|
88
|
+
- the result, model reliability (retries, invalid answers)
|
|
89
|
+
- how often the model agreed with the bot's obvious moves
|
|
90
|
+
- speed and wall time
|
|
91
|
+
- the debrief answers, and every decision with the model's reasoning
|
|
92
|
+
|
|
93
|
+
### resume
|
|
94
|
+
|
|
95
|
+
Ctrl+C (or `kill`, which sends SIGTERM) stops a game after the current move and saves it. Continue it with the session id it prints:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
bundle exec plai_testr hearts resume 20261024-143012-a1b2
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A resumed game plays exactly as it would have without the interruption. If the game's data, code or the PlAI Testr version changed since the game started, `resume` refuses, because it could no longer replay exactly; start a new game with `play`. Ctrl+C twice quits at once, losing only the move in progress.
|
|
102
|
+
|
|
103
|
+
### debrief
|
|
104
|
+
|
|
105
|
+
Asks a finished game's players the debrief questions again and rebuilds its report:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
bundle exec plai_testr hearts debrief 20261024-143012-a1b2 --model qwen3:30b --think
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
By default it uses the game's player model, without thinking. Use it to try a bigger model, thinking, or changed debrief questions on the same game without replaying it. Earlier answers are kept in the saved game.
|
|
112
|
+
|
|
113
|
+
### preflight, compare, list
|
|
114
|
+
|
|
115
|
+
- `preflight` runs `play`'s checks without playing.
|
|
116
|
+
- `compare` writes a table of every finished model game, grouped by model and host: games, speed, reliability, endings and wins by persona. `--since 20261020` limits it to sessions from that date on (session ids start with the date and time).
|
|
117
|
+
- `list` shows every saved session with its status.
|
|
118
|
+
|
|
119
|
+
## Settings
|
|
120
|
+
|
|
121
|
+
`config/plai_testr.rb` holds settings for the whole app. All are commented out until you set them:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
PlaiTestr.configure do |config|
|
|
125
|
+
config.max_decisions = 100_000 # stops any game that runs this long
|
|
126
|
+
config.player_model = 'qwen3:8b'
|
|
127
|
+
config.ollama_host = 'localhost'
|
|
128
|
+
config.ollama_port = 11434
|
|
129
|
+
config.model_settings = { context_size: 8192 }
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Environment variables set the same defaults, handy for one-off runs: `PLAI_TESTR_PLAYER_MODEL`, `PLAI_TESTR_REFEREE_MODEL`, `PLAI_TESTR_OLLAMA_HOST`, `PLAI_TESTR_OLLAMA_PORT`.
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
PLAI_TESTR_OLLAMA_HOST=studio.local bundle exec plai_testr hearts play
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
To use Ollama on another computer, set `ollama_host` to its name or address, and start Ollama there with `OLLAMA_HOST=0.0.0.0` so it accepts connections from the network.
|
|
140
|
+
|
|
141
|
+
### Model settings
|
|
142
|
+
|
|
143
|
+
| Setting | Default | Used for |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `context_size` | 8192 | How much text the model reads per turn (without `--think`); the referee |
|
|
146
|
+
| `long_context_size` | 16384 | Games with `--think`; the debrief |
|
|
147
|
+
| `menu_budget` | 512 | The longest answer allowed for a menu choice |
|
|
148
|
+
| `think_budget` | 8192 | The longest answer allowed for a thinking turn |
|
|
149
|
+
| `debrief_budget` | 1024 | The longest answer allowed for the debrief |
|
|
150
|
+
| `referee_budget` | 2048 | The longest answer allowed for a ruling |
|
|
151
|
+
| `temperature` | 0.6 | How varied the players' choices are |
|
|
152
|
+
|
|
153
|
+
Sizes and budgets are in tokens (roughly ¾ of a word each). Values outside sensible limits are adjusted with a warning, and the values used are listed in each report. A game can set its own in its registration.
|
|
154
|
+
|
|
155
|
+
## Output files
|
|
156
|
+
|
|
157
|
+
Each game folder holds:
|
|
158
|
+
|
|
159
|
+
- `reports/<session_id>.md`: a model game's report
|
|
160
|
+
- `reports/botrun-<time>.md`, `reports/comparison-<time>.md`: botrun and compare reports
|
|
161
|
+
- `sessions/<session_id>/state.json`: the saved game, replaced after every decision
|
|
162
|
+
- `sessions/<session_id>/transcript.jsonl`: one JSON object per decision, with the model's reasoning. After a crash the last line may be cut off.
|
|
163
|
+
- `sessions/<session_id>/think.log`: every model reply, with Ollama's timings
|
|
164
|
+
|
|
165
|
+
Both folders are ignored by git. Delete old sessions freely; reports don't depend on them, except that `resume` and `debrief` need theirs.
|
|
166
|
+
|
|
167
|
+
## Working effectively
|
|
168
|
+
|
|
169
|
+
A sequence that works well, from fast and cheap to slow and detailed:
|
|
170
|
+
|
|
171
|
+
1. **Botrun first.** `botrun --games 1000 --personas all` gives balance numbers in seconds to minutes, and finds crashes and games that never end. Rerun it after every rules change.
|
|
172
|
+
2. **Replay what looks odd.** The report lists seeds worth a look. `play --seed N --mode bot` replays one as a saved session, with every decision in its report.
|
|
173
|
+
3. **Hybrid model games.** `play --mode hybrid` checks a surprising bot result with model players, at a fraction of the time of a full model game.
|
|
174
|
+
4. **Full model games**, a few seeds at a time, for reasoning and debrief feedback. Use the same seeds when comparing models or rules versions, so the deals match.
|
|
175
|
+
5. **Compare.** `compare` puts models and hosts side by side.
|
|
176
|
+
6. **Re-debrief** instead of replaying, when you only want better answers about a game.
|
|
177
|
+
|
|
178
|
+
Larger models and `--think` play better but take far longer: try a small model without thinking first.
|
|
179
|
+
|
|
180
|
+
### Scripting
|
|
181
|
+
|
|
182
|
+
To play several seeds in a row:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
for seed in 1 2 3; do
|
|
186
|
+
bundle exec plai_testr hearts play --seed "$seed" --quiet || break
|
|
187
|
+
done
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- `--quiet` prints only a progress line and the result; `--verbose` adds the raw model output (`play`) or every game (`botrun`).
|
|
191
|
+
- An interrupted `play` exits with status 130, so `|| break` stops the loop on Ctrl+C.
|
|
192
|
+
- Errors print one line. `PLAI_TESTR_DEBUG=1` shows the full backtrace.
|
|
193
|
+
- A botrun prints its report when it finishes, so it can be piped: `bundle exec plai_testr hearts botrun --games 1000 | less`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Technical reference
|
|
2
|
+
|
|
3
|
+
How PlAI Testr works, for anyone writing or reviewing harness code. [Ingesting a game manually](../ingesting-a-game-manually.md) gives the order to build a game in; these pages cover each part in depth.
|
|
4
|
+
|
|
5
|
+
- [The runner](runner.md): the decision loop, every hook, moves and actions, helpers, randomness and limits, checking a prompt
|
|
6
|
+
- [State](state.md): game and player state, hidden information, checkpoints and resume
|
|
7
|
+
- [Rules and components](rules-and-components.md): `rules.json`, `rules.md`, and component files
|
|
8
|
+
- [Personas and bots](personas-and-bots.md): `personas.yml`, `bots.yml`, `bot_choice`, and the bot's part in model games
|
|
9
|
+
- [Debrief](debrief.md): how the debrief prompt is built, and writing good facts and questions
|
|
10
|
+
- [Reports](reports.md): adding sections to session and botrun reports
|
|
11
|
+
- [Specs](specs.md): the standard checks every game runs, and writing rules specs
|
|
12
|
+
- [Nested decisions with FrameQueue](frame-queue.md): attacks and responses, triggered effects, decisions inside decisions
|
|
13
|
+
- [Yes/no rulings with the referee](referee.md): rulings on free-text conditions by a second model
|
|
14
|
+
|
|
15
|
+
The source is the final word: hooks and their defaults are in [`lib/plai_testr/session/runner.rb`](../../lib/plai_testr/session/runner.rb), and the example games in [`templates/examples/`](../../templates/examples) use most of what's described here.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Debrief
|
|
2
|
+
|
|
3
|
+
After a model game, each player answers the game's `debrief_questions`, and the answers go in the report. Small local models can't reliably recall a long game from its log, and they invent what they can't recall, so the gem gives them a summary of the facts first.
|
|
4
|
+
|
|
5
|
+
## The prompt
|
|
6
|
+
|
|
7
|
+
The gem builds the debrief prompt in a fixed order:
|
|
8
|
+
|
|
9
|
+
1. **`debrief_summary(player)`**, built by the gem (don't override it):
|
|
10
|
+
- **Outcome**: winner, end reason, final scores
|
|
11
|
+
- **Game facts**: `debrief_facts(player)`, shown as YAML
|
|
12
|
+
- **Your decisions**: how many the model made, how often it chose the bot's obvious move, fallbacks
|
|
13
|
+
- **Your plan as it changed**: the player's own `plan` each time it changed, with the decision and step
|
|
14
|
+
2. **`debrief_view_for(player)`**: the final position. Default: the result, final scores, `view_text_for` and the player's visible log.
|
|
15
|
+
|
|
16
|
+
The model is told to treat the summary as correct, to cite the round of any event it mentions, and to say when something isn't in the facts rather than guess.
|
|
17
|
+
|
|
18
|
+
The debrief uses the player model without thinking, unless `play` is given `--debrief-model` or `--debrief-think`.
|
|
19
|
+
|
|
20
|
+
## `debrief_facts`
|
|
21
|
+
|
|
22
|
+
Returns a Hash with plain-language keys:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
def debrief_facts(_player)
|
|
26
|
+
{
|
|
27
|
+
'how_it_ended' => "Player 3 reached 100 points after 9 hands",
|
|
28
|
+
'players' => players.to_h do |p|
|
|
29
|
+
[p, { 'final_score' => pstate(p).score, 'hearts_taken' => data.dig('tally', p, 'hearts'), 'moons_shot' => data.dig('tally', p, 'moons') }]
|
|
30
|
+
end
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **Count what the questions ask about.** Per player: final score and points by source, counts of key actions, races or majorities and who won them; and who ended the game, when and how.
|
|
36
|
+
- **Facts, not conclusions.** "Player 3 had 4 Candy Corn, the most" is a fact; "Candy Corn was the strongest card" answers the question for the model.
|
|
37
|
+
- **Compute only from saved state** (`data`, player state). `debrief <session_id>` rebuilds the facts from the checkpoint, possibly after the code changed, so instance variables are gone. Keep the counters the facts need in `data` as the game is played; `tally(player, key)` is made for this.
|
|
38
|
+
|
|
39
|
+
Without `debrief_facts`, the facts are `botrun_stats`. `check!`, run by every game command, warns when a game defines neither.
|
|
40
|
+
|
|
41
|
+
## `debrief_questions`
|
|
42
|
+
|
|
43
|
+
Ask about the player's own experience, pointed at something the facts or its own plans can answer:
|
|
44
|
+
|
|
45
|
+
- *Which of your decisions mattered most, and why? Name the round.*
|
|
46
|
+
- *Which of your plays scored less than you expected? What happened instead?*
|
|
47
|
+
- *Which rule was unclear or confusing when you had to use it? Name the situation.*
|
|
48
|
+
|
|
49
|
+
Avoid open questions with nothing behind them ("what felt pointless?"): models fill them with invented events. `debrief_questions(player)` takes the player, so questions can differ by persona.
|
|
50
|
+
|
|
51
|
+
## Checking and re-asking
|
|
52
|
+
|
|
53
|
+
- Preflight builds every player's debrief prompt after its lint games, and the [standard checks](specs.md) build them too, so a broken hook shows up before a model game.
|
|
54
|
+
- `debrief <session_id> [--model M] [--think]` re-asks a finished game's players and rebuilds the report, keeping earlier answers under `debrief_history` in the checkpoint. Use it to try better facts or questions, or a bigger model, on the same game without replaying it.
|
|
55
|
+
- Debrief answers are model opinions. Check a claim against the facts or the turn log before acting on it.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Personas and bots
|
|
2
|
+
|
|
3
|
+
A **persona** is a play style. For model players it is strategy text in the system prompt; for bots, a set of parameters the bot rules read. Each player gets one persona per game, so a botrun or a set of model games can compare styles. A **bot** is the game's scripted decision-maker, `bot_choice`.
|
|
4
|
+
|
|
5
|
+
Both are optional: a game without personas plays everyone the same way, and a game without a bot can't botrun (and model games fall back to random moves when a model never answers validly).
|
|
6
|
+
|
|
7
|
+
## `data/personas.yml`
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
basic_strategy: &basics
|
|
11
|
+
pass_dangerous: Pass away the Q♠, high spades (A♠, K♠) and high hearts unless you have many low spades to protect them.
|
|
12
|
+
duck: Play just under the winning card to avoid taking tricks that carry points.
|
|
13
|
+
|
|
14
|
+
personas:
|
|
15
|
+
safe_player:
|
|
16
|
+
<<: *basics
|
|
17
|
+
goal: Take as few points as you can in every hand. Never win a trick you could lose.
|
|
18
|
+
moon_shooter:
|
|
19
|
+
<<: *basics
|
|
20
|
+
goal: Watch for a hand strong enough to take every heart and the Q♠. Go for it when you can; otherwise play safe.
|
|
21
|
+
pass_dangerous: Keep high hearts and the Q♠ when your hand is strong enough to take every trick.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `basic_strategy` is advice every player gets. `<<: *basics` merges it into each persona.
|
|
25
|
+
- Each key under `personas` is a persona's name, used with `--personas`. Every field under it is shown to the model. Reusing a `basic_strategy` key overrides that item; a new key adds one.
|
|
26
|
+
- The gem reads only `basic_strategy` and `personas`. Other top-level keys are yours, for a custom `persona_for`.
|
|
27
|
+
- The default `persona_for(player)` builds the system prompt's strategy text from this file; `display_name` shows the persona's name, title-cased, in the console and reports.
|
|
28
|
+
|
|
29
|
+
Personas are assigned per game: `--personas a,b` in seat order (`*` for random), otherwise at random from the seed. `botrun --personas all` cycles every arrangement of personas across seats, and needs at least as many personas as seats.
|
|
30
|
+
|
|
31
|
+
Personas shape debrief answers as well as play: a player told to favor a card will praise it. Compare debriefs across personas before concluding anything.
|
|
32
|
+
|
|
33
|
+
## `data/bots.yml`
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
bot_strategy: &bot_defaults
|
|
37
|
+
pass_dangerous: true
|
|
38
|
+
win_clean_tricks: true
|
|
39
|
+
|
|
40
|
+
bots:
|
|
41
|
+
safe_player:
|
|
42
|
+
<<: *bot_defaults
|
|
43
|
+
win_clean_tricks: false
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`bot_strategy` holds the defaults; `bots` holds per-persona overrides, keyed by the persona names in `personas.yml` (an unknown name is an error). The bot reads them with `bot_strategy_for(player)`. The model never sees this file.
|
|
47
|
+
|
|
48
|
+
## `bot_choice`
|
|
49
|
+
|
|
50
|
+
`bot_choice(player, moves, pick: nil)` lives in `lib/<name>/bot.rb`, a module included in the runner. It gets the checked menu and returns `nil` (no rule applies) or:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
{ 'choice' => 2, 'rule' => 'duck', 'obvious' => false } # choice is 1-based
|
|
54
|
+
{ 'choices' => [1, 4, 5], 'rule' => 'pass_dangerous', 'obvious' => false } # pick turns
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **`rule`** names the idea behind the move (`duck`, `dump_queen`, `shed_deadwood`). Botrun reports count how often each rule was used; `none` means the bot had no rule and took option 1.
|
|
58
|
+
- **`obvious`** marks a move that is never worse than any alternative. `--mode hybrid` plays obvious moves without asking the model, and every model game's report scores the model against them (*Bot agreement*). Mark a move obvious only when it really is; a wrong mark skews both.
|
|
59
|
+
- Find options by pattern-matching their actions, and return a 1-based choice: `i = moves.index { |m| m[:action] in { play: { value: ^uid } } }` then `{ 'choice' => i + 1, ... }`.
|
|
60
|
+
|
|
61
|
+
Keep the bot simple and readable. It is a baseline for balance numbers and a crash test, not a strong player. Bot results describe the bot's strategy; confirm a surprising one with a few model games.
|
|
62
|
+
|
|
63
|
+
### Where the bot is used
|
|
64
|
+
|
|
65
|
+
- **Botruns and `--mode bot`**: the bot makes every decision.
|
|
66
|
+
- **`--mode hybrid`**: the bot makes its obvious decisions; the model makes the rest.
|
|
67
|
+
- **Model games**: the turn log records the bot's choice next to the model's, for *Bot agreement*. When the model never answers validly, the bot's choice is played.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Reports
|
|
2
|
+
|
|
3
|
+
Every model game and botrun ends in a markdown report. The gem's sections cover what every game has; a game adds its own for what matters in that game.
|
|
4
|
+
|
|
5
|
+
## Session reports
|
|
6
|
+
|
|
7
|
+
Each model game's report (`reports/<session_id>.md`) has a header (session, rules version, players and personas, model and settings, seed, decisions, wall time), then the result, model reliability, bot agreement, speed, warnings, the debrief, and the turn log.
|
|
8
|
+
|
|
9
|
+
`report_sections` adds sections after the gem's own, as an array of markdown strings:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
def report_sections
|
|
13
|
+
rows = players.map { |p| "| #{p} | #{pstate(p).score} | #{data.dig('tally', p, 'hearts')} |" }
|
|
14
|
+
["## Hearts\n\n| Player | Score | Hearts taken |\n|---|---|---|\n#{rows.join("\n")}"]
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Tables are aligned automatically. Return `false` from `report_debrief?` to leave out the gem's debrief section and render the answers yourself in `report_sections` (from `data['debrief']`).
|
|
19
|
+
|
|
20
|
+
Reports are rebuilt from saved state (by `resume` and `debrief`), so compute sections from `data`, not instance variables.
|
|
21
|
+
|
|
22
|
+
## Botrun reports
|
|
23
|
+
|
|
24
|
+
A botrun keeps a small result for each game: seed, winner, personas, end reason, decisions, bot rules used, warnings. The report summarizes them: endings, wins by persona and seat, matchups, game length, bot rules, and seeds worth replaying.
|
|
25
|
+
|
|
26
|
+
To add game-specific numbers:
|
|
27
|
+
|
|
28
|
+
1. Return them from `botrun_stats` in the runner, as JSON-safe values. It is called once at the end of each game.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
def botrun_stats = { 'hands' => data['hand'], 'taken' => totals }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. Subclass `PlaiTestr::Reporter::BotrunReport` and return extra sections from `extra_sections`. Each game's numbers are in `@played` (games that didn't crash), under `'stats'`. `table(title, headers, rows, note)`, `pct` and `percentile` are available.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
module Hearts
|
|
38
|
+
module Reporter
|
|
39
|
+
class BotrunReport < PlaiTestr::Reporter::BotrunReport
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def extra_sections
|
|
43
|
+
counts = @played.filter_map { |r| r.dig('stats', 'hands') }.sort
|
|
44
|
+
[table('## Hands per game', %w[Mean Max], ["| #{counts.sum.fdiv(counts.length).round(1)} | #{counts.max} |"])]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
3. Register it: `PlaiTestr.register('hearts', ..., botrun_report_class: Hearts::Reporter::BotrunReport)`.
|
|
52
|
+
|
|
53
|
+
`botrun_stats` doubles as the debrief facts when a game has no `debrief_facts`.
|
|
54
|
+
|
|
55
|
+
## Model comparison
|
|
56
|
+
|
|
57
|
+
`compare` reads every finished session and writes one row per model and host. To add columns, subclass `PlaiTestr::Reporter::ModelComparison`, override `extra_columns` (a list of `[header, ->(sessions) { value }]` pairs; each session is a hash with `:state`, its saved game state, plus `:turns` and `:seed`) and `notes` (footnotes), and register it with `comparison_class:`.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Rules and components
|
|
2
|
+
|
|
3
|
+
A game's `data/` folder describes the game: what the model players read, the numbers the engine uses, and the components.
|
|
4
|
+
|
|
5
|
+
## `data/rules.md`
|
|
6
|
+
|
|
7
|
+
The rules text the model players read every turn (the default `rules_text_for`). Write it for a player, not a programmer.
|
|
8
|
+
|
|
9
|
+
- End it with a **Rulings** section: rules questions settled with the designer, dated, so models and future readers see them.
|
|
10
|
+
- Add a **Simplifications** section for any rule the harness deliberately simplifies (see `games/gin_rummy`).
|
|
11
|
+
- Keep open questions and engine choices the designer hasn't confirmed out of `rules.md`, in `games/<name>/NOTES.md`, until they're settled.
|
|
12
|
+
- If the rules are long, override `rules_text_for` to send only the sections the current step needs, calling `super` for the file.
|
|
13
|
+
|
|
14
|
+
## `data/rules.json`
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"game": {
|
|
19
|
+
"name": "Hearts",
|
|
20
|
+
"version": "1.0.0",
|
|
21
|
+
"description": "A 4-player trick-taking card game. ...",
|
|
22
|
+
"player_count": { "min": 4, "max": 4, "default": 4 }
|
|
23
|
+
},
|
|
24
|
+
"points_to_end": 100
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `game.name` and `game.version` are required. The version appears in every report, so bump it when the rules change.
|
|
29
|
+
- `game.description` is what the model players read first.
|
|
30
|
+
- `game.player_count` sets the allowed `--players` range and the default. The [standard checks](specs.md) play every count in the range.
|
|
31
|
+
- Anything else is the game's: points to win, hand sizes, limits. The runner reads them from `@rules` (`@rules['points_to_end']`), so tuning a number needs no code change.
|
|
32
|
+
|
|
33
|
+
The file is validated when the game loads, and errors name the field.
|
|
34
|
+
|
|
35
|
+
## Components
|
|
36
|
+
|
|
37
|
+
Card lists, tiles and other components go in `data/components/`, one file per component, named after the file:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
PlaiTestr::Deck.build(@rules, component: 'cards') # data/components/cards.csv (or .yml, .json)
|
|
41
|
+
PlaiTestr::Deck.build(@rules) # the first component
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`Deck.build` returns a list of hashes with string keys, one per copy, each with its own `uid` (1, 2, 3, … in file order). Shuffle it with `@rng`: `Deck.build(@rules).shuffle(random: @rng)`.
|
|
45
|
+
|
|
46
|
+
A component file is a list of records:
|
|
47
|
+
|
|
48
|
+
- **CSV**: a header row, then one row per entry. Numbers become numbers; empty cells are left out.
|
|
49
|
+
- **YAML or JSON**: a list of hashes.
|
|
50
|
+
|
|
51
|
+
A `copies` field makes that many copies. A malformed file fails to load with the file and entry named. Both example games read `data/components/cards.csv`.
|
|
52
|
+
|
|
53
|
+
A component can also be inline under `components` in `rules.json` (`{ "name": "deck", "type": "card_deck", "values": [...] }`), but files are easier to edit and review.
|
|
54
|
+
|
|
55
|
+
### Converting a designer's files
|
|
56
|
+
|
|
57
|
+
PlAI Testr reads only this format. Converting a designer's own files (spreadsheets, YAML with metadata, exports from card tools) is the game's job:
|
|
58
|
+
|
|
59
|
+
- Keep the fields the engine or the players need; leave out print layout, art notes and other metadata.
|
|
60
|
+
- If the source keeps changing, write a small rerunnable script in the game folder (`games/<name>/import.rb`) that rebuilds the file, and a spec that fails when the copy is behind.
|
|
61
|
+
- Check the counts (cards per type, total) after converting.
|