sashite-feen 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/README.md +243 -418
- data/lib/sashite/feen/dumper/hands.rb +105 -0
- data/lib/sashite/feen/dumper/piece_placement.rb +75 -143
- data/lib/sashite/feen/dumper/style_turn.rb +15 -30
- data/lib/sashite/feen/dumper.rb +12 -42
- data/lib/sashite/feen/error.rb +5 -73
- data/lib/sashite/feen/errors/cardinality_error.rb +18 -0
- data/lib/sashite/feen/errors/hands_error.rb +20 -0
- data/lib/sashite/feen/errors/parse_error.rb +20 -0
- data/lib/sashite/feen/errors/piece_placement_error.rb +25 -0
- data/lib/sashite/feen/errors/style_turn_error.rb +18 -0
- data/lib/sashite/feen/parser/hand.rb +243 -0
- data/lib/sashite/feen/parser/hands.rb +102 -0
- data/lib/sashite/feen/parser/piece_placement.rb +422 -311
- data/lib/sashite/feen/parser/style_turn.rb +80 -76
- data/lib/sashite/feen/parser.rb +187 -56
- data/lib/sashite/feen/shared/ascii.rb +52 -0
- data/lib/sashite/feen/shared/limits.rb +24 -0
- data/lib/sashite/feen/shared/separators.rb +18 -0
- data/lib/sashite/feen.rb +23 -50
- data/lib/sashite-feen.rb +0 -11
- metadata +22 -38
- data/LICENSE.md +0 -21
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +0 -149
- data/lib/sashite/feen/hands.rb +0 -80
- data/lib/sashite/feen/parser/pieces_in_hand.rb +0 -243
- data/lib/sashite/feen/placement.rb +0 -307
- data/lib/sashite/feen/position.rb +0 -77
- data/lib/sashite/feen/styles.rb +0 -72
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sashite-feen
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cyril Kato
|
|
@@ -10,68 +10,52 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: qi
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 13.0.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: sashite-sin
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - "~>"
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '2.1'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - "~>"
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '2.1'
|
|
25
|
+
version: 13.0.0
|
|
40
26
|
description: |
|
|
41
|
-
FEEN (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
principles. FEEN extends traditional FEN notation to support multiple game systems (chess,
|
|
45
|
-
shōgi, xiangqi, makruk), cross-style games, multi-dimensional boards, and captured pieces
|
|
46
|
-
held in reserve. Built on EPIN (piece notation) and SIN (style notation) foundations,
|
|
47
|
-
FEEN enables canonical position representation across diverse abstract strategy board games.
|
|
48
|
-
Perfect for game engines, position analysis tools, and hybrid gaming systems requiring
|
|
49
|
-
comprehensive board state representation.
|
|
27
|
+
FEEN (Field Expression Encoding Notation) implementation for Ruby.
|
|
28
|
+
Encodes board game positions with piece placement, hands, and style-turn fields
|
|
29
|
+
for abstract strategy board games with a canonical, rule-agnostic format.
|
|
50
30
|
email: contact@cyril.email
|
|
51
31
|
executables: []
|
|
52
32
|
extensions: []
|
|
53
33
|
extra_rdoc_files: []
|
|
54
34
|
files:
|
|
55
|
-
- LICENSE.md
|
|
56
35
|
- README.md
|
|
57
36
|
- lib/sashite-feen.rb
|
|
58
37
|
- lib/sashite/feen.rb
|
|
59
38
|
- lib/sashite/feen/dumper.rb
|
|
39
|
+
- lib/sashite/feen/dumper/hands.rb
|
|
60
40
|
- lib/sashite/feen/dumper/piece_placement.rb
|
|
61
|
-
- lib/sashite/feen/dumper/pieces_in_hand.rb
|
|
62
41
|
- lib/sashite/feen/dumper/style_turn.rb
|
|
63
42
|
- lib/sashite/feen/error.rb
|
|
64
|
-
- lib/sashite/feen/
|
|
43
|
+
- lib/sashite/feen/errors/cardinality_error.rb
|
|
44
|
+
- lib/sashite/feen/errors/hands_error.rb
|
|
45
|
+
- lib/sashite/feen/errors/parse_error.rb
|
|
46
|
+
- lib/sashite/feen/errors/piece_placement_error.rb
|
|
47
|
+
- lib/sashite/feen/errors/style_turn_error.rb
|
|
65
48
|
- lib/sashite/feen/parser.rb
|
|
49
|
+
- lib/sashite/feen/parser/hand.rb
|
|
50
|
+
- lib/sashite/feen/parser/hands.rb
|
|
66
51
|
- lib/sashite/feen/parser/piece_placement.rb
|
|
67
|
-
- lib/sashite/feen/parser/pieces_in_hand.rb
|
|
68
52
|
- lib/sashite/feen/parser/style_turn.rb
|
|
69
|
-
- lib/sashite/feen/
|
|
70
|
-
- lib/sashite/feen/
|
|
71
|
-
- lib/sashite/feen/
|
|
53
|
+
- lib/sashite/feen/shared/ascii.rb
|
|
54
|
+
- lib/sashite/feen/shared/limits.rb
|
|
55
|
+
- lib/sashite/feen/shared/separators.rb
|
|
72
56
|
homepage: https://github.com/sashite/feen.rb
|
|
73
57
|
licenses:
|
|
74
|
-
-
|
|
58
|
+
- Apache-2.0
|
|
75
59
|
metadata:
|
|
76
60
|
bug_tracker_uri: https://github.com/sashite/feen.rb/issues
|
|
77
61
|
documentation_uri: https://rubydoc.info/github/sashite/feen.rb/main
|
|
@@ -93,8 +77,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
93
77
|
- !ruby/object:Gem::Version
|
|
94
78
|
version: '0'
|
|
95
79
|
requirements: []
|
|
96
|
-
rubygems_version:
|
|
80
|
+
rubygems_version: 4.0.5
|
|
97
81
|
specification_version: 4
|
|
98
|
-
summary: FEEN (
|
|
99
|
-
position
|
|
82
|
+
summary: FEEN (Field Expression Encoding Notation) implementation for Ruby providing
|
|
83
|
+
rule-agnostic board position encoding.
|
|
100
84
|
test_files: []
|
data/LICENSE.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# The MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Sashité
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
all copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
-
THE SOFTWARE.
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Sashite
|
|
4
|
-
module Feen
|
|
5
|
-
module Dumper
|
|
6
|
-
# Dumper for the pieces-in-hand field (second field of FEEN).
|
|
7
|
-
#
|
|
8
|
-
# Converts a Hands object into its FEEN string representation,
|
|
9
|
-
# encoding captured pieces held by each player in canonical sorted order.
|
|
10
|
-
#
|
|
11
|
-
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
12
|
-
module PiecesInHand
|
|
13
|
-
# Player separator in pieces-in-hand field.
|
|
14
|
-
PLAYER_SEPARATOR = "/"
|
|
15
|
-
|
|
16
|
-
# Dump a Hands object into its FEEN pieces-in-hand string.
|
|
17
|
-
#
|
|
18
|
-
# Generates canonical representation with pieces sorted according to
|
|
19
|
-
# FEEN ordering rules: by quantity (descending), base letter (ascending),
|
|
20
|
-
# case (uppercase first), prefix (-, +, none), and suffix (none, ').
|
|
21
|
-
#
|
|
22
|
-
# @param hands [Hands] The hands object containing pieces for both players
|
|
23
|
-
# @return [String] FEEN pieces-in-hand field string
|
|
24
|
-
#
|
|
25
|
-
# @example No pieces in hand
|
|
26
|
-
# dump(hands)
|
|
27
|
-
# # => "/"
|
|
28
|
-
#
|
|
29
|
-
# @example First player has pieces
|
|
30
|
-
# dump(hands)
|
|
31
|
-
# # => "2P/p"
|
|
32
|
-
#
|
|
33
|
-
# @example Both players have pieces
|
|
34
|
-
# dump(hands)
|
|
35
|
-
# # => "RBN/2p"
|
|
36
|
-
def self.dump(hands)
|
|
37
|
-
first_player_str = dump_player_pieces(hands.first_player)
|
|
38
|
-
second_player_str = dump_player_pieces(hands.second_player)
|
|
39
|
-
|
|
40
|
-
"#{first_player_str}#{PLAYER_SEPARATOR}#{second_player_str}"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Dump pieces for a single player.
|
|
44
|
-
#
|
|
45
|
-
# Groups identical pieces, counts them, sorts canonically, and formats
|
|
46
|
-
# with count prefix when needed (e.g., "3P" for three pawns).
|
|
47
|
-
#
|
|
48
|
-
# @param pieces [Array] Array of piece objects for one player
|
|
49
|
-
# @return [String] Formatted piece string (empty if no pieces)
|
|
50
|
-
#
|
|
51
|
-
# @example Single piece types
|
|
52
|
-
# dump_player_pieces([pawn1, pawn2, pawn3, rook1])
|
|
53
|
-
# # => "3PR"
|
|
54
|
-
#
|
|
55
|
-
# @example Empty hand
|
|
56
|
-
# dump_player_pieces([])
|
|
57
|
-
# # => ""
|
|
58
|
-
private_class_method def self.dump_player_pieces(pieces)
|
|
59
|
-
return "" if pieces.empty?
|
|
60
|
-
|
|
61
|
-
grouped = group_pieces(pieces)
|
|
62
|
-
sorted = sort_grouped_pieces(grouped)
|
|
63
|
-
format_pieces(sorted)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Group identical pieces and count occurrences.
|
|
67
|
-
#
|
|
68
|
-
# @param pieces [Array] Array of piece objects
|
|
69
|
-
# @return [Hash] Hash mapping piece strings to counts
|
|
70
|
-
#
|
|
71
|
-
# @example
|
|
72
|
-
# group_pieces([pawn1, pawn2, rook1])
|
|
73
|
-
# # => {"P" => 2, "R" => 1}
|
|
74
|
-
private_class_method def self.group_pieces(pieces)
|
|
75
|
-
pieces.group_by(&:to_s).transform_values(&:size)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Sort grouped pieces according to FEEN canonical ordering.
|
|
79
|
-
#
|
|
80
|
-
# Sorting rules (in order of precedence):
|
|
81
|
-
# 1. By quantity (descending) - most pieces first
|
|
82
|
-
# 2. By base letter (ascending, case-insensitive)
|
|
83
|
-
# 3. By case - uppercase before lowercase
|
|
84
|
-
# 4. By prefix - "-", "+", then none
|
|
85
|
-
# 5. By suffix - none, then "'"
|
|
86
|
-
#
|
|
87
|
-
# @param grouped [Hash] Hash of piece strings to counts
|
|
88
|
-
# @return [Array<Array>] Sorted array of [piece_string, count] pairs
|
|
89
|
-
#
|
|
90
|
-
# @example
|
|
91
|
-
# sort_grouped_pieces({"p" => 2, "P" => 3, "R" => 1, "+K" => 1, "K'" => 1})
|
|
92
|
-
# # => [["+K", 1], ["K'", 1], ["P", 3], ["p", 2], ["R", 1]]
|
|
93
|
-
private_class_method def self.sort_grouped_pieces(grouped)
|
|
94
|
-
grouped.sort_by do |piece_str, count|
|
|
95
|
-
[
|
|
96
|
-
-count, # Quantity (descending)
|
|
97
|
-
extract_base_letter(piece_str), # Base letter (ascending)
|
|
98
|
-
piece_str.match?(/[A-Z]/) ? 0 : 1, # Case (uppercase first)
|
|
99
|
-
prefix_order(piece_str), # Prefix order
|
|
100
|
-
piece_str.end_with?("'") ? 1 : 0 # Suffix order (none first)
|
|
101
|
-
]
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Extract base letter from piece string (without modifiers).
|
|
106
|
-
#
|
|
107
|
-
# @param piece_str [String] EPIN piece string
|
|
108
|
-
# @return [String] Uppercase base letter
|
|
109
|
-
#
|
|
110
|
-
# @example
|
|
111
|
-
# extract_base_letter("+K'") # => "K"
|
|
112
|
-
# extract_base_letter("-p") # => "P"
|
|
113
|
-
private_class_method def self.extract_base_letter(piece_str)
|
|
114
|
-
piece_str.gsub(/[+\-']/, "").upcase
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Determine prefix sorting order.
|
|
118
|
-
#
|
|
119
|
-
# @param piece_str [String] EPIN piece string
|
|
120
|
-
# @return [Integer] Sort order (0 for "-", 1 for "+", 2 for none)
|
|
121
|
-
#
|
|
122
|
-
# @example
|
|
123
|
-
# prefix_order("-K") # => 0
|
|
124
|
-
# prefix_order("+K") # => 1
|
|
125
|
-
# prefix_order("K") # => 2
|
|
126
|
-
private_class_method def self.prefix_order(piece_str)
|
|
127
|
-
return 0 if piece_str.start_with?("-")
|
|
128
|
-
return 1 if piece_str.start_with?("+")
|
|
129
|
-
|
|
130
|
-
2
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# Format sorted pieces with count prefixes.
|
|
134
|
-
#
|
|
135
|
-
# @param sorted [Array<Array>] Sorted array of [piece_string, count] pairs
|
|
136
|
-
# @return [String] Formatted piece string
|
|
137
|
-
#
|
|
138
|
-
# @example
|
|
139
|
-
# format_pieces([["P", 3], ["R", 1], ["p", 2]])
|
|
140
|
-
# # => "3PR2p"
|
|
141
|
-
private_class_method def self.format_pieces(sorted)
|
|
142
|
-
sorted.map do |piece_str, count|
|
|
143
|
-
count > 1 ? "#{count}#{piece_str}" : piece_str
|
|
144
|
-
end.join
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
data/lib/sashite/feen/hands.rb
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Sashite
|
|
4
|
-
module Feen
|
|
5
|
-
# Immutable representation of pieces held in hand by each player.
|
|
6
|
-
#
|
|
7
|
-
# Stores captured pieces that players hold in reserve, available for
|
|
8
|
-
# placement back onto the board in games that support drop mechanics
|
|
9
|
-
# (such as shogi, crazyhouse, etc.).
|
|
10
|
-
#
|
|
11
|
-
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
12
|
-
class Hands
|
|
13
|
-
# @return [Array] Array of pieces held by first player
|
|
14
|
-
attr_reader :first_player
|
|
15
|
-
|
|
16
|
-
# @return [Array] Array of pieces held by second player
|
|
17
|
-
attr_reader :second_player
|
|
18
|
-
|
|
19
|
-
# Create a new immutable Hands object.
|
|
20
|
-
#
|
|
21
|
-
# @param first_player [Array] Pieces in first player's hand
|
|
22
|
-
# @param second_player [Array] Pieces in second player's hand
|
|
23
|
-
#
|
|
24
|
-
# @example Empty hands
|
|
25
|
-
# hands = Hands.new([], [])
|
|
26
|
-
#
|
|
27
|
-
# @example First player has captured pieces
|
|
28
|
-
# hands = Hands.new([pawn1, pawn2], [])
|
|
29
|
-
#
|
|
30
|
-
# @example Both players have captured pieces
|
|
31
|
-
# hands = Hands.new([rook, bishop], [pawn1, pawn2, knight])
|
|
32
|
-
def initialize(first_player, second_player)
|
|
33
|
-
@first_player = first_player.sort_by(&:to_s).freeze
|
|
34
|
-
@second_player = second_player.sort_by(&:to_s).freeze
|
|
35
|
-
|
|
36
|
-
freeze
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Check if both hands are empty.
|
|
40
|
-
#
|
|
41
|
-
# @return [Boolean] True if neither player has pieces in hand
|
|
42
|
-
#
|
|
43
|
-
# @example
|
|
44
|
-
# hands.empty? # => true
|
|
45
|
-
def empty?
|
|
46
|
-
first_player.empty? && second_player.empty?
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Convert hands to their FEEN string representation.
|
|
50
|
-
#
|
|
51
|
-
# @return [String] FEEN pieces-in-hand field
|
|
52
|
-
#
|
|
53
|
-
# @example
|
|
54
|
-
# hands.to_s
|
|
55
|
-
# # => "2P/p"
|
|
56
|
-
def to_s
|
|
57
|
-
Dumper::PiecesInHand.dump(self)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Compare two hands for equality.
|
|
61
|
-
#
|
|
62
|
-
# @param other [Hands] Another hands object
|
|
63
|
-
# @return [Boolean] True if both players' pieces are equal
|
|
64
|
-
def ==(other)
|
|
65
|
-
other.is_a?(Hands) &&
|
|
66
|
-
first_player == other.first_player &&
|
|
67
|
-
second_player == other.second_player
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
alias eql? ==
|
|
71
|
-
|
|
72
|
-
# Generate hash code for hands.
|
|
73
|
-
#
|
|
74
|
-
# @return [Integer] Hash code based on both players' pieces
|
|
75
|
-
def hash
|
|
76
|
-
[first_player, second_player].hash
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../error"
|
|
4
|
-
require_relative "../hands"
|
|
5
|
-
|
|
6
|
-
require "sashite/epin"
|
|
7
|
-
|
|
8
|
-
module Sashite
|
|
9
|
-
module Feen
|
|
10
|
-
module Parser
|
|
11
|
-
# Parser for the pieces-in-hand field (second field of FEEN).
|
|
12
|
-
#
|
|
13
|
-
# Converts a FEEN pieces-in-hand string into a Hands object,
|
|
14
|
-
# decoding captured pieces held by each player with optional
|
|
15
|
-
# count prefixes.
|
|
16
|
-
#
|
|
17
|
-
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
18
|
-
module PiecesInHand
|
|
19
|
-
# Player separator in pieces-in-hand field.
|
|
20
|
-
PLAYER_SEPARATOR = "/"
|
|
21
|
-
|
|
22
|
-
# Pattern to match EPIN pieces (optional state prefix, letter, optional derivation suffix).
|
|
23
|
-
EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
|
|
24
|
-
|
|
25
|
-
# Parse a FEEN pieces-in-hand string into a Hands object.
|
|
26
|
-
#
|
|
27
|
-
# @param string [String] FEEN pieces-in-hand field string
|
|
28
|
-
# @return [Hands] Parsed hands object
|
|
29
|
-
# @raise [Error::Syntax] If hands format is invalid
|
|
30
|
-
# @raise [Error::Piece] If EPIN notation is invalid
|
|
31
|
-
# @raise [Error::Count] If piece counts are invalid
|
|
32
|
-
#
|
|
33
|
-
# @example No pieces in hand
|
|
34
|
-
# parse("/") # => Hands.new([], [])
|
|
35
|
-
#
|
|
36
|
-
# @example First player has pieces
|
|
37
|
-
# parse("2P/p") # => Hands.new([P, P], [p])
|
|
38
|
-
#
|
|
39
|
-
# @example Both players have pieces
|
|
40
|
-
# parse("RBN/2p") # => Hands.new([R, B, N], [p, p])
|
|
41
|
-
def self.parse(string)
|
|
42
|
-
first_str, second_str = split_players(string)
|
|
43
|
-
|
|
44
|
-
first_player_pieces = parse_player_pieces(first_str)
|
|
45
|
-
second_player_pieces = parse_player_pieces(second_str)
|
|
46
|
-
|
|
47
|
-
Hands.new(first_player_pieces, second_player_pieces)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Split pieces-in-hand string into first and second player parts.
|
|
51
|
-
#
|
|
52
|
-
# @param string [String] Pieces-in-hand field string
|
|
53
|
-
# @return [Array(String, String)] First and second player strings
|
|
54
|
-
# @raise [Error::Syntax] If separator is missing
|
|
55
|
-
#
|
|
56
|
-
# @example
|
|
57
|
-
# split_players("2P/p") # => ["2P", "p"]
|
|
58
|
-
# split_players("/") # => ["", ""]
|
|
59
|
-
private_class_method def self.split_players(string)
|
|
60
|
-
parts = string.split(PLAYER_SEPARATOR, 2)
|
|
61
|
-
|
|
62
|
-
raise Error::Syntax, "pieces-in-hand must contain '#{PLAYER_SEPARATOR}' separator" unless parts.size == 2
|
|
63
|
-
|
|
64
|
-
parts
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Parse pieces for a single player.
|
|
68
|
-
#
|
|
69
|
-
# Extracts pieces with optional count prefixes and expands them
|
|
70
|
-
# into individual piece objects.
|
|
71
|
-
#
|
|
72
|
-
# @param string [String] Player's pieces string (e.g., "2PRB")
|
|
73
|
-
# @return [Array] Array of piece objects
|
|
74
|
-
# @raise [Error::Syntax] If format is invalid
|
|
75
|
-
# @raise [Error::Piece] If EPIN notation is invalid
|
|
76
|
-
# @raise [Error::Count] If counts are invalid
|
|
77
|
-
#
|
|
78
|
-
# @example Single pieces
|
|
79
|
-
# parse_player_pieces("RBN") # => [R, B, N]
|
|
80
|
-
#
|
|
81
|
-
# @example With counts
|
|
82
|
-
# parse_player_pieces("3P2R") # => [P, P, P, R, R]
|
|
83
|
-
#
|
|
84
|
-
# @example Empty
|
|
85
|
-
# parse_player_pieces("") # => []
|
|
86
|
-
private_class_method def self.parse_player_pieces(string)
|
|
87
|
-
return [] if string.empty?
|
|
88
|
-
|
|
89
|
-
pieces = []
|
|
90
|
-
chars = string.chars
|
|
91
|
-
i = 0
|
|
92
|
-
|
|
93
|
-
while i < chars.size
|
|
94
|
-
count, epin_str, consumed = extract_piece_with_count(chars, i)
|
|
95
|
-
piece = parse_piece(epin_str)
|
|
96
|
-
|
|
97
|
-
count.times { pieces << piece }
|
|
98
|
-
i += consumed
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
pieces
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Extract a piece with optional count prefix from character array.
|
|
105
|
-
#
|
|
106
|
-
# Handles multi-digit counts and EPIN notation extraction.
|
|
107
|
-
#
|
|
108
|
-
# @param chars [Array<String>] Array of characters
|
|
109
|
-
# @param start_index [Integer] Starting index
|
|
110
|
-
# @return [Array(Integer, String, Integer)] Count, EPIN string, and chars consumed
|
|
111
|
-
# @raise [Error::Syntax] If format is invalid
|
|
112
|
-
# @raise [Error::Count] If count is invalid
|
|
113
|
-
#
|
|
114
|
-
# @example Single piece
|
|
115
|
-
# extract_piece_with_count(['K', 'Q'], 0) # => [1, "K", 1]
|
|
116
|
-
#
|
|
117
|
-
# @example Multiple pieces
|
|
118
|
-
# extract_piece_with_count(['3', 'P', 'R'], 0) # => [3, "P", 2]
|
|
119
|
-
#
|
|
120
|
-
# @example Large count
|
|
121
|
-
# extract_piece_with_count(['1', '2', 'P'], 0) # => [12, "P", 3]
|
|
122
|
-
private_class_method def self.extract_piece_with_count(chars, start_index)
|
|
123
|
-
i = start_index
|
|
124
|
-
|
|
125
|
-
# Extract optional count (may be multi-digit)
|
|
126
|
-
count_chars = []
|
|
127
|
-
while i < chars.size && digit?(chars[i])
|
|
128
|
-
count_chars << chars[i]
|
|
129
|
-
i += 1
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
count = if count_chars.empty?
|
|
133
|
-
1
|
|
134
|
-
else
|
|
135
|
-
count_str = count_chars.join
|
|
136
|
-
count_val = count_str.to_i
|
|
137
|
-
validate_count(count_val, count_str)
|
|
138
|
-
count_val
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# Extract EPIN piece
|
|
142
|
-
raise Error::Syntax, "expected piece after count at position #{start_index}" if i >= chars.size
|
|
143
|
-
|
|
144
|
-
epin_str, epin_consumed = extract_epin(chars, i)
|
|
145
|
-
i += epin_consumed
|
|
146
|
-
|
|
147
|
-
consumed = i - start_index
|
|
148
|
-
[count, epin_str, consumed]
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# Extract EPIN notation from character array.
|
|
152
|
-
#
|
|
153
|
-
# Handles state prefixes (+/-), base letter, and derivation suffix (').
|
|
154
|
-
#
|
|
155
|
-
# @param chars [Array<String>] Array of characters
|
|
156
|
-
# @param start_index [Integer] Starting index
|
|
157
|
-
# @return [Array(String, Integer)] EPIN string and number of characters consumed
|
|
158
|
-
# @raise [Error::Syntax] If EPIN format is incomplete
|
|
159
|
-
#
|
|
160
|
-
# @example Simple piece
|
|
161
|
-
# extract_epin(['K', 'Q'], 0) # => ["K", 1]
|
|
162
|
-
#
|
|
163
|
-
# @example Enhanced piece with derivation
|
|
164
|
-
# extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
|
|
165
|
-
private_class_method def self.extract_epin(chars, start_index)
|
|
166
|
-
i = start_index
|
|
167
|
-
piece_chars = []
|
|
168
|
-
|
|
169
|
-
# Optional state prefix
|
|
170
|
-
if i < chars.size && ["+", "-"].include?(chars[i])
|
|
171
|
-
piece_chars << chars[i]
|
|
172
|
-
i += 1
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# Base letter (required)
|
|
176
|
-
if i >= chars.size || !letter?(chars[i])
|
|
177
|
-
raise Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
piece_chars << chars[i]
|
|
181
|
-
i += 1
|
|
182
|
-
|
|
183
|
-
# Optional derivation suffix
|
|
184
|
-
if i < chars.size && chars[i] == "'"
|
|
185
|
-
piece_chars << chars[i]
|
|
186
|
-
i += 1
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
piece_str = piece_chars.join
|
|
190
|
-
consumed = i - start_index
|
|
191
|
-
|
|
192
|
-
[piece_str, consumed]
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# Check if character is a digit.
|
|
196
|
-
#
|
|
197
|
-
# @param char [String] Single character
|
|
198
|
-
# @return [Boolean] True if character is 0-9
|
|
199
|
-
private_class_method def self.digit?(char)
|
|
200
|
-
char >= "0" && char <= "9"
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# Check if character is a letter.
|
|
204
|
-
#
|
|
205
|
-
# @param char [String] Single character
|
|
206
|
-
# @return [Boolean] True if character is A-Z or a-z
|
|
207
|
-
private_class_method def self.letter?(char)
|
|
208
|
-
(char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# Validate piece count.
|
|
212
|
-
#
|
|
213
|
-
# @param count [Integer] Piece count
|
|
214
|
-
# @param count_str [String] Original count string for error messages
|
|
215
|
-
# @raise [Error::Count] If count is invalid
|
|
216
|
-
private_class_method def self.validate_count(count, count_str)
|
|
217
|
-
raise Error::Count, "piece count must be at least 1, got #{count_str}" if count < 1
|
|
218
|
-
|
|
219
|
-
return unless count > 999
|
|
220
|
-
|
|
221
|
-
raise Error::Count, "piece count too large: #{count_str}"
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Parse EPIN string into a piece object.
|
|
225
|
-
#
|
|
226
|
-
# @param epin_str [String] EPIN notation string
|
|
227
|
-
# @return [Object] Piece identifier object
|
|
228
|
-
# @raise [Error::Piece] If EPIN is invalid
|
|
229
|
-
#
|
|
230
|
-
# @example
|
|
231
|
-
# parse_piece("K") # => Epin::Identifier
|
|
232
|
-
# parse_piece("+R'") # => Epin::Identifier
|
|
233
|
-
private_class_method def self.parse_piece(epin_str)
|
|
234
|
-
raise Error::Piece, "invalid EPIN notation: #{epin_str}" unless EPIN_PATTERN.match?(epin_str)
|
|
235
|
-
|
|
236
|
-
Sashite::Epin.parse(epin_str)
|
|
237
|
-
rescue StandardError => e
|
|
238
|
-
raise Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
end
|