cribbage_game 0.1.0 → 1.0.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f082325ee2fa9bad9eb816d32295d2c7efd3e2fee83698184d48f2e966308263
4
- data.tar.gz: 8781e57a759011c7ecbc55e76b0965f2de5e076d0d2ffb7e571afe48364a107f
3
+ metadata.gz: 28b316ae4a4a4e446b114ea4f9615199078ead5aa27dcee342a7d75e53cdb5d1
4
+ data.tar.gz: a178f8da7d001822da9f8bcb13f5d9e291c63a91c53ba30b11d991dc7a93e5f9
5
5
  SHA512:
6
- metadata.gz: 4f0aabf20f150a33c422a5670209fedda21a51a275f1a7114eaa07eaac7a786fd6d206f31a44ebdb6b11827fe4f98950cdcde18ede6c3e0086f381cac1f15593
7
- data.tar.gz: c501dd8c015ab55f95ca9bf536042616a52b18c0718f7f135c6920c5917fbcf05df5afee1f619bc140e98271d4ae4817cfeb240b4583df97b7c94107ce438e50
6
+ metadata.gz: 7ca57a72414bd5652bbb4d0e86e20cbc22fff89f17ed508c949785adaa17a83c0fec9c3c81ef04817fd7c10e3917c5b13cb55ecb34ca96917ac4da1eb9190011
7
+ data.tar.gz: c70f594049fa96113247949d3491c222f5200fe86328ae4e0583bb405737c04412a65c2d17733ff955b5ddc1db30397e49b15096e88d28e588704cfd4103d300
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
- ## [Unreleased]
1
+ # Changelog
2
2
 
3
- ## [0.1.0] - 2022-11-20
3
+ ## 1.0.0 - 2026-09-07
4
4
 
5
- - Initial release
5
+ - Added support for three-player games.
6
+ - Added interactive AI gameplay.
7
+ - Ruby 3.0.0 is now the minimum supported version.
8
+ - Preserved the existing two-player API and behavior.
9
+
10
+ ## 0.1.0 - 2023-12-07
11
+
12
+ - Initial release.
13
+ - Added support for two-player games.
data/Gemfile CHANGED
@@ -4,8 +4,3 @@ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in cribbage_game.gemspec
6
6
  gemspec
7
-
8
- gem "rake", "~> 13.0"
9
-
10
-
11
- gem "standard", "~> 1.3"
data/Gemfile.lock CHANGED
@@ -1,73 +1,120 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cribbage_game (0.1.0)
4
+ cribbage_game (1.0.0)
5
5
  aasm (~> 5.0, >= 5.0.6)
6
- card_deck
6
+ card_deck (~> 4.02)
7
+ logger (>= 1.7)
8
+ ostruct (~> 0.6.0)
7
9
  sum_all_number_combinations (~> 0.1.2)
8
10
 
9
11
  GEM
10
12
  remote: https://rubygems.org/
11
13
  specs:
12
- aasm (5.4.0)
14
+ aasm (5.5.2)
13
15
  concurrent-ruby (~> 1.0)
14
- ast (2.4.2)
16
+ ast (2.4.3)
15
17
  card_deck (4.02)
16
- concurrent-ruby (1.1.10)
17
- diff-lcs (1.3)
18
- json (2.6.2)
19
- parallel (1.22.1)
20
- parser (3.1.2.1)
18
+ cgi (0.5.2)
19
+ concurrent-ruby (1.3.8)
20
+ date (3.5.1)
21
+ diff-lcs (1.6.2)
22
+ erb (4.0.4.1)
23
+ cgi (>= 0.3.3)
24
+ io-console (0.9.2)
25
+ irb (1.18.0)
26
+ pp (>= 0.6.0)
27
+ prism (>= 1.3.0)
28
+ rdoc (>= 4.0.0)
29
+ reline (>= 0.4.2)
30
+ json (2.21.2)
31
+ language_server-protocol (3.17.0.6)
32
+ lint_roller (1.1.0)
33
+ logger (1.7.0)
34
+ ostruct (0.6.3)
35
+ parallel (1.28.0)
36
+ parser (3.3.12.0)
21
37
  ast (~> 2.4.1)
38
+ racc
39
+ pp (0.6.4)
40
+ prettyprint
41
+ prettyprint (0.2.0)
42
+ prism (1.9.0)
43
+ psych (5.5.0)
44
+ date
45
+ stringio
46
+ racc (1.8.1)
22
47
  rainbow (3.1.1)
23
- rake (13.0.6)
24
- regexp_parser (2.6.1)
25
- rexml (3.2.5)
26
- rspec (3.9.0)
27
- rspec-core (~> 3.9.0)
28
- rspec-expectations (~> 3.9.0)
29
- rspec-mocks (~> 3.9.0)
30
- rspec-core (3.9.0)
31
- rspec-support (~> 3.9.0)
32
- rspec-expectations (3.9.0)
48
+ rake (13.4.2)
49
+ rdoc (7.2.0)
50
+ erb
51
+ psych (>= 4.0.0)
52
+ tsort
53
+ regexp_parser (2.12.0)
54
+ reline (0.7.0)
55
+ io-console (~> 0.5)
56
+ rspec (3.13.2)
57
+ rspec-core (~> 3.13.0)
58
+ rspec-expectations (~> 3.13.0)
59
+ rspec-mocks (~> 3.13.0)
60
+ rspec-core (3.13.6)
61
+ rspec-support (~> 3.13.0)
62
+ rspec-expectations (3.13.5)
33
63
  diff-lcs (>= 1.2.0, < 2.0)
