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,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
|