connect_n 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f576942d7bcaf43312e72ace215cb8f92fea00e6f615387151bd2f674c826267
4
+ data.tar.gz: 947c671cb8af51ca156c2fdd3d58d66dae4c0db3994fd4e82f91e3aa13dbb0b4
5
+ SHA512:
6
+ metadata.gz: 7ecfb976380fd669a3094a7825e91ed3d312a7ce557c84e6eaca4d2d6b5bfbb67df35357fcfbdfe943ee45b8c1f338f74fc26d01ed5d9c62963ca383e5a43062
7
+ data.tar.gz: 79c4840a51f839a87a0e8aaa9111e5dc7702628cddfca2c5fb93f255b9b144bd88dfe5f13be2f0ca73121b0fc1a9a49ec5c0a68a62216f06f0d0f48d52ddd089
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectN
4
+ class Board
5
+ attr_reader :table, :empty_disc, :cols_amount, :rows_amount
6
+
7
+ def initialize(rows_amount: 6, cols_amount: 7, empty_disc: '⚪')
8
+ @empty_disc = empty_disc
9
+
10
+ @rows_amount = rows_amount
11
+
12
+ @cols_amount = cols_amount
13
+
14
+ @table = Array.new(rows_amount) { Array.new(cols_amount) { empty_disc } }
15
+ end
16
+
17
+ def initialize_copy(original_board)
18
+ super
19
+ @empty_disc = @empty_disc.clone
20
+ @table = @table.clone.map(&:clone)
21
+ end
22
+
23
+ def drop_disc(disc, at_col:)
24
+ row_num = col_at(at_col).index(empty_disc)
25
+ row_at(row_num)[at_col] = disc
26
+ [row_num, at_col, disc]
27
+ end
28
+
29
+ def valid_pick?(pick)
30
+ valid_col?(pick) and col_at(pick).include?(empty_disc)
31
+ end
32
+
33
+ def filled?
34
+ !table.flatten.include?(empty_disc)
35
+ end
36
+
37
+ def draw
38
+ table.each{ |row| draw_border || draw_row(row) }
39
+ draw_border
40
+ draw_col_nums
41
+ end
42
+
43
+ def cell_at(row_num, col_num)
44
+ return unless valid_cell?(row_num, col_num)
45
+
46
+ row_at(row_num)[col_num]
47
+ end
48
+
49
+ def cols = table.transpose.map(&:reverse)
50
+
51
+ def rows = table
52
+
53
+ def col_at(n)
54
+ return unless valid_col?(n)
55
+
56
+ table.transpose[n].reverse
57
+ end
58
+
59
+ def row_at(n)
60
+ return unless valid_row?(n)
61
+
62
+ n = rows_amount - 1 - n
63
+ table[n]
64
+ end
65
+
66
+ private
67
+
68
+ def draw_border
69
+ cols_amount.times { print '+----' } and puts '+'
70
+ end
71
+
72
+ def draw_row(row)
73
+ puts '| ' + row.join(' | ') + ' |'
74
+ end
75
+
76
+ def draw_col_nums
77
+ print '|'
78
+ (1..cols_amount).each do |num|
79
+ num.even? ? print(' ') : print(' ')
80
+ print num
81
+ num.odd? ? print(' |') : print(' |')
82
+ end
83
+ puts
84
+ end
85
+
86
+ def valid_cell?(row_num, col_num) = valid_row?(row_num) && valid_col?(col_num)
87
+
88
+ def valid_row?(n) = n.between?(0, rows_amount - 1)
89
+
90
+ def valid_col?(n) = n.between?(0, cols_amount - 1)
91
+ end
92
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../player/human_player/human_player'
4
+ require_relative '../player/computer_player/computer_player'
5
+ require_relative '../game/game'
6
+ require_relative '../board/board'
7
+
8
+ module ConnectN
9
+ class Demo
10
+ attr_reader :parameters, :game
11
+
12
+ def initialize
13
+ @parameters = { human_players: [] }
14
+ end
15
+
16
+ def launch
17
+ if !Game.games('connect_n_saved_games.yaml').empty? && Game.resume?
18
+ game_name = Game.select_game_name
19
+ @game = Game.load game_name, 'connect_n_saved_games.yaml'
20
+ return Game.resume game
21
+ end
22
+
23
+ setup_parameters
24
+ @game = if parameters[:mode] == 'multiplayer'
25
+ multiplayer_game
26
+ else
27
+ single_player_game
28
+ end
29
+ game.play('connect_n_saved_games.yaml')
30
+ end
31
+
32
+ private
33
+
34
+ def setup_parameters
35
+ parameters[:human_players].push [human_name, disc]
36
+
37
+ parameters[:cols_amount] = cols_amount
38
+ parameters[:rows_amount] = rows_amount
39
+ parameters[:min_to_win] = min_to_win
40
+
41
+ parameters[:mode] = mode
42
+
43
+ if parameters[:mode] == 'multiplayer'
44
+ parameters[:human_players].push [human_name => disc]
45
+ else
46
+ parameters[:difficulty] = difficulty
47
+ parameters[:human_starts?] = human_starts?
48
+ end
49
+ end
50
+
51
+ def cols_amount
52
+ PROMPT.ask 'How many columns do you want in the board?', convert: :int, default: 7
53
+ end
54
+
55
+ def rows_amount
56
+ PROMPT.ask 'How many rows do you want in the board?', convert: :int, default: 6
57
+ end
58
+
59
+ def min_to_win
60
+ PROMPT.ask(
61
+ 'Minimum number of aligned similar discs necessary to win : ',
62
+ convert: :int,
63
+ default: 4
64
+ )
65
+ end
66
+
67
+ def difficulty
68
+ PROMPT.slider 'Difficulty : ', [*0..10], default: 0
69
+ end
70
+
71
+ def mode
72
+ PROMPT.select('Choose a game mode : ', ['Single Player', 'Multiplayer']).downcase
73
+ end
74
+
75
+ def human_starts?
76
+ PROMPT.yes? 'Do you wanna play first?'
77
+ end
78
+
79
+ def disc
80
+ PROMPT.ask 'Enter a character that will represent your disc : ', default: '🔥' do |q|
81
+ q.validate(/^.?$/)
82
+ q.messages[:valid?] = 'Please enter a single character.'
83
+ end
84
+ end
85
+
86
+ def human_name
87
+ PROMPT.ask 'Enter your name : ', default: ENV['USER']
88
+ end
89
+
90
+ def multiplayer_game
91
+ human_players = multiplayer_players
92
+ board = Board.new(
93
+ cols_amount: parameters[:cols_amount],
94
+ rows_amount: parameters[:rows_amount]
95
+ )
96
+ Game.new(
97
+ board: board,
98
+ first_player: players.first,
99
+ second_player: players.last,
100
+ min_to_win: parameters[:min_to_win]
101
+ )
102
+ end
103
+
104
+ def single_player_game
105
+ board = Board.new(
106
+ cols_amount: parameters[:cols_amount],
107
+ rows_amount: parameters[:rows_amount]
108
+ )
109
+ players = single_player_players(board)
110
+ Game.new(
111
+ board: board,
112
+ first_player: players.first,
113
+ second_player: players.last,
114
+ min_to_win: parameters[:min_to_win]
115
+ )
116
+ end
117
+
118
+ def multiplayer_players
119
+ parameters[:human_players].map do |name, disc|
120
+ HumanPlayer.new(name: name, disc: disc)
121
+ end
122
+ end
123
+
124
+ def single_player_players(board)
125
+ name, disc = parameters[:human_players].first
126
+ players = [
127
+ HumanPlayer.new(name: name, disc: disc),
128
+ ComputerPlayer.new(
129
+ board: board,
130
+ opponent_disc: disc,
131
+ min_to_win: parameters[:min_to_win],
132
+ difficulty: parameters[:difficulty]
133
+ )
134
+ ]
135
+ parameters[:human_starts?] ? players : players.rotate
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-box'
4
+ require 'yaml'
5
+
6
+ require_relative '../player/human_player/human_player'
7
+ require_relative '../player/computer_player/computer_player'
8
+ require_relative '../board/board'
9
+ require_relative '../winnable/winnable'
10
+
11
+ module ConnectN
12
+ class Game
13
+ include Winnable
14
+
15
+ attr_reader :board, :players, :min_to_win
16
+
17
+ PERMITTED_CLASSES = [Symbol, Game, Board, HumanPlayer, ComputerPlayer]
18
+
19
+ def initialize(
20
+ board:,
21
+ first_player:,
22
+ second_player:,
23
+ min_to_win: 4
24
+ )
25
+ @board = board
26
+ @players = [first_player, second_player]
27
+ @min_to_win = min_to_win
28
+ end
29
+
30
+ def play(yaml_fn = nil)
31
+ welcome
32
+ loop do
33
+ current_player = players.first
34
+
35
+ pick = current_player.pick
36
+
37
+ break self.class.save(self, self.class.name_game, yaml_fn) if self.class.save? pick
38
+
39
+ next invalid_pick unless board.valid_pick? pick
40
+
41
+ row_num, col_num, disc = board.drop_disc(current_player.disc, at_col: pick)
42
+
43
+ clear_display
44
+ board.draw
45
+
46
+ break over(current_player) if over?(board, row_num, col_num, disc)
47
+
48
+ players.rotate!
49
+ end
50
+ end
51
+
52
+ def over?(board, row, col, disc) = win?(board, row, col, disc) || board.filled?
53
+
54
+ def play_again? = PROMPT.yes? 'Would you like to play again?'
55
+
56
+ def self.save?(input) = input == ':w'
57
+
58
+ def self.resume? = PROMPT.yes? 'Do you want to resume a game?'
59
+
60
+ def self.resume(game) = game.play
61
+
62
+ def self.load(name, yaml_fn) = games(yaml_fn)[name.to_sym]
63
+
64
+ def self.games(yaml_fn)
65
+ YAML.safe_load_file(
66
+ yaml_fn,
67
+ permitted_classes: PERMITTED_CLASSES,
68
+ aliases: true
69
+ ) || {}
70
+ end
71
+
72
+ def self.save(game, name, yaml_fn)
73
+ games = games(yaml_fn)
74
+ games[name.to_sym] = game
75
+ dumped_games = YAML.dump(games)
76
+ File.write(yaml_fn, dumped_games)
77
+ end
78
+
79
+ def self.name_game = PROMPT.ask 'Name your game : '
80
+
81
+ def self.select_game_name(yaml_fn)
82
+ games = games(yaml_fn).keys.map.with_index(1) { "#{_2} -> #{_1}" }
83
+ PROMPT.select 'Choose a saved game : ', games, convert: :sym
84
+ end
85
+
86
+ def welcome
87
+ text = <<~TEXT
88
+ Welcome to Connect Four
89
+ #{' '}
90
+ To play, Enter a number from 1
91
+ to #{board.cols_amount}
92
+ The number corresponds to the
93
+ column order starting from the
94
+ left.
95
+ Enter anything to proceed.
96
+ TEXT
97
+ puts TTY::Box.frame text, padding: 2, align: :center
98
+ board.draw
99
+ end
100
+
101
+ def invalid_pick
102
+ PROMPT.error 'Invalid Column Number'
103
+ end
104
+
105
+ def over(winner)
106
+ phrase = board.filled? ? 'It is a tie!' : "#{winner.name} has won!"
107
+ puts TTY::Box.sucess(phrase)
108
+ end
109
+
110
+ private
111
+
112
+ def clear_display
113
+ puts "\e[1;1H\e[2J"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../player'
4
+ require_relative '../../winnable/winnable'
5
+
6
+ module ConnectN
7
+ class ComputerPlayer < Player
8
+ include Winnable
9
+
10
+ attr_accessor :difficulty, :delay
11
+ attr_reader :opponent_disc, :min_to_win
12
+
13
+ def initialize(
14
+ name: 'Computer',
15
+ disc: '🧊',
16
+ min_to_win: 4,
17
+ difficulty: 0,
18
+ delay: 0,
19
+ board:,
20
+ opponent_disc:
21
+ )
22
+ super(name: name, disc: disc)
23
+ @min_to_win = min_to_win
24
+ @difficulty = difficulty
25
+ @delay = delay
26
+ @board = board
27
+ @opponent_disc = opponent_disc
28
+ @scores = { disc => 10000, opponent_disc => -10000 }
29
+ end
30
+
31
+ def pick
32
+ sleep delay
33
+ best_score = -Float::INFINITY
34
+ best_pick = nil
35
+ @board.cols_amount.times do |pick|
36
+ next unless @board.valid_pick?(pick)
37
+
38
+ board_copy = @board.clone
39
+ row_num, col_num = board_copy.drop_disc(disc, at_col: pick)
40
+ score = minimax(board_copy, disc, row_num, col_num)
41
+ if score >= best_score
42
+ best_score = score
43
+ best_pick = pick
44
+ end
45
+ end
46
+ best_pick
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :scores
52
+
53
+ def minimax(
54
+ current_board,
55
+ current_disc,
56
+ row_num,
57
+ col_num,
58
+ moves_counter = 0,
59
+ alpha = -Float::INFINITY,
60
+ beta = Float::INFINITY,
61
+ depth = difficulty,
62
+ maximizing: false
63
+ )
64
+ return calculate_win_score(current_disc, moves_counter) if win?(current_board, row_num, col_num, current_disc)
65
+
66
+ return 0 if current_board.filled?
67
+
68
+ return heuristic(current_board, current_disc) if depth <= 0
69
+
70
+ if maximizing
71
+ score = -Float::INFINITY
72
+ @board.cols_amount.times do |pick|
73
+ next unless current_board.valid_pick?(pick)
74
+
75
+ board_copy = current_board.clone
76
+ row_num, col_num = board_copy.drop_disc(disc, at_col: pick)
77
+ score = [
78
+ score,
79
+ minimax(board_copy, disc, row_num, col_num, moves_counter + 1, alpha, beta, depth - 1, maximizing: false)
80
+ ].max
81
+ break if score >= beta
82
+
83
+ alpha = [alpha, score].max
84
+ end
85
+ else
86
+ score = Float::INFINITY
87
+ @board.cols_amount.times do |pick|
88
+ next unless current_board.valid_pick?(pick)
89
+
90
+ board_copy = current_board.clone
91
+ row_num, col_num = board_copy.drop_disc(opponent_disc, at_col: pick)
92
+ score = [
93
+ score,
94
+ minimax(board_copy, opponent_disc, row_num, col_num, moves_counter + 1, alpha, beta, depth - 1, maximizing: true)
95
+ ].min
96
+ break if score <= alpha
97
+
98
+ beta = [beta, score].min
99
+ end
100
+ end
101
+ score
102
+ end
103
+
104
+ def calculate_win_score(disc, moves_counter)
105
+ score = scores[disc] * @board.cols_amount * @board.rows_amount
106
+ score / moves_counter.to_f
107
+ end
108
+
109
+ def heuristic(board, disc)
110
+ value = 0
111
+ board.rows.each do |row|
112
+ row.each_cons(min_to_win).each do |set_of_n|
113
+ disc_count = set_of_n.count disc
114
+ opponent_disc_count = set_of_n.count opponent_disc
115
+ next if [disc_count, opponent_disc_count].none?(&:zero?)
116
+
117
+ value += disc_count
118
+ value -= opponent_disc_count
119
+ end
120
+ end
121
+ value
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+
5
+ require_relative '../player'
6
+
7
+ module ConnectN
8
+ class HumanPlayer < Player
9
+ attr_accessor :save_key
10
+
11
+ def initialize(name: 'Human', disc: '🔥', save_key: ':w')
12
+ @save_key = save_key
13
+ super name: name, disc: disc
14
+ end
15
+
16
+ def pick
17
+ input = PROMPT.ask('Please enter a column number : ')
18
+ input == save_key ? input : input.to_i - 1
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectN
4
+ class Player
5
+ attr_accessor :name, :disc
6
+
7
+ def initialize(name:, disc:)
8
+ @name = name
9
+ @disc = disc
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+
5
+ module ConnectN
6
+ PROMPT = TTY::Prompt.new
7
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectN
4
+ module Winnable
5
+ def win?(board, row_num, col_num, disc)
6
+ return true if vertical_win?(board, row_num, col_num, disc)
7
+
8
+ # k = -1 => backward diagonal \
9
+ # k = 0 => horizontal --
10
+ # k = 1 => forward diagonal /
11
+ (-1..1).any? do |k|
12
+ l = l_discs(board, row_num, col_num, disc, k)
13
+ r = r_discs(board, row_num, col_num, disc, k)
14
+ l + r + 1 >= min_to_win
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def vertical_win?(board, row_num, col_num, disc)
21
+ v = v_discs(board, row_num, col_num, disc)
22
+ v + 1 >= min_to_win
23
+ end
24
+
25
+ # Count similar discs on the left side
26
+ def l_discs(board, row_num, col_num, disc, k)
27
+ range = (1...min_to_win)
28
+ amount = range.find { |i| board.cell_at(row_num - k * i, col_num - i) != disc }
29
+
30
+ # amount can be nil if there are more similar discs than min_to_win
31
+ # in which case it should count as a win
32
+ amount ||= min_to_win
33
+
34
+ # Subtract the dropped disc
35
+ amount -= 1
36
+ end
37
+
38
+ # Count similar discs on the right side
39
+ def r_discs(board, row_num, col_num, disc, k)
40
+ range = (1...min_to_win)
41
+ amount = range.find { |i| board.cell_at(row_num + k * i, col_num + i) != disc }
42
+
43
+ # amount can be nil if there are more similar discs than min_to_win
44
+ # in which case it should count as a win
45
+ amount ||= min_to_win
46
+
47
+ # Subtract the dropped disc
48
+ amount -= 1
49
+ end
50
+
51
+ # Count similar discs forming a vertical line
52
+ def v_discs(board, row_num, col_num, disc)
53
+ range = (1...min_to_win)
54
+ amount = range.find do |i|
55
+ board.cell_at(row_num - i, col_num) != disc
56
+ end
57
+
58
+ # amount can be nil if there are more similar discs than min_to_win
59
+ # in which case it should count as a win
60
+ amount ||= min_to_win
61
+
62
+ # Subtract the dropped disc
63
+ amount -= 1
64
+ end
65
+ end
66
+ end
data/lib/connect_n.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connect_n/prompt/prompt'
4
+ require_relative 'connect_n/player/human_player/human_player'
5
+ require_relative 'connect_n/player/computer_player/computer_player'
6
+ require_relative 'connect_n/demo/demo'
7
+ require_relative 'connect_n/game/game'
8
+ require_relative 'connect_n/board/board'
9
+
10
+ module ConnectN; end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: connect_n
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Lhoussaine (Jee-El) Ghallou
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-01-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: tty-prompt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.23.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.23.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: tty-box
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.7.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.7.0
41
+ description: A more general version of connect-4 where you try to connect N similar
42
+ discs. It comes with several features and a friendly API that allows you to customize
43
+ the game however you want!
44
+ email: ''
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/connect_n.rb
50
+ - lib/connect_n/board/board.rb
51
+ - lib/connect_n/demo/demo.rb
52
+ - lib/connect_n/game/game.rb
53
+ - lib/connect_n/player/computer_player/computer_player.rb
54
+ - lib/connect_n/player/human_player/human_player.rb
55
+ - lib/connect_n/player/player.rb
56
+ - lib/connect_n/prompt/prompt.rb
57
+ - lib/connect_n/winnable/winnable.rb
58
+ homepage: https://rubygems.org/gems/connect_n
59
+ licenses:
60
+ - MIT
61
+ metadata: {}
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.4.1
78
+ signing_key:
79
+ specification_version: 4
80
+ summary: Connect-N!
81
+ test_files: []