[2018][ruby][15.1] movement
diff --git a/2018/ruby/day_15.rb b/2018/ruby/day_15.rb
index 154d324..9430e00 100644
--- a/2018/ruby/day_15.rb
+++ b/2018/ruby/day_15.rb
@@ -1,23 +1,190 @@
+require "set"
+
require "minitest"
class Combat
+ class EndCombat < StandardError; end
+
+ def initialize(map)
+ @map = map
+ end
+
+ def fight
+ return enum_for(__method__) unless block_given?
+
+ turn_order.each do |pos|
+ end
+
+ yield @map
+ rescue EndCombat
+ yield @map
+ end
+
+ def turn_order
+ @map.units.keys.sort
+ end
+end
+
+class TestCombat < Minitest::Test
+ def test_fight
+ map = Map.parse(<<~MAP)
+ #########
+ #G..G..G#
+ #.......#
+ #.......#
+ #G..E..G#
+ #.......#
+ #.......#
+ #G..G..G#
+ #########
+ MAP
+ combat = Combat.new(map)
+ rounds = combat.fight
+
+ map = rounds.next
+ assert_equal <<~MAP.chomp, map.to_s
+ #########
+ #.G...G.#
+ #...G...#
+ #...E..G#
+ #.G.....#
+ #.......#
+ #G..G..G#
+ #.......#
+ #########
+ MAP
+ end
+
+ def test_turn_order
+ map = Map.parse(<<~MAP)
+ #######
+ #.G.E.#
+ #E.G.E#
+ #.G.E.#
+ #######
+ MAP
+ combat = Combat.new(map)
+
+ expected = [[1,2], [1,4], [2,1], [2,3], [2,5], [3,2], [3,4]].map {|pos| Position[*pos]}
+ assert_equal expected, combat.turn_order
+ end
+end
+
+class Turn
+ def initialize(map, pos)
+ @map, @pos = map, pos
+ @unit = @map.fetch(@pos)
+ end
+
+ def move
+ raise EndCombat if targets.empty?
+ return if can_attack?
+
+ chosen = nearest.min
+ haystack = @map.in_range(@pos)
+ dijkstra(chosen)
+ .select {|pos,_| haystack.include?(pos) }
+ .min_by {|pos,distance| [distance, pos] }
+ .first
+ end
+
+ def targets
+ @map.units.select {|_, other| other.is_a?(@unit.enemy) }
+ end
+
+ def can_attack?
+ @map.in_range(@pos).any? {|pos| targets.has_key?(pos) }
+ end
+
+ def in_range
+ targets.flat_map {|pos, _| @map.in_range(pos) }.uniq
+ end
+
+ def reachable
+ dijkstra(@pos).select {|pos,_| in_range.include?(pos) }
+ end
+
+ def nearest
+ nearest = Hash.new {|h,k| h[k] = [] }
+ reachable.each do |pos, distance|
+ nearest[distance] << pos
+ end
+ nearest.min_by(&:first).last
+ end
+
+ private
+
+ def dijkstra(pos)
+ distances = { pos => 0 }
+ queue = [ pos ]
+ while pos = queue.shift
+ distance = distances.fetch(pos) + 1
+ @map.in_range(pos).reject {|pos| distances.has_key?(pos) }.each do |pos|
+ distances[pos] = distance
+ queue << pos
+ end
+ end
+ distances
+ end
+end
+
+class TestTurn < Minitest::Test
+ def setup
+ map = Map.parse(<<~MAP)
+ #######
+ #E..G.#
+ #...#.#
+ #.G.#G#
+ #######
+ MAP
+ @turn = Turn.new(map, Position[1,1])
+ end
+
+ def test_targets
+ assert_equal [[1,4], [3,2], [3,5]].to_set, @turn.targets.keys.map(&:to_a).to_set
+ end
+
+ def test_in_range
+ assert_equal [[1,3], [1,5], [2,2], [2,5], [3,1], [3,3]].to_set, @turn.in_range.map(&:to_a).to_set
+ end
+
+ def test_reachable
+ assert_equal [[1,3], [2,2], [3,1], [3,3]].to_set, @turn.reachable.keys.map(&:to_a).to_set
+ end
+
+ def test_nearest
+ assert_equal [[1,3], [2,2], [3,1]].to_set, @turn.nearest.map(&:to_a).to_set
+ end
+
+ def test_move
+ assert_equal Position[1,2], @turn.move
+
+ turn = Turn.new(Map.parse(<<~MAP), Position[1,2])
+ #######
+ #.E...#
+ #.....#
+ #...G.#
+ #######
+ MAP
+ assert_equal Position[1,3], turn.move
+ end
end
-class Grid
+class Map
def self.parse(s)
occupied = {}
s.chomp.lines.each.with_index do |row, y|
- row.chomp.chars.each.with_index do |pos, x|
- case pos
+ row.chomp.chars.each.with_index do |square, x|
+ case square
when ?#
- occupied[[y, x]] = :wall
+ occupied[Position[y, x]] = Wall.new
when ?E
- occupied[[y, x]] = Elf.new
+ occupied[Position[y, x]] = Elf.new
when ?G
- occupied[[y, x]] = Goblin.new
+ occupied[Position[y, x]] = Goblin.new
when ?.
else
- fail "Invalid character: #{pos.inspect}"
+ fail "Invalid character: #{square.inspect}"
end
end
end
@@ -29,17 +196,31 @@ class Grid
end
def [](pos)
- y, x = pos
- @occupied[[y, x]]
+ @occupied[pos]
+ end
+
+ def fetch(pos)
+ @occupied.fetch(pos)
+ end
+
+ def units
+ @occupied.select {|_, sq| sq.is_a?(Unit) }
+ end
+
+ def in_range(pos)
+ [-1, 1]
+ .flat_map {|d| [[pos.y, pos.x+d], [pos.y+d, pos.x]] }
+ .map {|pos| Position[*pos] }
+ .reject {|pos| @occupied.has_key?(pos) }
end
def to_s
- max_y = @occupied.keys.map(&:first).max
- max_x = @occupied.keys.map(&:last).max
+ max_y = @occupied.keys.map(&:y).max
+ max_x = @occupied.keys.map(&:x).max
(0..max_y).map {|y|
(0..max_x).map {|x|
- case @occupied[[y, x]]
- when :wall
+ case @occupied[Position[y, x]]
+ when Wall
?#
when Elf
?E
@@ -48,43 +229,79 @@ class Grid
when nil
?.
else
- fail "Unexpected object: #{@occupied[[y, x]]}"
+ fail "Unexpected object: #{@occupied[Position[y, x]]}"
end
}.join
}.join(?\n)
end
end
-class TestGrid < Minitest::Test
+class TestMap < Minitest::Test
def test_parse
- grid = Grid.parse(<<~GRID)
+ map = Map.parse(<<~MAP)
+ #######
+ #E..G.#
+ #...#.#
+ #.G.#G#
+ #######
+ MAP
+
+ assert_instance_of Wall, map[Position[0,0]]
+ assert_instance_of Wall, map[Position[2,4]]
+ assert_instance_of Wall, map[Position[4,6]]
+
+ assert_instance_of Elf, map[Position[1,1]]
+
+ assert_instance_of Goblin, map[Position[1,4]]
+ assert_instance_of Goblin, map[Position[3,5]]
+
+ assert_nil map[Position[1,2]]
+ assert_nil map[Position[2,5]]
+ end
+
+ def test_units
+ map = Map.parse(<<~MAP)
#######
#E..G.#
#...#.#
#.G.#G#
#######
- GRID
+ MAP
- assert_equal :wall, grid[[0,0]]
- assert_equal :wall, grid[[2,4]]
- assert_equal :wall, grid[[4,6]]
+ assert_equal 4, map.units.length
+ end
+end
- assert_instance_of Elf, grid[[1,1]]
- assert_instance_of Goblin, grid[[1,4]]
- assert_instance_of Goblin, grid[[3,5]]
+Position = Struct.new(:y, :x) do
+ def self.[](y, x)
+ self.new(y, x)
+ end
- assert_nil grid[[1,2]]
- assert_nil grid[[2,5]]
+ def <=>(other)
+ [self.y, self.x] <=> [other.y, other.x]
+ end
+
+ def to_a
+ [y, x]
end
end
class Unit
end
+class Wall
+end
+
class Elf < Unit
+ def enemy
+ Goblin
+ end
end
class Goblin < Unit
+ def enemy
+ Elf
+ end
end
if __FILE__ == $0