34
- rspec-support (~> 3.9.0)
35
- rspec-mocks (3.9.0)
64
+ rspec-support (~> 3.13.0)
65
+ rspec-mocks (3.13.8)
36
66
  diff-lcs (>= 1.2.0, < 2.0)
37
- rspec-support (~> 3.9.0)
38
- rspec-support (3.9.0)
39
- rubocop (1.39.0)
67
+ rspec-support (~> 3.13.0)
68
+ rspec-support (3.13.7)
69
+ rubocop (1.88.2)
40
70
  json (~> 2.3)
41
- parallel (~> 1.10)
42
- parser (>= 3.1.2.1)
71
+ language_server-protocol (~> 3.17.0.2)
72
+ lint_roller (~> 1.1.0)
73
+ parallel (>= 1.10)
74
+ parser (>= 3.3.0.2)
43
75
  rainbow (>= 2.2.2, < 4.0)
44
- regexp_parser (>= 1.8, < 3.0)
45
- rexml (>= 3.2.5, < 4.0)
46
- rubocop-ast (>= 1.23.0, < 2.0)
76
+ regexp_parser (>= 2.9.3, < 3.0)
77
+ rubocop-ast (>= 1.49.0, < 2.0)
47
78
  ruby-progressbar (~> 1.7)
48
- unicode-display_width (>= 1.4.0, < 3.0)
49
- rubocop-ast (1.23.0)
50
- parser (>= 3.1.1.0)
51
- rubocop-performance (1.15.0)
52
- rubocop (>= 1.7.0, < 2.0)
53
- rubocop-ast (>= 0.4.0)
54
- ruby-progressbar (1.11.0)
55
- standard (1.18.0)
56
- rubocop (= 1.39.0)
57
- rubocop-performance (= 1.15.0)
79
+ unicode-display_width (>= 2.4.0, < 4.0)
80
+ rubocop-ast (1.50.0)
81
+ parser (>= 3.3.7.2)
82
+ prism (~> 1.7)
83
+ rubocop-performance (1.26.1)
84
+ lint_roller (~> 1.1)
85
+ rubocop (>= 1.75.0, < 2.0)
86
+ rubocop-ast (>= 1.47.1, < 2.0)
87
+ ruby-progressbar (1.13.0)
88
+ standard (1.56.0)
89
+ language_server-protocol (~> 3.17.0.2)
90
+ lint_roller (~> 1.0)
91
+ rubocop (~> 1.88.0)
92
+ standard-custom (~> 1.0.0)
93
+ standard-performance (~> 1.8)
94
+ standard-custom (1.0.2)
95
+ lint_roller (~> 1.0)
96
+ rubocop (~> 1.50)
97
+ standard-performance (1.9.0)
98
+ lint_roller (~> 1.1)
99
+ rubocop-performance (~> 1.26.0)
100
+ stringio (3.2.0)
58
101
  sum_all_number_combinations (0.1.2)
59
- unicode-display_width (2.3.0)
102
+ tsort (0.2.0)
103
+ unicode-display_width (3.2.0)
104
+ unicode-emoji (~> 4.1)
105
+ unicode-emoji (4.2.0)
60
106
 
61
107
  PLATFORMS
62
108
  ruby
63
- x64-mingw32
109
+ x64-unknown
64
110
  x86-mingw32
65
111
 
66
112
  DEPENDENCIES
67
113
  cribbage_game!
114
+ irb
68
115
  rake (~> 13.0)
69
116
  rspec (~> 3.0)
70
- standard (~> 1.3)
117
+ standard (~> 1.56)
71
118
 
72
119
  BUNDLED WITH
73
120
  2.3.6
data/README.md CHANGED
@@ -1,27 +1,17 @@
1
1
  # CribbageGame
2
2
 
