just_shogi 0.1.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/Gemfile +5 -0
  5. data/Gemfile.lock +24 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +80 -0
  8. data/Rakefile +10 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/just_shogi.gemspec +29 -0
  12. data/lib/just_shogi.rb +8 -0
  13. data/lib/just_shogi/errors/causes_check_error.rb +22 -0
  14. data/lib/just_shogi/errors/dropped_into_check_error.rb +22 -0
  15. data/lib/just_shogi/errors/error.rb +20 -0
  16. data/lib/just_shogi/errors/invalid_move_error.rb +22 -0
  17. data/lib/just_shogi/errors/invalid_promotion_error.rb +22 -0
  18. data/lib/just_shogi/errors/moved_into_check_error.rb +22 -0
  19. data/lib/just_shogi/errors/no_piece_error.rb +22 -0
  20. data/lib/just_shogi/errors/not_players_turn_error.rb +22 -0
  21. data/lib/just_shogi/errors/off_board_error.rb +22 -0
  22. data/lib/just_shogi/errors/piece_not_found_error.rb +22 -0
  23. data/lib/just_shogi/errors/square_occupied_error.rb +22 -0
  24. data/lib/just_shogi/game_state.rb +355 -0
  25. data/lib/just_shogi/hand.rb +48 -0
  26. data/lib/just_shogi/piece_factory.rb +87 -0
  27. data/lib/just_shogi/pieces/fuhyou.rb +23 -0
  28. data/lib/just_shogi/pieces/ginshou.rb +23 -0
  29. data/lib/just_shogi/pieces/gyokushou.rb +9 -0
  30. data/lib/just_shogi/pieces/hisha.rb +23 -0
  31. data/lib/just_shogi/pieces/kakugyou.rb +23 -0
  32. data/lib/just_shogi/pieces/keima.rb +23 -0
  33. data/lib/just_shogi/pieces/kin_base.rb +23 -0
  34. data/lib/just_shogi/pieces/kinshou.rb +9 -0
  35. data/lib/just_shogi/pieces/kyousha.rb +23 -0
  36. data/lib/just_shogi/pieces/narigin.rb +9 -0
  37. data/lib/just_shogi/pieces/narikei.rb +9 -0
  38. data/lib/just_shogi/pieces/narikyou.rb +9 -0
  39. data/lib/just_shogi/pieces/ou_base.rb +70 -0
  40. data/lib/just_shogi/pieces/oushou.rb +9 -0
  41. data/lib/just_shogi/pieces/piece.rb +18 -0
  42. data/lib/just_shogi/pieces/ryuuma.rb +23 -0
  43. data/lib/just_shogi/pieces/ryuuou.rb +23 -0
  44. data/lib/just_shogi/pieces/tokin.rb +9 -0
  45. data/lib/just_shogi/promotion_factory.rb +71 -0
  46. data/lib/just_shogi/square.rb +51 -0
  47. data/lib/just_shogi/square_set.rb +64 -0
  48. data/lib/just_shogi/version.rb +3 -0
  49. metadata +147 -0
