pgn2 1.2.1 → 1.4.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.
@@ -0,0 +1,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # {PGN::Notation} generates Standard Algebraic Notation (SAN) for a single
5
+ # move described in coordinate form -- an origin square, a destination
6
+ # square, and an optional promotion piece -- given the {PGN::Position}
7
+ # *before* the move is played.
8
+ #
9
+ # Unlike {PGN::Move}, which *parses* an existing SAN string, {PGN::Notation}
10
+ # *builds* SAN from coordinates. This requires the kind of legality analysis
11
+ # the rest of the gem does not perform: attack detection, "does this move
12
+ # leave the mover's king in check", disambiguation among same-type pieces
13
+ # that can legally reach the destination, and check (+) / checkmate (#)
14
+ # suffix detection (the latter needing full legal-move generation for the
15
+ # side to move).
16
+ #
17
+ # The board is addressed with the same 0x88 integer indices the rest of the
18
+ # gem uses (see {PGN::Board}); an index is on-board when
19
+ # `(idx & 0x88).zero?`.
20
+ #
21
+ # @example
22
+ # PGN::Notation.san(PGN::Position.start, 'g1', 'f3') #=> "Nf3"
23
+ # PGN::Notation.san_from_fen(PGN::FEN::INITIAL, 'e2', 'e4') #=> "e4"
24
+ #
25
+ class Notation
26
+ # 0x88 single-step offsets for a knight (symmetric, so they double as
27
+ # attack deltas).
28
+ KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
29
+ # 0x88 single-step offsets for a king (symmetric).
30
+ KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
31
+ BISHOP_DIRS = [-15, 15, -17, 17].freeze
32
+ ROOK_DIRS = [-1, 1, -16, 16].freeze
33
+
34
+ # Build SAN for a coordinate move from a {PGN::Position}.
35
+ #
36
+ # @param position [PGN::Position] the position *before* the move
37
+ # @param from [String] origin square in algebraic notation ("e2")
38
+ # @param to [String] destination square in algebraic notation ("e4")
39
+ # @param promotion [String, nil] promotion piece letter ("q"/"Q"/"n"...);
40
+ # case-insensitive; SAN always emits an uppercase letter
41
+ # @return [String] the move in SAN, e.g. "Nf3", "exd5", "O-O", "Ra8#"
42
+ #
43
+ def self.san(position, from, to, promotion = nil)
44
+ new(position).san(from, to, promotion)
45
+ end
46
+
47
+ # Convenience: build SAN directly from a FEN string.
48
+ #
49
+ # @param fen [String] the FEN of the position *before* the move
50
+ # @param (see .san)
51
+ # @return [String] the move in SAN
52
+ #
53
+ def self.san_from_fen(fen, from, to, promotion = nil)
54
+ san(PGN::FEN.new(fen).to_position, from, to, promotion)
55
+ end
56
+
57
+ def initialize(position)
58
+ @position = position
59
+ @board = position.board
60
+ @player = position.player
61
+ @mover = (@player == :white ? 'w' : 'b')
62
+ @enemy = (@player == :white ? 'b' : 'w')
63
+ end
64
+
65
+ # @param (see .san)
66
+ # @return [String]
67
+ def san(from, to, promotion = nil)
68
+ from_idx = @board.index_of(from)
69
+ to_idx = @board.index_of(to)
70
+ piece = @board.at_index(from_idx)
71
+ raise ArgumentError, "no piece on #{from}" if piece.nil?
72
+
73
+ promotion = promotion.upcase if promotion
74
+
75
+ castling = piece.upcase == 'K' && ((from_idx & 0x0F) - (to_idx & 0x0F)).abs == 2
76
+ capture = castling ? false : capture?(piece, from_idx, to_idx)
77
+
78
+ body =
79
+ if castling
80
+ to_idx < from_idx ? 'O-O-O' : 'O-O'
81
+ elsif piece.upcase == 'P'
82
+ pawn_body(from_idx, to_idx, capture, promotion)
83
+ else
84
+ piece_body(piece, from_idx, to_idx, capture)
85
+ end
86
+
87
+ body + suffix(from_idx, to_idx, piece, promotion, castling)
88
+ end
89
+
90
+ private
91
+
92
+ # -- body construction -------------------------------------------------
93
+
94
+ def pawn_body(from_idx, to_idx, capture, promotion)
95
+ dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
96
+ promo = promotion ? "=#{promotion}" : ''
97
+ if capture
98
+ "#{Board::INDEX_TO_FILE[from_idx & 0x0F]}x#{dest}#{promo}"
99
+ else
100
+ "#{dest}#{promo}"
101
+ end
102
+ end
103
+
104
+ def piece_body(piece, from_idx, to_idx, capture)
105
+ disamb = disambiguation(piece, from_idx, to_idx)
106
+ dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
107
+ parts = [piece.upcase, disamb]
108
+ parts << 'x' if capture
109
+ parts << dest
110
+ parts.join
111
+ end
112
+
113
+ # Disambiguate a non-pawn, non-castling move among same-type pieces that
114
+ # can *legally* reach the destination. Returns '' when none can.
115
+ def disambiguation(piece, from_idx, to_idx)
116
+ others = same_type_legals(piece, from_idx, to_idx)
117
+ return '' if others.empty?
118
+
119
+ from_file = from_idx & 0x0F
120
+ from_rank = from_idx >> 4
121
+ file_clash = others.any? { |o| (o & 0x0F) == from_file }
122
+ rank_clash = others.any? { |o| (o >> 4) == from_rank }
123
+
124
+ if !file_clash
125
+ Board::INDEX_TO_FILE[from_file]
126
+ elsif !rank_clash
127
+ Board::INDEX_TO_RANK[from_rank]
128
+ else
129
+ Board::INDEX_TO_FILE[from_file] + Board::INDEX_TO_RANK[from_rank]
130
+ end
131
+ end
132
+
133
+ # Other squares holding `piece` whose move to `to_idx` is legal.
134
+ def same_type_legals(piece, from_idx, to_idx)
135
+ (0...128).each_with_object([]) do |idx, acc|
136
+ next if (idx & 0x88) != 0
137
+ next if idx == from_idx
138
+ next unless @board.at_index(idx) == piece
139
+
140
+ acc << idx if reaches?(piece, idx, to_idx) && legal_after?(idx, to_idx, piece)
141
+ end
142
+ end
143
+
144
+ # -- check / checkmate suffix -----------------------------------------
145
+
146
+ def suffix(from_idx, to_idx, piece, promotion, castling)
147
+ nb = apply_move(from_idx, to_idx, piece, promotion, castling)
148
+ opp_king = king_idx(nb, @enemy)
149
+ return '' unless opp_king && attacked?(nb, opp_king, @mover)
150
+
151
+ result_pos = PGN::Position.new(
152
+ nb,
153
+ @player == :white ? :black : :white,
154
+ [],
155
+ new_ep_square(piece, from_idx, to_idx),
156
+ 0,
157
+ @position.fullmove
158
+ )
159
+ any_legal_move?(result_pos) ? '+' : '#'
160
+ end
161
+
162
+ # -- move application -------------------------------------------------
163
+
164
+ def apply_move(from_idx, to_idx, piece, promotion, castling)
165
+ nb = @board.dup
166
+ return castle!(nb, from_idx, to_idx, piece) if castling
167
+
168
+ ep = ep_capture_square(piece, from_idx, to_idx)
169
+ nb.update_index(ep, nil) if ep
170
+ nb.update_index(from_idx, nil)
171
+ nb.update_index(to_idx, promotion ? promoted(promotion) : piece)
172
+ nb
173
+ end
174
+
175
+ def castle!(board, from_idx, to_idx, piece)
176
+ rank = from_idx >> 4
177
+ rook = piece.upcase == 'K' ? 'R' : 'r'
178
+ board.update_index(from_idx, nil)
179
+ board.update_index(to_idx, piece)
180
+ if to_idx < from_idx # queenside
181
+ board.update_index(rank * 16, nil)
182
+ board.update_index((rank * 16) + 3, rook)
183
+ else # kingside
184
+ board.update_index((rank * 16) + 7, nil)
185
+ board.update_index((rank * 16) + 5, rook)
186
+ end
187
+ board
188
+ end
189
+
190
+ def promoted(promotion)
191
+ @player == :white ? promotion.upcase : promotion.downcase
192
+ end
193
+
194
+ # En-passant captured-pawn square (or nil): a pawn moving diagonally to
195
+ # an empty destination must be capturing en passant.
196
+ def ep_capture_square(piece, from_idx, to_idx)
197
+ return nil unless piece.upcase == 'P'
198
+ return nil if (from_idx & 0x0F) == (to_idx & 0x0F)
199
+
200
+ @board.at_index(to_idx).nil? ? ((from_idx >> 4) * 16) + (to_idx & 0x0F) : nil
201
+ end
202
+
203
+ # The en-passant target square left behind by a double pawn push, for the
204
+ # resulting position (so an escaping ep capture is considered for mate).
205
+ def new_ep_square(piece, from_idx, to_idx)
206
+ return nil unless piece.upcase == 'P'
207
+ return nil if ((from_idx >> 4) - (to_idx >> 4)).abs != 2
208
+
209
+ mid = ((from_idx >> 4) + (to_idx >> 4)) / 2
210
+ Board::INDEX_TO_FILE[from_idx & 0x0F] + Board::INDEX_TO_RANK[mid]
211
+ end
212
+
213
+ # -- legality ---------------------------------------------------------
214
+
215
+ # Is `piece` from `from_idx` to `to_idx` legal (own king safe after)?
216
+ def legal_after?(from_idx, to_idx, piece)
217
+ nb = @board.dup
218
+ ep = ep_capture_square(piece, from_idx, to_idx)
219
+ nb.update_index(ep, nil) if ep
220
+ nb.update_index(from_idx, nil)
221
+ nb.update_index(to_idx, piece)
222
+ king = king_idx(nb, @mover)
223
+ king && !attacked?(nb, king, @enemy)
224
+ end
225
+
226
+ # Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
227
+ # (Sliders honor blockers; the destination may be empty or enemy-occupied.)
228
+ def reaches?(piece, from_idx, to_idx)
229
+ return false if from_idx == to_idx
230
+
231
+ case piece.upcase
232
+ when 'N'
233
+ KNIGHT_OFFS.any? { |o| (from_idx + o) == to_idx }
234
+ when 'K'
235
+ KING_OFFS.any? { |o| (from_idx + o) == to_idx }
236
+ when 'B', 'R', 'Q'
237
+ slider_reaches?(piece.upcase, from_idx, to_idx)
238
+ else
239
+ false
240
+ end
241
+ end
242
+
243
+ def slider_reaches?(piece_up, from_idx, to_idx)
244
+ slider_dirs(piece_up).any? do |off|
245
+ i = from_idx + off
246
+ while (i & 0x88).zero?
247
+ return true if i == to_idx
248
+ break if @board.at_index(i)
249
+
250
+ i += off
251
+ end
252
+ false
253
+ end
254
+ end
255
+
256
+ # Capture detection for the *played* move (used in the SAN body).
257
+ def capture?(piece, from_idx, to_idx)
258
+ if piece.upcase == 'P'
259
+ (from_idx & 0x0F) != (to_idx & 0x0F) # any diagonal pawn move captures
260
+ else
261
+ target = @board.at_index(to_idx)
262
+ target && !own?(target, @mover)
263
+ end
264
+ end
265
+
266
+ def own?(piece, color)
267
+ color == 'w' ? piece == piece.upcase : piece == piece.downcase
268
+ end
269
+
270
+ # Slider ray directions for a piece letter ('B'/'R'/'Q').
271
+ def slider_dirs(piece_up)
272
+ case piece_up
273
+ when 'B' then BISHOP_DIRS
274
+ when 'R' then ROOK_DIRS
275
+ else BISHOP_DIRS + ROOK_DIRS
276
+ end
277
+ end
278
+
279
+ # -- attack / legality helpers ----------------------------------------
280
+
281
+ def king_idx(board, color)
282
+ king = color == 'w' ? 'K' : 'k'
283
+ (0...128).each do |idx|
284
+ next if (idx & 0x88) != 0
285
+
286
+ return idx if board.at_index(idx) == king
287
+ end
288
+ nil
289
+ end
290
+
291
+ # Is `target` attacked by `color`-colored pieces on `board`?
292
+ def attacked?(board, target, color)
293
+ pawn_attacked?(board, target, color) ||
294
+ knight_attacked?(board, target, color) ||
295
+ king_attacked?(board, target, color) ||
296
+ slider_attacked?(board, target, color)
297
+ end
298
+
299
+ def pawn_attacked?(board, target, color)
300
+ offs = color == 'w' ? [-15, -17] : [15, 17]
301
+ pawn = color == 'w' ? 'P' : 'p'
302
+ offs.any? do |off|
303
+ i = target + off
304
+ (i & 0x88).zero? && board.at_index(i) == pawn
305
+ end
306
+ end
307
+
308
+ def knight_attacked?(board, target, color)
309
+ knight = color == 'w' ? 'N' : 'n'
310
+ KNIGHT_OFFS.any? do |off|
311
+ i = target + off
312
+ (i & 0x88).zero? && board.at_index(i) == knight
313
+ end
314
+ end
315
+
316
+ def king_attacked?(board, target, color)
317
+ king = color == 'w' ? 'K' : 'k'
318
+ KING_OFFS.any? do |off|
319
+ i = target + off
320
+ (i & 0x88).zero? && board.at_index(i) == king
321
+ end
322
+ end
323
+
324
+ def slider_attacked?(board, target, color)
325
+ bishop = color == 'w' ? 'B' : 'b'
326
+ rook = color == 'w' ? 'R' : 'r'
327
+ queen = color == 'w' ? 'Q' : 'q'
328
+ ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
329
+ ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
330
+ end
331
+
332
+ def ray_hit?(board, target, dirs)
333
+ dirs.each do |off|
334
+ i = target + off
335
+ while (i & 0x88).zero?
336
+ piece = board.at_index(i)
337
+ if piece
338
+ return true if yield(piece)
339
+ break
340
+ end
341
+ i += off
342
+ end
343
+ end
344
+ false
345
+ end
346
+
347
+ # -- legal-move generation (for checkmate detection) ------------------
348
+
349
+ def any_legal_move?(position)
350
+ board = position.board
351
+ color = position.player == :white ? 'w' : 'b'
352
+ ep_idx = ep_target_idx(position, board)
353
+
354
+ (0...128).any? do |idx|
355
+ next if (idx & 0x88) != 0
356
+
357
+ piece = board.at_index(idx)
358
+ piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
359
+ end
360
+ end
361
+
362
+ def ep_target_idx(position, board)
363
+ ep = position.en_passant
364
+ return nil if ep.nil? || ep == '-' || ep.empty?
365
+
366
+ board.index_of(ep)
367
+ end
368
+
369
+ # Yields true if `piece` at `idx` has any legal move (escapes check).
370
+ def move_from?(board, idx, piece, color, ep_idx)
371
+ up = piece.upcase
372
+ case up
373
+ when 'N' then leaper_moves?(board, idx, piece, color, KNIGHT_OFFS)
374
+ when 'K' then leaper_moves?(board, idx, piece, color, KING_OFFS)
375
+ when 'B', 'R', 'Q'
376
+ slider_moves?(board, idx, piece, color, slider_dirs(up))
377
+ when 'P'
378
+ pawn_moves?(board, idx, piece, color, ep_idx)
379
+ else
380
+ false
381
+ end
382
+ end
383
+
384
+ def leaper_moves?(board, idx, piece, color, offs)
385
+ offs.any? do |off|
386
+ t = idx + off
387
+ (t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
388
+ end
389
+ end
390
+
391
+ def slider_moves?(board, idx, piece, color, dirs)
392
+ dirs.any? do |off|
393
+ t = idx + off
394
+ while (t & 0x88).zero?
395
+ tp = board.at_index(t)
396
+ if tp.nil?
397
+ return true if safe?(board, idx, t, piece, nil)
398
+ else
399
+ return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
400
+ break
401
+ end
402
+ t += off
403
+ end
404
+ false
405
+ end
406
+ end
407
+
408
+ def pawn_moves?(board, idx, piece, color, ep_idx)
409
+ pawn_pushes?(board, idx, piece, color) ||
410
+ pawn_captures?(board, idx, piece, color, ep_idx)
411
+ end
412
+
413
+ def pawn_pushes?(board, idx, piece, color)
414
+ dir = color == 'w' ? 16 : -16
415
+ start_rank = color == 'w' ? 1 : 6
416
+ promo_rank = color == 'w' ? 7 : 0
417
+
418
+ t = idx + dir
419
+ return false unless (t & 0x88).zero? && board.at_index(t).nil?
420
+
421
+ promo = (t >> 4 == promo_rank) ? 'Q' : nil
422
+ return true if safe?(board, idx, t, piece, promo)
423
+
424
+ t2 = idx + (dir * 2)
425
+ (idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
426
+ end
427
+
428
+ def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
429
+ promo_rank = color == 'w' ? 7 : 0
430
+ caps = color == 'w' ? [15, 17] : [-15, -17]
431
+ caps.any? do |off|
432
+ t = idx + off
433
+ next false unless (t & 0x88).zero?
434
+
435
+ tp = board.at_index(t)
436
+ promo = (t >> 4 == promo_rank) ? 'Q' : nil
437
+ if tp && !own?(tp, color)
438
+ safe?(board, idx, t, piece, promo)
439
+ elsif tp.nil? && t == ep_idx
440
+ safe?(board, idx, t, piece, nil)
441
+ else
442
+ false
443
+ end
444
+ end
445
+ end
446
+
447
+ def landable?(board, target, color)
448
+ tp = board.at_index(target)
449
+ tp.nil? || !own?(tp, color)
450
+ end
451
+
452
+ # Apply a pseudo-move (with ep + promotion) on a copy and test own-king
453
+ # safety. The moving side is derived from `piece`'s case.
454
+ def safe?(board, from_idx, to_idx, piece, promotion)
455
+ nb = board.dup
456
+ if piece.upcase == 'P' && (from_idx & 0x0F) != (to_idx & 0x0F) && board.at_index(to_idx).nil?
457
+ nb.update_index(((from_idx >> 4) * 16) + (to_idx & 0x0F), nil) # ep capture
458
+ end
459
+ nb.update_index(from_idx, nil)
460
+ nb.update_index(to_idx, place_piece(piece, promotion))
461
+ color = own?(piece, 'w') ? 'w' : 'b'
462
+ king = king_idx(nb, color)
463
+ king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
464
+ end
465
+
466
+ # The piece letter to place on the destination after a pseudo-move.
467
+ def place_piece(piece, promotion)
468
+ return piece unless promotion
469
+
470
+ white = piece == piece.upcase
471
+ white ? promotion.upcase : promotion.downcase
472
+ end
473
+ end
474
+ end
@@ -21,8 +21,10 @@ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
21
21
  def next_token
22
22
  pair = @lexer.next_token_pair
23
23
  return [false, false] unless pair
24
- type, value = pair
25
- [translate_type(type), value]
24
+ # Mutate the lexer's [type, value] pair in place rather than allocating
25
+ # a second translated pair -- one array per token is the Racc floor.
26
+ pair[0] = translate_type(pair[0])
27
+ pair
26
28
  end
27
29
 
28
30
  private
data/lib/pgn/pgn_parser.y CHANGED
@@ -106,8 +106,10 @@ rule
106
106
  def next_token
107
107
  pair = @lexer.next_token_pair
108
108
  return [false, false] unless pair
109
- type, value = pair
110
- [translate_type(type), value]
109
+ # Mutate the lexer's [type, value] pair in place rather than allocating
110
+ # a second translated pair -- one array per token is the Racc floor.
111
+ pair[0] = translate_type(pair[0])
112
+ pair
111
113
  end
112
114
 
113
115
  private
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '1.2.1'.freeze
2
+ VERSION = '1.4.0'.freeze
3
3
  end
data/lib/pgn.rb CHANGED
@@ -4,6 +4,7 @@ require 'pgn/game'
4
4
  require 'pgn/move'
5
5
  require 'pgn/move_calculator'
6
6
  require 'pgn/lexer'
7
+ require 'pgn/notation'
7
8
  require 'pgn/pgn_parser'
8
9
  require 'pgn/parser'
9
10
  require 'pgn/position'
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Helper: build a Position from a FEN string.
6
+ def pos(fen)
7
+ PGN::FEN.new(fen).to_position
8
+ end
9
+
10
+ describe PGN::Notation do
11
+ # ------------------------------------------------------------------
12
+ # Public API
13
+ # ------------------------------------------------------------------
14
+ describe '.san' do
15
+ it 'generates a plain pawn push from coordinates' do
16
+ expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
17
+ 'e2', 'e4')).to eq('e4')
18
+ end
19
+
20
+ it 'generates a knight move with the piece letter' do
21
+ expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
22
+ 'g1', 'f3')).to eq('Nf3')
23
+ end
24
+
25
+ it 'generates a pawn capture with the origin file' do
26
+ fen = 'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2'
27
+ expect(PGN::Notation.san(pos(fen), 'e4', 'd5')).to eq('exd5')
28
+ end
29
+
30
+ it 'generates an en passant capture' do
31
+ fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
32
+ expect(PGN::Notation.san(pos(fen), 'e5', 'd6')).to eq('exd6')
33
+ end
34
+
35
+ it 'generates a pawn promotion' do
36
+ fen = '8/P7/6k1/8/8/8/8/4K3 w - - 0 1'
37
+ expect(PGN::Notation.san(pos(fen), 'a7', 'a8', 'q')).to eq('a8=Q')
38
+ end
39
+
40
+ it 'generates a capture promotion' do
41
+ fen = '1n6/P7/6k1/8/8/8/8/4K3 w - - 0 1'
42
+ expect(PGN::Notation.san(pos(fen), 'a7', 'b8', 'q')).to eq('axb8=Q')
43
+ end
44
+
45
+ it 'generates kingside castling' do
46
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
47
+ expect(PGN::Notation.san(pos(fen), 'e1', 'g1')).to eq('O-O')
48
+ end
49
+
50
+ it 'generates queenside castling' do
51
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
52
+ expect(PGN::Notation.san(pos(fen), 'e1', 'c1')).to eq('O-O-O')
53
+ end
54
+
55
+ it 'generates kingside castling for black' do
56
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1'
57
+ expect(PGN::Notation.san(pos(fen), 'e8', 'g8')).to eq('O-O')
58
+ end
59
+ end
60
+
61
+ describe '.san disambiguation' do
62
+ it 'disambiguates knights by file' do
63
+ # Knights on c3 and d4 both reach b5.
64
+ fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
65
+ expect(PGN::Notation.san(pos(fen), 'd4', 'b5')).to eq('Ndb5')
66
+ expect(PGN::Notation.san(pos(fen), 'c3', 'b5')).to eq('Ncb5')
67
+ end
68
+
69
+ it 'disambiguates rooks by rank when on the same file' do
70
+ # White rooks on a1 and a8 both reach a4 (file a clear).
71
+ fen = 'R7/8/8/4k3/8/8/8/R3K3 w - - 0 1'
72
+ expect(PGN::Notation.san(pos(fen), 'a1', 'a4')).to eq('R1a4')
73
+ expect(PGN::Notation.san(pos(fen), 'a8', 'a4')).to eq('R8a4')
74
+ end
75
+
76
+ it 'disambiguates queens by full square when file and rank both clash' do
77
+ # Queens on c1, f1, f4 all reach c4. The f1 queen shares file f with
78
+ # f4 and rank 1 with c1, so only the full square disambiguates.
79
+ fen = '7k/8/8/8/5Q2/8/8/2Q2Q1K w - - 0 1'
80
+ expect(PGN::Notation.san(pos(fen), 'f1', 'c4')).to eq('Qf1c4')
81
+ end
82
+
83
+ it 'omits disambiguation when only one piece can legally move there' do
84
+ # Two knights both pseudo-reach d2, but the e4 knight is pinned
85
+ # on the e-file (moving it exposes the king on e1 to the rook on e8),
86
+ # so only the b1 knight can legally play Nd2 — no disambiguation.
87
+ fen = '4r2k/8/8/8/4N3/8/8/1N2K3 w - - 0 1'
88
+ expect(PGN::Notation.san(pos(fen), 'b1', 'd2')).to eq('Nd2')
89
+ end
90
+ end
91
+
92
+ describe '.san check and checkmate suffixes' do
93
+ it 'appends + for a checking move' do
94
+ fen = '7k/8/8/8/8/8/6R1/6K1 w - - 0 1'
95
+ expect(PGN::Notation.san(pos(fen), 'g2', 'g8')).to eq('Rg8+')
96
+ end
97
+
98
+ it 'appends # for a checkmating move' do
99
+ fen = '6k1/5ppp/8/8/8/8/8/R6K w - - 0 1'
100
+ expect(PGN::Notation.san(pos(fen), 'a1', 'a8')).to eq('Ra8#')
101
+ end
102
+
103
+ it 'appends # to a promotion that gives checkmate' do
104
+ # White pawn b7 promotes with check on a king cornered on h8.
105
+ fen = '7k/1P6/8/8/8/8/8/6K1 w - - 0 1'
106
+ expect(PGN::Notation.san(pos(fen), 'b7', 'b8', 'q')).to eq('b8=Q+')
107
+ end
108
+
109
+ it 'does not append a suffix for a quiet move' do
110
+ fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
111
+ expect(PGN::Notation.san(pos(fen), 'd2', 'd4')).to eq('d4')
112
+ end
113
+ end
114
+
115
+ describe '.san_from_fen' do
116
+ it 'computes SAN directly from a FEN string' do
117
+ expect(PGN::Notation.san_from_fen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
118
+ 'e2', 'e4')).to eq('e4')
119
+ end
120
+
121
+ it 'computes SAN for black to move' do
122
+ fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1'
123
+ expect(PGN::Notation.san_from_fen(fen, 'd7', 'd5')).to eq('d5')
124
+ end
125
+ end
126
+ end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset
8
8
  - Murilo Vasconcelos
