plai_testr 0.1.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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +176 -0
  5. data/bin/plai_testr +5 -0
  6. data/docs/frame_queue.md +123 -0
  7. data/docs/referee.md +59 -0
  8. data/lib/plai_testr/agents/ollama_client.rb +177 -0
  9. data/lib/plai_testr/agents/player_agent.rb +216 -0
  10. data/lib/plai_testr/agents/referee_agent.rb +56 -0
  11. data/lib/plai_testr/botrun.rb +60 -0
  12. data/lib/plai_testr/cli.rb +190 -0
  13. data/lib/plai_testr/config.rb +39 -0
  14. data/lib/plai_testr/deck.rb +11 -0
  15. data/lib/plai_testr/errors.rb +9 -0
  16. data/lib/plai_testr/event_log.rb +24 -0
  17. data/lib/plai_testr/game.rb +50 -0
  18. data/lib/plai_testr/generator.rb +50 -0
  19. data/lib/plai_testr/model_settings.rb +25 -0
  20. data/lib/plai_testr/names.rb +7 -0
  21. data/lib/plai_testr/personas.rb +97 -0
  22. data/lib/plai_testr/preflight.rb +70 -0
  23. data/lib/plai_testr/reporter/botrun_report.rb +141 -0
  24. data/lib/plai_testr/reporter/model_comparison.rb +104 -0
  25. data/lib/plai_testr/reporter/report_builder.rb +154 -0
  26. data/lib/plai_testr/reporter/think_log.rb +32 -0
  27. data/lib/plai_testr/rules_loader.rb +40 -0
  28. data/lib/plai_testr/session/frame_queue.rb +47 -0
  29. data/lib/plai_testr/session/manager.rb +77 -0
  30. data/lib/plai_testr/session/runner.rb +555 -0
  31. data/lib/plai_testr/state/base.rb +13 -0
  32. data/lib/plai_testr/state/game_state.rb +102 -0
  33. data/lib/plai_testr/state/player_state.rb +61 -0
  34. data/lib/plai_testr/version.rb +3 -0
  35. data/lib/plai_testr.rb +61 -0
  36. data/templates/app/.gitignore +3 -0
  37. data/templates/app/.rspec +3 -0
  38. data/templates/app/AGENTS.md +158 -0
  39. data/templates/app/CLAUDE.md +1 -0
  40. data/templates/app/Gemfile.erb +7 -0
  41. data/templates/app/README.md.erb +91 -0
  42. data/templates/app/config/plai_testr.rb +15 -0
  43. data/templates/app/spec/spec_helper.rb +25 -0
  44. data/templates/examples/gin_rummy/NOTES.md +12 -0
  45. data/templates/examples/gin_rummy/data/bots.yml +18 -0
  46. data/templates/examples/gin_rummy/data/personas.yml +26 -0
  47. data/templates/examples/gin_rummy/data/rules.json +76 -0
  48. data/templates/examples/gin_rummy/data/rules.md +44 -0
  49. data/templates/examples/gin_rummy/lib/gin_rummy/bot.rb +34 -0
  50. data/templates/examples/gin_rummy/lib/gin_rummy/game.rb +8 -0
  51. data/templates/examples/gin_rummy/lib/gin_rummy/melds.rb +51 -0
  52. data/templates/examples/gin_rummy/lib/gin_rummy/session/runner.rb +197 -0
  53. data/templates/examples/gin_rummy/lib/gin_rummy/state/game_state.rb +19 -0
  54. data/templates/examples/gin_rummy/lib/gin_rummy.rb +9 -0
  55. data/templates/examples/gin_rummy/spec/melds_spec.rb +26 -0
  56. data/templates/examples/gin_rummy/spec/session/runner_spec.rb +105 -0
  57. data/templates/examples/hearts/data/bots.yml +22 -0
  58. data/templates/examples/hearts/data/personas.yml +27 -0
  59. data/templates/examples/hearts/data/rules.json +73 -0
  60. data/templates/examples/hearts/data/rules.md +30 -0
  61. data/templates/examples/hearts/lib/hearts/bot.rb +53 -0
  62. data/templates/examples/hearts/lib/hearts/game.rb +16 -0
  63. data/templates/examples/hearts/lib/hearts/reporter/botrun_report.rb +29 -0
  64. data/templates/examples/hearts/lib/hearts/session/runner.rb +255 -0
  65. data/templates/examples/hearts/lib/hearts/state/game_state.rb +19 -0
  66. data/templates/examples/hearts/lib/hearts.rb +11 -0
  67. data/templates/examples/hearts/spec/session/runner_spec.rb +122 -0
  68. data/templates/game/data/bots.yml +13 -0
  69. data/templates/game/data/personas.yml +16 -0
  70. data/templates/game/data/rules.json.erb +16 -0
  71. data/templates/game/data/rules.md.erb +11 -0
  72. data/templates/game/lib/__name__/bot.rb.erb +7 -0
  73. data/templates/game/lib/__name__/session/runner.rb.erb +36 -0
  74. data/templates/game/lib/__name__/state/game_state.rb.erb +11 -0
  75. data/templates/game/lib/__name__.rb.erb +10 -0
  76. data/templates/game/spec/session/runner_spec.rb.erb +29 -0
  77. metadata +153 -0