@@ -0,0 +1,22 @@
1
+ require 'just_shogi/errors/error'
2
+
3
+ module JustShogi
4
+
5
+ # = NoPieceError
6
+ #
7
+ # A no piece error with a message
8
+ class NoPieceError < Error
9
+
10
+ # New no piece errors can be instantiated with
11
+ #
12
+ # @option [String] message
13
+ # the message to display.
14
+ #
15
+ # ==== Example:
16
+ # # Instantiates a new NoPieceError
17
+ # JustShogi::NoPieceError.new("Custom Message")
18
+ def initialize(message="There is no piece to move.")
19
+ super
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ require 'just_shogi/errors/error'
2
+
3
+ module JustShogi
4
+
5
+ # = NotPlayersTurnError
6
+ #
7
+ # A not players turn error with a message
8
+ class NotPlayersTurnError < Error
9
+
10
+ # New not players turn errors can be instantiated with
11
+ #
12
+ # @option [String] message
13
+ # the message to display.
14
+ #
15
+ # ==== Example:
16
+ # # Intantiates a new NotPlayersTurnError
17
+ # JustShogi::NotPlayersTurnError.new("Custom Message")
18
+ def initialize(message="It is not the player's turn yet.")
19
+ super
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ require 'just_shogi/errors/error'
2
+
3
+ module JustShogi
4
+
5
+ # = OffBoardError
6
+ #
7
+ # An off board error with a message
8
+ class OffBoardError < Error
9
+
10
+ # New off board errors can be instantiated with
11
+ #
12
+ # @option [String] message
13
+ # the message to display.
14
+ #
15
+ # ==== Example:
16
+ # # Instantiates a new OffBoardError
17
+ # JustShogi::OffBoardError.new("Custom Message")
18
+ def initialize(message="Cannot move off board.")
19
+ super
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ require 'just_shogi/errors/error'
2
+
3
+ module JustShogi
4
+
5
+ # = PieceNotFoundError
6
+ #
7
+ # An piece not found error with a message
8
+ class PieceNotFoundError < Error
9
+
10
+ # New piece not found errors can be instantiated with
11
+ #
12
+ # @option [String] message
13
+ # the message to display.
14
+ #
15
+ # ==== Example:
16
+ # # Instantiates a new PieceNotFoundError
17
+ # JustShogi::PieceNotFoundError.new("Custom Message")
18
+ def initialize(message="The piece could not be found.")
19
+ super
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ require 'just_shogi/errors/error'
2
+
3
+ module JustShogi
4
+
5
+ # = SquareOccupiedError
6
+ #
7
+ # A square occupied error with a message
8
+ class SquareOccupiedError < Error
9
+
10
+ # New invalid promotion errors can be instantiated with
11
+ #
12
+ # @option [String] message
13
+ # the message to display.
14
+ #
15
+ # ==== Example:
16
+ # # Instantiates a new SquareOccupiedError
17
+ # JustShogi::SquareOccupiedError.new("Custom Message")
18
+ def initialize(message="Square is already occupied.")
19
+ super
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,355 @@
1
+ require 'just_shogi/errors/no_piece_error'
2
+ require 'just_shogi/errors/not_players_turn_error'
3
+ require 'just_shogi/errors/off_board_error'
4
+ require 'just_shogi/errors/invalid_move_error'
5
+ require 'just_shogi/errors/moved_into_check_error'
6
+ require 'just_shogi/errors/invalid_promotion_error'
7
+ require 'just_shogi/errors/piece_not_found_error'
8
+ require 'just_shogi/errors/square_occupied_error'
9
+ require 'just_shogi/errors/dropped_into_check_error'
10
+ require 'just_shogi/square_set'
11
+ require 'just_shogi/hand'
12
+ require 'just_shogi/promotion_factory'
13
+
14
+ module JustShogi
15
+
16
+ # = Game State
17
+ #
18
+ # Represents a game of Shogi in progress.
19
+ class GameState
20
+ def initialize(current_player_number: , squares: [], hands: [])
21
+ @current_player_number = current_player_number
22
+ @squares = if squares.is_a?(SquareSet)
23
+ squares
24
+ else
25
+ SquareSet.new(squares: squares)
26
+ end
27
+ @hands = if hands.is_a?(Array)
28
+ if hands.all? { |hand| hand.is_a?(Hand) }
29
+ hands
30
+ elsif hands.all? { |hand| hand.is_a?(Hash) }
31
+ hands.map { |hand| Hand.new(**hand) }
32
+ else
33
+ raise ArgumentError, "hands must all be the same class"
34
+ end
35
+ else
36
+ raise ArgumentError, "hands must be an array"
37
+ end
38
+ end
39
+
40
+ attr_reader :current_player_number, :squares, :hands, :errors
41
+
42
+ # Instantiates a new GameState object in the starting position.
43
+ #
44
+ # @return [GameState]
45
+ def self.default
46
+ new(
47
+ current_player_number: 1,
48
+ squares: [
49
+ { id: '91', x: 0, y: 0, piece: { id: 1, player_number: 2, type: 'kyousha' } },
50
+ { id: '81', x: 1, y: 0, piece: { id: 2, player_number: 2, type: 'keima' } },
51
+ { id: '71', x: 2, y: 0, piece: { id: 3, player_number: 2, type: 'ginshou' } },
52
+ { id: '61', x: 3, y: 0, piece: { id: 4, player_number: 2, type: 'kinshou' } },
53
+ { id: '51', x: 4, y: 0, piece: { id: 5, player_number: 2, type: 'oushou' } },
54
+ { id: '41', x: 5, y: 0, piece: { id: 6, player_number: 2, type: 'kinshou' } },
55
+ { id: '31', x: 6, y: 0, piece: { id: 7, player_number: 2, type: 'ginshou' } },
56
+ { id: '21', x: 7, y: 0, piece: { id: 8, player_number: 2, type: 'keima' } },
57
+ { id: '11', x: 8, y: 0, piece: { id: 9, player_number: 2, type: 'kyousha' } },
58
+
59
+ { id: '92', x: 0, y: 1, piece: nil },
60
+ { id: '82', x: 1, y: 1, piece: { id: 10, player_number: 2, type: 'hisha' } },
61
+ { id: '72', x: 2, y: 1, piece: nil },
62
+ { id: '62', x: 3, y: 1, piece: nil },
63
+ { id: '52', x: 4, y: 1, piece: nil },
64
+ { id: '42', x: 5, y: 1, piece: nil },
65
+ { id: '32', x: 6, y: 1, piece: nil },
66
+ { id: '22', x: 7, y: 1, piece: { id: 11, player_number: 2, type: 'kakugyou' } },
67
+ { id: '12', x: 8, y: 1, piece: nil },
68
+
69
+ { id: '93', x: 0, y: 2, piece: { id: 12, player_number: 2, type: 'fuhyou' } },
70
+ { id: '83', x: 1, y: 2, piece: { id: 13, player_number: 2, type: 'fuhyou' } },
71
+ { id: '73', x: 2, y: 2, piece: { id: 14, player_number: 2, type: 'fuhyou' } },
72
+ { id: '63', x: 3, y: 2, piece: { id: 15, player_number: 2, type: 'fuhyou' } },
73
+ { id: '53', x: 4, y: 2, piece: { id: 16, player_number: 2, type: 'fuhyou' } },
74
+ { id: '43', x: 5, y: 2, piece: { id: 17, player_number: 2, type: 'fuhyou' } },
75
+ { id: '33', x: 6, y: 2, piece: { id: 18, player_number: 2, type: 'fuhyou' } },
76
+ { id: '23', x: 7, y: 2, piece: { id: 19, player_number: 2, type: 'fuhyou' } },
77
+ { id: '13', x: 8, y: 2, piece: { id: 20, player_number: 2, type: 'fuhyou' } },
78
+
79
+ { id: '94', x: 0, y: 3, piece: nil },
80
+ { id: '84', x: 1, y: 3, piece: nil },
81
+ { id: '74', x: 2, y: 3, piece: nil },
82
+ { id: '64', x: 3, y: 3, piece: nil },
83
+ { id: '54', x: 4, y: 3, piece: nil },
84
+ { id: '44', x: 5, y: 3, piece: nil },
85
+ { id: '34', x: 6, y: 3, piece: nil },
86
+ { id: '24', x: 7, y: 3, piece: nil },
87
+ { id: '14', x: 8, y: 3, piece: nil },
88
+
89
+ { id: '95', x: 0, y: 4, piece: nil },
90
+ { id: '85', x: 1, y: 4, piece: nil },
91
+ { id: '75', x: 2, y: 4, piece: nil },
92
+ { id: '65', x: 3, y: 4, piece: nil },
93
+ { id: '55', x: 4, y: 4, piece: nil },
94
+ { id: '45', x: 5, y: 4, piece: nil },
95
+ { id: '35', x: 6, y: 4, piece: nil },
96
+ { id: '25', x: 7, y: 4, piece: nil },
97
+ { id: '15', x: 8, y: 4, piece: nil },
98
+
99
+ { id: '96', x: 0, y: 5, piece: nil },
100
+ { id: '86', x: 1, y: 5, piece: nil },
101
+ { id: '76', x: 2, y: 5, piece: nil },
102
+ { id: '66', x: 3, y: 5, piece: nil },
103
+ { id: '56', x: 4, y: 5, piece: nil },
104
+ { id: '46', x: 5, y: 5, piece: nil },
105
+ { id: '36', x: 6, y: 5, piece: nil },
106
+ { id: '26', x: 7, y: 5, piece: nil },
107
+ { id: '16', x: 8, y: 5, piece: nil },
108
+
109
+ { id: '97', x: 0, y: 6, piece: { id: 21, player_number: 1, type: 'fuhyou' } },
110
+ { id: '87', x: 1, y: 6, piece: { id: 22, player_number: 1, type: 'fuhyou' } },
111
+ { id: '77', x: 2, y: 6, piece: { id: 23, player_number: 1, type: 'fuhyou' } },
112
+ { id: '67', x: 3, y: 6, piece: { id: 24, player_number: 1, type: 'fuhyou' } },
113
+ { id: '57', x: 4, y: 6, piece: { id: 25, player_number: 1, type: 'fuhyou' } },
114
+ { id: '47', x: 5, y: 6, piece: { id: 26, player_number: 1, type: 'fuhyou' } },
115
+ { id: '37', x: 6, y: 6, piece: { id: 27, player_number: 1, type: 'fuhyou' } },
116
+ { id: '27', x: 7, y: 6, piece: { id: 28, player_number: 1, type: 'fuhyou' } },
117
+ { id: '17', x: 8, y: 6, piece: { id: 29, player_number: 1, type: 'fuhyou' } },
118
+
119
+ { id: '98', x: 0, y: 7, piece: nil },
120
+ { id: '88', x: 1, y: 7, piece: { id: 30, player_number: 1, type: 'kakugyou' } },
121
+ { id: '78', x: 2, y: 7, piece: nil },
122
+ { id: '68', x: 3, y: 7, piece: nil },
123
+ { id: '58', x: 4, y: 7, piece: nil },
124
+ { id: '48', x: 5, y: 7, piece: nil },
125
+ { id: '38', x: 6, y: 7, piece: nil },
126
+ { id: '28', x: 7, y: 7, piece: { id: 31, player_number: 1, type: 'hisha' } },
127
+ { id: '18', x: 8, y: 7, piece: nil },
128
+
129
+ { id: '99', x: 0, y: 8, piece: { id: 32, player_number: 1, type: 'kyousha' } },
130
+ { id: '89', x: 1, y: 8, piece: { id: 33, player_number: 1, type: 'keima' } },
131
+ { id: '79', x: 2, y: 8, piece: { id: 34, player_number: 1, type: 'ginshou' } },
132
+ { id: '69', x: 3, y: 8, piece: { id: 35, player_number: 1, type: 'kinshou' } },
133
+ { id: '59', x: 4, y: 8, piece: { id: 36, player_number: 1, type: 'gyokushou' } },
134
+ { id: '49', x: 5, y: 8, piece: { id: 37, player_number: 1, type: 'kinshou' } },
135
+ { id: '39', x: 6, y: 8, piece: { id: 38, player_number: 1, type: 'ginshou' } },
136
+ { id: '29', x: 7, y: 8, piece: { id: 39, player_number: 1, type: 'keima' } },
137
+ { id: '19', x: 8, y: 8, piece: { id: 40, player_number: 1, type: 'kyousha' } }
138
+ ],
139
+ hands: [
140
+ { player_number: 1, pieces: [] },
141
+ { player_number: 2, pieces: [] },
142
+ ]
143
+ )
144
+ end
145
+
146
+ # serializes the game state as ahash
147
+ #
148
+ # @return [Hash]
149
+ def as_json
150
+ {
151
+ current_player_number: current_player_number,
152
+ squares: squares.as_json,
153
+ hands: hands.map(&:as_json)
154
+ }
155
+ end
156
+
157
+ # deep clone of the game state
158
+ #
159
+ # @return [GameState]
160
+ def clone
161
+ self.class.new(**as_json)
162
+ end
163
+
164
+ # Moves a piece owned by the player, from one square, to another.
165
+ #
166
+ # It has an option to promote the moving piece.
167
+ # It moves the piece and returns true if the move is valid and it's the player's turn.
168
+ # It returns false otherwise.
169
+ #
170
+ # ==== Example:
171
+ # # Moves a piece from a square to perform a move
172
+ # game_state.move(1, '77', '78')
173
+ #
174
+ # @param [Fixnum] player_number
175
+ # the player number, 1 or 2.
176
+ #
177
+ # @param [String] from_id
178
+ # the id of the from square
179
+ #
180
+ # @param [String] to_id
181
+ # the id of the to square
182
+ #
183
+ # @param [boolean] promote
184
+ #
185
+ # @return [Boolean]
186
+ def move(player_number, from_id, to_id, promote = false)
187
+ @errors = []
188
+
189
+ from = squares.find_by_id(from_id)
190
+ to = squares.find_by_id(to_id)
191
+
192
+ if current_player_number != player_number
193
+ @errors.push JustShogi::NotPlayersTurnError.new
194
+ elsif from.unoccupied?
195
+ @errors.push JustShogi::NoPieceError.new
196
+ elsif to.nil?
197
+ @errors.push JustShogi::OffBoardError.new
198
+ elsif promote && !promotable?(player_number, from, to)
199
+ @errors.push JustShogi::InvalidPromotionError.new
200
+ elsif from.piece.can_move?(from, to, self)
201
+
202
+ duplicate = self.clone
203
+ duplicate.perform_complete_move(player_number, from_id, to_id, promote)
204
+
205
+ if duplicate.in_check?(current_player_number)
206
+ @errors.push JustShogi::MovedIntoCheckError.new
207
+ else
208
+ perform_complete_move(player_number, from_id, to_id, promote)
209
+ end
210
+ else
211
+ @errors.push JustShogi::InvalidMoveError.new
212
+ end
213
+
214
+ @errors.empty?
215
+ end
216
+
217
+ def drop(player_number, piece_id, square_id)
218
+ @errors = []
219
+
220
+ piece = hand_for_player(player_number).find_piece_by_id(piece_id)
221
+ square = squares.find_by_id(square_id)
222
+
223
+ if current_player_number != player_number
224
+ @errors.push JustShogi::NotPlayersTurnError.new
225
+ elsif piece.nil?
226
+ @errors.push JustShogi::PieceNotFoundError.new
227
+ elsif square.nil?
228
+ @errors.push JustShogi::OffBoardError.new
229
+ elsif square.occupied?
230
+ @errors.push JustShogi::SquareOccupiedError.new
231
+ else
232
+ duplicate = self.clone
233
+ duplicate.perform_complete_drop(player_number, piece_id, square_id)
234
+
235
+ if duplicate.in_check?(opposing_player_number)
236
+ @errors.push JustShogi::DroppedIntoCheckError.new
237
+ else
238
+ perform_complete_drop(player_number, piece_id, square_id)
239
+ end
240
+ end
241
+
242
+ @errors.empty?
243
+ end
244
+
245
+ # The player number of the winner. It returns nil if there is no winner.
246
+ #
247
+ # @return [Fixnum,NilClass]
248
+ def winner
249
+ case
250
+ when in_checkmate?(1)
251
+ 2
252
+ when in_checkmate?(2)
253
+ 1
254
+ else
255
+ nil
256
+ end
257
+ end
258
+
259
+ # Moves a piece owned by the player, from one square, to another, with the option to promote.
260
+ #
261
+ # It moves the piece and returns true if the move is valid and it's the player's turn.
262
+ # It returns false otherwise.
263
+ # It promotes if possible and specified.
264
+ #
265
+ def perform_complete_move(player_number, from_id, to_id, promote = false)
266
+ from = squares.find_by_id(from_id)
267
+ to = squares.find_by_id(to_id)
268
+
269
+ captured = to.occupied? ? to : nil
270
+
271
+ @last_change = { type: 'move', data: { player_number: player_number, from: from_id, to: to_id } }
272
+
273
+ perform_move(player_number, from, to, captured)
274
+
275
+ promote_piece(to) if promote
276
+ pass_turn
277
+ end
278
+
279
+ def perform_complete_drop(player_number, piece_id, square_id)
280
+ hand = hand_for_player(player_number)
281
+ square = squares.find_by_id(square_id)
282
+
283
+ @last_change = { type: 'drop', data: { player_number: player_number, piece: piece_id, square: square_id } }
284
+
285
+ piece = hand.pop_piece(piece_id)
286
+ square.piece = piece
287
+
288
+ pass_turn
289
+ end
290
+
291
+ def in_check?(player_number)
292
+ ou_square = squares.find_ou_for_player(player_number)
293
+ threatened_by = squares.threatened_by(opposing_player_number(player_number), self)
294
+ threatened_by.include?(ou_square)
295
+ end
296
+
297
+ def in_checkmate?(player_number)
298
+ (in_check?(player_number) || non_ou_pieces_cannot_move?(player_number)) && ou_cannot_move?(player_number)
299
+ end
300
+
301
+ private
302
+
303
+ def non_ou_pieces_cannot_move?(player_number)
304
+ squares.occupied_by_player(player_number).excluding_piece(JustShogi::OuBase).all? { |s| s.piece.destinations(s, self).empty? }
305
+ end
306
+
307
+ def ou_cannot_move?(player_number)
308
+ ou_square = squares.find_ou_for_player(player_number)
309
+ destinations = ou_square.piece.destinations(ou_square, self)
310
+ destinations.all? do |destination|
311
+ duplicate = self.clone
312
+ duplicate.perform_complete_move(player_number, ou_square.id, destination.id)
313
+ duplicate.in_check?(player_number)
314
+ end
315
+ end
316
+
317
+ def pass_turn
318
+ @current_player_number = opposing_player_number
319
+ end
320
+
321
+ def opposing_player_number(player_number = nil)
322
+ if player_number
323
+ other_player_number(player_number)
324
+ else
325
+ other_player_number(@current_player_number)
326
+ end
327
+ end
328
+
329
+ def other_player_number(player_number)
330
+ (player_number == 1) ? 2 : 1
331
+ end
332
+
333
+ def hand_for_player(player_number)
334
+ hands.find { |h| h.player_number == player_number }
335
+ end
336
+
337
+ def perform_move(player_number, from, to, captured)
338
+ if captured
339
+ hand_for_player(player_number).push_piece(to.piece)
340
+ captured.piece = nil
341
+ end
342
+ to.piece = from.piece
343
+ from.piece = nil
344
+ end
345
+
346
+ def promotable?(player_number, from, to)
347
+ PromotionFactory.new(from.piece).promotable? && to.promotion_zone(player_number)
348
+ end
349
+
350
+ def promote_piece(square)
351
+ new_piece = PromotionFactory.new(square.piece).promote
352
+ square.piece = new_piece
353
+ end
354
+ end
355
+ end