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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/bin/plai_testr +5 -0
- data/docs/frame_queue.md +123 -0
- data/docs/referee.md +59 -0
- data/lib/plai_testr/agents/ollama_client.rb +177 -0
- data/lib/plai_testr/agents/player_agent.rb +216 -0
- data/lib/plai_testr/agents/referee_agent.rb +56 -0
- data/lib/plai_testr/botrun.rb +60 -0
- data/lib/plai_testr/cli.rb +190 -0
- data/lib/plai_testr/config.rb +39 -0
- data/lib/plai_testr/deck.rb +11 -0
- data/lib/plai_testr/errors.rb +9 -0
- data/lib/plai_testr/event_log.rb +24 -0
- data/lib/plai_testr/game.rb +50 -0
- data/lib/plai_testr/generator.rb +50 -0
- data/lib/plai_testr/model_settings.rb +25 -0
- data/lib/plai_testr/names.rb +7 -0
- data/lib/plai_testr/personas.rb +97 -0
- data/lib/plai_testr/preflight.rb +70 -0
- data/lib/plai_testr/reporter/botrun_report.rb +141 -0
- data/lib/plai_testr/reporter/model_comparison.rb +104 -0
- data/lib/plai_testr/reporter/report_builder.rb +154 -0
- data/lib/plai_testr/reporter/think_log.rb +32 -0
- data/lib/plai_testr/rules_loader.rb +40 -0
- data/lib/plai_testr/session/frame_queue.rb +47 -0
- data/lib/plai_testr/session/manager.rb +77 -0
- data/lib/plai_testr/session/runner.rb +555 -0
- data/lib/plai_testr/state/base.rb +13 -0
- data/lib/plai_testr/state/game_state.rb +102 -0
- data/lib/plai_testr/state/player_state.rb +61 -0
- data/lib/plai_testr/version.rb +3 -0
- data/lib/plai_testr.rb +61 -0
- data/templates/app/.gitignore +3 -0
- data/templates/app/.rspec +3 -0
- data/templates/app/AGENTS.md +158 -0
- data/templates/app/CLAUDE.md +1 -0
- data/templates/app/Gemfile.erb +7 -0
- data/templates/app/README.md.erb +91 -0
- data/templates/app/config/plai_testr.rb +15 -0
- data/templates/app/spec/spec_helper.rb +25 -0
- data/templates/examples/gin_rummy/NOTES.md +12 -0
- data/templates/examples/gin_rummy/data/bots.yml +18 -0
- data/templates/examples/gin_rummy/data/personas.yml +26 -0
- data/templates/examples/gin_rummy/data/rules.json +76 -0
- data/templates/examples/gin_rummy/data/rules.md +44 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/bot.rb +34 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/game.rb +8 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/melds.rb +51 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/session/runner.rb +197 -0
- data/templates/examples/gin_rummy/lib/gin_rummy/state/game_state.rb +19 -0
- data/templates/examples/gin_rummy/lib/gin_rummy.rb +9 -0
- data/templates/examples/gin_rummy/spec/melds_spec.rb +26 -0
- data/templates/examples/gin_rummy/spec/session/runner_spec.rb +105 -0
- data/templates/examples/hearts/data/bots.yml +22 -0
- data/templates/examples/hearts/data/personas.yml +27 -0
- data/templates/examples/hearts/data/rules.json +73 -0
- data/templates/examples/hearts/data/rules.md +30 -0
- data/templates/examples/hearts/lib/hearts/bot.rb +53 -0
- data/templates/examples/hearts/lib/hearts/game.rb +16 -0
- data/templates/examples/hearts/lib/hearts/reporter/botrun_report.rb +29 -0
- data/templates/examples/hearts/lib/hearts/session/runner.rb +255 -0
- data/templates/examples/hearts/lib/hearts/state/game_state.rb +19 -0
- data/templates/examples/hearts/lib/hearts.rb +11 -0
- data/templates/examples/hearts/spec/session/runner_spec.rb +122 -0
- data/templates/game/data/bots.yml +13 -0
- data/templates/game/data/personas.yml +16 -0
- data/templates/game/data/rules.json.erb +16 -0
- data/templates/game/data/rules.md.erb +11 -0
- data/templates/game/lib/__name__/bot.rb.erb +7 -0
- data/templates/game/lib/__name__/session/runner.rb.erb +36 -0
- data/templates/game/lib/__name__/state/game_state.rb.erb +11 -0
- data/templates/game/lib/__name__.rb.erb +10 -0
- data/templates/game/spec/session/runner_spec.rb.erb +29 -0
- 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,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
|