twisty_puzzles 0.0.1

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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/CODE_OF_CONDUCT.md +76 -0
  4. data/LICENSE +21 -0
  5. data/README.md +32 -0
  6. data/ext/twisty_puzzles/native/extconf.rb +5 -0
  7. data/lib/twisty_puzzles/abstract_direction.rb +54 -0
  8. data/lib/twisty_puzzles/abstract_move.rb +170 -0
  9. data/lib/twisty_puzzles/abstract_move_parser.rb +45 -0
  10. data/lib/twisty_puzzles/algorithm.rb +155 -0
  11. data/lib/twisty_puzzles/algorithm_transformation.rb +33 -0
  12. data/lib/twisty_puzzles/axis_face_and_direction_move.rb +78 -0
  13. data/lib/twisty_puzzles/cancellation_helper.rb +165 -0
  14. data/lib/twisty_puzzles/color_scheme.rb +174 -0
  15. data/lib/twisty_puzzles/commutator.rb +118 -0
  16. data/lib/twisty_puzzles/compiled_algorithm.rb +48 -0
  17. data/lib/twisty_puzzles/compiled_cube_algorithm.rb +67 -0
  18. data/lib/twisty_puzzles/compiled_skewb_algorithm.rb +28 -0
  19. data/lib/twisty_puzzles/coordinate.rb +318 -0
  20. data/lib/twisty_puzzles/cube.rb +660 -0
  21. data/lib/twisty_puzzles/cube_constants.rb +53 -0
  22. data/lib/twisty_puzzles/cube_direction.rb +27 -0
  23. data/lib/twisty_puzzles/cube_move.rb +384 -0
  24. data/lib/twisty_puzzles/cube_move_parser.rb +100 -0
  25. data/lib/twisty_puzzles/cube_print_helper.rb +160 -0
  26. data/lib/twisty_puzzles/cube_state.rb +113 -0
  27. data/lib/twisty_puzzles/letter_scheme.rb +72 -0
  28. data/lib/twisty_puzzles/move_type_creator.rb +27 -0
  29. data/lib/twisty_puzzles/parser.rb +222 -0
  30. data/lib/twisty_puzzles/part_cycle_factory.rb +59 -0
  31. data/lib/twisty_puzzles/puzzle.rb +26 -0
  32. data/lib/twisty_puzzles/reversible_applyable.rb +37 -0
  33. data/lib/twisty_puzzles/rotation.rb +105 -0
  34. data/lib/twisty_puzzles/skewb_direction.rb +24 -0
  35. data/lib/twisty_puzzles/skewb_move.rb +59 -0
  36. data/lib/twisty_puzzles/skewb_move_parser.rb +73 -0
  37. data/lib/twisty_puzzles/skewb_notation.rb +147 -0
  38. data/lib/twisty_puzzles/skewb_state.rb +163 -0
  39. data/lib/twisty_puzzles/state_helper.rb +32 -0
  40. data/lib/twisty_puzzles/sticker_cycle.rb +70 -0
  41. data/lib/twisty_puzzles/twisty_puzzles_error.rb +6 -0
  42. data/lib/twisty_puzzles/utils/array_helper.rb +109 -0
  43. data/lib/twisty_puzzles/utils/string_helper.rb +26 -0
  44. data/lib/twisty_puzzles/utils.rb +7 -0
  45. data/lib/twisty_puzzles/version.rb +3 -0
  46. data/lib/twisty_puzzles.rb +5 -0
  47. metadata +249 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwistyPuzzles
4
+
5
+ # Class for creating one move type from its parts.
6
+ # Helper class for parsing logic.
7
+ class MoveTypeCreator
8
+ def initialize(capture_keys, move_class)
9
+ raise TypeError unless move_class.is_a?(Class)
10
+ raise TypeError unless capture_keys.all? { |k| k.is_a?(Symbol) }
11
+
12
+ @capture_keys = capture_keys.freeze
13
+ @move_class = move_class
14
+ end
15
+
16
+ def applies_to?(parsed_parts)
17
+ parsed_parts.keys.sort == @capture_keys.sort
18
+ end
19
+
20
+ def create(parsed_parts)
21
+ raise ArgumentError unless applies_to?(parsed_parts)
22
+
23
+ fields = @capture_keys.map { |name| parsed_parts[name] }
24
+ @move_class.new(*fields)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'twisty_puzzles/algorithm'
4
+ require 'twisty_puzzles/commutator'
5
+ require 'twisty_puzzles/cube_move_parser'
6
+ require 'twisty_puzzles/skewb_move_parser'
7
+ require 'twisty_puzzles/twisty_puzzles_error'
8
+
9
+ module TwistyPuzzles
10
+ # rubocop:disable Style/Documentation
11
+
12
+ # rubocop:enable Style/Documentation
13
+ class CommutatorParseError < TwistyPuzzlesError
14
+ end
15
+
16
+ # Parser for commutators and algorithms.
17
+ class Parser
18
+ OPENING_BRACKET = '['
19
+ OPENING_PAREN = '('
20
+ CLOSING_BRACKET = ']'
21
+ CLOSING_PAREN = ')'
22
+ TIMES = '*'
23
+
24
+ def initialize(alg_string, move_parser)
25
+ @alg_string = alg_string
26
+ @scanner = StringScanner.new(alg_string)
27
+ @move_parser = move_parser
28
+ end
29
+
30
+ def parse_open_paren
31
+ complain('beginning of trigger') unless @scanner.getch == OPENING_PAREN
32
+ end
33
+
34
+ def parse_close_paren
35
+ complain('end of trigger') unless @scanner.getch == CLOSING_PAREN
36
+ end
37
+
38
+ def parse_times
39
+ complain('times symbol of multiplier') unless @scanner.getch == TIMES
40
+ end
41
+
42
+ def parse_factor
43
+ number = @scanner.scan(/\d+/)
44
+ complain('factor of multiplier') unless number
45
+ Integer(number, 10)
46
+ end
47
+
48
+ def parse_multiplier
49
+ skip_spaces
50
+ parse_times
51
+ skip_spaces
52
+ parse_factor
53
+ end
54
+
55
+ def parse_trigger
56
+ parse_open_paren
57
+ skip_spaces
58
+ moves = parse_moves_with_triggers
59
+ skip_spaces
60
+ parse_close_paren
61
+ skip_spaces
62
+ case @scanner.peek(1)
63
+ when TIMES
64
+ moves * parse_multiplier
65
+ when ('0'..'9')
66
+ moves * parse_factor
67
+ else
68
+ moves
69
+ end
70
+ end
71
+
72
+ # Parses at least one move and allows for triggers in parentheses.
73
+ def parse_moves_with_triggers
74
+ skip_spaces
75
+ if @scanner.peek(1) == OPENING_PAREN
76
+ parse_trigger + parse_moves_with_triggers
77
+ else
78
+ parse_moves
79
+ end
80
+ end
81
+
82
+ # Parses at least one move.
83
+ def parse_nonempty_moves
84
+ moves = parse_moves
85
+ complain('move') if moves.empty?
86
+ moves
87
+ end
88
+
89
+ # Parses a series of moves.
90
+ def parse_moves
91
+ moves = []
92
+ while (m = begin skip_spaces; parse_move_internal end)
93
+ moves.push(m)
94
+ end
95
+ Algorithm.new(moves)
96
+ end
97
+
98
+ def complain(parsed_object)
99
+ raise CommutatorParseError, <<~ERROR.chomp
100
+ Couldn't parse #{parsed_object} here:
101
+ #{@alg_string}
102
+ #{' ' * @scanner.pos}^"
103
+ ERROR
104
+ end
105
+
106
+ def check_eos(parsed_object)
107
+ complain("end of #{parsed_object}") unless @scanner.eos?
108
+ end
109
+
110
+ def parse_open_bracket
111
+ complain('beginning of commutator') unless @scanner.getch == OPENING_BRACKET
112
+ end
113
+
114
+ def parse_close_bracket
115
+ complain('end of commutator') unless @scanner.getch == CLOSING_BRACKET
116
+ end
117
+
118
+ def parse_commutator
119
+ skip_spaces
120
+ if @scanner.peek(1) == OPENING_BRACKET
121
+ parse_commutator_internal
122
+ else
123
+ FakeCommutator.new(parse_moves_with_triggers)
124
+ end
125
+ end
126
+
127
+ def parse_algorithm
128
+ skip_spaces
129
+ parse_moves_with_triggers
130
+ end
131
+
132
+ def parse_setup_commutator_inner
133
+ skip_spaces
134
+ if @scanner.peek(1) == OPENING_BRACKET
135
+ parse_pure_commutator
136
+ else
137
+ FakeCommutator.new(parse_moves_with_triggers)
138
+ end
139
+ end
140
+
141
+ def parse_pure_commutator
142
+ skip_spaces
143
+ parse_open_bracket
144
+ first_part = parse_nonempty_moves
145
+ skip_spaces
146
+ complain('middle of pure commutator') unless @scanner.getch == ','
147
+ second_part = parse_nonempty_moves
148
+ skip_spaces
149
+ parse_close_bracket
150
+ PureCommutator.new(first_part, second_part)
151
+ end
152
+
153
+ def parse_commutator_internal_after_separator(setup_or_first_part, separator)
154
+ if [':', ';'].include?(separator)
155
+ inner_commutator = parse_setup_commutator_inner
156
+ SetupCommutator.new(setup_or_first_part, inner_commutator)
157
+ elsif separator == ','
158
+ second_part = parse_nonempty_moves
159
+ PureCommutator.new(setup_or_first_part, second_part)
160
+ else
161
+ complain('end of setup or middle of pure commutator') unless @scanner.eos?
162
+ end
163
+ end
164
+
165
+ def parse_commutator_internal
166
+ skip_spaces
167
+ parse_open_bracket
168
+ setup_or_first_part = parse_nonempty_moves
169
+ skip_spaces
170
+ separator = @scanner.getch
171
+ comm = parse_commutator_internal_after_separator(setup_or_first_part, separator)
172
+ skip_spaces
173
+ parse_close_bracket
174
+ skip_spaces
175
+ complain('end of commutator') unless @scanner.eos?
176
+ comm
177
+ end
178
+
179
+ def parse_move_internal
180
+ move = @scanner.scan(@move_parser.regexp)
181
+ return unless move
182
+
183
+ @move_parser.parse_move(move)
184
+ end
185
+
186
+ def skip_spaces
187
+ @scanner.skip(/\s+/)
188
+ end
189
+ end
190
+
191
+ def parse_commutator(alg_string, complete_parse = true)
192
+ parser = Parser.new(alg_string, CubeMoveParser::INSTANCE)
193
+ commutator = parser.parse_commutator
194
+ parser.check_eos('commutator') if complete_parse
195
+ commutator
196
+ end
197
+
198
+ def parse_cube_algorithm(alg_string, complete_parse = true)
199
+ parser = Parser.new(alg_string, CubeMoveParser::INSTANCE)
200
+ algorithm = parser.parse_algorithm
201
+ parser.check_eos('algorithm') if complete_parse
202
+ algorithm
203
+ end
204
+
205
+ def parse_cube_move(move_string)
206
+ CubeMoveParser::INSTANCE.parse_move(move_string)
207
+ end
208
+
209
+ alias parse_algorithm parse_cube_algorithm
210
+ alias parse_move parse_cube_move
211
+
212
+ def parse_skewb_algorithm(alg_string, notation, complete_parse = true)
213
+ parser = Parser.new(alg_string, SkewbMoveParser.new(notation))
214
+ algorithm = parser.parse_algorithm
215
+ parser.check_eos('algorithm') if complete_parse
216
+ algorithm
217
+ end
218
+
219
+ def parse_skewb_move(move_string, notation)
220
+ SkewbMoveParser.new(notation).parse_move(move_string)
221
+ end
222
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'twisty_puzzles/sticker_cycle'
4
+ require 'twisty_puzzles/utils/array_helper'
5
+
6
+ module TwistyPuzzles
7
+
8
+ # Factory for sticker cycles given part cycles.
9
+ class PartCycleFactory
10
+ include Utils::ArrayHelper
11
+
12
+ def initialize(cube_size, incarnation_index)
13
+ CubeState.check_cube_size(cube_size)
14
+ unless incarnation_index.is_a?(Integer) && incarnation_index >= 0
15
+ raise ArgumentError, "Invalid incarnation index #{incarnation_index}."
16
+ end
17
+
18
+ @cube_size = cube_size
19
+ @incarnation_index = incarnation_index
20
+ @cache = {}
21
+ end
22
+
23
+ def coordinates(part)
24
+ @cache[part] ||= Coordinate.solved_positions(part, @cube_size, @incarnation_index)
25
+ end
26
+
27
+ def multi_twist(parts)
28
+ unless parts.all? { |p| p.is_a?(Corner) || p.is_a?(Edge) }
29
+ raise TypeError, 'Twists are only supported for edges and corners.'
30
+ end
31
+
32
+ cycles = parts.map { |p| StickerCycle.new(@cube_size, coordinates(p)) }
33
+ StickerCycles.new(@cube_size, cycles)
34
+ end
35
+
36
+ def check_type_consistency(parts)
37
+ return unless parts.any? { |p| p.class != parts.first.class }
38
+
39
+ raise TypeError, "Cycles of heterogenous piece types #{parts.inspect} are not supported."
40
+ end
41
+
42
+ def construct(parts)
43
+ if parts.length < 2
44
+ raise ArgumentError, 'Cycles of length smaller than 2 are not supported.'
45
+ end
46
+
47
+ unless @incarnation_index < parts.first.num_incarnations(@cube_size)
48
+ raise ArgumentError, "Incarnation index #{@incarnation_index} for cube size " \
49
+ "#{@cube_size} is not supported for #{parts.first.inspect}."
50
+ end
51
+
52
+ check_types(parts, Part)
53
+ check_type_consistency(parts)
54
+ part_coordinates = parts.map { |p| coordinates(p) }
55
+ cycles = part_coordinates.transpose.map { |c| StickerCycle.new(@cube_size, c) }
56
+ StickerCycles.new(@cube_size, cycles)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TwistyPuzzles
4
+
5
+ # Represents one type of puzzle.
6
+ class Puzzle
7
+ def initialize(name)
8
+ @name = name
9
+ end
10
+
11
+ NXN_CUBE = Puzzle.new('nxn cube')
12
+ SKEWB = Puzzle.new('skewb')
13
+
14
+ attr_reader :name
15
+
16
+ def eql?(other)
17
+ self.class == other.class && name == other.name
18
+ end
19
+
20
+ def hash
21
+ @hash ||= [self.class, @name].hash
22
+ end
23
+
24
+ alias == eql?
25
+ end
26
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'twisty_puzzles/skewb_state'
4
+
5
+ module TwistyPuzzles
6
+
7
+ # Module that makes a class that has an `apply_to` and `reverse` be able to apply temporarily.
8
+ module ReversibleApplyable
9
+ def apply_to_dupped(puzzle_state)
10
+ dupped = puzzle_state.dup
11
+ apply_to(dupped)
12
+ dupped
13
+ end
14
+
15
+ # Applies the current algorithm/cycle/whatever to the given puzzle state and yields the
16
+ # modified version. The puzzle state will be the same as the original after this function
17
+ # returns.
18
+ # Whether the yielded puzzle state is actually the same as the passed one or a copy is an
19
+ # implementation detail.
20
+ def apply_temporarily_to(puzzle_state)
21
+ return yield(apply_to_dupped(puzzle_state)) if with_dup_is_faster?(puzzle_state)
22
+
23
+ apply_to(puzzle_state)
24
+ begin
25
+ yield(puzzle_state)
26
+ ensure
27
+ inverse.apply_to(puzzle_state)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def with_dup_is_faster?(state)
34
+ !state.is_a?(CubeState) || state.n <= 4
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'twisty_puzzles/algorithm'
4
+ require 'twisty_puzzles/axis_face_and_direction_move'
5
+ require 'twisty_puzzles/cube'
6
+ require 'twisty_puzzles/cube_direction'
7
+ require 'twisty_puzzles/cube_move'
8
+ require 'twisty_puzzles/puzzle'
9
+
10
+ module TwistyPuzzles
11
+
12
+ # A rotation of a Skewb or cube.
13
+ class Rotation < AxisFaceAndDirectionMove
14
+ ALL_ROTATIONS = Face::ELEMENTS.product(CubeDirection::ALL_DIRECTIONS).map { |f, d| new(f, d) }
15
+ NON_ZERO_ROTATIONS =
16
+ Face::ELEMENTS.product(CubeDirection::NON_ZERO_DIRECTIONS).map { |f, d| new(f, d) }
17
+ LEFT = new(Face::U, CubeDirection::BACKWARD)
18
+ RIGHT = new(Face::U, CubeDirection::FORWARD)
19
+
20
+ # Translates a Skewb direction into a cube direction.
21
+ def self.translated_direction(direction)
22
+ case direction
23
+ when SkewbDirection::ZERO then CubeDirection::ZERO
24
+ when SkewbDirection::FORWARD then CubeDirection::FORWARD
25
+ when SkewbDirection::BACKWARD then CubeDirection::BACKWARD
26
+ end
27
+ end
28
+
29
+ # Returns an algorithm consisting of two rotations that are equivalent to rotating
30
+ # the puzzle around a corner.
31
+ # Takes a Skewb direction as an argument (even for cubes) because rotating around
32
+ # is like a Skewb move given that it's modulo 3.
33
+ def self.around_corner(corner, skewb_direction)
34
+ raise TypeError unless corner.is_a?(Corner)
35
+ raise TypeError unless skewb_direction.is_a?(SkewbDirection)
36
+
37
+ direction = translated_direction(skewb_direction)
38
+
39
+ Algorithm.new([
40
+ Rotation.new(corner.faces[skewb_direction.value], direction),
41
+ Rotation.new(corner.faces[0], direction)
42
+ ])
43
+ end
44
+
45
+ def to_s
46
+ "#{AXES[@axis_face.axis_priority]}#{canonical_direction.name}"
47
+ end
48
+
49
+ def puzzles
50
+ [Puzzle::SKEWB, Puzzle::NXN_CUBE]
51
+ end
52
+
53
+ def slice_move?
54
+ false
55
+ end
56
+
57
+ # Returns an alternative representation of the same rotation
58
+ def alternative
59
+ Rotation.new(@axis_face.opposite, @direction.inverse)
60
+ end
61
+
62
+ def equivalent_internal?(other, _cube_size)
63
+ [self, alternative].include?(other)
64
+ end
65
+
66
+ def prepend_rotation(other, _cube_size)
67
+ if same_axis?(other)
68
+ direction = translated_direction(other.axis_face)
69
+ Algorithm.move(Rotation.new(other.axis_face, direction + other.direction))
70
+ elsif @direction.double_move? && other.direction.double_move?
71
+ used_axis_priorities = [@axis_face, other.axis_face].map(&:axis_priority)
72
+ # Note that there are two solutions, but any works.
73
+ remaining_face =
74
+ Face::ELEMENTS.find { |f| !used_axis_priorities.include?(f.axis_priority) }
75
+ Algorithm.move(Rotation.new(remaining_face, CubeDirection::DOUBLE))
76
+ end
77
+ end
78
+
79
+ def prepend_fat_m_slice_move(_other, _cube_size)
80
+ nil
81
+ end
82
+
83
+ def prepend_fat_move(other, cube_size)
84
+ return unless compatible_fat_move?(other)
85
+
86
+ Algorithm.move(
87
+ FatMove.new(other.axis_face.opposite, other.direction, other.inverted_width(cube_size))
88
+ )
89
+ end
90
+
91
+ def prepend_slice_move(_other, _cube_size)
92
+ nil
93
+ end
94
+
95
+ def move_count(_cube_size, _metric = :htm)
96
+ 0
97
+ end
98
+
99
+ private
100
+
101
+ def compatible_fat_move?(other)
102
+ same_axis?(other) && translated_direction(other.axis_face) == other.direction.inverse
103
+ end
104
+ end
105
+ end