9
- autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: bundler
@@ -168,6 +167,7 @@ files:
168
167
  - lib/pgn/lexer.rb
169
168
  - lib/pgn/move.rb
170
169
  - lib/pgn/move_calculator.rb
170
+ - lib/pgn/notation.rb
171
171
  - lib/pgn/parser.rb
172
172
  - lib/pgn/pgn_parser.rb
173
173
  - lib/pgn/pgn_parser.y
@@ -181,6 +181,7 @@ files:
181
181
  - spec/lexer_spec.rb
182
182
  - spec/move_calculator_spec.rb
183
183
  - spec/move_spec.rb
184
+ - spec/notation_spec.rb
184
185
  - spec/parser_explicit_spec.rb
185
186
  - spec/parser_spec.rb
186
187
  - spec/pgn_files/alternate_castling.pgn
@@ -208,7 +209,6 @@ metadata:
208
209
  source_code_uri: https://github.com/muriloime/pgn
209
210
  bug_tracker_uri: https://github.com/muriloime/pgn/issues
210
211
  changelog_uri: https://github.com/muriloime/pgn/blob/main/CHANGELOG.md
211
- post_install_message:
212
212
  rdoc_options: []
213
213
  require_paths:
214
214
  - lib
@@ -223,8 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
223
223
  - !ruby/object:Gem::Version
224
224
  version: '0'
225
225
  requirements: []
226
- rubygems_version: 3.5.22
227
- signing_key:
226
+ rubygems_version: 4.0.10
228
227
  specification_version: 4
229
228
  summary: A PGN parser for Ruby
230
229
  test_files:
@@ -234,6 +233,7 @@ test_files:
234
233
  - spec/lexer_spec.rb
235
234
  - spec/move_calculator_spec.rb
236
235
  - spec/move_spec.rb
236
+ - spec/notation_spec.rb
237
237
  - spec/parser_explicit_spec.rb
238
238
  - spec/parser_spec.rb
239
239
  - spec/pgn_files/alternate_castling.pgn