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
37 # Number of actions that can be taken by each player in sequence
38 $MAX_MOVES_PER_TURN = 3
42 # Moves can only be [1..6] spaces
43 class InvalidMoveError < StandardError
46 # Game is won when only one player has uncaptured pieces
47 class GameWonNotice < StandardError
50 # A position on the board.
52 attr_reader :symbol, :contains
54 def initialize(symbol)
60 # A tile that makes up the board. Each tile has two sides, each with six
61 # positions. Tiles can be either way up, and either way round.
63 attr_reader :exposed, :front, :back
65 def initialize(front, back)
66 @front = front.collect {|s| Position.new(s)}
67 @back = back.collect {|s| Position.new(s)}
69 @exposed_side = :front
73 if @exposed_side == :front
78 @exposed_side = :front
83 @exposed = @exposed.reverse
91 attr_reader :positions
94 # A laborious procedure to create all the positions and tie them all together
95 def initialize(tiles_used)
96 # A hash of all positions, indexed by position names
97 @tiles = [Tile.new([:hat, :keys, :gun, :bottle, :skull, :dagger],
98 [:hat, :keys, :gun, :bottle, :skull, :dagger]),
99 Tile.new([:gun, :hat, :dagger, :skull, :bottle, :keys],
100 [:gun, :hat, :dagger, :skull, :bottle, :keys]),
101 Tile.new([:skull, :gun, :bottle, :keys, :dagger, :hat],
102 [:skull, :gun, :bottle, :keys, :dagger, :hat]),
103 Tile.new([:dagger, :bottle, :keys, :gun, :hat, :skull],
104 [:dagger, :bottle, :keys, :gun, :hat, :skull]),
105 Tile.new([:keys, :dagger, :skull, :hat, :gun, :bottle],
106 [:keys, :dagger, :skull, :hat, :gun, :bottle]),
107 Tile.new([:bottle, :skull, :hat, :dagger, :keys, :gun],
108 [:bottle, :skull, :hat, :dagger, :keys, :gun])
109 ].shuffle[0...tiles_used]
110 @tiles = @tiles.each do |t|
118 @positions = [Position.new(:cell)]
119 @tiles.each {|t| t.exposed.each {|p| @positions << p}}
120 @positions << Position.new(:boat)
132 # For each position, show its name and what it touches
135 @positions.each {|position| out_string << "#{position.symbol}\n"}
142 # Each piece on the board is an object
144 attr_reader :player, :number
145 attr_accessor :position
147 def initialize(position, player, number)
154 "#{@player}:#{@number}"
161 def move_to(new_position)
162 @position = new_position
170 attr_reader :piece, :origin, :destination
172 def initialize(piece, origin, destination)
175 @destination = destination
179 "#{@piece.to_s}: #{board.positions.index(@origin)} -> #{board.positions.index(@destination)}"
182 # Write a move to a string
183 # Note the inverse, String#to_move, is defined below
185 "#{@piece.player}: #{@origin.to_s} -> #{@destination.to_s}"
196 # A class to record each of the states previously found in a game.
197 # Note that this is a deep copy of the pieces and what they've captured, so
198 # you'll need to convert back
200 attr_accessor :move, :player, :pieces_after_move, :deck_after_move,
201 :players_cards_after_move, :moves_by_current_player
202 # this_game_state = GameState.new(move, player, @board, @pieces, @deck, @players_cards)
205 def initialize(move, player, board, pieces, deck, players_cards, moves_by_current_player)
208 @pieces_after_move = Hash.new
209 pieces.each {|k, p| @pieces_after_move[k] = p.dup}
213 @move.to_s == other.move.to_s and
214 @player == other.player and
215 @piece_after_move == other.pieces_after_move
221 # A game of Cartagena. It keeps a history of all previous states.
225 attr_reader :current_player, :players, :players_cards
226 attr_reader :moves_by_current_player
232 def initialize(players = 6, number_of_tiles = 6, pieces_each = 6)
233 @board = Board.new(number_of_tiles)
236 @players = [].fill(0, players) {|i| i}
239 @moves_by_current_player = 0
241 1.upto($CARDS_PER_SYMBOL) {|x| @cards.concat($SYMBOLS)}
242 @deck = @cards.shuffle
245 0.upto(pieces_each - 1) do |count|
246 piece = Piece.new(@board.positions[0], p, count)
247 @pieces[p][count] = piece
248 @board.positions[0].contains << piece
250 @players_cards[p] = []
251 deal_cards!($INITIAL_CARDS_PER_PLAYER, p)
255 def deal_cards!(number_of_cards, player = @current_player)
256 1.upto(number_of_cards) do
257 @deck = @cards.shuffle if @deck.empty?
258 @players_cards[player] << @deck.pop
262 # Check that a move is valid. Throw an exception if it's invalid
263 def validate_move(move, player = @current_player)
264 # Check the move is a valid one
265 raise(InvalidMoveError, "Move #{move}: Player #{player} does not exist") unless @players.include?(player)
266 raise(InvalidMoveError, "Move #{move}: None of player #{player}'s pieces on position #{move.origin}") unless move.origin.contains.find {|pc| pc.player == player}
267 raise(InvalidMoveError, "Move #{move}: Origin and destination are the same") if move.origin == move.destination
269 origin_position = @board.positions.index(move.origin)
270 destination_position = @board.positions.index(move.destination)
271 # Is this move an advance or a retreat?
272 if destination_position > origin_position
274 unless @players_cards[player].find {|c| c == move.destination.symbol}
275 raise(InvalidMoveError, "Player #{player} does not have a card to move a piece to a #{move.destination.symbol} square")
277 # Check target square is vacant
278 raise(InvalidMoveError, "Advance move #{move}: destination occupied") unless move.destination.contains.empty?
279 # Check all the intervening squares with this symbol are occupied
280 intervening_empty_position = @board.positions[origin_position...destination_position].index_find do |p|
281 p.symbol == move.destination.symbol and
284 raise(InvalidMoveError, "Advance move #{move}: location #{intervening_empty_position} is empty") if intervening_empty_position
287 # Check target position has one or two pieces already on it
288 destination_count = move.destination.contains.length
289 raise(InvalidMoveError, "Retreat move #{move}: destination has no pieces already on it") if destination_count == 0
290 raise(InvalidMoveError, "Retreat move #{move}: destination has too many (#{destination_count}) pieces already on it") if destination_count >= $MAX_PIECES_PER_POSITION
291 # Check none of the intervening squares have any pieces on them
292 # puts "Checking positions #{destination_position} to #{origin_position}"
293 intervening_target_position = @board.positions[(destination_position + 1)...origin_position].index_find do |p|
294 # puts "Examining postition #{p} at location #{@board.positions.index(p)} which contains #{p.contains.length} pieces"
295 p.contains.length > 0 and
296 p.contains.length < $MAX_PIECES_PER_POSITION
298 raise(InvalidMoveError, "Retreat move #{move.show(@board)}: location #{intervening_target_position} is a viable target") if intervening_target_position
302 # Apply a single move to a game.
303 def apply_move!(move, player = @current_player)
305 validate_move(move, player)
308 move.origin.contains.delete move.piece
309 move.destination.contains << move.piece
310 move.piece.position = move.destination
313 if @board.positions.index(move.destination) > @board.positions.index(move.origin)
315 @players_cards[player].delete_at(@players_cards[player].index(move.destination.symbol))
318 deal_cards!(move.destination.contains.length - 1, player)
321 # Record the new stae
322 # this_game_state = GameState.new(move, player, @board, @pieces, @deck, @players_cards, @moves_by_current_player)
323 # @history << this_game_state
325 if player == @current_player
326 @moves_by_current_player = @moves_by_current_player + 1
329 # If this player has all their pieces in the boat, declare a win.
330 if @pieces[player].all? {|pc| pc.position == :boat}
331 raise(GameWonNotice, "Game won by #{player}")
337 # if @history.length > 1
339 # state_to_restore = @history[-2]
340 # @current_player = @history[-1].player
341 # @pieces.each do |name, piece|
342 # copy_piece = state_to_restore.pieces_after_move[name]
343 # piece.position = copy_piece.position
346 # elsif @history.length == 1
348 # @current_player = @players[1]
349 # @pieces.each do |name, piece|
350 # piece.position = @board.positions[piece.colour]
356 # Apply a list of moves in order
357 def apply_moves!(moves)
359 apply_move!(move, @current_player)
360 next_player! if @moves_by_current_player >= $MOVES_PER_TURN
365 # Set the current player to be the next player
367 original_player = @current_player
368 if @current_player == @players[-1]
369 @current_player = @players[0]
371 @current_player = @players[@players.index(@current_player) + 1]
373 @moves_by_current_player = 0
378 # Return an array of all possible moves from this state, given the active player
379 def possible_moves(player = @current_player)
381 @pieces[player].each do |piece|
382 # Do a forward move for each card held
383 unless piece.position == @board.positions[-1]
384 @players_cards[player].each do |card|
385 destination = @board.positions[@board.positions.index(piece.position)..-1].find do |pos|
386 (pos.symbol == card and pos.contains == []) or
389 # puts "Player #{player}, card #{card}, piece #{piece.number} at position #{@board.positions.index(piece.position)} to #{@board.positions.index(destination)}, a #{destination.symbol}"
390 moves << Move.new(piece, piece.position, destination)
393 # Do a reverse move for the piece
394 unless piece.position == board.positions[0]
395 destination = @board.positions[0...@board.positions.index(piece.position)].reverse.find do |pos|
396 pos.contains.length == 1 or pos.contains.length == 2
399 # 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"
400 moves << Move.new(piece, piece.position, destination)
404 # moves.each {|m| puts m.show(@board)}
408 def build_state_string
409 outstr = "Current player = #{@current_player}\n"
410 0.upto((@board.positions.length)-1) do |i|
411 outstr << "#{i}: #{@board.positions[i].symbol}: "
412 @board.positions[i].contains.each do |piece|
413 outstr << "P#{piece.player}:#{piece.number} "
417 0.upto((@players.length)-1) do |i|
418 outstr << "Player #{i} holds " << (@players_cards[i].sort_by {|c| c.to_s}).join(', ') << "\n"
420 outstr << "Deck holds " << @deck.join(', ') << "\n"
424 # Show the state of the board
426 puts build_state_string
427 # @pieces.keys.sort.each do |piece_name|
428 # if @pieces[piece_name].captured
429 # puts "Piece #{piece_name} captured, at #{@pieces[piece_name].position}"
431 # puts "Piece #{piece_name} is at #{@pieces[piece_name].position}, holds #{(@pieces[piece_name].contains.collect{|c| c.name}).join(' ')}"
445 def set_testing_game!
451 # Given a set of lines from an input file, turn them into a Game object and
452 # a set of Move objects.
453 # Note the multiple return values.
455 def Game.read_game(gamelines)
456 gamelines.each {|l| l.chomp!}
457 game = Game.new(gamelines[0].to_i, 6, 6, 6, 6)
459 gamelines[1..-2].each {|m| moves << m.to_move(game)}
460 return game, moves, gamelines[-1].to_i
467 # Extension to String class to convert a move-description string into a Move object.
468 # This is the inverse of the Move#to_s method
471 move_elements = self.downcase.split
472 piece_name = move_elements[0]
473 destination_name = move_elements[-1]
474 if destination_name.length > 2 and
475 destination_name[-2,2] == game.board.centre.place[-2,2]
476 destination_name = game.board.centre.place
478 raise(InvalidMoveError, "Invalid piece in move read") unless game.pieces.has_key?(piece_name)
479 raise(InvalidMoveError, "Invalid destination in move read") unless game.board.positions.has_key?(destination_name)
480 # Deal with the synonyms for the centre position
481 via_base = (destination_name.length == 1 or move_elements.length > 2)
482 Move.new(game.pieces[piece_name], game.board.positions[destination_name], via_base)
487 # Read a game description file and convert it into a Game object and set of Move objects.
488 # Note that Game.read_game method returns multiple values, so this one does too.
490 def IO.read_game(filename)
491 gamelines = IO.readlines(filename)
492 return Game.read_game(gamelines)
496 # Extension to the Array class to include the Array#shuffle function
502 def index_find(&block)