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,34 @@
1
+ module GinRummy
2
+ module Bot
3
+ def bot_choice(player, moves, pick: nil)
4
+ params = moves.map { |m| m[:action].values.first }
5
+ step == 'draw' ? draw_policy(params) : discard_policy(player, params)
6
+ end
7
+
8
+ private
9
+
10
+ def draw_policy(params)
11
+ params[1][:deadwood] < params[0][:deadwood] ? choose(1, 'take_useful_discard') : choose(0, 'draw_stock')
12
+ end
13
+
14
+ def discard_policy(player, params)
15
+ options = params.each_index
16
+ gin = options.find { |i| params[i][:knock] && params[i][:deadwood].zero? }
17
+ knock = options.select { |i| params[i][:knock] }.min_by { |i| params[i][:deadwood] }
18
+ shed = options.reject { |i| params[i][:knock] }.min_by do |i|
19
+ [params[i][:deadwood], -Melds.value(hand(player).find { |c| c['uid'] == params[i][:value] })]
20
+ end
21
+
22
+ if gin then choose(gin, 'gin', obvious: true)
23
+ elsif knock && knock_now?(player, params[knock][:deadwood]) then choose(knock, 'knock')
24
+ else choose(shed, 'shed_deadwood')
25
+ end
26
+ end
27
+
28
+ def knock_now?(player, deadwood)
29
+ deadwood <= bot_strategy_for(player)['knock_at'] || stock.length <= LATE_STOCK
30
+ end
31
+
32
+ def choose(index, rule, obvious: false) = { 'choice' => index + 1, 'rule' => rule, 'obvious' => obvious }
33
+ end
34
+ end
@@ -0,0 +1,8 @@
1
+ module GinRummy
2
+ SUITS = %w[clubs diamonds spades hearts].freeze
3
+ HAND_SIZE = 10
4
+ DEAD_STOCK = 2
5
+ MAX_HANDS = 50
6
+ MAX_TURNS = 120
7
+ LATE_STOCK = 10
8
+ end
@@ -0,0 +1,51 @@
1
+ module GinRummy
2
+ module Melds
3
+ Arrangement = Data.define(:melds, :deadwood) do
4
+ def points = Melds.points(deadwood)
5
+ end
6
+
7
+ def self.value(card) = [card['rank'], 10].min
8
+ def self.points(cards) = cards.sum { |c| value(c) }
9
+
10
+ def self.best(cards) = search(cards, candidates(cards))
11
+
12
+ def self.lay_off(cards, melds)
13
+ melds = melds.map(&:dup)
14
+ cards = cards.dup
15
+
16
+ while (card = cards.find { |c| melds.any? { |m| fits?(c, m) } })
17
+ melds.find { |m| fits?(card, m) } << card
18
+ cards.delete(card)
19
+ end
20
+ cards
21
+ end
22
+
23
+ def self.candidates(cards)
24
+ sets = cards.group_by { |c| c['rank'] }.values.select { |g| g.length >= 3 }
25
+ .flat_map { |g| g.length == 4 ? [g, *g.combination(3)] : [g] }
26
+ runs = cards.group_by { |c| c['suit'] }.values.flat_map do |suited|
27
+ suited.sort_by { |c| c['rank'] }.slice_when { |a, b| b['rank'] != a['rank'] + 1 }.flat_map do |streak|
28
+ (3..streak.length).flat_map { |size| streak.each_cons(size).to_a }
29
+ end
30
+ end
31
+ sets + runs
32
+ end
33
+
34
+ def self.search(cards, candidates)
35
+ candidates.each_with_index.reduce(Arrangement.new(melds: [], deadwood: cards)) do |best, (meld, i)|
36
+ next best unless (meld - cards).empty?
37
+
38
+ rest = search(cards - meld, candidates.drop(i + 1))
39
+ rest.points < best.points ? Arrangement.new(melds: [meld, *rest.melds], deadwood: rest.deadwood) : best
40
+ end
41
+ end
42
+
43
+ def self.fits?(card, meld)
44
+ ranks = meld.map { |c| c['rank'] }
45
+
46
+ if ranks.uniq.one? then card['rank'] == ranks.first && meld.length < 4
47
+ else card['suit'] == meld.first['suit'] && [ranks.min - 1, ranks.max + 1].include?(card['rank'])
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,197 @@
1
+ module GinRummy
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 = 10
8
+
9
+ DEBRIEF_QUESTIONS = [
10
+ 'Which decision in this game mattered most, and why?',
11
+ 'When did you choose to knock (or not), and was it the right call?',
12
+ 'Which choices felt automatic (the choice did not matter)?'
13
+ ].freeze
14
+
15
+ def setup
16
+ data.merge!('hand' => 0, 'log' => [])
17
+ deal(players.last)
18
+ out.puts " Setup (seed #{@seed}): first to #{points_to_win} points; #{players.map { |p| display_name(p) }.join(' vs ')}."
19
+ end
20
+
21
+ def next_player = @game_state.active
22
+
23
+ def legal_moves(player)
24
+ case step
25
+ when 'draw' then draw_moves(player)
26
+ when 'discard' then discard_moves(player)
27
+ end
28
+ end
29
+
30
+ def apply_action_effects(player, action)
31
+ case action
32
+ in { draw: { source: } } then draw(player, source)
33
+ in { discard: { value:, knock: true } } then discard(player, value, knock: true)
34
+ in { discard: { value: } } then discard(player, value, knock: false)
35
+ end
36
+ end
37
+
38
+ def rules_text_for(_player) = @rules_text ||= File.read(RULES_PATH)
39
+
40
+ def view_text_for(player)
41
+ best = Melds.best(hand(player))
42
+
43
+ [
44
+ "Hand #{data['hand']}. Scores (first to #{points_to_win}): #{players.map { |p| "#{whose(p, player)} #{pstate(p).score}" }.join(', ')}",
45
+ "Your hand: #{list(hand(player))}",
46
+ "Your best melds: #{melds_text(best)}; deadwood #{best.points}",
47
+ "Discard pile: #{top ? "#{top['name']} on top (#{discard_pile.length} cards)" : 'empty'}. Stock: #{stock.length} cards. #{opponent(player)} holds #{hand(opponent(player)).length} cards.",
48
+ '',
49
+ instruction
50
+ ].join("\n")
51
+ end
52
+
53
+ def turn_phase(_player) = "hand #{data['hand']} · #{step}"
54
+
55
+ def recent_events_for(player) = event_log.recent_for(player, RECENT_LOG)
56
+
57
+ def debrief_questions(_player) = DEBRIEF_QUESTIONS
58
+
59
+ private
60
+
61
+ def draw_moves(player)
62
+ now = Melds.best(hand(player)).points
63
+ with = hand(player).map { |c| Melds.best(hand(player) + [top] - [c]).points }.min
64
+
65
+ [
66
+ option('Draw the top card of the stock (face-down)', public: 'drew from the stock', draw: { source: 'stock', deadwood: now }),
67
+ option("Take the #{top['name']} from the discard pile (best deadwood after your discard: #{with}; now: #{now})",
68
+ public: "took the #{top['name']} from the discard pile", draw: { source: 'discard', deadwood: with })
69
+ ]
70
+ end
71
+
72
+ def discard_moves(player)
73
+ hand(player).reject { |c| c['uid'] == data['taken'] }.flat_map do |card|
74
+ deadwood = Melds.best(hand(player) - [card]).points
75
+ params = { value: card['uid'], deadwood: deadwood }
76
+
77
+ [
78
+ option("Discard #{card['name']} (deadwood after: #{deadwood})", public: "discarded #{card['name']}", discard: params),
79
+ (knock_option(card, params) if deadwood <= knock_limit)
80
+ ].compact
81
+ end
82
+ end
83
+
84
+ def knock_option(card, params)
85
+ gin = params[:deadwood].zero?
86
+ option(gin ? "Discard #{card['name']} and go gin" : "Discard #{card['name']} and knock with #{params[:deadwood]} deadwood",
87
+ public: "discarded #{card['name']} and #{gin ? 'went gin' : 'knocked'}", discard: params.merge(knock: true))
88
+ end
89
+
90
+ def deal(dealer)
91
+ deck = PlaiTestr::Deck.build(@rules).shuffle(random: @rng)
92
+
93
+ players.each { |p| hand(p).replace(sorted(deck.shift(HAND_SIZE))) }
94
+ data.merge!('hand' => data['hand'] + 1, 'dealer' => dealer, 'discard' => [deck.shift], 'stock' => deck,
95
+ 'step' => 'draw', 'active' => opponent(dealer), 'taken' => nil, 'turns' => 0)
96
+ log("Hand #{data['hand']}: #{dealer} dealt; the upcard is #{top['name']}")
97
+ end
98
+
99
+ def draw(player, source)
100
+ card = source == 'stock' ? stock.shift : discard_pile.pop
101
+
102
+ hand(player).replace(sorted(hand(player) + [card]))
103
+ data.merge!('step' => 'discard', 'taken' => (card['uid'] if source == 'discard'))
104
+
105
+ if source == 'stock'
106
+ log("#{player} drew from the stock")
107
+ log("You drew the #{card['name']}", to: player)
108
+ else
109
+ log("#{player} took the #{card['name']} from the discard pile")
110
+ end
111
+ end
112
+
113
+ def discard(player, uid, knock:)
114
+ card = take(player, uid)
115
+ discard_pile << card
116
+ log("#{player} discarded #{card['name']}")
117
+
118
+ data['turns'] += 1
119
+
120
+ if knock then score_hand(player)
121
+ elsif stock.length <= DEAD_STOCK then dead_hand('draw', "The stock is down to #{DEAD_STOCK} cards: hand #{data['hand']} is a draw")
122
+ elsif data['turns'] >= MAX_TURNS then dead_hand('turn_cap', "Hand #{data['hand']} is a draw")
123
+ else data.merge!('step' => 'draw', 'active' => opponent(player), 'taken' => nil)
124
+ end
125
+ end
126
+
127
+ def score_hand(knocker)
128
+ defender = opponent(knocker)
129
+ knock = Melds.best(hand(knocker))
130
+ defense = Melds.best(hand(defender))
131
+ gin = knock.deadwood.empty?
132
+ left = gin ? defense.deadwood : Melds.lay_off(defense.deadwood, knock.melds)
133
+ margin = Melds.points(left) - knock.points
134
+
135
+ winner, points, result =
136
+ if gin then [knocker, margin + @rules.fetch('gin_bonus'), 'gin']
137
+ elsif margin.positive? then [knocker, margin, 'knock']
138
+ else [defender, @rules.fetch('undercut_bonus') - margin, 'undercut']
139
+ end
140
+
141
+ pstate(winner).apply_changes('score' => pstate(winner).score + points)
142
+ data['results'] = data.fetch('results', {}).merge(result => data.dig('results', result).to_i + 1)
143
+ log("#{knocker} #{gin ? 'went gin' : "knocked with #{knock.points}"} (#{melds_text(knock)}); " \
144
+ "#{defender} had #{Melds.points(left)} deadwood (#{melds_text(defense)}#{", laid off #{list(defense.deadwood - left)}" unless gin || left == defense.deadwood}). " \
145
+ "#{winner} scores #{points}#{' for the undercut' if result == 'undercut'}.")
146
+
147
+ if pstate(winner).score >= points_to_win then end_game(winner, "reached_#{points_to_win}")
148
+ elsif data['hand'] >= MAX_HANDS then end_game(players.max_by { |p| pstate(p).score }, 'hand_limit')
149
+ else deal(winner)
150
+ end
151
+ end
152
+
153
+ def dead_hand(result, text)
154
+ log(text)
155
+ data['results'] = data.fetch('results', {}).merge(result => data.dig('results', result).to_i + 1)
156
+ data['hand'] >= MAX_HANDS ? end_game(players.max_by { |p| pstate(p).score }, 'hand_limit') : deal(data['dealer'])
157
+ end
158
+
159
+ def end_game(winner, reason)
160
+ @game_state.apply_changes('status' => { 'game_over' => true, 'winner' => winner, 'end_reason' => reason })
161
+ end
162
+
163
+ def instruction
164
+ case step
165
+ when 'draw' then 'Draw the top card of the stock, or take the top card of the discard pile.'
166
+ when 'discard'
167
+ "Discard one card. If your deadwood after discarding is #{knock_limit} or less you may knock; with 0 deadwood you go gin." \
168
+ "#{" You cannot discard the #{hand(@game_state.active).find { |c| c['uid'] == data['taken'] }['name']} you just took." if data['taken']}"
169
+ end
170
+ end
171
+
172
+ def melds_text(arrangement)
173
+ melds = arrangement.melds.map { |m| "[#{list(m)}]" }.join(' ')
174
+ "#{melds.empty? ? 'no melds' : melds}#{"; unmatched #{list(arrangement.deadwood)}" if arrangement.deadwood.any?}"
175
+ end
176
+
177
+ def take(player, uid) = hand(player).delete_at(hand(player).index { |c| c['uid'] == uid })
178
+
179
+ def sorted(cards) = cards.sort_by { |c| [SUITS.index(c['suit']), c['rank']] }
180
+
181
+ def opponent(player) = (players - [player]).first
182
+
183
+ def whose(player, viewer) = player == viewer ? 'You' : player
184
+
185
+ def list(cards) = cards.map { |c| c['name'] }.join(', ')
186
+ def top = discard_pile.last
187
+ def points_to_win = @rules.fetch('points_to_win')
188
+ def knock_limit = @rules.fetch('knock_limit')
189
+ def step = @game_state.step
190
+ def stock = @game_state.stock
191
+ def discard_pile = @game_state.discard_pile
192
+ def players = @game_state.players
193
+ def pstate(player) = @game_state.player_state(player)
194
+ def hand(player) = pstate(player).hand
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,19 @@
1
+ module GinRummy
2
+ module State
3
+ class GameState < PlaiTestr::State::GameState
4
+ def step = @data['step']
5
+ def active = @data['active']
6
+ def stock = @data['stock'] ||= []
7
+ def discard_pile = @data['discard'] ||= []
8
+
9
+ private
10
+
11
+ def data_for(observer)
12
+ @data.except('stock', 'log').merge(
13
+ 'stock_size' => stock.length,
14
+ 'log' => PlaiTestr::EventLog.new(@data.fetch('log', [])).visible_to(observer)
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'gin_rummy/game'
2
+ require_relative 'gin_rummy/melds'
3
+ require_relative 'gin_rummy/state/game_state'
4
+ require_relative 'gin_rummy/bot'
5
+ require_relative 'gin_rummy/session/runner'
6
+
7
+ PlaiTestr.register('gin_rummy',
8
+ runner_class: GinRummy::Session::Runner,
9
+ game_state_class: GinRummy::State::GameState)
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe GinRummy::Melds do
4
+ let(:deck) { PlaiTestr::Deck.build(PlaiTestr.load('gin_rummy').rules) }
5
+
6
+ def cards(*names) = names.map { |name| deck.find { |c| c['name'] == name } }
7
+ def names(cards) = cards.map { |c| c['name'] }
8
+
9
+ it 'finds the arrangement with the least deadwood' do
10
+ best = described_class.best(cards('7♣', '7♦', '7♥', '8♥', '9♥', 'K♠', 'A♣'))
11
+
12
+ expect(best.melds.map { |m| names(m) }).to eq([%w[7♥ 8♥ 9♥]])
13
+ expect(best.points).to eq(25)
14
+ end
15
+
16
+ it 'plays aces low only' do
17
+ expect(described_class.best(cards('A♠', '2♠', '3♠')).points).to eq(0)
18
+ expect(described_class.best(cards('Q♠', 'K♠', 'A♠')).points).to eq(21)
19
+ end
20
+
21
+ it 'lays off deadwood onto runs and sets' do
22
+ melds = [cards('4♠', '5♠', '6♠'), cards('9♣', '9♦', '9♥')]
23
+
24
+ expect(names(described_class.lay_off(cards('3♠', '7♠', '9♠', 'K♦'), melds))).to eq(['K♦'])
25
+ end
26
+ end
@@ -0,0 +1,105 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe GinRummy::Session::Runner do
4
+ let(:game) { PlaiTestr.load('gin_rummy') }
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 10 cards each and turns up one card; the non-dealer starts' do
22
+ expect(game.players.map { |p| state.player_state(p).hand.length }).to eq([10, 10])
23
+ expect(state.discard_pile.length).to eq(1)
24
+ expect(state.stock.length).to eq(31)
25
+ expect(state.active).to eq('Player 1')
26
+ end
27
+
28
+ it 'does not let a player discard the card they just took from the discard pile' do
29
+ taken = state.discard_pile.last['name']
30
+ runner.apply_action_effects('Player 1', { draw: { source: 'discard' } })
31
+
32
+ expect(labels('Player 1').grep(/#{taken}/)).to be_empty
33
+ end
34
+
35
+ it 'offers a knock only when the deadwood after discarding is 10 or less' do
36
+ give('Player 1', 'A♣', '2♣', '3♣', '7♦', '7♥', '7♠', 'J♠', 'Q♠', 'K♠', '4♦', '9♥')
37
+ state.data['step'] = 'discard'
38
+
39
+ expect(labels('Player 1')).to include('Discard 9♥ and knock with 4 deadwood')
40
+ expect(labels('Player 1').grep(/Discard A♣ and knock/)).to be_empty
41
+ end
42
+
43
+ it 'shows what taking the top discard would do to the deadwood' do
44
+ expect(labels('Player 1').last).to match(/\ATake the .+ from the discard pile \(best deadwood after your discard: \d+; now: \d+\)\z/)
45
+ end
46
+
47
+ it 'declares a hand that runs too long a draw and deals the next' do
48
+ state.data.merge!('turns' => GinRummy::MAX_TURNS - 1, 'step' => 'discard')
49
+ runner.apply_action_effects('Player 1', { discard: { value: state.player_state('Player 1').hand.first['uid'] } })
50
+
51
+ expect(state.data['hand']).to eq(2)
52
+ expect(state.data.dig('results', 'turn_cap')).to eq(1)
53
+ end
54
+
55
+ it 'scores an undercut to the defender' do
56
+ give('Player 1', 'A♣', '2♣', '3♣', '7♦', '7♥', '7♠', 'J♠', 'Q♠', 'K♠', '5♦')
57
+ give('Player 2', '4♣', '5♣', '6♣', '8♦', '8♥', '8♠', '9♦', '10♦', 'J♦', '2♥')
58
+ runner.send(:score_hand, 'Player 1')
59
+
60
+ expect(state.scores).to eq('Player 1' => 0, 'Player 2' => 28)
61
+ end
62
+
63
+ describe 'personas' do
64
+ it 'gives each player a different persona when none are requested' do
65
+ expect(state.data['personas'].values).to contain_exactly('quick_knocker', 'gin_hunter')
66
+ end
67
+
68
+ it 'refuses an unknown persona' do
69
+ expect { runner_with(%w[gambler *]) }.to raise_error(PlaiTestr::PersonaError, /Unknown persona "gambler"/)
70
+ end
71
+
72
+ def bot_rule(persona)
73
+ state.data['personas']['Player 1'] = persona
74
+ runner.bot_choice('Player 1', runner.legal_moves('Player 1'))['rule']
75
+ end
76
+
77
+ it 'has the gin hunter hold a knock that the quick knocker takes' do
78
+ give('Player 1', 'A♣', '2♣', '3♣', '7♦', '7♥', '7♠', 'J♠', 'Q♠', 'K♠', '4♦', '9♥')
79
+ state.data['step'] = 'discard'
80
+
81
+ expect(bot_rule('quick_knocker')).to eq('knock')
82
+ expect(bot_rule('gin_hunter')).to eq('shed_deadwood')
83
+ end
84
+ end
85
+
86
+ it 'builds every prompt a model player would see' do
87
+ (1..3).each do |seed|
88
+ session = runner_with(nil, seed: seed)
89
+ session.setup
90
+ until session.game_over?
91
+ player = session.next_player
92
+ %i[rules_text_for view_text_for recent_events_for persona_for turn_phase].each { |hook| session.public_send(hook, player) }
93
+ session.play_turn
94
+ end
95
+ game.players.each { |p| session.debrief_view_for(p) }
96
+ end
97
+ end
98
+
99
+ it 'plays whole games with its bot rules' do
100
+ results = PlaiTestr::Botrun.new(game: game, players: game.players, seeds: (1..10).to_a, personas: ['all']).run
101
+
102
+ expect(results.map { |r| r['error'] }.compact).to be_empty
103
+ expect(results.map { |r| r['winner'] }).to all(start_with('Player'))
104
+ end
105
+ end
@@ -0,0 +1,22 @@
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
+ pass_dangerous: true
14
+ win_clean_tricks: true
15
+
16
+ bots:
17
+ safe_player:
18
+ <<: *bot_defaults
19
+ win_clean_tricks: false
20
+ moon_shooter:
21
+ <<: *bot_defaults
22
+ pass_dangerous: false
@@ -0,0 +1,27 @@
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
+ pass_dangerous: Pass away the Q♠, high spades (A♠, K♠) and high hearts unless you have many low spades to protect them.
17
+ duck: Play just under the winning card to avoid taking tricks that carry points.
18
+ dump_when_void: When you cannot follow suit, drop the Q♠ or high hearts on someone else.
19
+
20
+ personas:
21
+ safe_player:
22
+ <<: *basics
23
+ goal: Take as few points as you can in every hand. Never win a trick you could lose.
24
+ moon_shooter:
25
+ <<: *basics
26
+ goal: Watch for a hand strong enough to take every heart and the Q♠ (26 points to everyone else). Go for it when you can; otherwise play safe.
27
+ pass_dangerous: Keep high hearts and the Q♠ when your hand is strong enough to take every trick; pass low cards instead.
@@ -0,0 +1,73 @@
1
+ {
2
+ "game": {
3
+ "name": "Hearts",
4
+ "version": "1.0.0",
5
+ "description": "A 4-player trick-taking card game. Avoid taking hearts (1 point each) and the Queen of Spades (13 points). When a player reaches 100 points, the player with the fewest points wins.",
6
+ "player_count": { "min": 4, "max": 4, "default": 4 }
7
+ },
8
+
9
+ "points_to_end": 100,
10
+
11
+ "components": [
12
+ {
13
+ "name": "Deck",
14
+ "type": "card_deck",
15
+ "count": 52,
16
+ "description": "Standard 52-card deck. Aces are high (rank 14).",
17
+ "values": [
18
+ { "name": "2♣", "rank": 2, "suit": "clubs" },
19
+ { "name": "3♣", "rank": 3, "suit": "clubs" },
20
+ { "name": "4♣", "rank": 4, "suit": "clubs" },
21
+ { "name": "5♣", "rank": 5, "suit": "clubs" },
22
+ { "name": "6♣", "rank": 6, "suit": "clubs" },
23
+ { "name": "7♣", "rank": 7, "suit": "clubs" },
24
+ { "name": "8♣", "rank": 8, "suit": "clubs" },
25
+ { "name": "9♣", "rank": 9, "suit": "clubs" },
26
+ { "name": "10♣", "rank": 10, "suit": "clubs" },
27
+ { "name": "J♣", "rank": 11, "suit": "clubs" },
28
+ { "name": "Q♣", "rank": 12, "suit": "clubs" },
29
+ { "name": "K♣", "rank": 13, "suit": "clubs" },
30
+ { "name": "A♣", "rank": 14, "suit": "clubs" },
31
+ { "name": "2♦", "rank": 2, "suit": "diamonds" },
32
+ { "name": "3♦", "rank": 3, "suit": "diamonds" },
33
+ { "name": "4♦", "rank": 4, "suit": "diamonds" },
34
+ { "name": "5♦", "rank": 5, "suit": "diamonds" },
35
+ { "name": "6♦", "rank": 6, "suit": "diamonds" },
36
+ { "name": "7♦", "rank": 7, "suit": "diamonds" },
37
+ { "name": "8♦", "rank": 8, "suit": "diamonds" },
38
+ { "name": "9♦", "rank": 9, "suit": "diamonds" },
39
+ { "name": "10♦", "rank": 10, "suit": "diamonds" },
40
+ { "name": "J♦", "rank": 11, "suit": "diamonds" },
41
+ { "name": "Q♦", "rank": 12, "suit": "diamonds" },
42
+ { "name": "K♦", "rank": 13, "suit": "diamonds" },
43
+ { "name": "A♦", "rank": 14, "suit": "diamonds" },
44
+ { "name": "2♠", "rank": 2, "suit": "spades" },
45
+ { "name": "3♠", "rank": 3, "suit": "spades" },
46
+ { "name": "4♠", "rank": 4, "suit": "spades" },
47
+ { "name": "5♠", "rank": 5, "suit": "spades" },
48
+ { "name": "6♠", "rank": 6, "suit": "spades" },
49
+ { "name": "7♠", "rank": 7, "suit": "spades" },
50
+ { "name": "8♠", "rank": 8, "suit": "spades" },
51
+ { "name": "9♠", "rank": 9, "suit": "spades" },
52
+ { "name": "10♠", "rank": 10, "suit": "spades" },
53
+ { "name": "J♠", "rank": 11, "suit": "spades" },
54
+ { "name": "Q♠", "rank": 12, "suit": "spades" },
55
+ { "name": "K♠", "rank": 13, "suit": "spades" },
56
+ { "name": "A♠", "rank": 14, "suit": "spades" },
57
+ { "name": "2♥", "rank": 2, "suit": "hearts" },
58
+ { "name": "3♥", "rank": 3, "suit": "hearts" },
59
+ { "name": "4♥", "rank": 4, "suit": "hearts" },
60
+ { "name": "5♥", "rank": 5, "suit": "hearts" },
61
+ { "name": "6♥", "rank": 6, "suit": "hearts" },
62
+ { "name": "7♥", "rank": 7, "suit": "hearts" },
63
+ { "name": "8♥", "rank": 8, "suit": "hearts" },
64
+ { "name": "9♥", "rank": 9, "suit": "hearts" },
65
+ { "name": "10♥", "rank": 10, "suit": "hearts" },
66
+ { "name": "J♥", "rank": 11, "suit": "hearts" },
67
+ { "name": "Q♥", "rank": 12, "suit": "hearts" },
68
+ { "name": "K♥", "rank": 13, "suit": "hearts" },
69
+ { "name": "A♥", "rank": 14, "suit": "hearts" }
70
+ ]
71
+ }
72
+ ]
73
+ }
@@ -0,0 +1,30 @@
1
+ # Hearts
2
+
3
+ v1.0.0
4
+
5
+ A trick-taking game for 4 players. Points are bad: the player with the **fewest** points when the game ends wins.
6
+
7
+ ## Cards
8
+
9
+ A standard 52-card deck. Aces are high. Each heart (♥) is worth 1 point; the Queen of Spades (Q♠) is worth 13. All other cards are worth nothing.
10
+
11
+ ## Each hand
12
+
13
+ 1. **Deal.** Every player gets 13 cards.
14
+ 2. **Pass.** Each player chooses 3 cards to pass, without seeing what they receive. The direction rotates every hand: left, right, across, then no passing (hold), and repeats.
15
+ 3. **Tricks.** The player holding the 2♣ leads it to start the first trick. Going clockwise, each player plays one card. You must follow the suit that was led if you can; if you cannot, you may play any card. The highest card of the suit led wins the trick, and its winner leads the next trick.
16
+ 4. **Restrictions.**
17
+ - On the first trick, you may not play a heart or the Q♠ unless you have nothing else.
18
+ - You may not lead a heart until hearts are broken (a heart has been played on an earlier trick), unless your hand has only hearts.
19
+ 5. **Scoring.** After all 13 tricks, each player adds the points in the tricks they took.
20
+ - **Shooting the moon:** a player who takes all 13 hearts and the Q♠ scores 0, and every other player scores 26.
21
+
22
+ ## End of the game
23
+
24
+ When any player has 100 or more points at the end of a hand, the game ends. The player with the fewest points wins; tied low scores share the win.
25
+
26
+ ## Strategy notes
27
+
28
+ - Pass away the Q♠, high spades (A♠, K♠) and high hearts unless you have many low spades to protect them.
29
+ - Play just under the winning card to avoid taking tricks that carry points.
30
+ - When you cannot follow suit, it is a chance to drop the Q♠ or high hearts on someone else.