3
- Two player cribbage game. https://bicyclecards.com/how-to-play/cribbage/
3
+ Ruby [cribbage](https://bicyclecards.com/how-to-play/cribbage/) game engine. Includes a `play_with_ai` game runner for play against AI players using OpenAI models
4
4
 
5
5
  ## Installation
6
6
 
7
- Add this line to your application's Gemfile:
8
-
9
- ```ruby
10
- gem 'cribbage_game'
7
+ ```sh
8
+ gem install cribbage_game
11
9
  ```
12
10
 
13
- And then execute:
14
-
15
- $ bundle install
16
-
17
- Or install it yourself as:
18
-
19
- $ gem install cribbage_game
20
-
21
11
  ## Usage
22
12
 
23
13
  ```ruby
24
- game = Game.new
14
+ game = CribbageGame::Game.new({ number_of_players: 2 })
25
15
  game.cut_for_deal # randomly determines dealer
26
16
  game.deal # deals 6 cards to game.dealer.hand & game.opponent.hand
27
17
  game.dealer.discard ["7d", "qc"] # takes cards from hand and puts in crib
@@ -35,19 +25,31 @@ game.submit_hand_scores(game.dealer)
35
25
  game.submit_crib_scores # adds the crib's score to game.dealer.total_score
36
26
  game.deal # begin next round
37
27
  ```
38
- ### Initialization options
39
- ```ruby
40
- game = Game.new ({
41
- points_to_win: 35, # overrides default of 121
42
- game_over_cb: lambda { puts "game over" } # function called on game_over
43
- })
44
- ```
45
28
 
46
29
  ## Development
47
30
 
48
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
31
+ ```sh
32
+ bundle install
33
+ bundle exec rake spec # tests
34
+ bundle exec rake standard # linting
35
+ ./bin/console # shell script to start an interactive console
36
+ ```
37
+
38
+ ### Play against AI
49
39
 
50
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
40
+ ## Dependencies
41
+ ```sh
42
+ bundle install
43
+ gem install ruby-openai
44
+ export OPENAI_API_KEY=your-api-key
45
+ ```
46
+
47
+ ## Playing the game
48
+ ```sh
49
+ bin/play_with_ai --help # print command line options
50
+ bin/play_with_ai --players 3 --name Kevin --points-to-win 32 # initialize game
51
+ rdbg -O --port 12345 bin/play_with_ai # run with debugger
52
+ ```
51
53
 
52
54
  ## Contributing
53
55
 
data/bin/play_with_ai ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "cribbage_game/play_with_ai"
6
+
7
+ begin
8
+ HumanVsAiRunner.from_argv(ARGV).run
9
+ rescue ArgumentError => error
10
+ warn error.message
11
+ exit 1
12
+ end
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Two player cribbage card game"
12
12
  spec.homepage = "https://github.com/buchowski/ruby-cribbage"
13
13
  spec.license = "MIT"
14
- spec.required_ruby_version = ">= 2.6.0"
14
+ spec.required_ruby_version = ">= 3.0.0"
15
15
 
16
16
  spec.metadata["homepage_uri"] = spec.homepage
17
17
  spec.metadata["source_code_uri"] = "https://github.com/buchowski/ruby-cribbage"
@@ -20,18 +20,25 @@ Gem::Specification.new do |spec|
20
20
  # Specify which files should be added to the gem when it is released.
21
21
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
22
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
- `git ls-files -z`.split("\x0").reject do |f|
24
- (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
23
+ files = `git ls-files -z`.split("\x0").reject do |f|
24
+ (f == __FILE__) || f.end_with?(".gem") || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
25
25
  end
26
+ (files + ["bin/play_with_ai"]).uniq
26
27
  end
27
- spec.bindir = "exe"
28
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
+ spec.bindir = "bin"
29
+ spec.executables = ["play_with_ai"]
29
30
  spec.require_paths = ["lib"]
30
31
 
31
- spec.add_dependency "card_deck"
32
+ spec.add_dependency "card_deck", "~> 4.02"
32
33
  spec.add_dependency "aasm", "~> 5.0", ">= 5.0.6"
33
34
  spec.add_dependency "sum_all_number_combinations", "~> 0.1.2"
35
+ spec.add_dependency "ostruct", "~> 0.6.0"
36
+ spec.add_dependency "logger", ">= 1.7"
37
+
34
38
  spec.add_development_dependency "rspec", "~> 3.0"
39
+ spec.add_development_dependency "rake", "~> 13.0"
40
+ spec.add_development_dependency "standard", "~> 1.56"
41
+ spec.add_development_dependency "irb"
35
42
 
36
43
  # For more information and examples about making a new gem, check out our
37
44
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -4,11 +4,25 @@ module CribbageGame
4
4
  class Fsm
5
5
  include AASM
6
6
 
7
+ attr_accessor :game
8
+
9
+ def initialize(game = nil)
10
+ @game = game
11
+ end
12
+
13
+ def three_player?
14
+ game&.players&.size == 3
15
+ end
16
+
17
+ def two_player?
18
+ !three_player?
19
+ end
20
+
7
21
  aasm do
8
22
  state :cutting_for_deal, initial: true
9
23
  state :flipping_top_card
10
24
  state :discarding, :dealing, :playing
11
- state :scoring_opponent_hand, :scoring_dealer_hand, :scoring_dealer_crib
25
+ state :scoring_opponent_hand, :scoring_opponent_2_hand, :scoring_dealer_hand, :scoring_dealer_crib
12
26
  state :game_over
13
27
 
14
28
  event :deal do
@@ -30,7 +44,9 @@ module CribbageGame
30
44
 
31
45
  event :score do
32
46
  transitions from: :playing, to: :scoring_opponent_hand
33
- transitions from: :scoring_opponent_hand, to: :scoring_dealer_hand
47
+ transitions from: :scoring_opponent_hand, to: :scoring_dealer_hand, if: :two_player?
48
+ transitions from: :scoring_opponent_hand, to: :scoring_opponent_2_hand, if: :three_player?
49
+ transitions from: :scoring_opponent_2_hand, to: :scoring_dealer_hand
34
50
  transitions from: :scoring_dealer_hand, to: :scoring_dealer_crib
35
51
  end
36
52
 
@@ -1,7 +1,7 @@
1
- require "cribbage_game/card"
2
- require "cribbage_game/score"
3
- require "cribbage_game/player"
4
- require "cribbage_game/fsm"
1
+ require_relative "card"
2
+ require_relative "score"
3
+ require_relative "player"
4
+ require_relative "fsm"
5
5
 
6
6
  module CribbageGame
7
7
  class NotYourTurnError < RuntimeError; end
@@ -20,9 +20,15 @@ module CribbageGame
20
20
  @points_to_win = args[:points_to_win] || 121
21
21
  @auto_score = args[:is_auto_score] || true
22
22
  @game_over_cb = args[:game_over_cb] || lambda {}
23
- @players = 2.times.map { |id| Player.new self, id.to_s }
23
+
24
+ @number_of_players = args.fetch(:number_of_players, 2)
25
+ unless [2, 3].include?(@number_of_players)
26
+ raise ArgumentError, ":number_of_players must be either 2 or 3"
27
+ end
28
+
29
+ @players = @number_of_players.times.map { |id| Player.new self, id.to_s }
24
30
  @score_client = Score.new self
25
- @fsm = Fsm.new
31
+ @fsm = Fsm.new(self)
26
32
  @deck = self.class.get_cards_hash CardDeck::Deck.new.cards
27
33
  @round = 0
28
34
  @winner = nil
@@ -30,6 +36,10 @@ module CribbageGame
30
36
  reset_cards
31
37
  end
32
38
 
39
+ def scorecards
40
+ @score_client.scorecards
41
+ end
42
+
33
43
  def self.get_cards_hash cards
34
44
  cards.each_with_object({}) do |card, card_map|
35
45
  card_map[card.id] = card
@@ -40,16 +50,33 @@ module CribbageGame
40
50
  card_ids.to_h { |id| [id, true] }
41
51
  end
42
52
 
53
+ def is_three_player_game?
54
+ @number_of_players == 3
55
+ end
56
+
57
+ def player_index(player)
58
+ @players.index(player)
59
+ end
60
+
61
+ def next_player(player)
62
+ current_index = player_index(player)
63
+ @players[(current_index + 1) % @players.size]
64
+ end
65
+
43
66
  def undealt_card_ids
44
- @deck.keys.difference(@dealer.hand.keys, opponent.hand.keys)
67
+ dealt_cards = @players.flat_map { |player| player.hand.keys }
68
+ dealt_cards += @crib if @crib
69
+ dealt_cards << @cut_card if @cut_card
70
+
71
+ @deck.keys.difference(dealt_cards)
45
72
  end
46
73
 
47
74
  def opponent
48
- @players.difference([@dealer]).first
75
+ next_player(@dealer)
49
76
  end
50
77
 
51
- def not_whose_turn
52
- @players.difference([@whose_turn]).first
78
+ def opponent_2
79
+ is_three_player_game? ? next_player(opponent) : nil
53
80
  end
54
81
 
55
82
  def can_play_card? card_id
@@ -65,16 +92,12 @@ module CribbageGame
65
92
  card_ids.map { |card_id| can_play_card?(card_id) }.any?
66
93
  end
67
94
 
68
- def can_whose_turn_play?
69
- has_playable_card? @whose_turn.hand
70
- end
71
-
72
- def can_not_whose_turn_play?
73
- has_playable_card? not_whose_turn.hand
95
+ def can_player_play? player
96
+ player.nil? ? false : has_playable_card?(player.hand)
74
97
  end
75
98
 
76
- def can_either_player_play?
77
- can_whose_turn_play? || can_not_whose_turn_play?
99
+ def can_any_player_play?
100
+ @players.any? { |player| can_player_play? player }
78
101
  end
79
102
 
80
103
  def pile_score
@@ -105,16 +128,24 @@ module CribbageGame
105
128
  raise WrongStateError if !@fsm.cutting_for_deal?
106
129
 
107
130
  @dealer = @players.sample
108
- @whose_turn = opponent
131
+ @whose_turn = next_player(@dealer)
109
132
  @fsm.deal
110
133
  end
111
134
 
112
135
  def deal
113
136
  raise WrongStateError if !@fsm.dealing?
114
137
 
115
- random_card_ids = @deck.keys.sample 12
116
- @dealer.hand = self.class.get_hand_hash random_card_ids.slice!(0, 6)
117
- opponent.hand = self.class.get_hand_hash random_card_ids.slice!(0, 6)
138
+ cards_per_player = is_three_player_game? ? 5 : 6
139
+ cards_to_draw = @players.size * cards_per_player
140
+ # draw an extra card for the crib in three player game
141
+ cards_to_draw += 1 if is_three_player_game?
142
+
143
+ random_card_ids = @deck.keys.sample cards_to_draw
144
+ @players.each do |player|
145
+ player.hand = self.class.get_hand_hash random_card_ids.slice!(0, cards_per_player)
146
+ end
147
+
148
+ @crib << random_card_ids.shift if is_three_player_game?
118
149
 
119
150
  @fsm.discard
120
151
  end
@@ -124,13 +155,22 @@ module CribbageGame
124
155
 
125
156
  player.hand[card_id] = false
126
157
  @pile << card_id
127
- is_last_card = !can_either_player_play?
158
+ is_last_card = !can_any_player_play?
128
159
  @score_client.score_play(@pile, is_last_card, player)
129
160
  @score_client.submit_play_score player
130
161
  return if we_have_a_winner?
131
162
 
132
163
  @pile = [] if is_last_card
133
- @whose_turn = not_whose_turn if can_not_whose_turn_play?
164
+ next_player = next_player(player)
165
+ next_next_player = is_three_player_game? ? next_player(next_player) : nil
166
+ @whose_turn =
167
+ if can_player_play? next_player
168
+ next_player
169
+ elsif can_player_play? next_next_player
170
+ next_next_player
171
+ else
172
+ player
173
+ end
134
174
  @fsm.score if player_hands_empty?
135
175
  end
136
176
 
@@ -138,7 +178,7 @@ module CribbageGame
138
178
  raise WrongStateError if !@fsm.flipping_top_card?
139
179
 
140
180
  @cut_card = top_card || undealt_card_ids.sample
141
- @whose_turn = opponent
181
+ @whose_turn = next_player(@dealer)
142
182
  @score_client.score_crib
143
183
  @score_client.score_hands
144
184
  @fsm.play
@@ -157,9 +197,11 @@ module CribbageGame
157
197
  def submit_hand_scores player
158
198
  raise NotYourTurnError if player == @dealer && !@fsm.scoring_dealer_hand?
159
199
  raise NotYourTurnError if player == opponent && !@fsm.scoring_opponent_hand?
200
+ raise NotYourTurnError if player == opponent_2 && !@fsm.scoring_opponent_2_hand?
160
201
 
161
202
  @score_client.submit_scores player, :hand
162
203
  return if we_have_a_winner?
204
+
163
205
  @fsm.score
164
206
  end
165
207
 
@@ -169,7 +211,7 @@ module CribbageGame
169
211
  @score_client.submit_scores @dealer, :crib
170
212
  return if we_have_a_winner?
171
213
  reset_cards
172
- @dealer = opponent
214
+ @dealer = next_player(@dealer)
173
215
  @fsm.deal
174
216
  end
175
217
 
@@ -0,0 +1,305 @@
1
+ require "json"
2
+ require "optparse"
3
+ require_relative "game"
4
+
5
+ class HumanVsAiRunner
6
+ MODEL = "gpt-5-nano"
7
+ USAGE = "Usage: play_with_ai [--name NAME] [--players 2|3] [--points-to-win POINTS] [--all-ai] [--log-ai-inputs]"
8
+
9
+ DISCARD_TOOL = {
10
+ "type" => "function",
11
+ "name" => "discard",
12
+ "description" => "Discard the required number of cards from the player's hand",
13
+ "parameters" => {
14
+ "type" => "object",
15
+ "properties" => {
16
+ "card_ids" => {
17
+ "type" => "array",
18
+ "items" => {"type" => "string"},
19
+ "description" => "The card identifiers to discard"
20
+ }
21
+ },
22
+ "required" => ["card_ids"],
23
+ "additionalProperties" => false
24
+ }
25
+ }.freeze
26
+
27
+ PLAY_CARD_TOOL = {
28
+ "type" => "function",
29
+ "name" => "play_card",
30
+ "description" => "Play one legal card from the player's hand",
31
+ "parameters" => {
32
+ "type" => "object",
33
+ "properties" => {
34
+ "card_id" => {
35
+ "type" => "string",
36
+ "description" => "A card identifier such as 5h, kd, or 10s"
37
+ }
38
+ },
39
+ "required" => ["card_id"],
40
+ "additionalProperties" => false
41
+ }
42
+ }.freeze
43
+
44
+ def initialize(number_of_players: 2, points_to_win: 121, name: nil, all_ai: false, log_ai_inputs: false, client: nil, input: $stdin, output: $stdout)
45
+ unless [2, 3].include?(number_of_players)
46
+ raise ArgumentError, "number_of_players must be either 2 or 3"
47
+ end
48
+
49
+ @number_of_players = number_of_players
50
+ @points_to_win = points_to_win
51
+ @all_ai = all_ai
52
+ @human_name = name.to_s.strip
53
+ @human_name = "Human Player" if @human_name.empty?
54
+ @log_ai_inputs = log_ai_inputs
55
+ @client = client || default_client
56
+ @input = input
57
+ @output = output
58
+ end
59
+
60
+ def self.from_argv(arguments)
61
+ options = {name: nil, number_of_players: 2, points_to_win: 121, all_ai: false, log_ai_inputs: false}
62
+ parser = OptionParser.new do |opts|
63
+ opts.banner = USAGE
64
+ opts.on_tail("-h", "--help", "Show available options") do
65
+ puts opts
66
+ exit
67
+ end
68
+ opts.on("--name NAME", String, "Human player's name (default: Human Player)") { |name| options[:name] = name }
69
+ opts.on("--players COUNT", Integer, "Number of players (2 or 3; default: 2)") do |count|
70
+ options[:number_of_players] = count
71
+ end
72
+ opts.on("--number-of-players COUNT", Integer, "Alias for --players (default: 2)") do |count|
73
+ options[:number_of_players] = count
74
+ end
75
+ opts.on("--points-to-win POINTS", Integer, "Points needed to win (default: 121)") do |points|
76
+ options[:points_to_win] = points
77
+ end
78
+ opts.on("--log-ai-inputs", "Log AI inputs and failed tool attempts") do
79
+ options[:log_ai_inputs] = true
80
+ end
81
+ opts.on("--all-ai", "Watch all players play by AI") do
82
+ options[:all_ai] = true
83
+ end
84
+ end
85
+
86
+ remaining_arguments = parser.parse!(arguments.dup)
87
+ raise ArgumentError, "Unexpected arguments: #{remaining_arguments.join(" ")}" unless remaining_arguments.empty?
88
+
89
+ new(**options)
90
+ rescue OptionParser::ParseError, ArgumentError => error
91
+ raise ArgumentError, "#{error.message}\n#{USAGE}"
92
+ end
93
+
94
+ def run
95
+ game = CribbageGame::Game.new(number_of_players: @number_of_players, points_to_win: @points_to_win)
96
+ human = @all_ai ? nil : game.players.first
97
+ human.name = @human_name unless human.nil?
98
+ @human_player = human
99
+
100
+ game.cut_for_deal
101
+ until game.fsm.game_over?
102
+ announce_round(game, human)
103
+ game.deal
104
+ discard_hands(game, human)
105
+ game.flip_top_card
106
+ log("Cut card: #{game.cut_card}")
107
+ play_cards(game, human)
108
+ break if game.fsm.game_over?
109
+
110
+ log("")
111
+ score_round(game)
112
+ log("")
113
+ end
114
+
115
+ log("Winner: #{player_label(game.winner)} with #{game.winner.total_score} points.")
116
+ game
117
+ end
118
+
119
+ private
120
+
121
+ def default_client
122
+ require "openai"
123
+ OpenAI::Client.new(
124
+ access_token: ENV.fetch("OPENAI_API_KEY"),
125
+ log_errors: true
126
+ )
127
+ rescue LoadError
128
+ raise LoadError, "The AI runner requires the ruby-openai gem"
129
+ end
130
+
131
+ def discard_hands(game, human)
132
+ game.players.each do |player|
133
+ count = game.is_three_player_game? ? 1 : 2
134
+ if player == human
135
+ human_discard(player, count)
136
+ else
137
+ ai_discard(player, count)
138
+ end
139
+ end
140
+ end
141
+
142
+ def human_discard(player, count)
143
+ loop do
144
+ ids = prompt("Your hand: #{available_cards(player).join(", ")}\nDiscard #{count} card#{"s" if count > 1}: ")
145
+ .split(/[\s,]+/)
146
+ .reject(&:empty?)
147
+
148
+ if ids.size == count && ids.uniq.size == count && ids.all? { |id| player.hand[id] }
149
+ player.discard(ids)
150
+ return
151
+ end
152
+
153
+ log("Please enter exactly #{count} card IDs from your hand.")
154
+ end
155
+ end
156
+
157
+ def ai_discard(player, count)
158
+ arguments = ai_tool_arguments(
159
+ player,
160
+ DISCARD_TOOL,
161
+ "Choose #{count} cards to discard from your hand: #{available_cards(player).join(", ")} ."
162
+ ) do |data|
163
+ ids = data["card_ids"]
164
+ ids.is_a?(Array) && ids.size == count && ids.uniq.size == count && ids.all? { |id| player.hand[id] }
165
+ end
166
+
167
+ ids = arguments.fetch("card_ids")
168
+ log("#{player_label(player)} discards: #{ids.join(", ")}")
169
+ player.discard(ids)
170
+ end
171
+
172
+ def play_cards(game, human)
173
+ while game.fsm.playing? && !game.player_hands_empty?
174
+ player = game.whose_turn
175
+ playable = available_cards(player).select { |id| game.can_play_card?(id) }
176
+
177
+ card_id = if player == human
178
+ prompt_for_play(game, player, playable)
179
+ else
180
+ ai_play(game, player, playable)
181
+ end
182
+
183
+ pile_was_active = !game.pile.empty?
184
+ pile_total_after_play = game.pile_score + game.deck.fetch(card_id).value
185
+ game.play_card(player, card_id)
186
+ play_score = game.scorecards.fetch(game.round).fetch(:play).last
187
+ log("#{player_label(player)} played #{card_id}; pile total #{pile_total_after_play}")
188
+ if play_score[:points] > 0
189
+ log("#{player_label(player)} scored #{play_score[:points]} point#{"s" if play_score[:points] != 1} during play: #{format_score_reasons(play_score[:reasons])}.")
190
+ end
191
+ if pile_was_active && game.pile.empty?
192
+ log("")
193
+ end
194
+ end
195
+ end
196
+
197
+ def prompt_for_play(game, player, playable)
198
+ loop do
199
+ card_id = prompt(
200
+ "Your cards: #{available_cards(player).join(", ")} " \
201
+ "(playable: #{playable.join(", ")}; pile: #{game.pile_score})\nPlay card: "
202
+ ).strip.downcase
203
+ return card_id if playable.include?(card_id)
204
+
205
+ log("Choose one of the playable cards: #{playable.join(", ")}.")
206
+ end
207
+ end
208
+
209
+ def ai_play(game, player, playable)
210
+ arguments = ai_tool_arguments(
211
+ player,
212
+ PLAY_CARD_TOOL,
213
+ "Choose one card to play. Playable cards: #{playable.join(", ")}. " \
214
+ "Current pile total: #{game.pile_score}."
215
+ ) { |data| playable.include?(data["card_id"]) }
216
+
217
+ arguments.fetch("card_id")
218
+ end
219
+
220
+ def score_round(game)
221
+ round_scorecard = game.scorecards.fetch(game.round)
222
+ pegging_scores = round_scorecard.fetch(:play, []).group_by { |play| play.fetch(:player_id) }
223
+ scoring_players = [game.opponent]
224
+ scoring_players << game.opponent_2 if game.is_three_player_game?
225
+ scoring_players << game.dealer
226
+
227
+ scoring_players.each do |player|
228
+ pegging_points = pegging_scores.fetch(player.id, []).sum { |play| play.fetch(:points) }
229
+ log("#{player_label(player)} pegging score: #{pegging_points} point#{"s" if pegging_points != 1}.")
230
+ hand_score = round_scorecard.fetch(player.id).fetch(:hand).fetch(:total_score)
231
+ log("#{player_label(player)} hand score: #{hand_score} point#{"s" if hand_score != 1}.")
232
+ game.submit_hand_scores(player)
233
+ break if game.fsm.game_over?
234
+ end
235
+ return if game.fsm.game_over?
236
+
237
+ crib_owner = game.dealer
238
+ crib_score = round_scorecard.fetch(crib_owner.id).fetch(:crib).fetch(:total_score)
239
+ game.submit_crib_scores
240
+ log("#{player_label(crib_owner)} crib score: #{crib_score} point#{"s" if crib_score != 1}.")
241
+ log("Player totals: #{game.players.map { |player| "#{player_label(player)}: #{player.total_score}" }.join(", ")}")
242
+ end
243
+
244
+ def ai_tool_arguments(player, tool, input)
245
+ 3.times do |attempt|
246
+ log("#{player_label(player)} input: #{input}") if @log_ai_inputs
247
+ response = @client.responses.create(
248
+ parameters: {
249
+ model: MODEL,
250
+ input: input,
251
+ tools: [tool],
252
+ tool_choice: "required"
253
+ }
254
+ )
255
+ function_call = response.dig("output")&.find { |item| item["type"] == "function_call" }
256
+ tool_name = function_call&.fetch("name", tool["name"])
257
+ raw_arguments = function_call&.fetch("arguments", nil)
258
+ arguments = raw_arguments && JSON.parse(raw_arguments)
259
+ return arguments if arguments && yield(arguments)
260
+
261
+ if @log_ai_inputs
262
+ log("#{player_label(player)} failed tool #{tool_name} with arguments #{arguments.inspect} (attempt #{attempt + 1}).")
263
+ end
264
+ rescue JSON::ParserError, KeyError, TypeError
265
+ if @log_ai_inputs
266
+ log("#{player_label(player)} failed tool #{tool_name || tool["name"]} with raw arguments #{raw_arguments.inspect} (attempt #{attempt + 1}).")
267
+ end
268
+ end
269
+
270
+ raise "#{player_label(player)} failed to provide a legal action"
271
+ end
272
+
273
+ def announce_round(game, human)
274
+ dealer = (game.dealer == human) ? @human_name : player_label(game.dealer)
275
+ log("Round #{game.round}: dealer is #{dealer}.")
276
+ end
277
+
278
+ def player_label(player)
279
+ return @human_name if player == @human_player
280
+
281
+ ai_names = @all_ai ? {"0" => "One", "1" => "Two", "2" => "Three"} : {"1" => "One", "2" => "Two", "0" => "Three"}
282
+ ai_name = ai_names.fetch(player.id, player.id)
283
+ "AI Player #{ai_name}"
284
+ end
285
+
286
+ def format_score_reasons(reasons)
287
+ reasons.map { |reason| "#{reason[:type].tr("_", " ")} (#{reason[:points]})" }.join(", ")
288
+ end
289
+
290
+ def available_cards(player)
291
+ player.hand.select { |_id, available| available }.keys
292
+ end
293
+
294
+ def prompt(message)
295
+ @output.print(message)
296
+ line = @input.gets
297
+ raise "Input ended before the game finished" if line.nil?
298
+
299
+ line
300
+ end
301
+
302
+ def log(message)
303
+ @output.puts(message)
304
+ end
305
+ end
@@ -1,4 +1,4 @@
1
- require "cribbage_game/score_utils"
1
+ require_relative "score_utils"
2
2
 
3
3
  module CribbageGame
4
4
  class Score
@@ -9,6 +9,8 @@ module CribbageGame
9
9
  @scorecards = {}
10
10
  end
11
11
 
12
+ attr_reader :scorecards
13
+
12
14
  def get_cards card_ids
13
15
  card_ids.map { |id| @game.deck[id] }
14
16
  end
@@ -47,9 +49,10 @@ module CribbageGame
47
49
  def score_play pile_ids, is_last_card, player
48
50
  @scorecards[@game.round] = @scorecards[@game.round] || {play: []}
49
51
  pile_cards = get_cards pile_ids
50
- points = get_pile_points(pile_cards, is_last_card) # pass pile cards not ids
52
+ score = get_pile_points(pile_cards, is_last_card) # pass pile cards not ids
51
53
  @scorecards[@game.round][:play] << {
52
- points: points,
54
+ points: score[:points],
55
+ reasons: score[:reasons],
53
56
  player_id: player.id,
54
57
  pile: pile_ids,
55
58
  card_id: pile_ids.last
@@ -112,15 +112,26 @@ module CribbageGame
112
112
 
113
113
  throw "31 should always be is_last_card" if pile_score == 31 && !is_last_card
114
114
 
115
- points = 0
116
- points = 1 if pile_score == 31
117
- points = 2 if pile_score == 15
115
+ reasons = []
116
+ if pile_score == 31
117
+ reasons << {type: "thirty_one", points: 2}
118
+ elsif pile_score == 15
119
+ reasons << {type: "fifteen", points: 2}
120
+ end
121
+ if is_last_card && pile_score != 31
122
+ reasons << {type: "last_card", points: 1}
123
+ end
124
+
125
+ consecutive_points = score_consecutive pile_cards
126
+ if consecutive_points > 0
127
+ consecutive_type = {2 => "pair", 6 => "three_of_a_kind", 12 => "four_of_a_kind"}.fetch(consecutive_points)
128
+ reasons << {type: consecutive_type, points: consecutive_points}
129
+ end
118
130
 
119
- points += 1 if is_last_card
120
- points += score_consecutive pile_cards
121
- points += score_pile_runs pile_cards
131
+ run_points = score_pile_runs pile_cards
132
+ reasons << {type: "run", points: run_points} if run_points > 0
122
133
 
123
- points
134
+ {points: reasons.sum { |reason| reason[:points] }, reasons: reasons}
124
135
  end
125
136
  end
126
137
  end
@@ -1,3 +1,3 @@
1
1
  module CribbageGame
2
- VERSION = "0.1.0"
2
+ VERSION = "1.0.0"
3
3
  end
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cribbage_game
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - buchowski
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: card_deck
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '4.02'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '4.02'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: aasm
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -58,6 +58,34 @@ dependencies:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
60
  version: 0.1.2
61
+ - !ruby/object:Gem::Dependency
62
+ name: ostruct
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.6.0
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.6.0
75
+ - !ruby/object:Gem::Dependency
76
+ name: logger
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '1.7'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.7'
61
89
  - !ruby/object:Gem::Dependency
62
90
  name: rspec
63
91
  requirement: !ruby/object:Gem::Requirement
@@ -72,10 +100,53 @@ dependencies:
72
100
  - - "~>"
73
101
  - !ruby/object:Gem::Version
74
102
  version: '3.0'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rake
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '13.0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '13.0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: standard
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '1.56'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '1.56'
131
+ - !ruby/object:Gem::Dependency
132
+ name: irb
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
75
145
  description:
76
146
  email:
77
147
  - bucholz.adam@gmail.com
78
- executables: []
148
+ executables:
149
+ - play_with_ai
79
150
  extensions: []
80
151
  extra_rdoc_files: []
81
152
  files:
@@ -86,11 +157,13 @@ files:
86
157
  - LICENSE.txt
87
158
  - README.md
88
159
  - Rakefile
160
+ - bin/play_with_ai
89
161
  - cribbage_game.gemspec
90
162
  - lib/cribbage_game.rb
91
163
  - lib/cribbage_game/card.rb
92
164
  - lib/cribbage_game/fsm.rb
93
165
  - lib/cribbage_game/game.rb
166
+ - lib/cribbage_game/play_with_ai.rb
94
167
  - lib/cribbage_game/player.rb
95
168
  - lib/cribbage_game/score.rb
96
169
  - lib/cribbage_game/score_utils.rb
@@ -110,7 +183,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
110
183
  requirements:
111
184
  - - ">="
112
185
  - !ruby/object:Gem::Version
113
- version: 2.6.0
186
+ version: 3.0.0
114
187
  required_rubygems_version: !ruby/object:Gem::Requirement
115
188
  requirements:
116
189
  - - ">="