diff --git a/Gemfile b/Gemfile index bdc5d95..5baabe7 100644 --- a/Gemfile +++ b/Gemfile @@ -7,3 +7,5 @@ ruby file: "./.ruby-version" gem "thor", "~> 1.3" gem "rubocop", "~> 1.68" + +gem 'minitest' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index d13a81f..b9659c5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,7 @@ GEM ast (2.4.2) json (2.8.1) language_server-protocol (3.17.0.3) + minitest (5.25.4) parallel (1.26.3) parser (3.3.6.0) ast (~> 2.4.1) @@ -32,6 +33,7 @@ PLATFORMS x86_64-darwin-23 DEPENDENCIES + minitest rubocop (~> 1.68) thor (~> 1.3) diff --git a/katas/generic_falling_blocks_game_solver/murad/solver/plane.rb b/katas/generic_falling_blocks_game_solver/murad/solver/plane.rb new file mode 100644 index 0000000..ba99902 --- /dev/null +++ b/katas/generic_falling_blocks_game_solver/murad/solver/plane.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'matrix' + +class Plane + Block = Class.new(Object) do + def to_i = raise NotImplementedError + def to_s = raise NotImplementedError + + def taken? = raise NotImplementedError + def empty? = !taken? + end + EmptyBlock = Class.new(Block) do + def to_i = 0 + def to_s = ' ' + + def taken? = false + end + TakenBlock = Class.new(Block) do + def to_i = 1 + def to_s = '▓▓' + + def taken? = true + end + + MalformedPlane = Class.new(StandardError) + + def self.from_string(string_plane) + lines = string_plane.split("\n") + line_length = lines.max { _1.size }.size / 2 + rows = Array.new(lines.length) { Array.new(line_length) } + lines.each_with_index do |line, line_index| + line.ljust(line_length * 2).chars.each_slice(2).with_index do |tuple, tuple_index| + rows[line_index][tuple_index] = \ + case tuple + in ['▓','▓'] + TakenBlock.new + in [' ', ' '] + EmptyBlock.new + else + raise MalformedPlane + end + end + end + + new(Matrix[*rows]) + end + + def initialize(matrix) + @matrix = matrix + end + + def at(row, col) = matrix[row, col] + + def row_size = matrix.row_size + def col_size = matrix.column_size + + def each_with_index + matrix.each_with_index do |elem, row, col| + yield elem, row, col + end + end + + def rotate(n) + n %= 4 + case n + when 0 + self.class.new(matrix) + when 1 + self.class.new(Matrix.build(col_size, row_size) { |row, col| matrix[row_size - col - 1, row] }) + when 2 + self.class.new(Matrix.build(row_size, col_size) { |row, col| matrix[row_size - row - 1, col_size - col - 1] }) + when 3 + self.class.new(Matrix.build(col_size, row_size) { |row, col| matrix[col, col_size - row - 1] }) + end + end + + def level? + matrix.row_vectors.all? do |row_vector| + row_vector.to_a.map(&:to_i).uniq.size == 1 + end + end + + def to_s + matrix.row_vectors.map { _1.to_a.join('') }.join("\n") + end + + private + + attr_reader :matrix +end diff --git a/katas/generic_falling_blocks_game_solver/murad/solver/shape.rb b/katas/generic_falling_blocks_game_solver/murad/solver/shape.rb new file mode 100644 index 0000000..52879ef --- /dev/null +++ b/katas/generic_falling_blocks_game_solver/murad/solver/shape.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require_relative './plane' + +class Shape + def self.canonical_shape = raise NotImplementedError + def self.number_of_orientations = raise NotImplementedError + def self.name = raise NotImplementedError + + def initialize(orientation:) + @orientation = orientation + end + + attr_reader :orientation + + def height = plane.row_size + def width = plane.col_size + def at(row, col) = plane.at(row, col) + + def to_s = plane.to_s + + def each_with_index + plane.each_with_index do |elem, row, col| + yield elem, row, col + end + end + + def taken_blocks_per_row + result = Array.new(plane.row_size, 0) + each_with_index { |elem, row, _col| result[row] += elem.to_i } + result + end + + private + def plane = Plane.from_string(self.class.canonical_shape).rotate(orientation - 1) +end + +class AvailableShapes + def self.call = Shape.subclasses +end + +class Square < Shape + def self.canonical_shape + <<~SHAPE + ▓▓▓▓ + ▓▓▓▓ + SHAPE + end + + def self.number_of_orientations = 1 + + def self.name = 'Square' +end + +class Line < Shape + def self.canonical_shape + <<~SHAPE + ▓▓ + ▓▓ + ▓▓ + ▓▓ + SHAPE + end + + def self.number_of_orientations = 2 + + def self.name = 'Line' +end + +class Z < Shape + def self.canonical_shape + <<~SHAPE + ▓▓▓▓ + ▓▓▓▓ + SHAPE + end + + def self.number_of_orientations = 2 + + def self.name = 'Z' +end + +class L < Shape + def self.canonical_shape + <<~SHAPE + ▓▓ + ▓▓ + ▓▓▓▓ + SHAPE + end + + def self.number_of_orientations = 4 + + def self.name = 'L' +end diff --git a/katas/generic_falling_blocks_game_solver/murad/solver/solver.rb b/katas/generic_falling_blocks_game_solver/murad/solver/solver.rb new file mode 100644 index 0000000..cb65608 --- /dev/null +++ b/katas/generic_falling_blocks_game_solver/murad/solver/solver.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative './plane' +require_relative './shape' + +class Solver + MultiplePossibleSolutionsError = Class.new(StandardError) + + def initialize(game_plane) + @plane = Plane.from_string(game_plane) + return recommend_any if plane.level? + + @recommendations = [] + solve! + find_lowest_row_recommendations! + if recommendations.size > 1 + debug_state_msg = <<~MSG + Pretty sure all of them are equally viable. See for yourself: + PLANE: + #{plane} + RECOMMENDATIONS: + #{recommendations.map(&:to_debug_s).join("\n")} + MSG + raise MultiplePossibleSolutionsError, debug_state_msg + + end + @the_recommendation = recommendations.first + end + + def recommended_block + the_recommendation.shape_name + end + + def recommended_orientation + the_recommendation.orientation + end + + private + + attr_reader :plane, :recommendations, :the_recommendation + + def solve! + AvailableShapes.call.each do |shape_class| + shape_class.number_of_orientations.times do |orientation| + shape = shape_class.new(orientation: orientation + 1) + + 0.upto(plane.row_size - shape.height) do |row_idx| + 0.upto(plane.col_size - shape.width) do |col_idx| + next unless shape_fits_at?(shape, row_idx, col_idx) + + recommendations << Recommendation.new( + shape_name: shape.class.name, + orientation: shape_orientation_recommendation(shape), + row_idx:, + col_idx:, + max_depth: row_idx + shape.height, + taken_blocks_per_row: shape.taken_blocks_per_row, + shape:, + ) + end + end + end + end + end + + def shape_fits_at?(shape, row, col) + shape.each_with_index do |elem, erow, ecol| + return false if elem.to_i + plane.at(row + erow, col + ecol).to_i > 1 + end + true + end + + def shape_orientation_recommendation(shape) + return :any if shape.class.number_of_orientations == 1 + + "orientation_#{shape.orientation}".to_sym + end + + def find_lowest_row_recommendations! + lowest_end = recommendations.map(&:max_depth).max + recommendations.reject! { _1.max_depth != lowest_end } + + lowest_start = recommendations.map(&:row_idx).max + recommendations.reject! { _1.row_idx != lowest_start } + + # taken_blocks_per_row will be of equal sizes here + recommendations.first.taken_blocks_per_row.size.downto(1) do |row_idx| + biggest_value = recommendations.map { _1.taken_blocks_per_row[row_idx - 1] }.max + recommendations.reject! { _1.taken_blocks_per_row[row_idx - 1] != biggest_value } + end + end + + def recommend_any + @the_recommendation = Recommendation.new(shape_name: 'Any', orientation: :any) + end +end + +Recommendation = Struct.new(:shape_name, :orientation, :row_idx, :col_idx, :max_depth, :taken_blocks_per_row, :shape, keyword_init: true) do + def to_debug_s = to_s + "\n" + shape.to_s +end diff --git a/katas/generic_falling_blocks_game_solver/murad/test/solver/test_main.rb b/katas/generic_falling_blocks_game_solver/murad/test/solver/test_main.rb new file mode 100644 index 0000000..43d728c --- /dev/null +++ b/katas/generic_falling_blocks_game_solver/murad/test/solver/test_main.rb @@ -0,0 +1,150 @@ +require 'minitest/autorun' + +require_relative '../../solver/solver' + +class GenericFallingBlocksGameTest < Minitest::Test + def test_recommends_any_block_when_plane_is_level + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + assert_equal 'Any', described_class.new(game_plane).recommended_block + assert_equal :any, described_class.new(game_plane).recommended_orientation + end + + def test_correctly_recommends_square_block_any_orientation + game_plane = <<~PLANE + + + ▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'Square', instance.recommended_block + assert_equal :any, instance.recommended_orientation + end + + def test_correctly_recommends_square_block_deep + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'Square', instance.recommended_block + assert_equal :any, instance.recommended_orientation + end + + def test_correctly_recommends_line_block_orientation_1 + game_plane = <<~PLANE + + + ▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'Line', instance.recommended_block + assert_equal :orientation_1, instance.recommended_orientation + end + + def test_correctly_recommends_line_block_orientation_2 + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓▓▓ ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'Line', instance.recommended_block + assert_equal :orientation_2, instance.recommended_orientation + end + + def test_correctly_recommends_z_block_orientation_1 + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'Z', instance.recommended_block + assert_equal :orientation_1, instance.recommended_orientation + end + + def test_correctly_recommends_z_block_orientation_2 + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'Z', instance.recommended_block + assert_equal :orientation_2, instance.recommended_orientation + end + + def test_correctly_recommends_l_orientation_2 + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'L', instance.recommended_block + assert_equal :orientation_2, instance.recommended_orientation + end + + def test_correctly_recommends_l_orientation_3 + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'L', instance.recommended_block + assert_equal :orientation_3, instance.recommended_orientation + end + + def test_correctly_recommends_l_orientation_4 + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + instance = described_class.new(game_plane) + assert_equal 'L', instance.recommended_block + assert_equal :orientation_4, instance.recommended_orientation + end + + private + + def described_class + Solver + end +end diff --git a/katas/generic_falling_blocks_game_solver/murad/test/solver/test_plane.rb b/katas/generic_falling_blocks_game_solver/murad/test/solver/test_plane.rb new file mode 100644 index 0000000..7ffc443 --- /dev/null +++ b/katas/generic_falling_blocks_game_solver/murad/test/solver/test_plane.rb @@ -0,0 +1,22 @@ +require 'minitest/autorun' + +require_relative '../../solver/plane' + +class TestPlane < Minitest::Test + def test_simple_plane + game_plane = <<~PLANE + + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ + PLANE + plane = Plane.from_string(game_plane) + + assert_equal 10, plane.col_size + assert_equal 6, plane.row_size + assert plane.at(0, 0).empty? + assert plane.at(5, 9).taken? + end +end diff --git a/katas/generic_falling_blocks_game_solver/murad/test/solver/test_shape.rb b/katas/generic_falling_blocks_game_solver/murad/test/solver/test_shape.rb new file mode 100644 index 0000000..18b85ac --- /dev/null +++ b/katas/generic_falling_blocks_game_solver/murad/test/solver/test_shape.rb @@ -0,0 +1,14 @@ +require 'minitest/autorun' + +require_relative '../../solver/shape' + +class TestShape < Minitest::Test + def test_shape + AvailableShapes.call.each do |shape| + (1..shape.number_of_orientations).each do |n| + puts "SHAPE: #{shape.name}, ORIENTATION: #{n}" + puts shape.new(orientation: n).to_s + end + end + end +end