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,555 @@
1
+ module PlaiTestr
2
+ module Session
3
+ class Runner
4
+ RECENT_EVENTS = 8
5
+ MODES = %w[llm hybrid bot].freeze
6
+ MOVE_KEYS = %i[label public log action].freeze
7
+
8
+ attr_reader :seed, :out, :warnings, :game_state, :turn_log, :report_path
9
+
10
+ # Status of the last run: 'completed' or 'interrupted'.
11
+ attr_reader :status
12
+
13
+ def self.resume(game, session_id, out: $stdout, verbose: false)
14
+ checkpoint = Manager.new(session_id: session_id, persist: false).load_checkpoint
15
+ raise SessionError, "Session #{session_id} is already complete; its report is in #{PlaiTestr.reports_dir}" if checkpoint['status'] == 'completed'
16
+ unless checkpoint['fingerprint'] == game.fingerprint
17
+ raise SessionError, "#{game.name} has changed since session #{session_id} was saved (its data, code or the gem version), " \
18
+ 'so it cannot be resumed exactly. Start a new session with: play'
19
+ end
20
+
21
+ data = checkpoint.dig('game_state', 'data')
22
+ models = { player: data['player_model'], referee: data['referee_model'], think: data['think'] }
23
+ runner = new(game: game, players: checkpoint.dig('game_state', 'players'), models: models, seed: data['seed'],
24
+ mode: data['mode'], session_id: session_id, out: out, verbose: verbose)
25
+ runner.send(:restore, checkpoint)
26
+ runner
27
+ end
28
+
29
+ def initialize(game:, players:, models: {}, seed: nil, mode: 'llm', personas: nil, persist: true, out: $stdout, verbose: false,
30
+ echo_warnings: true, session_id: nil)
31
+ raise UsageError, "Unknown mode #{mode.inspect} (choose from #{MODES.join(', ')})" unless MODES.include?(mode)
32
+
33
+ @game = game
34
+ @rules = game.rules
35
+ @personas = game.personas
36
+ @models = models
37
+ @mode = mode
38
+ @persist = persist
39
+ @out = out
40
+ @echo_warnings = echo_warnings
41
+ @seed = seed || Kernel.rand(1_000_000)
42
+ @manager = Manager.new(session_id: session_id, persist: persist)
43
+ @game_state = game.game_state_class.new(players: players)
44
+ @turn_log = []
45
+ @warnings = []
46
+ @running = true
47
+ @fingerprint = game.fingerprint if persist
48
+ @client = Agents::OllamaClient.new(think_log: (think_log_path if persist), out: out, verbose: verbose)
49
+ @settings = resolve_settings
50
+ @rng = rng_for(0)
51
+ build_agents
52
+
53
+ data.merge!('seed' => @seed, 'mode' => @mode, 'player_model' => models[:player], 'referee_model' => models[:referee],
54
+ 'think' => models[:think] == true, 'host' => Socket.gethostname)
55
+ request = @personas.check_request(personas, players: players.length)
56
+ data['personas'] = @personas.assign(players, request, rng_for('personas')) if @personas.any?
57
+ end
58
+
59
+ def session_id
60
+ @manager.session_id
61
+ end
62
+
63
+ def run
64
+ with_signal_traps do
65
+ out.puts "Session #{session_id}"
66
+ out.puts "Game: #{@rules.dig('game', 'name')} v#{@rules.dig('game', 'version')}"
67
+ out.puts "Players: #{@game_state.players.map { |p| display_name(p) }.join(', ')}"
68
+ out.puts "Seed: #{@seed}"
69
+ out.puts "Press Ctrl+C to save state and exit.\n\n"
70
+
71
+ fit_settings_to_model unless @mode == 'bot'
72
+ setup unless @resumed
73
+ until game_over? || !@running
74
+ play_turn
75
+ yield @game_state.turn if block_given?
76
+ end
77
+ run_debrief if game_over? && @running && @mode != 'bot'
78
+ end
79
+ finish
80
+ rescue Interrupt
81
+ finish
82
+ end
83
+
84
+ # Plays a whole game in memory with no checkpoints, transcript or report (used by botruns).
85
+ def play_out
86
+ setup
87
+ play_turn until game_over?
88
+ self
89
+ end
90
+
91
+ def play_turn
92
+ player = next_player
93
+ @game_state.current_player = player
94
+ @rng = rng_for(decision)
95
+ moves = menu(player)
96
+ pick = pick_range(player)
97
+ check_menu(player, moves, pick)
98
+ phase = turn_phase(player)
99
+
100
+ if pick
101
+ play_pick_turn(player, moves, phase, *pick)
102
+ elsif moves.length == 1
103
+ play_forced_turn(player, moves.first, phase)
104
+ else
105
+ play_choice_turn(player, moves, phase)
106
+ end
107
+ stop_at_decision_limit
108
+ end
109
+
110
+ def menu(player_name) = legal_moves(player_name).map { |move| symbolize(move) }
111
+
112
+ def botrun_result
113
+ {
114
+ 'seed' => @seed,
115
+ 'winner' => @game_state.status['winner'],
116
+ 'end_reason' => @game_state.status['end_reason'],
117
+ 'names' => display_names,
118
+ 'personas' => data['personas'],
119
+ 'decisions' => @turn_log.length,
120
+ 'rules' => @turn_log.filter_map { |t| t['rule'] }.tally,
121
+ 'stats' => botrun_stats,
122
+ 'warnings' => @warnings
123
+ }
124
+ end
125
+
126
+ def option(label, public: nil, log: nil, **action)
127
+ { label: label, public: public, log: log, action: action }.compact
128
+ end
129
+
130
+ def data = @game_state.data
131
+
132
+ def log(text, to: nil, turn: decision)
133
+ player, decider = @acting
134
+ tag = decider unless decider == 'LLM'
135
+ event_log.add(text, turn: turn, to: to, actor: player, tag: tag)
136
+ end
137
+
138
+ def event_log = EventLog.new(data['log'] ||= [])
139
+
140
+ def persona(player) = data.dig('personas', player)
141
+
142
+ def persona_name(player) = persona(player)&.then { |key| @personas.name(key) }
143
+
144
+ def bot_strategy_for(player) = @personas.bot(persona(player))
145
+
146
+ def display_names
147
+ @game_state.players.to_h { |p| [p, display_name(p)] }
148
+ end
149
+
150
+ # --- Game hooks ------------------------------------------------------------------
151
+ # A move is { 'label' => String, 'action' => { name => params }, 'public' => String (optional),
152
+ # 'log' => String (optional) }. 'label' is what the acting player sees; 'public' is what
153
+ # opponents see; 'log' is the short text for the console and report.
154
+
155
+ def setup
156
+ raise NotImplementedError, "#{self.class} must implement #setup"
157
+ end
158
+
159
+ def legal_moves(_player_name)
160
+ raise NotImplementedError, "#{self.class} must implement #legal_moves"
161
+ end
162
+
163
+ def apply_action_effects(_player_name, _action)
164
+ raise NotImplementedError, "#{self.class} must implement #apply_action_effects"
165
+ end
166
+
167
+ # Who acts next. Default: strict alternation via GameState#advance_turn.
168
+ def next_player
169
+ @game_state.current_player
170
+ end
171
+
172
+ def pick_range(_player_name)
173
+ nil
174
+ end
175
+
176
+ def game_over?
177
+ @game_state.status['game_over'] == true
178
+ end
179
+
180
+ def turn_phase(_player_name)
181
+ nil
182
+ end
183
+
184
+ def persona_for(player_name)
185
+ key = persona(player_name) or return nil
186
+
187
+ "#{persona_name(player_name)}\n#{@personas.strategy(key).map { |item| "- #{item}" }.join("\n")}"
188
+ end
189
+
190
+ def display_name(player_name)
191
+ key = persona(player_name) or return player_name
192
+
193
+ data['personas'].values.count(key) > 1 ? "#{persona_name(player_name)} (#{player_name})" : persona_name(player_name)
194
+ end
195
+
196
+ # Rules text for the menu system prompt. Override to scope rules to the current phase.
197
+ def rules_text_for(_player_name)
198
+ @rules.dig('game', 'description').to_s
199
+ end
200
+
201
+ # What the player is allowed to see, as text. Override with a readable view.
202
+ def view_text_for(player_name)
203
+ JSON.pretty_generate(@game_state.serialize_for(player_name))
204
+ end
205
+
206
+ # Optional one-off note for this prompt (e.g. "your stack choice collided; choose again").
207
+ def prompt_note_for(_player_name)
208
+ nil
209
+ end
210
+
211
+ # Questions each player answers once after the game ends (designer feedback).
212
+ # Return [] to skip the debrief.
213
+ def debrief_questions(_player_name)
214
+ []
215
+ end
216
+
217
+ # What a player sees while answering the debrief; defaults to their normal view.
218
+ def debrief_view_for(player_name)
219
+ view_text_for(player_name)
220
+ end
221
+
222
+ def report_sections
223
+ []
224
+ end
225
+
226
+ def report_debrief? = true
227
+
228
+ # The bot policy. Return nil (no opinion) or { 'choice' => n } ({ 'choices' => [n, ...] } when
229
+ # pick is [min, max]) plus 'rule' => name and 'obvious' => true when the move is clearly best.
230
+ # Mode 'hybrid' plays obvious moves without asking the model; mode 'bot' plays every scripted
231
+ # move and option 1 when there is none. In 'llm' and 'hybrid' runs the turn log keeps what the
232
+ # bot would have chosen, to compare with the model.
233
+ def bot_choice(_player_name, _moves, pick: nil)
234
+ nil
235
+ end
236
+
237
+ def think_about?(_player_name) = true
238
+
239
+ # Game-specific numbers for one botrun game (see Reporter::BotrunReport).
240
+ def botrun_stats
241
+ {}
242
+ end
243
+
244
+ def recent_events_for(player_name)
245
+ event_log.recent_for(player_name, RECENT_EVENTS)
246
+ end
247
+
248
+ private
249
+
250
+ # --- Turns -----------------------------------------------------------------------
251
+
252
+ def decision = @game_state.turn + 1
253
+
254
+ def rng_for(key) = ::Random.new(Digest::SHA256.hexdigest("#{@seed}:#{key}").to_i(16))
255
+
256
+ def max_decisions = @game.max_decisions || PlaiTestr.config.max_decisions
257
+
258
+ def check_menu(player_name, moves, pick)
259
+ raise MoveError, "#{self.class}#legal_moves(#{player_name.inspect}) returned no moves at decision #{decision}" if moves.empty? && pick.nil?
260
+
261
+ moves.each_with_index do |move, i|
262
+ problem = move_problem(move, pick)
263
+ raise MoveError, "#{self.class}#legal_moves(#{player_name.inspect}), decision #{decision}, option #{i + 1}: #{problem}\n #{move.inspect}" if problem
264
+ end
265
+
266
+ repeated = pick ? [] : moves.map { |m| m[:label] }.tally.select { |_, count| count > 1 }.keys
267
+ warning("#{self.class}#legal_moves repeats the label(s) #{repeated.map(&:inspect).join(', ')}") if repeated.any?
268
+ end
269
+
270
+ def move_problem(move, pick)
271
+ return 'a move must be a Hash' unless move.is_a?(Hash)
272
+
273
+ unknown = move.keys - MOVE_KEYS
274
+ return "unknown key(s) #{unknown.map(&:inspect).join(', ')} (allowed: #{MOVE_KEYS.join(', ')})" if unknown.any?
275
+ return 'label must be a non-empty String' unless move[:label].is_a?(String) && !move[:label].strip.empty?
276
+
277
+ action = move[:action]
278
+ return 'action must have exactly one name with params: { play: { value: ... } }' unless action.is_a?(Hash) && action.length == 1 && action.values.first.is_a?(Hash)
279
+ return "pick turns need a value in each action's params" if pick && !action.values.first.key?(:value)
280
+
281
+ nil
282
+ end
283
+
284
+ def heading(player_name, phase)
285
+ "Turn #{decision} — #{display_name(player_name)}#{" · #{phase}" if phase}"
286
+ end
287
+
288
+ # Menu turns cannot be illegal (every option is legal by construction), so there is no
289
+ # referee call — an unparseable reply falls back to the bot's choice (or a random one) and is flagged.
290
+ def play_choice_turn(player_name, moves, phase)
291
+ out.puts "#{heading(player_name, phase)} (#{moves.length} options)"
292
+
293
+ script = script_for(player_name, moves)
294
+ result = scripted?(script) ? bot_result(script, 'choice' => 1) : ask(player_name, moves)
295
+ result = fallback(result, script, 'choice' => fallback_rng.rand(moves.length) + 1) if result['fallback']
296
+ move = moves[result['choice'] - 1]
297
+ out.puts " #{decider_mark(result)} #{move[:log] || move[:label]}#{decider_note(result)}"
298
+
299
+ apply(player_name, move[:action], result['decider'])
300
+ record_turn(player_name, move[:action], { 'phase' => phase, 'label' => move[:label], 'log' => move[:log],
301
+ 'public' => move[:public] || move[:label],
302
+ 'options' => moves.length, 'choice' => result['choice'] }
303
+ .merge(model_fields(result), decider_fields(result, script, 'choice')))
304
+ end
305
+
306
+ def play_forced_turn(player_name, move, phase)
307
+ out.puts "#{heading(player_name, phase)}: #{move[:log] || move[:label]} [only option]"
308
+
309
+ apply(player_name, move[:action], nil)
310
+ record_turn(player_name, move[:action], 'phase' => phase, 'label' => move[:label], 'log' => move[:log],
311
+ 'public' => move[:public] || move[:label],
312
+ 'options' => 1, 'choice' => 1, 'attempts' => 0, 'fallback' => false, 'auto' => true)
313
+ end
314
+
315
+ def play_pick_turn(player_name, moves, phase, min, max)
316
+ max = [max, moves.length].min
317
+ min = [min, max].min
318
+
319
+ if min == moves.length
320
+ script = nil
321
+ result = { 'choices' => (1..min).to_a, 'attempts' => 0, 'fallback' => false, 'auto' => true }
322
+ else
323
+ out.puts "#{heading(player_name, phase)} (pick #{min}-#{max} of #{moves.length})"
324
+ script = script_for(player_name, moves, pick: [min, max])
325
+ result = scripted?(script) ? bot_result(script, 'choices' => (1..min).to_a) : ask(player_name, moves, min: min, max: max)
326
+ result = fallback(result, script, 'choices' => (1..moves.length).to_a.sample(min, random: fallback_rng)) if result['fallback']
327
+ end
328
+
329
+ chosen = result['choices'].map { |i| moves[i - 1] }
330
+ label = chosen.empty? ? '(none)' : chosen.map { |m| m[:label] }.join('; ')
331
+ log = chosen.empty? ? '(none)' : chosen.map { |m| m[:log] || m[:label] }.join('; ')
332
+ name = moves.empty? ? :pick : moves.first[:action].keys.first
333
+ action = { name => { value: chosen.map { |m| m[:action].values.first[:value] } } }
334
+ out.puts " #{decider_mark(result)} #{log}#{decider_note(result)}" unless result['auto']
335
+
336
+ apply(player_name, action, result['decider'])
337
+ record_turn(player_name, action, { 'phase' => phase, 'label' => label, 'log' => log, 'public' => chosen.map { |m| m[:public] }.compact.join('; '),
338
+ 'options' => moves.length, 'choice' => result['choices'], 'auto' => result['auto'] || false }
339
+ .merge(model_fields(result), result['auto'] ? {} : decider_fields(result, script, 'choices')))
340
+ end
341
+
342
+ def ask(player_name, moves, **pick)
343
+ prompt = { rules_text: rules_text_for(player_name), view_text: view_text_for(player_name), labels: moves.map { |m| m[:label] },
344
+ plan: last_plan(player_name), recent: recent_events_for(player_name), prompt_note: prompt_note_for(player_name),
345
+ persona: persona_for(player_name), think: think_about?(player_name) }
346
+ agent = @agents[player_name]
347
+ result = waiting_on_model { pick.empty? ? agent.choose(**prompt) : agent.choose_many(**prompt, **pick) }
348
+ warning("context nearly full for #{player_name} at decision #{decision}; the prompt was probably truncated (raise context_size)") if result['context_full']
349
+
350
+ result.merge('decider' => 'LLM')
351
+ end
352
+
353
+ def script_for(player_name, moves, pick: nil)
354
+ bot_choice(player_name, moves, pick: pick)
355
+ rescue StandardError => e
356
+ raise if @mode == 'bot'
357
+
358
+ warning("bot rule failed at decision #{decision}, the model decides: #{e.class}: #{e.message}")
359
+ nil
360
+ end
361
+
362
+ def scripted?(script) = @mode == 'bot' || (@mode == 'hybrid' && script&.fetch('obvious', false))
363
+
364
+ def bot_result(script, default)
365
+ (script || default.merge('rule' => 'none')).merge('decider' => 'BOT', 'reasoning' => nil, 'plan' => nil, 'attempts' => 0, 'fallback' => false)
366
+ end
367
+
368
+ def fallback(result, script, random)
369
+ source = script ? script.merge('decider' => 'BOT') : random.merge('decider' => 'RNG')
370
+ result.merge(source.slice('choice', 'choices', 'rule', 'obvious', 'decider'))
371
+ end
372
+
373
+ def fallback_rng = rng_for("#{decision}:fallback")
374
+
375
+ def model_fields(result)
376
+ result.slice('reasoning', 'plan', 'attempts', 'fallback').merge(result['context_full'] ? { 'context_full' => true } : {})
377
+ end
378
+
379
+ def decider_fields(result, script, key)
380
+ fields = { 'decider' => result['decider'] }
381
+ fields.merge!('rule' => result['rule'], 'obvious' => result['obvious'] == true) if result['decider'] == 'BOT'
382
+ fields.merge!('bot_choice' => script[key], 'bot_rule' => script['rule'], 'bot_obvious' => script['obvious'] == true) if script && result['decider'] == 'LLM'
383
+ fields
384
+ end
385
+
386
+ def decider_mark(result) = "#{DECIDER_ICONS.fetch(result['decider'])} #{result['decider']} ->"
387
+
388
+ def decider_note(result)
389
+ notes = []
390
+ notes << " [rule: #{result['rule']}]" if result['decider'] == 'BOT'
391
+ notes << ' [FALLBACK]' if result['fallback']
392
+ notes << " (#{result['attempts']} tries)" if result['attempts'] > 1
393
+ notes.join
394
+ end
395
+
396
+ def apply(player_name, action, decider)
397
+ @acting = [player_name, decider]
398
+ apply_action_effects(player_name, action)
399
+ ensure
400
+ @acting = nil
401
+ end
402
+
403
+ def symbolize(move)
404
+ return move unless move.is_a?(Hash)
405
+
406
+ move = move.transform_keys(&:to_sym)
407
+ action = move[:action]
408
+ move[:action] = action.to_h { |name, params| [name.to_sym, params.is_a?(Hash) ? params.transform_keys(&:to_sym) : params] } if action.is_a?(Hash)
409
+ move
410
+ end
411
+
412
+ def record_turn(player_name, action, fields)
413
+ entry = { 'turn' => decision, 'player' => player_name, 'action' => JSON.parse(JSON.generate(action)) }.merge(fields)
414
+
415
+ @turn_log << entry
416
+ @game_state.advance_turn
417
+ return unless @persist
418
+
419
+ @manager.append_transcript_entry(entry)
420
+ save_checkpoint('in_progress')
421
+ end
422
+
423
+ def stop_at_decision_limit
424
+ return if game_over? || @game_state.turn < max_decisions
425
+
426
+ @game_state.apply_changes('status' => { 'game_over' => true, 'winner' => nil, 'end_reason' => 'decision_limit_reached' })
427
+ out.puts " Decision limit (#{max_decisions}) reached: the game ends with no winner."
428
+ end
429
+
430
+ def last_plan(player_name)
431
+ @turn_log.reverse.find { |t| t['player'] == player_name && t['plan'] }&.fetch('plan')
432
+ end
433
+
434
+ # Stored in data['debrief'] so a resumed session does not ask a player twice.
435
+ def run_debrief
436
+ @game_state.players.each do |player_name|
437
+ questions = debrief_questions(player_name)
438
+ next if questions.empty? || data.dig('debrief', player_name)
439
+
440
+ out.puts "Debrief — #{display_name(player_name)}"
441
+ answers = waiting_on_model do
442
+ @agents[player_name].debrief(rules_text: rules_text_for(player_name), view_text: debrief_view_for(player_name),
443
+ questions: questions, persona: persona_for(player_name))
444
+ end
445
+ data['debrief'] = (data['debrief'] || {}).merge(player_name => answers)
446
+ save_checkpoint('in_progress')
447
+ end
448
+ end
449
+
450
+ def warning(text)
451
+ warn " [warning] #{text}" if @echo_warnings && !@warnings.include?(text)
452
+ @warnings << text
453
+ end
454
+
455
+ # --- Models ------------------------------------------------------------------------
456
+
457
+ def resolve_settings(model_max: nil)
458
+ settings, notes = ModelSettings.resolve(@game.model_settings, model_max: model_max)
459
+ notes.each { |note| warning(note) }
460
+ data['model_settings'] = settings.transform_keys(&:to_s)
461
+ settings
462
+ end
463
+
464
+ def fit_settings_to_model
465
+ model_max = @models.values_at(:player, :referee).compact.uniq.filter_map { |model| @client.context_length(model) }.min or return
466
+ @settings = resolve_settings(model_max: model_max)
467
+ build_agents
468
+ end
469
+
470
+ def build_agents
471
+ name = @rules.dig('game', 'name')
472
+ @referee = @models[:referee] && Agents::RefereeAgent.new(model: @models[:referee], client: @client, game_name: name, settings: @settings)
473
+ @agents = @game_state.players.to_h do |player|
474
+ [player, Agents::PlayerAgent.new(name: player, model: @models[:player], client: @client, game_name: name, settings: @settings, think: @models[:think] == true)]
475
+ end
476
+ end
477
+
478
+ def waiting_on_model
479
+ @waiting = true
480
+ yield
481
+ ensure
482
+ @waiting = false
483
+ end
484
+
485
+ # --- Persistence and reporting ---------------------------------------------------
486
+
487
+ def restore(checkpoint)
488
+ @game_state = @game.game_state_class.from_h(checkpoint['game_state'])
489
+ @turn_log = checkpoint['turn_log']
490
+ @resumed = true
491
+ end
492
+
493
+ def finish
494
+ if game_over? && @running
495
+ @status = 'completed'
496
+ save_checkpoint('completed')
497
+ @report_path = generate_report
498
+ out.puts "\nGame complete. Report: #{@report_path}"
499
+ else
500
+ @status = 'interrupted'
501
+ save_checkpoint('interrupted')
502
+ out.puts "Saved after decision #{@game_state.turn}. Resume with: plai_testr #{@game.name} resume #{session_id}"
503
+ end
504
+ end
505
+
506
+ def think_log_path
507
+ File.join(@manager.session_dir, 'think.log')
508
+ end
509
+
510
+ def save_checkpoint(status)
511
+ @manager.save_checkpoint(@game_state, @turn_log, {
512
+ 'status' => status,
513
+ 'game' => @game.name,
514
+ 'rules_version' => @rules.dig('game', 'version'),
515
+ 'fingerprint' => @fingerprint
516
+ })
517
+ end
518
+
519
+ def generate_report
520
+ FileUtils.mkdir_p(PlaiTestr.reports_dir)
521
+ Reporter::ReportBuilder.new(
522
+ session_id: session_id,
523
+ rules: @rules,
524
+ game_state: @game_state,
525
+ turn_log: @turn_log,
526
+ think_log: Reporter::ThinkLog.load(think_log_path),
527
+ sections: report_sections,
528
+ names: display_names,
529
+ debrief: report_debrief?,
530
+ warnings: @warnings
531
+ ).build
532
+ end
533
+
534
+ # --- Signals -----------------------------------------------------------------------
535
+
536
+ def with_signal_traps
537
+ previous = %w[INT TERM].to_h { |signal| [signal, trap(signal) { interrupt! }] }
538
+ yield
539
+ ensure
540
+ previous&.each { |signal, handler| trap(signal, handler || 'DEFAULT') }
541
+ end
542
+
543
+ # First Ctrl+C (or SIGTERM): stop after the move in progress and save. While the model is
544
+ # still thinking nothing has changed yet, so that call is abandoned and the session saved now.
545
+ # Second Ctrl+C: exit immediately — the checkpoint from the last completed decision is on disk.
546
+ def interrupt!
547
+ exit!(130) unless @running
548
+
549
+ @running = false
550
+ $stderr.puts "\n\nInterrupted — saving. Press Ctrl+C again to quit immediately."
551
+ raise Interrupt if @waiting
552
+ end
553
+ end
554
+ end
555
+ end
@@ -0,0 +1,13 @@
1
+ module PlaiTestr
2
+ module State
3
+ class Base
4
+ def serialize
5
+ raise NotImplementedError, "#{self.class} must implement #serialize"
6
+ end
7
+
8
+ def serialize_for(observer)
9
+ raise NotImplementedError, "#{self.class} must implement #serialize_for"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,102 @@
1
+ module PlaiTestr
2
+ module State
3
+ class GameState < Base
4
+ attr_reader :turn, :current_player_index, :players, :status, :data
5
+
6
+ def initialize(players:, current_state: {})
7
+ @players = players
8
+ @turn = current_state.fetch('turn', 0)
9
+ @current_player_index = current_state.fetch('current_player_index', 0)
10
+ @status = current_state.fetch('status', {}).dup
11
+ @data = current_state.fetch('data', {}).dup
12
+ @player_states = build_player_states(players, current_state.fetch('player_states', {}))
13
+ end
14
+
15
+ def self.from_h(data)
16
+ new(players: data['players'], current_state: data)
17
+ end
18
+
19
+ def current_player
20
+ @players[@current_player_index]
21
+ end
22
+
23
+ # Lets games with non-alternating turn order (simultaneous choices, rotating
24
+ # starting players) decide who acts next. advance_turn still rotates by default.
25
+ def current_player=(name)
26
+ index = @players.index(name)
27
+ raise ArgumentError, "Unknown player: #{name.inspect}" unless index
28
+
29
+ @current_player_index = index
30
+ end
31
+
32
+ def player_state(name)
33
+ @player_states[name]
34
+ end
35
+
36
+ def advance_turn
37
+ @turn += 1
38
+ @current_player_index = (@current_player_index + 1) % @players.length
39
+ end
40
+
41
+ # Convenience accessor for reporter and win-condition evaluation
42
+ def scores
43
+ @player_states.transform_values(&:score)
44
+ end
45
+
46
+ # state_changes keys: "status" (object), "data" (object), "player_states" (object keyed by player name)
47
+ def apply_changes(state_changes)
48
+ return if state_changes.nil? || state_changes.empty?
49
+
50
+ @status.merge!(state_changes['status']) if state_changes['status']
51
+ @data.merge!(state_changes['data']) if state_changes['data']
52
+
53
+ state_changes.fetch('player_states', {}).each do |name, changes|
54
+ @player_states[name]&.apply_changes(changes)
55
+ end
56
+ end
57
+
58
+ # Full state — for checkpoints
59
+ def serialize
60
+ {
61
+ 'turn' => @turn,
62
+ 'current_player_index' => @current_player_index,
63
+ 'current_player' => current_player,
64
+ 'players' => @players,
65
+ 'status' => @status.dup,
66
+ 'data' => @data.dup,
67
+ 'player_states' => @player_states.transform_values(&:serialize)
68
+ }
69
+ end
70
+
71
+ # Per-player view — opponent hand contents are hidden
72
+ def serialize_for(observer)
73
+ {
74
+ 'turn' => @turn,
75
+ 'current_player' => current_player,
76
+ 'players' => @players,
77
+ 'status' => @status,
78
+ 'data' => data_for(observer),
79
+ 'player_states' => @player_states.transform_values { |ps| ps.serialize_for(observer) }
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ # Override in game-specific subclasses to filter or transform data
86
+ # per observer (e.g. hide face-down deck contents, show only counts).
87
+ # The full @data is always used by serialize (checkpoints).
88
+ def data_for(_observer)
89
+ @data
90
+ end
91
+
92
+ # Override in game-specific subclasses to use a richer PlayerState
93
+ def player_state_class
94
+ PlayerState
95
+ end
96
+
97
+ def build_player_states(players, saved)
98
+ players.to_h { |name| [name, saved[name] ? player_state_class.from_h(saved[name]) : player_state_class.new(name: name)] }
99
+ end
100
+ end
101
+ end
102
+ end