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,53 @@
1
+ module Hearts
2
+ module Bot
3
+ def bot_choice(player, moves, pick: nil)
4
+ cards = moves.map { |m| hand(player).find { |c| c['uid'] == m[:action].values.first[:value] } }
5
+ pick ? pass_policy(player, cards, pick.first) : play_policy(player, cards)
6
+ end
7
+
8
+ private
9
+
10
+ def pass_policy(player, cards, count)
11
+ ranked = cards.each_index.sort_by { |i| -danger(cards[i]) }
12
+ return { 'choices' => ranked.first(count).map { |i| i + 1 }, 'rule' => 'pass_dangerous' } if bot_strategy_for(player)['pass_dangerous']
13
+
14
+ { 'choices' => ranked.last(count).map { |i| i + 1 }, 'rule' => 'keep_dangerous' }
15
+ end
16
+
17
+ def play_policy(player, cards)
18
+ if trick.empty? then choose(cards, cards.min_by { |c| [Hearts.points(c), c['rank']] }, 'lead_low')
19
+ elsif cards.first['suit'] == led_suit then follow_policy(player, cards)
20
+ else dump_policy(cards)
21
+ end
22
+ end
23
+
24
+ def follow_policy(player, cards)
25
+ high = trick.select { |t| t['card']['suit'] == led_suit }.map { |t| t['card']['rank'] }.max
26
+ under = cards.select { |c| c['rank'] < high }
27
+
28
+ if under.any? then choose(cards, under.max_by { |c| c['rank'] }, 'duck')
29
+ elsif last_to_play? && points_in(trick.map { |t| t['card'] }).zero? && bot_strategy_for(player)['win_clean_tricks']
30
+ choose(cards, cards.reject { |c| c['name'] == QUEEN }.max_by { |c| c['rank'] } || cards.first, 'win_clean_trick')
31
+ else choose(cards, cards.min_by { |c| c['rank'] }, 'play_low')
32
+ end
33
+ end
34
+
35
+ def dump_policy(cards)
36
+ worst = cards.max_by { |c| danger(c) }
37
+ queen = worst['name'] == QUEEN
38
+ choose(cards, worst, queen ? 'dump_queen' : 'dump_high', obvious: queen)
39
+ end
40
+
41
+ def danger(card)
42
+ if card['name'] == QUEEN then 100
43
+ elsif card['suit'] == 'spades' && card['rank'] > 12 then 50 + card['rank']
44
+ elsif card['suit'] == 'hearts' then 20 + card['rank']
45
+ else card['rank']
46
+ end
47
+ end
48
+
49
+ def last_to_play? = trick.length == players.length - 1
50
+
51
+ def choose(cards, card, rule, obvious: false) = { 'choice' => cards.index(card) + 1, 'rule' => rule, 'obvious' => obvious }
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ module Hearts
2
+ SUITS = %w[clubs diamonds spades hearts].freeze
3
+ HAND_SIZE = 13
4
+ PASS_COUNT = 3
5
+ PASSES = %w[left right across hold].freeze
6
+ SEAT_SHIFT = { 'left' => 1, 'right' => -1, 'across' => 2 }.freeze
7
+ QUEEN = 'Q♠'
8
+ OPENER = '2♣'
9
+ MOON = 26
10
+
11
+ def self.points(card)
12
+ return 13 if card['name'] == QUEEN
13
+
14
+ card['suit'] == 'hearts' ? 1 : 0
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ module Hearts
2
+ module Reporter
3
+ class BotrunReport < PlaiTestr::Reporter::BotrunReport
4
+ private
5
+
6
+ def extra_sections
7
+ games = @played.select { |r| r['stats'] }
8
+ return [] if games.empty?
9
+
10
+ [hands(games), penalties(games)]
11
+ end
12
+
13
+ def hands(games)
14
+ counts = games.map { |r| r.dig('stats', 'hands') }.sort
15
+ table('## Hands per game', %w[Mean Median 90th Max], ["| #{counts.sum.fdiv(counts.length).round(1)} | #{percentile(counts, 50)} | #{percentile(counts, 90)} | #{counts.max} |"])
16
+ end
17
+
18
+ def penalties(games)
19
+ rows = games.first['names'].keys.map do |seat|
20
+ "| #{seat} | #{average(games, 'hearts', seat).round(1)} | #{average(games, 'queen', seat).round(2)} | #{games.sum { |r| r.dig('stats', 'taken', 'moons', seat) }} |"
21
+ end
22
+ table('## Penalties by seat', ['Seat', 'Hearts per game', 'Q♠ per game', 'Moons shot'], rows,
23
+ "Player 2 sits on Player 1's left, and so on around the table.")
24
+ end
25
+
26
+ def average(games, key, seat) = games.sum { |r| r.dig('stats', 'taken', key, seat) }.fdiv(games.length)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,255 @@
1
+ module Hearts
2
+ module Session
3
+ class Runner < PlaiTestr::Session::Runner
4
+ include Bot
5
+
6
+ RULES_PATH = File.expand_path('../../../data/rules.md', __dir__)
7
+ RECENT_LOG = 12
8
+
9
+ DEBRIEF_QUESTIONS = [
10
+ 'Which decision in this game mattered most, and why?',
11
+ 'Which rule or restriction was unclear or confusing?',
12
+ 'Which choices felt automatic (the choice did not matter)?'
13
+ ].freeze
14
+
15
+ def setup
16
+ data.merge!('hand' => 0, 'log' => [], 'taken_total' => %w[hearts queen moons].to_h { |key| [key, players.to_h { |p| [p, 0] }] })
17
+ deal
18
+ out.puts " Setup (seed #{@seed}): #{players.length} players; the game ends at #{limit} points."
19
+ end
20
+
21
+ def next_player = @game_state.active
22
+
23
+ def legal_moves(player)
24
+ case step
25
+ when 'pass'
26
+ hand(player).map { |card| option("Pass #{card['name']}#{" (#{pts(Hearts.points(card))})" if Hearts.points(card).positive?}", pass: { value: card['uid'] }) }
27
+ when 'play'
28
+ playable(player).map do |card|
29
+ facts = [(pts(Hearts.points(card)) if Hearts.points(card).positive?), (outcome(card) unless trick.empty?)].compact
30
+ option("Play #{card['name']}#{" (#{facts.join('; ')})" if facts.any?}", public: "played #{card['name']}", play: { value: card['uid'] })
31
+ end
32
+ end
33
+ end
34
+
35
+ def pick_range(_player) = step == 'pass' ? [PASS_COUNT, PASS_COUNT] : nil
36
+
37
+ def apply_action_effects(player, action)
38
+ case action
39
+ in { pass: { value: uids } } then pass(player, uids)
40
+ in { play: { value: uid } } then play(player, uid)
41
+ end
42
+ end
43
+
44
+ def rules_text_for(_player) = @rules_text ||= File.read(RULES_PATH)
45
+
46
+ def view_text_for(player)
47
+ [
48
+ "Hand #{data['hand']}. Scores (lower is better; the game ends at #{limit}): #{per_player(player) { |p| pstate(p).score }}",
49
+ ("Points taken this hand: #{per_player(player) { |p| points_in(taken[p]) }}. Hearts broken: #{data['hearts_broken'] ? 'yes' : 'no'}." if step == 'play'),
50
+ ("Current trick: #{trick_text(player)}" if step == 'play'),
51
+ "Your hand: #{list(hand(player))}",
52
+ '',
53
+ instruction(player)
54
+ ].compact.join("\n")
55
+ end
56
+
57
+ def turn_phase(_player)
58
+ step == 'pass' ? "hand #{data['hand']} · pass #{data['pass']}" : "hand #{data['hand']} · trick #{data['tricks'] + 1}"
59
+ end
60
+
61
+ def report_sections
62
+ rows = players.map { |p| "| #{p} | #{pstate(p).score} | #{totals['hearts'][p]} | #{totals['queen'][p]} | #{totals['moons'][p]} |" }
63
+
64
+ [<<~MD.strip]
65
+ ## Hearts
66
+
67
+ #{data['hand']} hands played.
68
+
69
+ | Player | Score | Hearts taken | Q♠ taken | Moons shot |
70
+ |---|---|---|---|---|
71
+ #{rows.join("\n")}
72
+ MD
73
+ end
74
+
75
+ def botrun_stats = { 'hands' => data['hand'], 'taken' => totals }
76
+
77
+ def recent_events_for(player) = event_log.recent_for(player, RECENT_LOG)
78
+
79
+ def debrief_questions(_player) = DEBRIEF_QUESTIONS
80
+
81
+ private
82
+
83
+ def deal
84
+ deck = PlaiTestr::Deck.build(@rules).shuffle(random: @rng)
85
+ number = data['hand'] + 1
86
+
87
+ players.each { |p| hand(p).replace(sorted(deck.shift(HAND_SIZE))) }
88
+ data.merge!('hand' => number, 'pass' => PASSES[(number - 1) % PASSES.length], 'passes' => {},
89
+ 'taken' => players.to_h { |p| [p, []] }, 'trick' => [], 'tricks' => 0, 'hearts_broken' => false)
90
+ log("Hand #{number} dealt: pass #{data['pass']}")
91
+
92
+ if data['pass'] == 'hold'
93
+ start_tricks
94
+ else
95
+ data.merge!('step' => 'pass', 'active' => players.first)
96
+ end
97
+ end
98
+
99
+ def pass(player, uids)
100
+ data['passes'][player] = uids
101
+
102
+ if data['passes'].length == players.length
103
+ exchange
104
+ else
105
+ data['active'] = seat(player, 1)
106
+ end
107
+ end
108
+
109
+ def exchange
110
+ given = data['passes'].to_h { |giver, uids| [giver, uids.map { |uid| take(giver, uid) }] }
111
+
112
+ given.each do |giver, cards|
113
+ receiver = seat(giver, SEAT_SHIFT.fetch(data['pass']))
114
+ hand(receiver).replace(sorted(hand(receiver) + cards))
115
+ log("You passed #{list(cards)} to #{receiver}", to: giver)
116
+ log("#{giver} passed you #{list(cards)}", to: receiver)
117
+ end
118
+ start_tricks
119
+ end
120
+
121
+ def start_tricks
122
+ data.merge!('step' => 'play', 'active' => players.find { |p| hand(p).any? { |c| c['name'] == OPENER } })
123
+ end
124
+
125
+ def play(player, uid)
126
+ card = take(player, uid)
127
+ trick << { 'player' => player, 'card' => card }
128
+ data['hearts_broken'] ||= card['suit'] == 'hearts'
129
+ log("#{player} played #{card['name']}")
130
+
131
+ if trick.length == players.length
132
+ finish_trick
133
+ else
134
+ data['active'] = seat(player, 1)
135
+ end
136
+ end
137
+
138
+ def finish_trick
139
+ winner = trick.select { |t| t['card']['suit'] == led_suit }.max_by { |t| t['card']['rank'] }['player']
140
+ cards = trick.map { |t| t['card'] }
141
+
142
+ taken[winner].concat(cards)
143
+ log("#{winner} took the trick (#{list(cards)})#{" for #{pts(points_in(cards))}" if points_in(cards).positive?}")
144
+ data.merge!('trick' => [], 'tricks' => data['tricks'] + 1, 'active' => winner)
145
+ score_hand if data['tricks'] == HAND_SIZE
146
+ end
147
+
148
+ def score_hand
149
+ points = players.to_h { |p| [p, points_in(taken[p])] }
150
+ shooter = points.key(MOON)
151
+ points = players.to_h { |p| [p, p == shooter ? 0 : MOON] } if shooter
152
+
153
+ players.each do |p|
154
+ pstate(p).apply_changes('score' => pstate(p).score + points[p])
155
+ totals['hearts'][p] += taken[p].count { |c| c['suit'] == 'hearts' }
156
+ totals['queen'][p] += 1 if taken[p].any? { |c| c['name'] == QUEEN }
157
+ end
158
+ totals['moons'][shooter] += 1 if shooter
159
+ log("Hand #{data['hand']} scored: #{players.map { |p| "#{p} +#{points[p]}" }.join(', ')}#{" (#{shooter} shot the moon)" if shooter}")
160
+
161
+ players.any? { |p| pstate(p).score >= limit } ? end_game : deal
162
+ end
163
+
164
+ def end_game
165
+ low = players.map { |p| pstate(p).score }.min
166
+ winners = players.select { |p| pstate(p).score == low }
167
+
168
+ @game_state.apply_changes('status' => { 'game_over' => true, 'winner' => winners.join(' & '),
169
+ 'end_reason' => winners.one? ? 'fewest_points' : 'tied_for_fewest_points' })
170
+ end
171
+
172
+ def playable(player)
173
+ cards = hand(player)
174
+
175
+ if trick.empty?
176
+ data['tricks'].zero? ? cards.select { |c| c['name'] == OPENER } : leads(cards)
177
+ else
178
+ follows(cards)
179
+ end
180
+ end
181
+
182
+ def leads(cards)
183
+ safe = cards.reject { |c| c['suit'] == 'hearts' }
184
+ data['hearts_broken'] || safe.empty? ? cards : safe
185
+ end
186
+
187
+ def follows(cards)
188
+ suited = cards.select { |c| c['suit'] == led_suit }
189
+ clean = cards.select { |c| Hearts.points(c).zero? }
190
+
191
+ if suited.any? then suited
192
+ elsif data['tricks'].zero? && clean.any? then clean
193
+ else cards
194
+ end
195
+ end
196
+
197
+ def trick_text(player)
198
+ return 'none yet (you lead)' if trick.empty?
199
+
200
+ points = points_in(trick.map { |t| t['card'] })
201
+ "#{trick.map { |t| "#{whose(t['player'], player)}: #{t['card']['name']}" }.join(', ')} (#{led_suit} led; #{points.zero? ? 'no points' : pts(points)} in it so far)"
202
+ end
203
+
204
+ def outcome(card)
205
+ best = trick.map { |t| t['card'] }.select { |c| c['suit'] == led_suit }.max_by { |c| c['rank'] }
206
+
207
+ if card['suit'] != led_suit then 'cannot win this trick'
208
+ elsif card['rank'] < best['rank'] then "stays under the #{best['name']}"
209
+ elsif last_to_play? then 'wins the trick'
210
+ else 'wins the trick so far'
211
+ end
212
+ end
213
+
214
+ def instruction(player)
215
+ case step
216
+ when 'pass' then "Choose #{PASS_COUNT} cards to pass #{data['pass']} to #{seat(player, SEAT_SHIFT.fetch(data['pass']))}."
217
+ when 'play' then play_instruction(player)
218
+ end
219
+ end
220
+
221
+ def play_instruction(player)
222
+ if trick.empty?
223
+ "You lead this trick.#{' Hearts are not broken, so you may not lead a heart unless you have only hearts.' unless data['hearts_broken']}"
224
+ elsif hand(player).any? { |c| c['suit'] == led_suit }
225
+ "You are following, not leading: you must play one of your #{led_suit}."
226
+ else
227
+ "You are following, not leading. You have no #{led_suit}, so you may play any card#{', but not a heart or the Q♠ unless you have nothing else' if data['tricks'].zero?}."
228
+ end
229
+ end
230
+
231
+ def take(player, uid) = hand(player).delete_at(hand(player).index { |c| c['uid'] == uid })
232
+
233
+ def sorted(cards) = cards.sort_by { |c| [SUITS.index(c['suit']), c['rank']] }
234
+
235
+ def seat(player, shift) = players[(players.index(player) + shift) % players.length]
236
+
237
+ def per_player(viewer) = players.map { |p| "#{whose(p, viewer)} #{yield p}" }.join(', ')
238
+
239
+ def whose(player, viewer) = player == viewer ? 'You' : player
240
+
241
+ def points_in(cards) = cards.sum { |c| Hearts.points(c) }
242
+ def list(cards) = cards.map { |c| c['name'] }.join(', ')
243
+ def pts(points) = points == 1 ? '1 pt' : "#{points} pts"
244
+ def led_suit = trick.first['card']['suit']
245
+ def limit = @rules.fetch('points_to_end')
246
+ def step = @game_state.step
247
+ def trick = @game_state.trick
248
+ def taken = @game_state.taken
249
+ def totals = data['taken_total']
250
+ def players = @game_state.players
251
+ def pstate(player) = @game_state.player_state(player)
252
+ def hand(player) = pstate(player).hand
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,19 @@
1
+ module Hearts
2
+ module State
3
+ class GameState < PlaiTestr::State::GameState
4
+ def step = @data['step']
5
+ def active = @data['active']
6
+ def trick = @data['trick'] ||= []
7
+ def taken = @data['taken'] ||= {}
8
+
9
+ private
10
+
11
+ def data_for(observer)
12
+ @data.except('passes', 'log').merge(
13
+ 'passed' => @data.fetch('passes', {}).keys,
14
+ 'log' => PlaiTestr::EventLog.new(@data.fetch('log', [])).visible_to(observer)
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ require_relative 'hearts/game'
2
+ require_relative 'hearts/state/game_state'
3
+ require_relative 'hearts/bot'
4
+ require_relative 'hearts/session/runner'
5
+ require_relative 'hearts/reporter/botrun_report'
6
+
7
+ PlaiTestr.register('hearts',
8
+ runner_class: Hearts::Session::Runner,
9
+ game_state_class: Hearts::State::GameState,
10
+ botrun_report_class: Hearts::Reporter::BotrunReport,
11
+ players: ['Player 1', 'Player 2', 'Player 3', 'Player 4'])
@@ -0,0 +1,122 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Hearts::Session::Runner do
4
+ let(:game) { PlaiTestr.load('hearts') }
5
+ let(:runner) { runner_with(nil) }
6
+ let(:state) { runner.game_state }
7
+ let(:deck) { PlaiTestr::Deck.build(game.rules) }
8
+
9
+ def runner_with(personas, seed: 1)
10
+ described_class.new(game: game, players: game.players, seed: seed, personas: personas, mode: 'bot', persist: false, out: StringIO.new)
11
+ end
12
+
13
+ def cards(*names) = names.map { |name| deck.find { |c| c['name'] == name } }
14
+
15
+ def give(player, *names) = state.player_state(player).hand.replace(cards(*names))
16
+
17
+ def labels(player) = runner.legal_moves(player).map { |m| m[:label] }
18
+
19
+ before { runner.setup }
20
+
21
+ it 'deals the whole deck, 13 cards each' do
22
+ hands = game.players.map { |p| state.player_state(p).hand }
23
+
24
+ expect(hands.map(&:length)).to all(eq(13))
25
+ expect(hands.flatten.map { |c| c['uid'] }.uniq.length).to eq(52)
26
+ end
27
+
28
+ it 'passes 3 cards to the left on the first hand, then starts with the 2♣' do
29
+ passed = game.players.to_h { |p| [p, state.player_state(p).hand.first(3)] }
30
+ game.players.each { |p| runner.apply_action_effects(p, { pass: { value: passed[p].map { |c| c['uid'] } } }) }
31
+
32
+ expect(state.player_state('Player 2').hand).to include(*passed['Player 1'])
33
+ expect(state.player_state(state.active).hand.map { |c| c['name'] }).to include('2♣')
34
+ expect(labels(state.active)).to eq(['Play 2♣'])
35
+ end
36
+
37
+ context 'during tricks' do
38
+ before { state.data.merge!('step' => 'play', 'tricks' => 1, 'trick' => [], 'active' => 'Player 1') }
39
+
40
+ it 'does not lead hearts until they are broken' do
41
+ give('Player 1', '5♥', '9♣')
42
+
43
+ expect(labels('Player 1')).to eq(['Play 9♣'])
44
+ end
45
+
46
+ it 'follows the suit led when it can, and says whether each card wins the trick' do
47
+ give('Player 2', '5♥', '7♣', 'K♣')
48
+ state.data['trick'] = [{ 'player' => 'Player 1', 'card' => cards('9♣').first }]
49
+
50
+ expect(labels('Player 2')).to eq(['Play 7♣ (stays under the 9♣)', 'Play K♣ (wins the trick so far)'])
51
+ end
52
+
53
+ it 'tells a player who cannot follow suit that any card is allowed' do
54
+ give('Player 2', 'Q♠', '5♥')
55
+ state.data.merge!('trick' => [{ 'player' => 'Player 1', 'card' => cards('2♣').first }], 'active' => 'Player 2')
56
+
57
+ expect(runner.view_text_for('Player 2')).to end_with('You are following, not leading. You have no clubs, so you may play any card.')
58
+ expect(labels('Player 2')).to eq(['Play Q♠ (13 pts; cannot win this trick)', 'Play 5♥ (1 pt; cannot win this trick)'])
59
+ end
60
+
61
+ it 'keeps points off the first trick unless there is nothing else' do
62
+ state.data.merge!('tricks' => 0, 'trick' => [{ 'player' => 'Player 1', 'card' => cards('2♣').first }])
63
+ give('Player 2', 'Q♠', '5♥', '9♦')
64
+
65
+ expect(labels('Player 2')).to eq(['Play 9♦ (cannot win this trick)'])
66
+ end
67
+ end
68
+
69
+ it 'scores 26 to everyone else when a player shoots the moon' do
70
+ state.data['taken'] = { 'Player 1' => deck.select { |c| Hearts.points(c).positive? }, 'Player 2' => [], 'Player 3' => [], 'Player 4' => [] }
71
+ runner.send(:score_hand)
72
+
73
+ expect(state.scores).to eq('Player 1' => 0, 'Player 2' => 26, 'Player 3' => 26, 'Player 4' => 26)
74
+ end
75
+
76
+ it 'counts hearts, the Q♠ and moons in the session report' do
77
+ state.data['taken'] = { 'Player 1' => cards('Q♠', '5♥'), 'Player 2' => cards('2♥'), 'Player 3' => [], 'Player 4' => [] }
78
+ runner.send(:score_hand)
79
+
80
+ expect(runner.report_sections.first).to include('| Player 1 | 14 | 1 | 1 | 0 |', '| Player 2 | 1 | 1 | 0 | 0 |')
81
+ end
82
+
83
+ describe 'personas' do
84
+ it 'seats four players with two personas, each used twice' do
85
+ expect(state.data['personas'].values.tally).to eq('safe_player' => 2, 'moon_shooter' => 2)
86
+ end
87
+
88
+ it 'has the moon shooter keep the dangerous cards the safe player passes' do
89
+ state.player_state('Player 1').hand.replace(cards('Q♠', 'A♥', '2♣', '3♦'))
90
+ moves = runner.legal_moves('Player 1')
91
+ state.data['personas']['Player 1'] = 'safe_player'
92
+ safe = runner.bot_choice('Player 1', moves, pick: [2, 2])
93
+ state.data['personas']['Player 1'] = 'moon_shooter'
94
+ moon = runner.bot_choice('Player 1', moves, pick: [2, 2])
95
+
96
+ expect(safe['choices'].map { |i| moves[i - 1][:label] }).to contain_exactly('Pass Q♠ (13 pts)', 'Pass A♥ (1 pt)')
97
+ expect(moon['choices'].map { |i| moves[i - 1][:label] }).to contain_exactly('Pass 2♣', 'Pass 3♦')
98
+ end
99
+ end
100
+
101
+ it 'builds every prompt a model player would see' do
102
+ (1..3).each do |seed|
103
+ session = runner_with(nil, seed: seed)
104
+ session.setup
105
+ until session.game_over?
106
+ player = session.next_player
107
+ %i[rules_text_for view_text_for recent_events_for persona_for turn_phase].each { |hook| session.public_send(hook, player) }
108
+ session.play_turn
109
+ end
110
+ game.players.each { |p| session.debrief_view_for(p) }
111
+ end
112
+ end
113
+
114
+ it 'plays whole games with its bot rules and reports penalties by seat' do
115
+ results = PlaiTestr::Botrun.new(game: game, players: game.players, seeds: (1..20).to_a).run
116
+ report = game.botrun_report_class.new(results, game: 'Hearts', version: '1.0.0', seconds: 1.0).to_markdown
117
+
118
+ expect(results.map { |r| r['error'] }.compact).to be_empty
119
+ expect(results.map { |r| r['winner'] }).to all(start_with('Player'))
120
+ expect(report).to include('## Hands per game', '## Penalties by seat')
121
+ end
122
+ end
@@ -0,0 +1,13 @@
1
+ # bot_strategy: parameters your bot (lib/<game>/bot.rb) reads (never shown to the model).
2
+ # Read them in bot_choice with bot_strategy_for(player).
3
+ # knock_at: 10
4
+ # lead_low: true
5
+ #
6
+ # bots: per-persona overrides, keyed by the persona's key in personas.yml.
7
+ # A key with no matching persona is an error.
8
+ # gin_hunter:
9
+ # <<: *bot_defaults
10
+ # knock_at: 3
11
+
12
+ bot_strategy: &bot_defaults {}
13
+ bots: {}
@@ -0,0 +1,16 @@
1
+ # basic_strategy: shared strategy, injected into each persona with <<: *basics.
2
+ # complete_tricks: Points come only from tricks you win; count them before you lead.
3
+ # avoid_penalties: Discard high cards early, while they're hard to punish.
4
+ #
5
+ # personas: play styles, picked with --personas a,b (or at random). The key is
6
+ # the persona's name; every field under it is shown to the model. To override
7
+ # a basic_strategy item, reuse its key exactly; a new key adds an item.
8
+ # moon_shooter:
9
+ # <<: *basics
10
+ # avoid_penalties: Keep hearts and the Q♠ if you can take every trick.
11
+ # safe_player:
12
+ # <<: *basics
13
+ # goal: Finish every hand with as few points as you can.
14
+
15
+ basic_strategy: &basics {}
16
+ personas: {}
@@ -0,0 +1,16 @@
1
+ {
2
+ "game": {
3
+ "name": "<%= title %>",
4
+ "version": "0.1.0",
5
+ "description": "One or two sentences the model players read first: what the game is and how to win.",
6
+ "player_count": { "min": 2, "max": 4, "default": 2 }
7
+ },
8
+
9
+ "components": [
10
+ {
11
+ "name": "Deck",
12
+ "type": "card_deck",
13
+ "values": []
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,11 @@
1
+ # <%= title %>
2
+
3
+ v0.1.0
4
+
5
+ ## Components
6
+
7
+ ## Setup
8
+
9
+ ## Turn
10
+
11
+ ## End of the game
@@ -0,0 +1,7 @@
1
+ module <%= module_name %>
2
+ module Bot
3
+ def bot_choice(_player, _moves, pick: nil)
4
+ nil
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ module <%= module_name %>
2
+ module Session
3
+ class Runner < PlaiTestr::Session::Runner
4
+ include Bot
5
+
6
+ RULES_PATH = File.expand_path('../../../data/rules.md', __dir__)
7
+
8
+ DEBRIEF_QUESTIONS = [
9
+ 'Which decision in this game mattered most, and why?',
10
+ 'Which rule was unclear or confusing?',
11
+ 'Which choices felt automatic (the choice did not matter)?'
12
+ ].freeze
13
+
14
+ def setup
15
+ raise NotImplementedError, "#{self.class}#setup: build the starting state; PlaiTestr::Deck.build(@rules).shuffle(random: @rng) gives a shuffled deck"
16
+ end
17
+
18
+ def legal_moves(_player)
19
+ raise NotImplementedError, "#{self.class}#legal_moves: return [option('Play 3♣', public: 'played 3♣', play: { value: uid }), ...]"
20
+ end
21
+
22
+ def apply_action_effects(_player, _action)
23
+ raise NotImplementedError, "#{self.class}#apply_action_effects: case action in { play: { value: uid } } then ...; apply it to @game_state; set status 'game_over', 'winner' and 'end_reason' when the game ends"
24
+ end
25
+
26
+ def rules_text_for(_player) = @rules_text ||= File.read(RULES_PATH)
27
+
28
+ def debrief_questions(_player) = DEBRIEF_QUESTIONS
29
+
30
+ private
31
+
32
+ def players = @game_state.players
33
+ def pstate(player) = @game_state.player_state(player)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ module <%= module_name %>
2
+ module State
3
+ class GameState < PlaiTestr::State::GameState
4
+ private
5
+
6
+ def data_for(observer)
7
+ @data.except('log').merge('log' => PlaiTestr::EventLog.new(@data.fetch('log', [])).visible_to(observer))
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ require_relative '<%= name %>/state/game_state'
2
+ require_relative '<%= name %>/bot'
3
+ require_relative '<%= name %>/session/runner'
4
+
5
+ PlaiTestr.register('<%= name %>',
6
+ runner_class: <%= module_name %>::Session::Runner,
7
+ game_state_class: <%= module_name %>::State::GameState,
8
+ # max_decisions: 5_000,
9
+ # model_settings: { context_size: 16_384 },
10
+ )
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe <%= module_name %>::Session::Runner do
4
+ let(:game) { PlaiTestr.load('<%= name %>') }
5
+
6
+ def runner(seed) = described_class.new(game: game, players: game.players, seed: seed, mode: 'bot', persist: false, out: StringIO.new)
7
+
8
+ it 'plays whole games with its bot rules' do
9
+ pending 'implement setup, legal_moves and apply_action_effects'
10
+
11
+ results = PlaiTestr::Botrun.new(game: game, players: game.players, seeds: (1..10).to_a).run
12
+
13
+ expect(results.map { |r| r['error'] }.compact).to be_empty
14
+ end
15
+
16
+ it 'builds every prompt a model player would see' do
17
+ pending 'implement the game, its bot rules and view_text_for'
18
+
19
+ (1..3).each do |seed|
20
+ session = runner(seed)
21
+ session.setup
22
+ until session.game_over?
23
+ player = session.next_player
24
+ %i[rules_text_for view_text_for recent_events_for persona_for turn_phase].each { |hook| session.public_send(hook, player) }
25
+ session.play_turn
26
+ end
27
+ end
28
+ end
29
+ end