tactical_tic_tac_toe 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 01bb765823d823d69d962bdcf3cd43e8bf7fc48f
4
+ data.tar.gz: d2215f338e0b37e2fe3e6fc2d7b17fb4c731e3c1
5
+ SHA512:
6
+ metadata.gz: f44cc813fba53dec3622cb8051a8551578e7ff941c12d11539e4d48b38eb118641abb19c8b2439aa8e26b6b8b09cd41c30513979363eb06417c8684d32be6772
7
+ data.tar.gz: bda0c00122f5e46f4234725dd4045f3541d757b30de32ec6f159c496bf1bb04fe7eb8927153bed5a42fd49204461fdc293d1ebfbd69ab486b879e45fe4a65396
data/bin/ttt ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "tactical_tic_tac_toe"
4
+
5
+ TicTacToe::Game.new({}).run
@@ -0,0 +1,8 @@
1
+ module TicTacToe
2
+ module AvailablePlayerTypes
3
+
4
+ HUMAN = :human
5
+ COMPUTER = :computer
6
+
7
+ end
8
+ end
data/lib/board.rb ADDED
@@ -0,0 +1,118 @@
1
+ module TicTacToe
2
+ class Board
3
+ BoardError = Class.new(StandardError)
4
+ BLANK_MARK = nil
5
+
6
+ attr_reader :size, :last_move_made
7
+
8
+ def self.blank_mark
9
+ BLANK_MARK
10
+ end
11
+
12
+ def initialize(parameters)
13
+ fail BoardError, "Given size is too small, must be 3 or greater" if parameters[:size] < 3
14
+
15
+ @size = parameters[:size]
16
+ config = parameters[:config] || blank_board_configuration
17
+ @cells = map_configuration_to_cells(config)
18
+ end
19
+
20
+ def read_cell(row, col)
21
+ fail BoardError, "Cell coordinates are out of bounds" if out_of_bounds?([row, col])
22
+
23
+ @cells[row][col]
24
+ end
25
+
26
+ def mark_cell(mark, row, col)
27
+ fail BoardError, "Cell coordinates are out of bounds" if out_of_bounds?([row, col])
28
+ fail BoardError, "Cannot alter a marked cell" if marked?([row, col])
29
+
30
+ @last_move_made = [row, col]
31
+ @cells[row][col] = mark
32
+ end
33
+
34
+ def lines
35
+ (0...@size).each_with_object([left_diag, right_diag]) do |index, lines|
36
+ lines << row_at(index) << col_at(index)
37
+ end
38
+ end
39
+
40
+ def all_coordinates
41
+ (0...@size).to_a.repeated_permutation(2).to_a
42
+ end
43
+
44
+ def blank_cell_coordinates
45
+ all_coordinates.reject { |coordinates| marked?(coordinates) }
46
+ end
47
+
48
+ def last_mark_made
49
+ return if @last_move_made.nil?
50
+ self.read_cell(*last_move_made)
51
+ end
52
+
53
+ def deep_copy
54
+ Board.new(size: @size, config: map_cells_to_configuration(@cells))
55
+ end
56
+
57
+ def blank?(coordinates)
58
+ self.read_cell(*coordinates) == BLANK_MARK
59
+ end
60
+
61
+ def marked?(coordinates)
62
+ !blank?(coordinates)
63
+ end
64
+
65
+ def out_of_bounds?(coordinates)
66
+ coordinates.any? { |i| !i.between?(0, @size - 1) }
67
+ end
68
+
69
+ def all_blank?
70
+ all_coordinates.all? { |coordinates| blank?(coordinates) }
71
+ end
72
+
73
+ def all_marked?
74
+ all_coordinates.all? { |coordinates| marked?(coordinates) }
75
+ end
76
+
77
+ def has_winning_line?
78
+ lines.each do |line|
79
+ return true if line.first != BLANK_MARK && line.all? { |cell| cell == line.first }
80
+ end
81
+ false
82
+ end
83
+
84
+ private
85
+
86
+ def map_configuration_to_cells(config)
87
+ if Math.sqrt(config.size).to_i != @size
88
+ fail BoardError, "Given size does not reconcile with given configuration"
89
+ end
90
+
91
+ config.each_slice(@size).to_a
92
+ end
93
+
94
+ def map_cells_to_configuration(cells)
95
+ cells.flatten
96
+ end
97
+
98
+ def blank_board_configuration
99
+ (0...@size**2).map { BLANK_MARK }
100
+ end
101
+
102
+ def row_at(row)
103
+ (0...@size).map { |col| self.read_cell(row, col) }
104
+ end
105
+
106
+ def col_at(col)
107
+ (0...@size).map { |row| self.read_cell(row, col) }
108
+ end
109
+
110
+ def left_diag
111
+ (0...@size).map { |index| self.read_cell(index, index) }
112
+ end
113
+
114
+ def right_diag
115
+ (0...@size).map { |row| self.read_cell(row, @size - row - 1) }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,96 @@
1
+ module TicTacToe
2
+ class CommandLineInterface
3
+
4
+ def initialize(parameters)
5
+ @input = parameters[:input]
6
+ @output = parameters[:output]
7
+ end
8
+
9
+ def game_setup_interaction(player_marks)
10
+ @output.puts instructions
11
+ player_marks.map { |mark| solicit_player_type(mark) }
12
+ end
13
+
14
+ def solicit_player_type(player_mark)
15
+ @output.puts "Is player #{player_mark} a human or computer?"
16
+ @output.puts "Enter 'computer' or 'human'."
17
+
18
+ cleaned_input = get_valid_input(/^(computer|human)$/)
19
+ cleaned_input.to_sym
20
+ end
21
+
22
+ def show_game_board(board)
23
+ row_separator = "----" * board.size + "-\n"
24
+ col_separator = "|"
25
+
26
+ @output.print assemble_board_string(board, row_separator, col_separator)
27
+ end
28
+
29
+ def solicit_move(player_mark)
30
+ @output.puts "Player #{player_mark}: select your move."
31
+ @output.puts "Enter your move coordinates in the format 'row, col' - eg. '0, 0'."
32
+
33
+ cleaned_input = get_valid_input(/^\s*\d+\s*,\s*\d+\s*$/)
34
+ cleaned_input.split(",").map(&:to_i)
35
+ end
36
+
37
+ def get_valid_input(valid_input_pattern)
38
+ catch(:success) do
39
+ loop do
40
+ cleaned_input = @input.gets.strip.downcase
41
+ throw(:success, cleaned_input) if valid_input_pattern =~ cleaned_input
42
+ @output.puts "Sorry, '#{cleaned_input}' is not valid input. Please try again."
43
+ end
44
+ end
45
+ end
46
+
47
+ def report_invalid_move(move_coordinates)
48
+ row, col = move_coordinates
49
+ @output.puts "Couldn't move at row: #{row}, column: #{col}. Please try again."
50
+ end
51
+
52
+ def report_move(player_mark, move_coordinates)
53
+ row, col = move_coordinates
54
+ @output.puts "Player #{player_mark} moved at row: #{row}, column: #{col}."
55
+ end
56
+
57
+ def report_game_over(winning_player)
58
+ if winning_player == :none
59
+ report_draw
60
+ else
61
+ report_win(winning_player)
62
+ end
63
+ end
64
+
65
+ def report_win(player_mark)
66
+ @output.puts "Player #{player_mark} wins!"
67
+ end
68
+
69
+ def report_draw
70
+ @output.puts "The game ended in a draw."
71
+ end
72
+
73
+ def instructions
74
+ <<-EOS
75
+ ~*~ Welcome to TIC TAC TOE ~*~
76
+ You've probably done this a million times before.
77
+ You don't need me to tell you what to do.
78
+ Go get 'em killer~
79
+ EOS
80
+ end
81
+
82
+ private
83
+
84
+ def assemble_board_string(board, row_separator, col_separator)
85
+ output_string = row_separator
86
+ (0...board.size).each do |row|
87
+ output_string += col_separator
88
+ (0...board.size).each do |col|
89
+ output_string += " #{board.read_cell(row, col) || " "} " + col_separator
90
+ end
91
+ output_string += "\n" + row_separator
92
+ end
93
+ output_string
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,86 @@
1
+ require "negamax"
2
+
3
+ module TicTacToe
4
+ class ComputerPlayer
5
+ attr_reader :player_mark
6
+
7
+ def initialize(parameters)
8
+ @board = parameters[:board]
9
+ @player_mark = parameters[:player_mark]
10
+ @opponent_mark = parameters[:opponent_mark]
11
+ end
12
+
13
+ def move
14
+ if @board.all_blank? && @board.size.odd?
15
+ [:row, :col].map { @board.size / 2 }
16
+ else
17
+ choose_best_move
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def choose_best_move
24
+ @negamax ||= create_negamax
25
+ @negamax.apply(initial_node).fetch(:last_move_made)
26
+ end
27
+
28
+ def create_negamax
29
+ parameters = {
30
+ child_node_generator: create_child_node_generator,
31
+ terminal_node_criterion: create_terminal_node_criterion,
32
+ evaluation_heuristic: create_evaluation_heuristic
33
+ }
34
+ Negamax.new(parameters)
35
+ end
36
+
37
+ def initial_node
38
+ {
39
+ board: @board,
40
+ current_player_mark: @player_mark,
41
+ last_move_made: nil
42
+ }
43
+ end
44
+
45
+ def create_child_node_generator
46
+ lambda do |node|
47
+ board = node.fetch(:board)
48
+ current_player_mark = node.fetch(:current_player_mark)
49
+
50
+ board.blank_cell_coordinates.map do |coordinates|
51
+ child_node = {
52
+ board: board.deep_copy,
53
+ current_player_mark: toggle_mark(current_player_mark),
54
+ last_move_made: coordinates
55
+ }
56
+ child_node[:board].mark_cell(current_player_mark, *coordinates)
57
+ child_node
58
+ end
59
+ end
60
+ end
61
+
62
+ def create_terminal_node_criterion
63
+ lambda do |node|
64
+ board = node.fetch(:board)
65
+
66
+ board.has_winning_line? || board.all_marked?
67
+ end
68
+ end
69
+
70
+ def create_evaluation_heuristic
71
+ lambda do |node|
72
+ board = node.fetch(:board)
73
+
74
+ board.lines.each do |line|
75
+ return 1 if line.all? { |cell| cell == @player_mark }
76
+ return -1 if line.all? { |cell| cell == @opponent_mark }
77
+ end
78
+ 0
79
+ end
80
+ end
81
+
82
+ def toggle_mark(mark)
83
+ mark == @player_mark ? @opponent_mark : @player_mark
84
+ end
85
+ end
86
+ end
data/lib/game.rb ADDED
@@ -0,0 +1,88 @@
1
+ require "board"
2
+ require "player_factory"
3
+ require "command_line_interface"
4
+
5
+ module TicTacToe
6
+ class Game
7
+ attr_reader :interface, :board, :player_marks, :players
8
+
9
+ def initialize(parameters)
10
+ @player_marks = parameters[:player_marks] || [:x, :o]
11
+ @board = parameters[:board] || create_default_board
12
+ @interface = parameters[:interface] || create_default_interface
13
+ @players = []
14
+ end
15
+
16
+ def run
17
+ set_up
18
+ handle_turns
19
+ handle_game_over
20
+ end
21
+
22
+ def set_up
23
+ player_types = @interface.game_setup_interaction(@player_marks)
24
+
25
+ @players = @player_marks.zip(player_types).map { |mark, type| create_player(mark, type) }
26
+ end
27
+
28
+ def handle_turns
29
+ catch(:game_over) do
30
+ @players.cycle do |current_player|
31
+ handle_one_turn(current_player)
32
+ throw :game_over if over?
33
+ end
34
+ end
35
+ end
36
+
37
+ def handle_one_turn(current_player)
38
+ @interface.show_game_board(@board)
39
+ coordinates = get_valid_move(current_player)
40
+ @board.mark_cell(current_player.player_mark, *coordinates)
41
+ @interface.report_move(current_player.player_mark, coordinates)
42
+ end
43
+
44
+ def get_valid_move(player)
45
+ loop do
46
+ coordinates = player.move
47
+ if @board.out_of_bounds?(coordinates) || @board.marked?(coordinates)
48
+ @interface.report_invalid_move(coordinates)
49
+ else
50
+ return coordinates
51
+ end
52
+ end
53
+ end
54
+
55
+ def handle_game_over
56
+ @interface.show_game_board(@board)
57
+ winning_player_mark = @board.has_winning_line? ? @board.last_mark_made : :none
58
+ @interface.report_game_over(winning_player_mark)
59
+ end
60
+
61
+ def over?
62
+ @board.has_winning_line? || @board.all_marked?
63
+ end
64
+
65
+ private
66
+
67
+ def create_default_board
68
+ Board.new(size: 3)
69
+ end
70
+
71
+ def create_default_interface
72
+ parameters = {
73
+ input: $stdin,
74
+ output: $stdout
75
+ }
76
+ CommandLineInterface.new(parameters)
77
+ end
78
+
79
+ def create_player(mark, type)
80
+ player_config = {
81
+ type: type,
82
+ game: self,
83
+ player_mark: mark
84
+ }
85
+ PlayerFactory.build(player_config)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,14 @@
1
+ module TicTacToe
2
+ class HumanPlayer
3
+ attr_reader :player_mark
4
+
5
+ def initialize(parameters)
6
+ @player_mark = parameters[:player_mark]
7
+ @interface = parameters[:interface]
8
+ end
9
+
10
+ def move
11
+ @interface.solicit_move(@player_mark)
12
+ end
13
+ end
14
+ end
data/lib/negamax.rb ADDED
@@ -0,0 +1,39 @@
1
+ module TicTacToe
2
+ class Negamax
3
+ def initialize(parameters)
4
+ @child_node_generator = parameters[:child_node_generator]
5
+ @terminal_node_criterion = parameters[:terminal_node_criterion]
6
+ @evaluation_heuristic = parameters[:evaluation_heuristic]
7
+ end
8
+
9
+ def apply(node)
10
+ if terminal_node?(node)
11
+ node
12
+ else
13
+ generate_child_nodes(node).max_by { |child| -score(child, -1) }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def score(node, color)
20
+ if terminal_node?(node)
21
+ evaluate(node, color)
22
+ else
23
+ generate_child_nodes(node).map { |child| -score(child, -color) }.max
24
+ end
25
+ end
26
+
27
+ def generate_child_nodes(node)
28
+ @child_node_generator.call(node)
29
+ end
30
+
31
+ def terminal_node?(node)
32
+ @terminal_node_criterion.call(node)
33
+ end
34
+
35
+ def evaluate(node, color)
36
+ @evaluation_heuristic.call(node) * color
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,89 @@
1
+ module TicTacToe
2
+ class OldComputerPlayer
3
+ attr_reader :player_mark
4
+
5
+ def initialize(parameters)
6
+ @player_mark = parameters[:player_mark]
7
+ @opponent_mark = parameters[:opponent_mark]
8
+ @board = parameters[:board]
9
+ end
10
+
11
+ def move
12
+ center_coordinate = [@board.size / 2, @board.size / 2]
13
+ if !@board.marked?(center_coordinate) && @board.size.odd?
14
+ center_coordinate
15
+ else
16
+ select_best_move
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def select_best_move
23
+ best_score_so_far = {
24
+ player: -Float::INFINITY, # highest score is best for player
25
+ opponent: Float::INFINITY # lowest score is best for opponent
26
+ }
27
+
28
+ successor_boards = generate_possible_successor_boards(@board, @player_mark)
29
+ best_board = successor_boards.max_by { |board| minimax(board, false, best_score_so_far) }
30
+ best_board.last_move_made
31
+ end
32
+
33
+ def generate_possible_successor_boards(board, mark)
34
+ board.blank_cell_coordinates.map do |coordinates|
35
+ successor_board = board.deep_copy
36
+ successor_board.mark_cell(mark, *coordinates)
37
+ successor_board
38
+ end
39
+ end
40
+
41
+ def minimax(board, my_turn, best_score_so_far)
42
+ if board.has_winning_line? || board.all_marked?
43
+ evaluate(board)
44
+ else
45
+ select_score_of_best_successor_board(board, my_turn, best_score_so_far.dup)
46
+ end
47
+ end
48
+
49
+ def evaluate(board)
50
+ board.lines.each do |line|
51
+ return Float::INFINITY if line.all? { |cell| cell == @player_mark }
52
+ return -Float::INFINITY if line.all? { |cell| cell == @opponent_mark }
53
+ end
54
+ 0
55
+ end
56
+
57
+ def select_score_of_best_successor_board(board, my_turn, best_score_so_far)
58
+ current_player_mark = my_turn ? @player_mark : @opponent_mark
59
+
60
+ successor_boards = generate_possible_successor_boards(board, current_player_mark)
61
+ scores = score_successor_boards(successor_boards, my_turn, best_score_so_far)
62
+
63
+ my_turn ? scores.max : scores.min
64
+ end
65
+
66
+ def score_successor_boards(successor_boards, my_turn, best_score_so_far)
67
+ successor_boards.each_with_object([]) do |board, scores|
68
+ score = minimax(board, !my_turn, best_score_so_far.dup)
69
+ scores << score
70
+
71
+ update_best_score_so_far(my_turn, best_score_so_far, score)
72
+ return scores if best_score_guaranteed_elsewhere?(best_score_so_far)
73
+ end
74
+ end
75
+
76
+ def update_best_score_so_far(my_turn, best_score_so_far, score)
77
+ if score > best_score_so_far[:player] && my_turn
78
+ best_score_so_far[:player] = score
79
+ end
80
+ if score < best_score_so_far[:opponent] && !my_turn
81
+ best_score_so_far[:opponent] = score
82
+ end
83
+ end
84
+
85
+ def best_score_guaranteed_elsewhere?(best_score_so_far)
86
+ best_score_so_far[:player] >= best_score_so_far[:opponent]
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,35 @@
1
+ require "human_player"
2
+ require "computer_player"
3
+ require "available_player_types"
4
+
5
+ module TicTacToe
6
+ module PlayerFactory
7
+ def self.build(config)
8
+ case config[:type]
9
+ when AvailablePlayerTypes::HUMAN
10
+ create_human_player(config)
11
+ when AvailablePlayerTypes::COMPUTER
12
+ create_computer_player(config)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def self.create_human_player(config)
19
+ human_parameters = {
20
+ player_mark: config[:player_mark],
21
+ interface: config[:game].interface
22
+ }
23
+ HumanPlayer.new(human_parameters)
24
+ end
25
+
26
+ def self.create_computer_player(config)
27
+ computer_parameters = {
28
+ player_mark: config[:player_mark],
29
+ opponent_mark: (config[:game].player_marks - [config[:player_mark]]).pop,
30
+ board: config[:game].board
31
+ }
32
+ ComputerPlayer.new(computer_parameters)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1 @@
1
+ require "game"