3 # Library to support Cartagena play
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 # Version 1.0:: 11 Jun 2008
26 $SYMBOLS = [:bottle, :dagger, :gun, :hat, :keys, :skull]
28 # The number of cards of each symbol in the deck
29 $CARDS_PER_SYMBOL = 17
31 # The number of cards initiall dealt to each player
32 $INITIAL_CARDS_PER_PLAYER = 3
34 # Maximum number of pieces on a position
35 $MAX_PIECES_PER_POSITION = 3
39 # Moves can only be [1..6] spaces
40 class InvalidMoveError < StandardError
43 # Game is won when only one player has uncaptured pieces
44 class GameWonNotice < StandardError
47 # A position on the board.
49 attr_reader :symbol, :contains
51 def initialize(symbol)
57 # A tile that makes up the board. Each tile has two sides, each with six
58 # positions. Tiles can be either way up, and either way round.
60 attr_reader :exposed, :front, :back
62 def initialize(front, back)
63 @front = front.collect {|s| Position.new(s)}
64 @back = back.collect {|s| Position.new(s)}
66 @exposed_side = :front
70 if @exposed_side == :front
75 @exposed_side = :front
80 @exposed = @exposed.reverse
88 attr_reader :positions
91 # A laborious procedure to create all the positions and tie them all together
92 def initialize(tiles_used)
93 # A hash of all positions, indexed by position names
94 @tiles = [Tile.new([:hat, :keys, :gun, :bottle, :skull, :dagger],
95 [:hat, :keys, :gun, :bottle, :skull, :dagger]),
96 Tile.new([:gun, :hat, :dagger, :skull, :bottle, :keys],
97 [:gun, :hat, :dagger, :skull, :bottle, :keys]),
98 Tile.new([:skull, :gun, :bottle, :keys, :dagger, :hat],
99 [:skull, :gun, :bottle, :keys, :dagger, :hat]),
100 Tile.new([:dagger, :bottle, :keys, :gun, :hat, :skull],
101 [:dagger, :bottle, :keys, :gun, :hat, :skull]),
102 Tile.new([:keys, :dagger, :skull, :hat, :gun, :bottle],
103 [:keys, :dagger, :skull, :hat, :gun, :bottle]),
104 Tile.new([:bottle, :skull, :hat, :dagger, :keys, :gun],
105 [:bottle, :skull, :hat, :dagger, :keys, :gun])
106 ].shuffle[0...tiles_used]
107 @tiles = @tiles.each do |t|
115 @positions = [Position.new(:cell)]
116 @tiles.each {|t| t.exposed.each {|p| @positions << p}}
117 @positions << Position.new(:boat)
129 # For each position, show its name and what it touches
132 @positions.each {|position| out_string << "#{position.symbol}\n"}
139 # Each piece on the board is an object
141 attr_reader :player, :number
142 attr_accessor :position
144 def initialize(position, player, number)
151 "#{@player}:#{@number}"
158 def move_to(new_position)
159 @position = new_position
167 attr_reader :piece, :origin, :destination
169 def initialize(piece, origin, destination)
172 @destination = destination
176 "#{@piece.to_s}: #{board.positions.index(@origin)} -> #{board.positions.index(@destination)}"
179 # Write a move to a string
180 # Note the inverse, String#to_move, is defined below
182 "#{@piece.player}: #{@origin.to_s} -> #{@destination.to_s}"
193 # A class to record each of the states previously found in a game.
194 # Note that this is a deep copy of the pieces and what they've captured, so
195 # you'll need to convert back
197 attr_accessor :move, :player, :pieces_after_move
199 def initialize(move, player, pieces)
202 @pieces_after_move = Hash.new
203 pieces.each {|k, p| @pieces_after_move[k] = p.dup}
207 @move.to_s == other.move.to_s and
208 @player == other.player and
209 @piece_after_move == other.pieces_after_move
215 # A game of Cartagena. It keeps a history of all previous states.
219 attr_reader :current_player, :players, :players_cards
225 def initialize(players = 6, number_of_tiles = 6, pieces_each = 6)
226 @board = Board.new(number_of_tiles)
229 @players = [].fill(0, players) {|i| i}
233 1.upto($CARDS_PER_SYMBOL) {|x| @cards.concat($SYMBOLS)}
234 @deck = @cards.shuffle
237 0.upto(pieces_each - 1) do |count|
238 piece = Piece.new(@board.positions[0], p, count)
239 @pieces[p][count] = piece
240 @board.positions[0].contains << piece
242 @players_cards[p] = []
243 deal_cards!($INITIAL_CARDS_PER_PLAYER, p)
247 def deal_cards!(number_of_cards, player = @current_player)
248 1.upto(number_of_cards) do
249 @deck = @cards.shuffle if @deck.empty?
250 @players_cards[player] << @deck.pop
254 # Check that a move is valid. Throw an exception if it's invalid
255 def validate_move(move, player = @current_player)
256 # Check the move is a valid one
257 raise(InvalidMoveError, "Move #{move}: Player #{player} does not exist") unless @players.include?(player)
258 raise(InvalidMoveError, "Move #{move}: None of player #{player}'s pieces on position #{move.origin}") unless move.origin.contains.find {|pc| pc.player == player}
259 raise(InvalidMoveError, "Move #{move}: Origin and destination are the same") if move.origin == move.destination
261 origin_position = @board.positions.index(move.origin)
262 destination_position = @board.positions.index(move.destination)
263 # Is this move an advance or a retreat?
264 if destination_position > origin_position
266 unless @players_cards[player].find {|c| c == move.destination.symbol}
267 raise(InvalidMoveError, "Player #{player} does not have a card to move a piece to a #{move.destination.symbol} square")
269 # Check target square is vacant
270 raise(InvalidMoveError, "Advance move #{move}: destination occupied") unless move.destination.contains.empty?
271 # Check all the intervening squares with this symbol are occupied
272 intervening_empty_position = @board.positions[origin_position...destination_position].index_find do |p|
273 p.symbol == move.destination.symbol and
276 raise(InvalidMoveError, "Advance move #{move}: location #{intervening_empty_position} is empty") if intervening_empty_position
279 # Check target position has one or two pieces already on it
280 destination_count = move.destination.contains.length
281 raise(InvalidMoveError, "Retreat move #{move}: destination has no pieces already on it") if destination_count == 0
282 raise(InvalidMoveError, "Retreat move #{move}: destination has too many (#{destination_count}) pieces already on it") if destination_count >= $MAX_PIECES_PER_POSITION
283 # Check none of the intervening squares have any pieces on them
284 # puts "Checking positions #{destination_position} to #{origin_position}"
285 intervening_target_position = @board.positions[(destination_position + 1)...origin_position].index_find do |p|
286 # puts "Examining postition #{p} at location #{@board.positions.index(p)} which contains #{p.contains.length} pieces"
287 p.contains.length > 0 and
288 p.contains.length < $MAX_PIECES_PER_POSITION
290 raise(InvalidMoveError, "Retreat move #{move.show(@board)}: location #{intervening_target_position} is a viable target") if intervening_target_position
294 # Apply a single move to a game.
295 def apply_move!(move, player = @current_player)
297 validate_move(move, player)
300 move.origin.contains.delete move.piece
301 move.destination.contains << move.piece
302 move.piece.position = move.destination
305 if @board.positions.index(move.destination) > @board.positions.index(move.origin)
307 @players_cards[player].delete_at(@players_cards[player].index(move.destination.symbol))
310 deal_cards!(move.destination.contains.length - 1, player)
313 # Record the new stae
314 # this_game_state = GameState.new(move, player, @pieces)
315 # @history << this_game_state
317 # If this player has all their pieces in the boat, declare a win.
318 if @pieces[player].all? {|pc| pc.position == :boat}
319 raise(GameWonNotice, "Game won by #{player}")
325 if @history.length > 1
327 state_to_restore = @history[-2]
328 @current_player = @history[-1].player
329 @pieces.each do |name, piece|
330 copy_piece = state_to_restore.pieces_after_move[name]
331 piece.position = copy_piece.position
334 elsif @history.length == 1
336 @current_player = @players[1]
337 @pieces.each do |name, piece|
338 piece.position = @board.positions[piece.colour]
344 # Apply a list of moves in order
345 def apply_moves!(moves)
348 moved_distance = board.distance_between[move.piece.position.place][@current_player] +
349 board.distance_between[@current_player][move.destination.place]
351 moved_distance = board.distance_between[move.piece.position.place][move.destination.place]
353 self.apply_move!(move, @current_player)
354 next_player! unless moved_distance == 6
359 # Set the current player to be the next player
361 original_player = @current_player
362 if @current_player == @players[-1]
363 @current_player = @players[1]
365 @current_player = @players[@players.index(@current_player) + 1]
371 # Return an array of all possible moves from this state, given the active player
372 def possible_moves(player = @current_player)
374 @pieces[player].each do |piece|
375 # Do a forward move for each card held
376 unless piece.position == @board.positions[-1]
377 @players_cards[player].each do |card|
378 destination = @board.positions[@board.positions.index(piece.position)..-1].find do |pos|
379 (pos.symbol == card and pos.contains == []) or
383 puts "Player #{player}, card #{card}, piece #{piece.number} at position #{@board.positions.index(piece.position)} moving to #{@board.positions.index(destination)}, a #{destination.symbol}"
385 # puts "Player #{player}, card #{card}, piece #{piece.number} at position #{@board.positions.index(piece.position)} to #{@board.positions.index(destination)}, a #{destination.symbol}"
387 moves << Move.new(piece, piece.position, destination)
388 puts "Added move #{piece.number}: #{piece.position.symbol} -> #{destination.symbol}"
391 # Do a reverse move for the piece
392 unless piece.position == board.positions[0]
393 destination = @board.positions[0...@board.positions.index(piece.position)].reverse.find do |pos|
394 pos.contains.length == 1 or pos.contains.length == 2
397 # puts "Player #{player}, piece #{piece.number} at position #{@board.positions.index(piece.position)} retreats to #{@board.positions.index(destination)}, a #{destination.symbol} containing #{destination.contains.length} pieces"
398 moves << Move.new(piece, piece.position, destination)
402 # moves.each {|m| puts m.show(@board)}
406 def build_state_string
407 outstr = "Current player = #{@current_player}\n"
408 0.upto((@board.positions.length)-1) do |i|
409 outstr << "#{i}: #{@board.positions[i].symbol}: "
410 @board.positions[i].contains.each do |piece|
411 outstr << "P#{piece.player}:#{piece.number} "
415 0.upto((@players.length)-1) do |i|
416 outstr << "Player #{i} holds " << (@players_cards[i].sort_by {|c| c.to_s}).join(', ') << "\n"
418 outstr << "Deck holds " << @deck.join(', ') << "\n"
422 # Show the state of the board
424 puts build_state_string
425 # @pieces.keys.sort.each do |piece_name|
426 # if @pieces[piece_name].captured
427 # puts "Piece #{piece_name} captured, at #{@pieces[piece_name].position}"
429 # puts "Piece #{piece_name} is at #{@pieces[piece_name].position}, holds #{(@pieces[piece_name].contains.collect{|c| c.name}).join(' ')}"
443 def set_testing_game!
449 # Given a set of lines from an input file, turn them into a Game object and
450 # a set of Move objects.
451 # Note the multiple return values.
453 def Game.read_game(gamelines)
454 gamelines.each {|l| l.chomp!}
455 game = Game.new(gamelines[0].to_i, 6, 6, 6, 6)
457 gamelines[1..-2].each {|m| moves << m.to_move(game)}
458 return game, moves, gamelines[-1].to_i
465 # Extension to String class to convert a move-description string into a Move object.
466 # This is the inverse of the Move#to_s method
469 move_elements = self.downcase.split
470 piece_name = move_elements[0]
471 destination_name = move_elements[-1]
472 if destination_name.length > 2 and
473 destination_name[-2,2] == game.board.centre.place[-2,2]
474 destination_name = game.board.centre.place
476 raise(InvalidMoveError, "Invalid piece in move read") unless game.pieces.has_key?(piece_name)
477 raise(InvalidMoveError, "Invalid destination in move read") unless game.board.positions.has_key?(destination_name)
478 # Deal with the synonyms for the centre position
479 via_base = (destination_name.length == 1 or move_elements.length > 2)
480 Move.new(game.pieces[piece_name], game.board.positions[destination_name], via_base)
485 # Read a game description file and convert it into a Game object and set of Move objects.
486 # Note that Game.read_game method returns multiple values, so this one does too.
488 def IO.read_game(filename)
489 gamelines = IO.readlines(filename)
490 return Game.read_game(gamelines)
494 # Extension to the Array class to include the Array#shuffle function
500 def index_find(&block)