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
25 # => * Now tracks played cards when generating new games
28 $SYMBOLS = [:bottle, :dagger, :gun, :hat, :keys, :skull]
30 # The number of cards of each symbol in the deck
31 $CARDS_PER_SYMBOL = 17
33 # The number of cards initiall dealt to each player
34 $INITIAL_CARDS_PER_PLAYER = 3
36 # Maximum number of pieces on a position
37 $MAX_PIECES_PER_POSITION = 3
39 # Number of actions that can be taken by each player in sequence
40 $MAX_MOVES_PER_TURN = 3
44 # Moves can only be [1..6] spaces
45 class InvalidMoveError
< StandardError
48 # Game is won when only one player has uncaptured pieces
49 class GameWonNotice
< StandardError
52 # A position on the board.
55 attr_accessor
:contains
57 def initialize(symbol
)
63 # A tile that makes up the board. Each tile has two sides, each with six
64 # positions. Tiles can be either way up, and either way round.
66 attr_reader
:exposed, :front, :back
68 def initialize(front
, back
)
69 @front = front
.collect
{|s
| Position
.new(s
)}
70 @back = back
.collect
{|s
| Position
.new(s
)}
72 @exposed_side = :front
76 if @exposed_side == :front
81 @exposed_side = :front
86 @exposed = @exposed.reverse
94 attr_accessor
:positions
97 # A laborious procedure to create all the positions and tie them all together
98 def initialize(tiles_used
)
99 # A hash of all positions, indexed by position names
100 @tiles = [Tile
.new([:hat, :keys, :gun, :bottle, :skull, :dagger],
101 [:hat, :keys, :gun, :bottle, :skull, :dagger]),
102 Tile
.new([:gun, :hat, :dagger, :skull, :bottle, :keys],
103 [:gun, :hat, :dagger, :skull, :bottle, :keys]),
104 Tile
.new([:skull, :gun, :bottle, :keys, :dagger, :hat],
105 [:skull, :gun, :bottle, :keys, :dagger, :hat]),
106 Tile
.new([:dagger, :bottle, :keys, :gun, :hat, :skull],
107 [:dagger, :bottle, :keys, :gun, :hat, :skull]),
108 Tile
.new([:keys, :dagger, :skull, :hat, :gun, :bottle],
109 [:keys, :dagger, :skull, :hat, :gun, :bottle]),
110 Tile
.new([:bottle, :skull, :hat, :dagger, :keys, :gun],
111 [:bottle, :skull, :hat, :dagger, :keys, :gun])
112 ].shuffle
[0...tiles_used
]
113 @tiles = @tiles.each
do |t
|
121 @positions = [Position
.new(:cell)]
122 @tiles.each
{|t
| t
.exposed
.each
{|p
| @positions << p
}}
123 @positions << Position
.new(:boat)
135 # For each position, show its name and what it touches
138 @positions.each
{|position
| out_string
<< "#{position.symbol}\n"}
145 # Each piece on the board is an object
147 attr_reader
:player, :number
148 attr_accessor
:position
150 def initialize(position
, player
, number
)
157 "#{@player}:#{@number}"
164 def show(convert_to_zero_based_player_number
= false)
165 if convert_to_zero_based_player_number
166 "#{@player + 1}:#{@number}"
168 "#{@player}:#{@number}"
172 def move_to(new_position
)
173 @position = new_position
181 attr_reader
:piece, :origin, :destination, :card_played
183 def initialize(piece
, origin
, destination
, card_played
= :unspecified)
186 @destination = destination
187 @card_played = card_played
190 def show(board
, convert_to_zero_based_player_number
= false)
191 if @card_played == :unspecified
192 "#{@piece.show(convert_to_zero_based_player_number)}: #{board.positions.index(@origin)} -> #{board.positions.index(@destination)}"
194 "#{@piece.show(convert_to_zero_based_player_number)}: #{board.positions.index(@origin)} -> #{board.positions.index(@destination)} (#{@card_played})"
198 def format(board
, convert_to_zero_based_player_number
= false)
199 display_player_number
= if convert_to_zero_based_player_number
then
204 if @card_played ==:unspecified
205 "#{display_player_number} #{board.positions.index(@origin)} #{board.positions.index(@destination)}"
207 "#{display_player_number} #{board.positions.index(@origin)} #{board.positions.index(@destination)} #{@card_played}"
211 # Write a move to a string
212 # Note the inverse, String#to_move, is defined below
214 "#{@piece.player} #{@origin.to_s} #{@destination.to_s} #{@card_played}"
225 # A class to record each of the states previously found in a game.
226 # Note that this is a deep copy of the pieces and what they've captured, so
227 # you'll need to convert back
229 attr_accessor
:move, :player, :board, :pieces_before_move, :deck_before_move,
230 :players_cards_before_move, :moves_by_current_player
231 # this_game_state = GameState.new(move, player, @board, @pieces, @deck, @players_cards)
234 def initialize(move
, player
, board
, pieces
, deck
, players_cards
, moves_by_current_player
)
235 @move, @player, @board, @pieces_before_move, @deck_before_move,
236 @players_cards_before_move, @moves_by_current_player =
237 copy_game_state(move
, player
, board
, pieces
, deck
, players_cards
,
238 moves_by_current_player
)
242 # @move.to_s == other.move.to_s and
243 # @player == other.player and
244 # @piece_after_move == other.pieces_after_move
247 def copy_game_state(move
, player
, board
, pieces
, deck
, players_cards
, moves_by_current_player
)
249 moving_player
= move
.piece
.player
251 copy_board
= board
.dup
252 copy_board
.positions
= board
.positions
.collect
{|pos
| pos
.dup
}
253 copy_board
.positions
.each
{|pos
| pos
.contains
= []}
255 copy_pieces
= pieces
.collect
do |players_pieces
|
256 players_pieces
.collect
do |piece
|
257 new_piece_position
= copy_board
.positions
[board
.positions
.index(piece
.position
)]
258 new_piece
= Piece
.new(new_piece_position
, piece
.player
, piece
.number
)
259 new_piece_position
.contains
<< new_piece
264 piece_index
= pieces
[moving_player
].index(move
.piece
)
265 origin_index
= board
.positions
.index(move
.origin
)
266 destination_index
= board
.positions
.index(move
.destination
)
267 copy_move
= Move
.new(copy_pieces
[moving_player
][piece_index
],
268 copy_board
.positions
[origin_index
],
269 copy_board
.positions
[destination_index
])
272 copy_players_cards
= players_cards
.collect
{|p
| p
.dup
}
273 copy_moves_by_current_player
= moves_by_current_player
275 return copy_move
, copy_player
, copy_board
, copy_pieces
, copy_deck
, copy_players_cards
, copy_moves_by_current_player
281 # A game of Cartagena. It keeps a history of all previous states.
285 attr_accessor
:current_player
287 attr_accessor
:players_cards
288 attr_accessor
:moves_by_current_player
295 def initialize(players
= 5, number_of_tiles
= 6, pieces_each
= 6)
296 @board = Board
.new(number_of_tiles
)
299 @players = [].fill(0, players
) {|i
| i
}
302 @moves_by_current_player = 0
304 1.upto($CARDS_PER_SYMBOL) {|x
| @cards.concat($SYMBOLS)}
305 @deck = @cards.shuffle
308 0.upto(pieces_each
- 1) do |count
|
309 piece
= Piece
.new(@board.positions
[0], p
, count
)
310 @pieces[p
][count
] = piece
311 @board.positions
[0].contains
<< piece
313 @players_cards[p
] = []
314 deal_cards
!($INITIAL_CARDS_PER_PLAYER, p
)
318 # Deal some cards to a player. Remove them from the deck. Refill the deck
320 def deal_cards
!(number_of_cards
, player
= @current_player)
321 1.upto(number_of_cards
) do
324 @players_cards.each
do |p
|
326 @deck.delete_at(@deck.index(c
))
329 @deck = @deck.shuffle
331 @players_cards[player
] << @deck.pop
335 # Check that a move is valid. Throw an exception if it's invalid
336 def validate_move(move
, player
= @current_player)
337 # Check the move is a valid one
338 raise(InvalidMoveError
, "Move #{move}: Player #{player} does not exist") unless @players.include?(player
)
339 raise(InvalidMoveError
, "Move #{move}: None of player #{player}'s pieces on position #{move.origin}") unless move
.origin
.contains
.find
{|pc
| pc
.player
== player
}
340 raise(InvalidMoveError
, "Move #{move}: Origin and destination are the same") if move
.origin
== move
.destination
342 origin_position
= @board.positions
.index(move
.origin
)
343 destination_position
= @board.positions
.index(move
.destination
)
344 # Is this move an advance or a retreat?
345 if destination_position
> origin_position
347 if move
.destination
== @board.positions
[-1] # A move into the boat
348 raise(InvalidMoveError
, "Move #{move}: Move into boat and card unspecified") if move
.destination
== @board.positions
[-1] and move
.card_played
== :unspecified
349 unless @players_cards[player
].find
{|c
| c
== move
.card_played
}
350 raise(InvalidMoveError
, "Player #{player} does not have a card to move a piece into the boat")
353 if move
.card_played
!= :unspecified and move
.destination
.symbol
!= move
.card_played
354 raise(InvalidMoveError
, "Player #{player} trying to move to #{move.destination}, a #{move.destination.symbol} square with a a #{move.card_played} card")
356 unless @players_cards[player
].find
{|c
| c
== move
.destination
.symbol
}
357 raise(InvalidMoveError
, "Player #{player} does not have a card to move a piece to a #{move.destination.symbol} square")
359 # Check target square is vacant
360 raise(InvalidMoveError
, "Advance move #{move}: destination occupied") unless move
.destination
.contains
.empty
?
362 # Check all the intervening squares with this symbol are occupied
363 intervening_empty_position
= @board.positions
[origin_position
...destination_position
].index_find
do |p
|
364 p
.symbol
== move
.destination
.symbol
and
367 raise(InvalidMoveError
, "Advance move #{move}: location #{intervening_empty_position} is empty") if intervening_empty_position
370 # Check target position has one or two pieces already on it
371 destination_count
= move
.destination
.contains
.length
372 raise(InvalidMoveError
, "Retreat move #{move}: destination has no pieces already on it") if destination_count
== 0
373 raise(InvalidMoveError
, "Retreat move #{move}: destination has too many (#{destination_count}) pieces already on it") if destination_count
>= $MAX_PIECES_PER_POSITION
374 # Check none of the intervening squares have any pieces on them
375 # puts "Checking positions #{destination_position} to #{origin_position}"
376 intervening_target_position
= @board.positions
[(destination_position
+ 1)...origin_position
].index_find
do |p
|
377 # puts "Examining postition #{p} at location #{@board.positions.index(p)} which contains #{p.contains.length} pieces"
378 p
.contains
.length
> 0 and
379 p
.contains
.length
< $MAX_PIECES_PER_POSITION
381 raise(InvalidMoveError
, "Retreat move #{move.show(@board)}: location #{intervening_target_position} is a viable target") if intervening_target_position
385 # Apply a single move to a game.
386 def apply_move
!(move
, player
= @current_player, validate
= true)
388 validate_move(move
, player
) if validate
390 raise(InvalidMoveError
, "Too many consecutive moves by #{@current_player}: has taken #{@moves_by_current_player} and validate is #{validate}") if validate
and player
== @current_player and @moves_by_current_player >= $MAX_MOVES_PER_TURN
392 # Record the old state
393 this_game_state
= GameState
.new(move
, @current_player, @board, @pieces, @deck, @players_cards, @moves_by_current_player)
394 @history << this_game_state
397 move
.origin
.contains
.delete move
.piece
398 move
.destination
.contains
<< move
.piece
399 move
.piece
.position
= move
.destination
401 if player
== @current_player
402 @moves_by_current_player += 1
404 @current_player = player
405 @moves_by_current_player = 1
409 if @board.positions
.index(move
.destination
) > @board.positions
.index(move
.origin
)
412 card_to_remove
= if move
.card_played
!= :unspecified
415 move
.destination
.symbol
417 if @players_cards[player
].include?(card_to_remove
)
418 @players_cards[player
].delete_at(@players_cards[player
].index(card_to_remove
))
423 deal_cards
!(move
.destination
.contains
.length
- 1, player
)
426 # If this player has all their pieces in the boat, declare a win.
427 if @pieces[player
].all
? {|pc
| pc
.position
== @board.positions
[-1]}
428 raise(GameWonNotice
, "Game won by #{player}")
434 state_to_restore
= @history[-1]
435 move
, @current_player, @board, @pieces, @deck, @players_cards,
436 @moves_by_current_player =
437 state_to_restore
.copy_game_state(state_to_restore
.move
,
438 state_to_restore
.player
,
439 state_to_restore
.board
,
440 state_to_restore
.pieces_before_move
,
441 state_to_restore
.deck_before_move
,
442 state_to_restore
.players_cards_before_move
,
443 state_to_restore
.moves_by_current_player
)
447 # Apply a list of moves in order
448 def apply_moves
!(moves
)
450 @current_player = move
.piece
.player
451 apply_move
!(move
, @current_player)
452 next_player
! if @moves_by_current_player >= $MOVES_PER_TURN
457 # Set the current player to be the next player
459 if @current_player == @players[-1]
460 @current_player = @players[0]
462 @current_player = @players[@players.index(@current_player) + 1]
464 @moves_by_current_player = 0
469 def reset_current_player(new_current_player
)
470 @current_player = new_current_player
471 @moves_by_current_player = 0
475 # Return an array of all possible moves from this state, given the active player
476 def possible_moves(player
= @current_player)
478 @pieces[player
].each
do |piece
|
479 # Do a forward move for each card held
480 unless piece
.position
== @board.positions
[-1]
481 @players_cards[player
].each
do |card
|
482 destination
= @board.positions
[@board.positions
.index(piece
.position
)..-1].find
do |pos
|
483 (pos
.symbol
== card
and pos
.contains
== []) or
486 # puts "Player #{player}, card #{card}, piece #{piece.number} at position #{@board.positions.index(piece.position)} to #{@board.positions.index(destination)}, a #{destination.symbol}"
487 moves
<< Move
.new(piece
, piece
.position
, destination
, card
)
490 # Do a reverse move for the piece
491 unless piece
.position
== board
.positions
[0]
492 destination
= @board.positions
[1...@board.positions
.index(piece
.position
)].reverse
.find
do |pos
|
493 pos
.contains
.length
== 1 or pos
.contains
.length
== 2
496 # 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"
497 moves
<< Move
.new(piece
, piece
.position
, destination
)
501 # moves.each {|m| puts m.show(@board)}
505 def build_state_string
506 outstr
= "Current player = #{@current_player}\n"
507 0.upto((@board.positions
.length
)-1) do |i
|
508 outstr
<< "#{i}: #{@board.positions[i].symbol}: "
509 @board.positions
[i
].contains
.each
do |piece
|
510 outstr
<< "P#{piece.player}:#{piece.number} "
514 0.upto((@players.length
)-1) do |i
|
515 outstr
<< "Player #{i} holds " << (@players_cards[i
].sort_by
{|c
| c
.to_s
}).join(', ') << "\n"
517 outstr
<< "Deck holds " << @deck.join(', ') << "\n"
521 # Show the state of the board
523 puts build_state_string
535 def set_testing_game
!
539 board_symbols
= [:cell, :gun, :keys, :dagger, :hat, :skull, :bottle,
540 :keys, :dagger, :skull, :hat, :gun, :bottle,
541 :dagger, :bottle, :keys, :gun, :hat, :skull,
542 :dagger, :skull, :bottle, :gun, :keys, :hat,
543 :hat, :dagger, :keys, :bottle, :gun, :skull,
544 :keys, :bottle, :skull, :dagger, :hat, :gun, :boat]
545 board_symbols
.each_index
do |i
|
546 @board.positions
[i
].symbol
= board_symbols
[i
]
549 @players_cards[0] = [:gun, :hat, :skull]
550 @players_cards[1] = [:bottle, :keys, :keys]
551 @players_cards[2] = [:bottle, :hat, :keys]
552 @players_cards[3] = [:bottle, :skull, :skull]
553 @players_cards[4] = [:bottle, :gun, :gun]
555 @deck = [:hat, :dagger, :hat, :hat, :skull, :bottle, :bottle, :bottle, :dagger,
556 :keys, :hat, :dagger, :skull, :skull, :dagger, :gun, :skull, :gun,
557 :gun, :skull, :keys, :keys, :gun, :hat, :dagger, :keys, :dagger,
558 :hat, :gun, :skull, :keys, :gun, :skull, :gun, :hat, :gun, :dagger,
559 :gun, :gun, :hat, :hat, :bottle, :gun, :dagger, :hat, :skull, :dagger,
560 :bottle, :hat, :skull, :gun, :bottle, :keys, :hat, :bottle, :keys,
561 :dagger, :bottle, :bottle, :dagger, :keys, :dagger, :dagger, :dagger,
562 :skull, :hat, :dagger, :dagger, :hat, :keys, :skull, :bottle, :skull,
563 :keys, :bottle, :keys, :bottle, :gun, :keys, :hat, :keys, :dagger,
564 :gun, :skull, :keys, :bottle, :skull]
568 # Given a set of lines from an input file, turn them into a Game object and
569 # a set of Move objects.
570 # Note the multiple return values.
572 def Game
.read_game(gamelines
)
573 gamelines
.each
{|l
| l
.chomp
!}
574 game
= Game
.new(gamelines
[0].to_i
, 6, 6)
578 game
.board
.positions
[i-1
] = Position
.new(gamelines
[i
].to_sym
)
581 # read all the moves so far
584 current_player_moves
= 0
586 player_card_counts
= game
.players_cards
.map
{|p
| $INITIAL_CARDS_PER_PLAYER}
587 unused_cards
= game
.cards
.map
{|c
| c
}
588 while gamelines
[i
].in_move_format
?
589 move
= gamelines
[i
].to_move(game
, true)
590 if move
.piece
.player
== current_player
591 current_player_moves
+= 1
593 current_player
= move
.piece
.player
594 current_player_moves
= 1
596 # if move is an advance move, decrement the cards held by the player and remove the card from unused cards
597 # throw an error if an advance move is made when all the cards of that type have been used
598 # if unused_cards becomes empty, restock it.
599 if game
.board
.positions
.index(move
.destination
) > game
.board
.positions
.index(move
.origin
)
600 raise(InvalidMoveError
, "Game reading: Player #{move.piece.player} attempting an advance move with no cards in hand. Move is #{gamelines[i]} on line #{i}") if player_card_counts
[move
.piece
.player
] == 0
601 card_used
= move
.destination
.symbol
602 if card_used
== :boat
603 card_used
= move
.card_played
605 raise(InvalidMoveError
, "Attempting to move into boat without a card specified") if card_used
== :unspecified
606 raise(InvalidMoveError
, "Game reading: Player #{move.piece.player} attempting an advance move to a #{move.destination.symbol} place when all those cards have been used. Move is #{gamelines[i]} on line #{i}") unless unused_cards
.include? card_used
607 player_card_counts
[current_player
] -= 1
608 unused_cards
.delete_at(unused_cards
.index(card_used
))
609 # if move is a retreat move, increment the cards held by the player
611 player_card_counts
[current_player
] += move
.destination
.contains
.length
612 if unused_cards
.length
== player_card_counts
.inject
{|sum
, n
| sum
+ n
}
613 unused_cards
= game
.cards
.map
{|c
| c
}
616 game
.apply_move
!(move
, move
.piece
.player
, false)
621 # note the current player
622 game
.current_player
= gamelines
[i
].to_i
- 1
623 if game
.current_player
== current_player
624 game
.moves_by_current_player
= current_player_moves
626 game
.moves_by_current_player
= 0
628 current_player
= game
.current_player
631 game
.players_cards
= game
.players_cards
.map
{|c
| []}
632 (i
+1).upto(gamelines
.length
- 1) do |j
|
633 game
.players_cards
[game
.current_player
] << gamelines
[j
].to_sym
634 raise(InvalidMoveError
, "Player #{game.current_player} given a #{gamelines[j]} card, but none left in deck") unless unused_cards
.index(gamelines
[j
].to_sym
)
635 unused_cards
.delete_at(unused_cards
.index(gamelines
[j
].to_sym
))
637 raise(InvalidMoveError
, "Player #{game.current_player} given #{game.players_cards[game.current_player].length} cards, but should have #{player_card_counts[game.current_player]} cards from play") if game
.players_cards
[game
.current_player
].length
!= player_card_counts
[game
.current_player
]
639 # Update the game deck
640 game
.deck
= unused_cards
.shuffle
641 player_card_counts
[game
.current_player
] = 0
642 player_card_counts
.each_index
do |player
|
643 if player_card_counts
[player
] > 0
644 game
.deal_cards
!(player_card_counts
[player
], player
)
653 # Extension to String class to convert a move-description string into a Move object.
654 # This is the inverse of the Move#to_s method
658 return ((elements
.length
== 3 or elements
.length
== 4) and
659 elements
[0].to_i
> 0 and elements
[0].to_i
< 6 and
660 elements
[1].to_i
>= 0 and elements
[1].to_i
<= 37 and
661 elements
[2].to_i
>= 0 and elements
[2].to_i
<= 37)
664 def to_move(game
, convert_to_zero_based_player_number
= false)
665 move_elements
= self.downcase
.split
666 player
= move_elements
[0].to_i
667 player
= player
- 1 if convert_to_zero_based_player_number
668 origin_index
= move_elements
[1].to_i
669 destination_index
= move_elements
[2].to_i
670 raise(InvalidMoveError
, "Invalid origin #{origin_index} in move read") unless origin_index
>= 0 and origin_index
< game
.board
.positions
.length
671 raise(InvalidMoveError
, "Invalid destination #{destination_index} in move read") unless destination_index
> 0 and destination_index
<= game
.board
.positions
.length
672 raise(InvalidMoveError
, "Player #{player} does not have a piece on position #{origin_index} in move read") unless game
.board
.positions
[origin_index
].contains
.any
? {|p
| p
.player
== player
}
673 piece
= game
.board
.positions
[origin_index
].contains
.find
{|p
| p
.player
== player
}
674 piece_name
= piece
.number
675 if destination_index
> origin_index
676 if move_elements
.length
== 4
677 card_used
= move_elements
[3].chomp
.to_sym
678 raise(InvalidMoveError
, "Card used (#{card_used}) does not match destination space (#{game.board.positions[destination_index].symbol})") unless card_used
== game
.board
.positions
[destination_index
].symbol
or destination_index
== game
.board
.positions
.length
- 1
679 Move
.new(game
.pieces
[player
][piece_name
],
680 game
.board
.positions
[origin_index
],
681 game
.board
.positions
[destination_index
],
684 card_used
= :unspecified
685 raise(InvalidMoveError
, "Move to boat without specifying card used") if destination_index
== game
.board
.positions
.length
+ 1
686 Move
.new(game
.pieces
[player
][piece_name
],
687 game
.board
.positions
[origin_index
],
688 game
.board
.positions
[destination_index
])
691 Move
.new(game
.pieces
[player
][piece_name
],
692 game
.board
.positions
[origin_index
],
693 game
.board
.positions
[destination_index
])
699 # Read a game description file and convert it into a Game object and set of Move objects.
700 # Note that Game.read_game method returns multiple values, so this one does too.
702 def IO
.read_game(filename
)
703 gamelines
= IO
.readlines(filename
)
704 return Game
.read_game(gamelines
)
708 # Extension to the Array class to include the Array#shuffle function
714 def index_find(&block
)