@@ -0,0 +1,216 @@
1
+ module PlaiTestr
2
+ module Agents
3
+ class PlayerAgent
4
+ MAX_PARSE_RETRIES = 2
5
+
6
+ def initialize(name:, model:, client:, game_name:, settings: PlaiTestr.config.model_settings, think: false)
7
+ @name = name
8
+ @model = model
9
+ @client = client
10
+ @game_name = game_name
11
+ @settings = settings
12
+ @think = think
13
+ end
14
+
15
+ # Response contract for menu turns. Ollama enforces this schema via `format`.
16
+ MENU_SCHEMA = {
17
+ 'type' => 'object',
18
+ 'properties' => {
19
+ 'choice' => { 'type' => 'integer' },
20
+ 'reasoning' => { 'type' => 'string' },
21
+ 'plan' => { 'type' => 'string' }
22
+ },
23
+ 'required' => %w[choice reasoning plan]
24
+ }.freeze
25
+
26
+ PICK_SCHEMA = {
27
+ 'type' => 'object',
28
+ 'properties' => {
29
+ 'choices' => { 'type' => 'array', 'items' => { 'type' => 'integer' } },
30
+ 'reasoning' => { 'type' => 'string' },
31
+ 'plan' => { 'type' => 'string' }
32
+ },
33
+ 'required' => %w[choices reasoning plan]
34
+ }.freeze
35
+
36
+ # Menu turn: the game supplies the rules text, a human-readable view, and a numbered list
37
+ # of legal moves; the model only has to return the number of one of them.
38
+ #
39
+ # Returns { 'choice' => Integer (1-based, always valid), 'reasoning', 'plan',
40
+ # 'attempts' => Integer, 'fallback' => Boolean }.
41
+ # Invalid or out-of-range replies are re-prompted; after MAX_PARSE_RETRIES 'choice' is nil
42
+ # and 'fallback' is true, and the runner picks the move instead.
43
+ def choose(rules_text:, view_text:, labels:, plan: nil, recent: [], prompt_note: nil, persona: nil, think: true)
44
+ messages = menu_messages(rules_text:, view_text:, labels:, plan:, recent:, prompt_note:, persona:)
45
+
46
+ ask(messages, MENU_SCHEMA, think, plan) do |parsed|
47
+ choice = parsed['choice'].to_s =~ /\A\d+\z/ ? parsed['choice'].to_i : nil
48
+ next { 'choice' => choice } if choice&.between?(1, labels.length)
49
+
50
+ "That was not a valid choice. Reply with JSON whose \"choice\" is a number from 1 to #{labels.length}."
51
+ end
52
+ end
53
+
54
+ def choose_many(rules_text:, view_text:, labels:, min:, max:, plan: nil, recent: [], prompt_note: nil, persona: nil, think: true)
55
+ rule = min == max ? "exactly #{min}" : "between #{min} and #{max}"
56
+ note = [prompt_note, "Choose #{rule} different options. Reply with JSON: {\"choices\": [<numbers>], \"reasoning\": \"...\", \"plan\": \"...\"}"].compact.join("\n")
57
+ messages = menu_messages(rules_text:, view_text:, labels:, plan:, recent:, prompt_note: note, persona:)
58
+
59
+ ask(messages, PICK_SCHEMA, think, plan) do |parsed|
60
+ choices = Array(parsed['choices']).map { |c| Integer(c, exception: false) }
61
+ valid = choices.none?(&:nil?) && choices.uniq.length == choices.length &&
62
+ choices.length.between?(min, max) && choices.all? { |c| c.between?(1, labels.length) }
63
+ next { 'choices' => choices } if valid
64
+
65
+ "That was not valid. \"choices\" must list #{rule} different numbers from 1 to #{labels.length}."
66
+ end
67
+ end
68
+
69
+ def prompt_tokens(**prompt)
70
+ @client.chat(model: @model, messages: menu_messages(**prompt), **chat_options(true).merge(num_predict: 1))['prompt_eval_count'].to_i
71
+ end
72
+
73
+ def context_size = @think ? @settings[:long_context_size] : @settings[:context_size]
74
+
75
+ # Asked once per player after the game, instead of collecting design notes every turn
76
+ # (per-turn notes were either filler or, from larger models, always empty).
77
+ #
78
+ # Returns [{ 'question' => String, 'answer' => String }] in question order. A missing or
79
+ # unparseable answer becomes an empty string; the debrief never raises on bad output.
80
+ def debrief(rules_text:, view_text:, questions:, persona: nil)
81
+ keys = questions.each_index.map { |i| "q#{i + 1}" }
82
+ schema = { 'type' => 'object', 'properties' => keys.to_h { |k| [k, { 'type' => 'string' }] }, 'required' => keys }
83
+ messages = [
84
+ { role: 'system', content: debrief_system_prompt(rules_text, persona) },
85
+ { role: 'user', content: debrief_prompt(view_text, questions, keys) }
86
+ ]
87
+ options = { temperature: @settings[:temperature], think: false, num_ctx: @settings[:long_context_size], num_predict: @settings[:debrief_budget] }
88
+
89
+ parsed = {}
90
+ (MAX_PARSE_RETRIES + 1).times do
91
+ response = @client.chat(model: @model, messages: messages, format: schema, **options)
92
+ parsed = parse_menu_response(response.dig('message', 'content').to_s)
93
+ break if parsed.is_a?(Hash) && keys.any? { |k| !parsed[k].to_s.strip.empty? }
94
+ end
95
+ parsed = {} unless parsed.is_a?(Hash)
96
+
97
+ questions.zip(keys).map { |q, k| { 'question' => q, 'answer' => parsed[k].to_s.strip } }
98
+ end
99
+
100
+ def menu_messages(rules_text:, view_text:, labels:, plan: nil, recent: [], prompt_note: nil, persona: nil)
101
+ [
102
+ { role: 'system', content: menu_system_prompt(rules_text, persona) },
103
+ { role: 'user', content: menu_turn_prompt(view_text, labels, plan, recent, prompt_note) }
104
+ ]
105
+ end
106
+
107
+ private
108
+
109
+ def ask(messages, schema, think, plan)
110
+ context_full = false
111
+
112
+ (MAX_PARSE_RETRIES + 1).times do |i|
113
+ response = @client.chat(model: @model, messages: messages, format: schema, **chat_options(think))
114
+ context_full ||= response['context_full'] == true
115
+ content = response.dig('message', 'content').to_s
116
+ parsed = parse_menu_response(content)
117
+ verdict = parsed.is_a?(Hash) ? yield(parsed) : 'That was not valid JSON. Reply with JSON only.'
118
+ return parsed.slice('reasoning', 'plan').merge(verdict, 'attempts' => i + 1, 'fallback' => false, 'context_full' => context_full) if verdict.is_a?(Hash)
119
+
120
+ messages += [
121
+ { role: 'assistant', content: content[0, 300] },
122
+ { role: 'user', content: verdict }
123
+ ]
124
+ end
125
+
126
+ { 'choice' => nil, 'choices' => nil, 'reasoning' => nil, 'plan' => plan,
127
+ 'attempts' => MAX_PARSE_RETRIES + 1, 'fallback' => true, 'context_full' => context_full }
128
+ end
129
+
130
+ def chat_options(think)
131
+ base = { temperature: @settings[:temperature], num_ctx: context_size }
132
+ return base.merge(think: false, num_predict: @settings[:menu_budget]) unless @think && think
133
+
134
+ base.merge(think: true, num_predict: @settings[:think_budget])
135
+ end
136
+
137
+ def debrief_system_prompt(rules_text, persona)
138
+ <<~PROMPT
139
+ You are #{@name}. You just finished a playtest of the tabletop game #{@game_name}.
140
+ Now you are a playtester giving the game designer honest feedback.
141
+
142
+ #{rules_text}
143
+ #{"\nTHE STRATEGY YOU PLAYED: #{persona}\n" if persona}
144
+
145
+ HOW TO ANSWER:
146
+ - Be specific: name actual components, rules, and decisions from your game.
147
+ - Stick to the facts under YOUR FINAL POSITION. Describe a card by the text given there, not from memory,
148
+ and check the log before saying something did or did not happen.
149
+ - 1-3 sentences per answer. Criticism is welcome; the designer wants to find problems.
150
+ - If nothing applies, answer "nothing" rather than inventing a problem.
151
+
152
+ Reply with JSON only.
153
+ PROMPT
154
+ end
155
+
156
+ def debrief_prompt(view_text, questions, keys)
157
+ list = questions.zip(keys).map { |q, k| "#{k}: #{q}" }.join("\n")
158
+
159
+ <<~PROMPT
160
+ YOUR FINAL POSITION:
161
+ #{view_text}
162
+
163
+ QUESTIONS:
164
+ #{list}
165
+
166
+ Reply with JSON only: {#{keys.map { |k| "\"#{k}\": \"<answer>\"" }.join(', ')}}
167
+ PROMPT
168
+ end
169
+
170
+ def menu_system_prompt(rules_text, persona)
171
+ <<~PROMPT
172
+ You are #{@name}, a player in a playtest of the tabletop game #{@game_name}. Play to win.
173
+
174
+ #{rules_text}
175
+ #{"\nYOUR STRATEGY (follow it in every decision): #{persona}\n" if persona}
176
+
177
+ HOW TO PLAY EACH TURN:
178
+ - You get YOUR VIEW (only what you are allowed to know) and a numbered list of LEGAL MOVES.
179
+ - Choose ONLY a number from LEGAL MOVES. Never invent cards or moves.
180
+ - Do not assume anything about hidden cards beyond what YOUR VIEW says.
181
+ - Keep a one-line plan and update it; your previous plan is shown to you.
182
+ - RECENT EVENTS lines starting with BOT: or RNG: were moves made for you automatically, not by you.
183
+
184
+ Reply with JSON only: {"choice": <number>, "reasoning": "<1-3 sentences>", "plan": "<one line>"}
185
+ PROMPT
186
+ end
187
+
188
+ def menu_turn_prompt(view_text, labels, plan, recent, prompt_note)
189
+ moves = labels.each_with_index.map { |label, i| "#{i + 1}. #{label}" }.join("\n")
190
+
191
+ <<~PROMPT
192
+ YOUR VIEW:
193
+ #{view_text}
194
+
195
+ RECENT EVENTS:
196
+ #{recent.empty? ? '(none yet)' : recent.map { |r| "- #{r}" }.join("\n")}
197
+
198
+ YOUR PLAN SO FAR: #{plan.to_s.empty? ? '(none yet)' : plan}
199
+ #{"\nNOTE: #{prompt_note}\n" if prompt_note}
200
+ LEGAL MOVES:
201
+ #{moves}
202
+
203
+ Reply with JSON only.
204
+ PROMPT
205
+ end
206
+
207
+ def parse_menu_response(content)
208
+ cleaned = content.gsub(/<think>.*?<\/think>/m, '').strip
209
+ json = cleaned[/\{.*\}/m]
210
+ json && JSON.parse(json)
211
+ rescue JSON::ParserError
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,56 @@
1
+ module PlaiTestr
2
+ module Agents
3
+ class RefereeAgent
4
+ MAX_PARSE_RETRIES = 2
5
+ TEMPERATURE = 0.1
6
+
7
+ RULING_SCHEMA = {
8
+ 'type' => 'object',
9
+ 'properties' => {
10
+ 'met' => { 'type' => 'boolean' },
11
+ 'why' => { 'type' => 'string' }
12
+ },
13
+ 'required' => %w[met why]
14
+ }.freeze
15
+
16
+ def initialize(model:, client:, game_name:, settings: PlaiTestr.config.model_settings)
17
+ @model = model
18
+ @client = client
19
+ @game_name = game_name
20
+ @options = { temperature: TEMPERATURE, think: true, num_ctx: settings[:context_size], num_predict: settings[:referee_budget] }
21
+ end
22
+
23
+ def ruling(question:, facts:)
24
+ messages = [
25
+ { role: 'system', content: system_prompt },
26
+ { role: 'user', content: "QUESTION: #{question}\n\nFACTS:\n#{facts}\n\nReply with JSON only: {\"met\": true|false, \"why\": \"<1 sentence>\"}" }
27
+ ]
28
+
29
+ (MAX_PARSE_RETRIES + 1).times do
30
+ response = @client.chat(model: @model, messages: messages, format: RULING_SCHEMA, **@options)
31
+ parsed = parse(response.dig('message', 'content').to_s)
32
+ return { 'met' => parsed['met'], 'why' => parsed['why'].to_s, 'fallback' => false } if parsed && [true, false].include?(parsed['met'])
33
+ end
34
+
35
+ { 'met' => false, 'why' => 'fallback: no valid ruling after retries', 'fallback' => true }
36
+ end
37
+
38
+ private
39
+
40
+ def system_prompt
41
+ <<~PROMPT
42
+ You are the rules referee for #{@game_name}.
43
+ You answer ONE yes/no question about whether a rule or card condition is met.
44
+ Use only the FACTS given. Do not assume anything that is not stated.
45
+ PROMPT
46
+ end
47
+
48
+ def parse(content)
49
+ json = content.gsub(/<think>.*?<\/think>/m, '')[/\{.*\}/m]
50
+ json && JSON.parse(json)
51
+ rescue JSON::ParserError
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,60 @@
1
+ module PlaiTestr
2
+ class Botrun
3
+ attr_reader :planned
4
+
5
+ def initialize(game:, players:, seeds:, personas: nil, out: nil)
6
+ @game = game
7
+ @players = players
8
+ @seeds = seeds
9
+ @personas = personas
10
+ @out = out
11
+ @planned = seeds.length
12
+ end
13
+
14
+ def run
15
+ catalog = @game.personas
16
+ catalog.check_request(@personas, players: @players.length, all: true)
17
+ pairings = all_pairings? ? catalog.pairings(@players.length) : []
18
+ raise PersonaError, "--personas all needs at least #{@players.length} personas to pair up" if all_pairings? && pairings.empty?
19
+
20
+ results = []
21
+ with_signal_traps do
22
+ @seeds.each_with_index do |seed, i|
23
+ break if @stopping
24
+
25
+ results << play(seed, all_pairings? ? pairings[i % pairings.length] : @personas)
26
+ yield results.length if block_given?
27
+ end
28
+ end
29
+ results
30
+ end
31
+
32
+ def stopped_early? = @stopping == true
33
+
34
+ private
35
+
36
+ def all_pairings? = @personas == [Personas::ALL]
37
+
38
+ def play(seed, personas)
39
+ game = @game.runner_class.new(game: @game, players: @players, seed: seed, personas: personas, mode: 'bot', persist: false,
40
+ out: @out || StringIO.new, echo_warnings: false)
41
+ game.play_out
42
+ game.botrun_result
43
+ rescue StandardError => e
44
+ { 'seed' => seed, 'personas_requested' => personas, 'error' => "#{e.class}: #{e.message}" }
45
+ end
46
+
47
+ def with_signal_traps
48
+ previous = %w[INT TERM].to_h do |signal|
49
+ [signal, trap(signal) do
50
+ exit!(130) if @stopping
51
+ @stopping = true
52
+ $stderr.puts "\n\nInterrupted — finishing this game, then reporting. Press Ctrl+C again to quit immediately."
53
+ end]
54
+ end
55
+ yield
56
+ ensure
57
+ previous&.each { |signal, handler| trap(signal, handler || 'DEFAULT') }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,190 @@
1
+ module PlaiTestr
2
+ class CLI
3
+ COMMANDS = %w[play botrun resume preflight compare list].freeze
4
+ GAME_COMMANDS = %w[play botrun resume preflight].freeze
5
+ PROGRESS_EVERY = 50
6
+
7
+ def self.start(argv)
8
+ $stdout.sync = true
9
+ PlaiTestr.load_config
10
+
11
+ case argv
12
+ in ['new', dir]
13
+ Generator.new.app(dir)
14
+ puts "\nNext:\n cd #{dir}\n bundle install\n bundle exec plai_testr hearts botrun --games 100\n bundle exec plai_testr hearts play"
15
+ in ['generate', 'game', name]
16
+ Generator.new.game(name)
17
+ puts "\nNext: describe the game in games/#{name}/data/, implement games/#{name}/lib/#{name}/session/runner.rb, then run bundle exec rspec games/#{name}"
18
+ in [String => name, *rest] unless name.start_with?('-')
19
+ new(PlaiTestr.load(name)).run(rest)
20
+ else
21
+ abort <<~USAGE
22
+ Usage:
23
+ #{File.basename($PROGRAM_NAME)} new <directory> Create a playtesting app with example games
24
+ #{File.basename($PROGRAM_NAME)} generate game <name> Add a game skeleton under games/<name>
25
+ #{File.basename($PROGRAM_NAME)} <game> <command> [options] Play a game (games: #{PlaiTestr.names.join(', ')})
26
+ USAGE
27
+ end
28
+ rescue Error => e
29
+ raise if e.is_a?(MoveError) || ENV['PLAI_TESTR_DEBUG']
30
+
31
+ abort e.message
32
+ end
33
+
34
+ def initialize(game)
35
+ @game = game
36
+ @options = {
37
+ player: PlaiTestr.config.player_model,
38
+ referee: PlaiTestr.config.referee_model,
39
+ think: false,
40
+ mode: 'llm',
41
+ games: 100,
42
+ seeds: [],
43
+ preflight: true
44
+ }
45
+
46
+ PlaiTestr.sessions_dir = game.sessions_dir
47
+ PlaiTestr.reports_dir = game.reports_dir
48
+ end
49
+
50
+ def run(argv)
51
+ args = parser.parse(argv)
52
+ command = args.first
53
+ return puts(parser.help) unless COMMANDS.include?(command)
54
+
55
+ @game.check!.each { |note| warn " [warning] #{note}" } if GAME_COMMANDS.include?(command)
56
+
57
+ case command
58
+ when 'play' then play
59
+ when 'botrun' then botrun
60
+ when 'resume' then resume(args[1])
61
+ when 'preflight' then Preflight.new(game: @game, players: seats, models: models, personas: @options[:personas]).run(@options[:seed] || 1)
62
+ when 'compare' then compare
63
+ when 'list' then list
64
+ end
65
+ rescue OptionParser::ParseError => e
66
+ raise UsageError, e.message
67
+ end
68
+
69
+ private
70
+
71
+ def models = { player: @options[:player], referee: @options[:referee], think: @options[:think] }
72
+
73
+ def parser
74
+ @parser ||= OptionParser.new do |o|
75
+ o.banner = <<~USAGE
76
+ #{@game.name}: automated playtesting
77
+
78
+ Commands:
79
+ play [--seed N] [--model M] [--think] [--mode M] [--players N] [--personas A,B] [--referee M] Play one game
80
+ botrun [--games N | --seeds 1,2,3] [--players N] [--personas all|A,B] Play many games with bot rules only (no model), then report
81
+ resume <session_id> Resume an interrupted session
82
+ preflight Check Ollama, pull and warm up the models, measure the prompt, lint with bot games
83
+ compare [--since ID] Compare completed games by model and host
84
+ list List sessions
85
+
86
+ Options:
87
+ USAGE
88
+ o.on('--seed N', Integer, 'play: seed for shuffles and dice (default: random 1-100, printed)') { |v| @options[:seed] = v }
89
+ o.on('--seeds LIST', Array, 'botrun: comma-separated seeds') { |v| @options[:seeds] = v.map { |s| Integer(s) } }
90
+ o.on('--games N', Integer, 'botrun: number of games, seeds 1..N (default 100)') { |v| @options[:games] = v }
91
+ o.on('--model M', "Ollama model for the players (default #{PlaiTestr.config.player_model})") { |v| @options[:player] = v }
92
+ o.on('--mode M', Session::Runner::MODES, 'llm (default): the model decides; hybrid: bot rules play obvious moves; bot: no model') { |v| @options[:mode] = v }
93
+ o.on('--think', 'Let the player model think before each choice (slower; stronger play)') { @options[:think] = true }
94
+ o.on('--referee M', 'Ollama model for yes/no rulings (only for games that ask for them)') { |v| @options[:referee] = v }
95
+ o.on('--players N', Integer, 'Number of players, within the game\'s player_count (default: the game\'s default)') { |v| @options[:player_count] = v }
96
+ o.on('--personas LIST', Array, 'One persona per player, comma-separated, * for random (default: random); botrun also takes "all"') { |v| @options[:personas] = v }
97
+ o.on('--skip-preflight', 'Skip the Ollama checks and the lint games') { @options[:preflight] = false }
98
+ o.on('--since ID', 'compare: only sessions whose id sorts at or after ID (e.g. 20261011-170000)') { |v| @options[:since] = v }
99
+ o.on('--quiet', 'Show only a progress line and the result') { @options[:quiet] = true }
100
+ o.on('--verbose', 'Also show the raw model output and timings (play), or every game (botrun)') { @options[:verbose] = true }
101
+ end
102
+ end
103
+
104
+ def seats
105
+ range = @game.rules.dig('game', 'player_count')
106
+ count = @options[:player_count] || range&.dig('default')
107
+ return @game.players unless count
108
+ raise UsageError, "#{@game.name}: #{count} players is outside #{range['min']}-#{range['max']}" if range && !count.between?(range['min'], range['max'])
109
+
110
+ Array.new(count) { |i| "Player #{i + 1}" }
111
+ end
112
+
113
+ def preflight? = @options[:preflight] && @options[:mode] != 'bot'
114
+
115
+ def output = @options[:quiet] ? StringIO.new : $stdout
116
+
117
+ def play
118
+ seed = @options[:seed] || rand(1..100)
119
+ players = seats
120
+ @game.personas.check_request(@options[:personas], players: players.length)
121
+ Preflight.new(game: @game, players: players, models: models, personas: @options[:personas], out: output).run(seed) if preflight?
122
+
123
+ runner = @game.runner_class.new(game: @game, players: players, models: models, seed: seed, mode: @options[:mode],
124
+ personas: @options[:personas], out: output, verbose: @options[:verbose])
125
+ play_session(runner)
126
+ end
127
+
128
+ def resume(session_id)
129
+ raise UsageError, "Usage: plai_testr #{@game.name} resume <session_id>" unless session_id
130
+
131
+ runner = @game.runner_class.resume(@game, session_id, out: output, verbose: @options[:verbose])
132
+ session_models = { player: runner.data['player_model'], referee: runner.data['referee_model'] }
133
+ Preflight.new(game: @game, players: runner.game_state.players, models: session_models, out: output).ollama! if @options[:preflight] && runner.data['mode'] != 'bot'
134
+ play_session(runner)
135
+ end
136
+
137
+ def play_session(runner)
138
+ started = clock
139
+ runner.run { |decisions| progress("decision #{decisions} · #{duration(clock - started)}", decisions) if @options[:quiet] }
140
+ if @options[:quiet]
141
+ puts
142
+ puts runner.status == 'completed' ? "Game complete (seed #{runner.seed}). Report: #{runner.report_path}" : "Saved. Resume with: plai_testr #{@game.name} resume #{runner.session_id}"
143
+ end
144
+ exit 130 if runner.status == 'interrupted'
145
+ end
146
+
147
+ def botrun
148
+ seeds = @options[:seeds].empty? ? (1..@options[:games]).to_a : @options[:seeds]
149
+ started = clock
150
+ botrun = Botrun.new(game: @game, players: seats, seeds: seeds, personas: @options[:personas], out: (@options[:verbose] ? $stdout : nil))
151
+ results = botrun.run { |done| progress("#{done}/#{seeds.length} games", done) }
152
+ rules = @game.rules
153
+
154
+ markdown = @game.botrun_report_class.new(results, game: rules.dig('game', 'name'), version: rules.dig('game', 'version'),
155
+ seconds: clock - started, personas: @options[:personas], planned: seeds.length).to_markdown
156
+ FileUtils.mkdir_p(PlaiTestr.reports_dir)
157
+ path = File.join(PlaiTestr.reports_dir, "botrun-#{Time.now.strftime('%Y%m%d-%H%M%S')}.md")
158
+ File.write(path, markdown)
159
+ puts @options[:quiet] ? "\nReport: #{path}" : "\n\n#{markdown}\nSaved: #{path}"
160
+ end
161
+
162
+ def compare
163
+ markdown = @game.comparison_class.new(PlaiTestr.sessions_dir, since: @options[:since]).to_markdown
164
+ FileUtils.mkdir_p(PlaiTestr.reports_dir)
165
+ path = File.join(PlaiTestr.reports_dir, "comparison-#{Time.now.strftime('%Y%m%d-%H%M%S')}.md")
166
+ File.write(path, markdown)
167
+ puts markdown, "\nSaved: #{path}"
168
+ end
169
+
170
+ def list
171
+ sessions = Session::Manager.list
172
+ return puts 'No sessions found.' if sessions.empty?
173
+
174
+ puts format('%-24s %-12s %s', 'SESSION_ID', 'STATUS', 'SAVED_AT')
175
+ sessions.each { |s| puts format('%-24s %-12s %s', *s.values_at('session_id', 'status', 'saved_at')) }
176
+ end
177
+
178
+ def progress(text, count)
179
+ if $stdout.tty?
180
+ print "\r #{text}\e[K"
181
+ elsif (count % PROGRESS_EVERY).zero?
182
+ puts " #{text}"
183
+ end
184
+ end
185
+
186
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
187
+
188
+ def duration(seconds) = format('%dm %02ds', seconds / 60, seconds % 60)
189
+ end
190
+ end
@@ -0,0 +1,39 @@
1
+ module PlaiTestr
2
+ class Config
3
+ MODEL_SETTINGS = {
4
+ context_size: 8192,
5
+ long_context_size: 16_384,
6
+ menu_budget: 512,
7
+ think_budget: 8192,
8
+ debrief_budget: 1024,
9
+ referee_budget: 2048,
10
+ temperature: 0.6
11
+ }.freeze
12
+
13
+ attr_accessor :max_decisions, :ollama_host, :ollama_port, :player_model, :referee_model
14
+ attr_reader :model_settings
15
+
16
+ def initialize
17
+ @max_decisions = 100_000
18
+ @ollama_host = ENV.fetch('PLAI_TESTR_OLLAMA_HOST', 'localhost')
19
+ @ollama_port = Integer(ENV.fetch('PLAI_TESTR_OLLAMA_PORT', '11434'))
20
+ @player_model = ENV.fetch('PLAI_TESTR_PLAYER_MODEL', 'qwen3:8b')
21
+ @referee_model = ENV['PLAI_TESTR_REFEREE_MODEL']
22
+ @model_settings = MODEL_SETTINGS.dup
23
+ end
24
+
25
+ def model_settings=(settings)
26
+ @model_settings = MODEL_SETTINGS.merge(settings)
27
+ end
28
+ end
29
+
30
+ def self.config = @config ||= Config.new
31
+
32
+ def self.configure = yield(config)
33
+
34
+ def self.reset_config! = @config = nil
35
+
36
+ def self.load_config(path = File.join(Dir.pwd, 'config', 'plai_testr.rb'))
37
+ Kernel.load(path) if File.exist?(path)
38
+ end
39
+ end
@@ -0,0 +1,11 @@
1
+ module PlaiTestr
2
+ module Deck
3
+ def self.build(rules, component: nil)
4
+ deck = rules.fetch('components', []).find { |c| c['type'] == 'card_deck' && (component.nil? || c['name'] == component) }
5
+ return [] unless deck
6
+
7
+ deck.fetch('values', []).flat_map { |card| Array.new(card.fetch('copies', 1)) { card.except('copies') } }
8
+ .each.with_index(1).map { |card, uid| card.merge('uid' => uid) }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module PlaiTestr
2
+ class Error < StandardError; end
3
+ class RulesError < Error; end
4
+ class PersonaError < Error; end
5
+ class SessionError < Error; end
6
+ class ModelError < Error; end
7
+ class UsageError < Error; end
8
+ class MoveError < Error; end
9
+ end
@@ -0,0 +1,24 @@
1
+ module PlaiTestr
2
+ class EventLog
3
+ def initialize(entries)
4
+ @entries = entries
5
+ end
6
+
7
+ def add(text, turn:, to: nil, actor: nil, tag: nil)
8
+ entry = { 'turn' => turn, 'text' => text, 'to' => to }
9
+ entry.merge!('actor' => actor, 'tag' => tag) if tag
10
+ @entries << entry
11
+ end
12
+
13
+ def visible_to(player)
14
+ @entries.select { |e| e['to'].nil? || e['to'] == player }
15
+ end
16
+
17
+ def recent_for(player, count)
18
+ visible_to(player).last(count).map do |e|
19
+ text = e['to'] ? "(private) #{e['text']}" : e['text']
20
+ e['tag'] && e['actor'] == player ? "#{e['tag']}: #{text}" : text
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,50 @@
1
+ module PlaiTestr
2
+ Game = Data.define(:name, :runner_class, :game_state_class, :players, :comparison_class, :botrun_report_class, :max_decisions, :model_settings) do
3
+ def root = File.join(PlaiTestr.games_dir, name)
4
+ def data_dir = File.join(root, 'data')
5
+ def rules_file = File.join(data_dir, 'rules.json')
6
+ def sessions_dir = File.join(root, 'sessions')
7
+ def reports_dir = File.join(root, 'reports')
8
+ def rules = RulesLoader.load(rules_file)
9
+ def personas = Personas.load(data_dir)
10
+
11
+ def check!
12
+ Dir.glob('**/*.{yml,yaml}', base: data_dir).each do |file|
13
+ YAML.safe_load_file(File.join(data_dir, file), aliases: true)
14
+ rescue Psych::SyntaxError => e
15
+ raise RulesError, "#{File.join(data_dir, file)}: #{e.message}"
16
+ end
17
+ rules
18
+ personas.lint
19
+ end
20
+
21
+ def fingerprint
22
+ files = %w[data lib].flat_map { |dir| Dir.glob(File.join(root, dir, '**', '*')) }.select { |path| File.file?(path) }.sort
23
+ digest = Digest::SHA256.new
24
+ digest << VERSION
25
+ files.each { |path| digest << path.delete_prefix(root) << File.binread(path) }
26
+ digest.hexdigest
27
+ end
28
+ end
29
+
30
+ def self.register(name, runner_class:, game_state_class:, players: ['Player 1', 'Player 2'], comparison_class: Reporter::ModelComparison,
31
+ botrun_report_class: Reporter::BotrunReport, max_decisions: nil, model_settings: {})
32
+ registry[name] = Game.new(name:, runner_class:, game_state_class:, players:, comparison_class:, botrun_report_class:, max_decisions:, model_settings:)
33
+ end
34
+
35
+ def self.load(name)
36
+ entry = File.join(games_dir, name, 'lib', "#{name}.rb")
37
+ raise UsageError, "Unknown game #{name.inspect}. Available: #{names.join(', ')}" unless File.exist?(entry)
38
+
39
+ require entry
40
+ registry.fetch(name)
41
+ end
42
+
43
+ def self.names
44
+ Dir.glob('*', base: games_dir).select { |name| File.exist?(File.join(games_dir, name, 'lib', "#{name}.rb")) }.sort
45
+ end
46
+
47
+ def self.registry
48
+ @registry ||= {}
49
+ end
50
+ end