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