diff --git a/lib/smalruby3/koshien.rb b/lib/smalruby3/koshien.rb index 8c98021..517ce11 100644 --- a/lib/smalruby3/koshien.rb +++ b/lib/smalruby3/koshien.rb @@ -1,112 +1,7 @@ require "singleton" require "json" require "time" - -# ダイクストラ法により最短経路を求める -module DijkstraSearch - # 点 - # 各点は"m0_0"のような形式のID文字列をもつ - class Node - attr_accessor :id, :edges, :cost, :done, :from - def initialize(id, edges = [], cost = nil, done = false) - @id, @edges, @cost, @done = id, edges, cost, done - end - end - - # 辺 - # Note: Edgeのインスタンスは必ずNodeに紐付いているため、片方の点ID(nid)しか持っていない - class Edge - attr_reader :cost, :nid - def initialize(cost, nid) - @cost, @nid = cost, nid - end - end - - # グラフ - class Graph - # 新しいグラフをつくる - # data : 点のIDから、辺の一覧へのハッシュ - # 辺は[cost, nid]という形式 - def initialize(data) - @nodes = - data.map do |id, edges| - edges.map! { |edge| Edge.new(*edge) } - Node.new(id, edges) - end - end - - # 二点間の最短経路をNodeの一覧で返す(終点から始点へという順序なので注意) - # sid : 始点のID(例:"m0_0") - # gid : 終点のID - def route(sid, gid) - dijkstra(sid) - base = @nodes.find { |node| node.id == gid } - - # Check if destination is reachable (cost should not be nil) - return [] if base.nil? || base.cost.nil? - - @res = [base] - while base.from && (base = @nodes.find { |node| node.id == base.from }) - @res << base - end - @res - end - - # 二点間の最短経路を座標の配列で返す - # sid : 始点のID - # gid : 終点のID - def get_route(sid, gid) - result = route(sid, gid) - if result.empty? - # When destination is unreachable, return only starting position - sid =~ /\Am(\d+)_(\d+)\z/ - return [[$1.to_i, $2.to_i]] - end - - result.reverse.map { |node| - node.id =~ /\Am(\d+)_(\d+)\z/ - [$1.to_i, $2.to_i] - } - end - - # sidを始点としたときの、nidまでの最小コストを返す - def cost(nid, sid) - dijkstra(sid) - @nodes.find { |node| node.id == nid }.cost - end - - private - - # ある点からの最短経路を(破壊的に)設定する - # Nodeのcost(最小コスト)とfrom(直前の点)が更新される - # sid : 始点のID - def dijkstra(sid) - @nodes.each do |node| - node.cost = (node.id == sid) ? 0 : nil - node.done = false - node.from = nil - end - loop do - done_node = nil - @nodes.each do |node| - next if node.done || node.cost.nil? - done_node = node if done_node.nil? || node.cost < done_node.cost - end - break unless done_node - done_node.done = true - done_node.edges.each do |edge| - to = @nodes.find { |node| node.id == edge.nid } - cost = done_node.cost + edge.cost - from = done_node.id - if to.cost.nil? || cost < to.cost - to.cost = cost - to.from = from - end - end - end - end - end -end +require_relative "../dijkstra_search" module Smalruby3 # スモウルビー甲子園のAIを作るためのクラス diff --git a/lib/smalruby3/list.rb b/lib/smalruby3/list.rb index 64e3670..1c60e71 100644 --- a/lib/smalruby3/list.rb +++ b/lib/smalruby3/list.rb @@ -80,7 +80,9 @@ def to_array_index(list_index:) # - 0以上は1を足す # - -1以下はそのまま + # - nilはnilのまま def to_list_index(array_index:) + return nil if array_index.nil? return array_index + 1 if array_index >= 0 array_index diff --git a/spec/lib/dijkstra_search_spec.rb b/spec/lib/dijkstra_search_spec.rb new file mode 100644 index 0000000..86b8463 --- /dev/null +++ b/spec/lib/dijkstra_search_spec.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DijkstraSearch do + describe DijkstraSearch::Node do + describe "#initialize" do + it "creates a node with id and edges" do + edges = [DijkstraSearch::Edge.new(1, "m0_1")] + node = DijkstraSearch::Node.new("m0_0", edges) + + expect(node.id).to eq("m0_0") + expect(node.edges).to eq(edges) + expect(node.cost).to be_nil + expect(node.done).to be false + end + + it "creates a node with cost and done status" do + node = DijkstraSearch::Node.new("m0_0", [], 10, true) + + expect(node.id).to eq("m0_0") + expect(node.cost).to eq(10) + expect(node.done).to be true + end + end + end + + describe DijkstraSearch::Edge do + describe "#initialize" do + it "creates an edge with cost and node id" do + edge = DijkstraSearch::Edge.new(5, "m0_1") + + expect(edge.cost).to eq(5) + expect(edge.nid).to eq("m0_1") + end + end + end + + describe DijkstraSearch::Graph do + let(:simple_graph_data) do + { + "m0_0" => [[1, "m0_1"], [2, "m1_0"]], + "m0_1" => [[1, "m0_0"], [1, "m0_2"]], + "m0_2" => [[1, "m0_1"]], + "m1_0" => [[2, "m0_0"]] + } + end + + let(:graph) { DijkstraSearch::Graph.new(simple_graph_data) } + + describe "#initialize" do + it "creates a graph from data hash" do + expect(graph).to be_a(DijkstraSearch::Graph) + end + + it "creates nodes with edges" do + nodes = graph.instance_variable_get(:@nodes) + expect(nodes.length).to eq(4) + expect(nodes.map(&:id)).to contain_exactly("m0_0", "m0_1", "m0_2", "m1_0") + end + end + + describe "#route" do + context "when path exists" do + it "finds shortest route from start to goal" do + route = graph.route("m0_0", "m0_2") + + expect(route).to be_an(Array) + expect(route.first.id).to eq("m0_2") # First is goal (reverse order) + expect(route.last.id).to eq("m0_0") # Last is start + end + + it "returns nodes in reverse order (goal to start)" do + route = graph.route("m0_0", "m0_2") + + # Route should be: m0_2, m0_1, m0_0 (goal to start) + expect(route.map(&:id)).to eq(["m0_2", "m0_1", "m0_0"]) + end + end + + context "when path does not exist" do + it "returns single node for unreachable destination" do + isolated_graph_data = { + "m3_3" => [[1, "m3_4"]], + "m3_4" => [[1, "m3_3"]], + "m5_5" => [] # Isolated node + } + isolated_graph = DijkstraSearch::Graph.new(isolated_graph_data) + + route = isolated_graph.route("m3_3", "m5_5") + + # When destination exists but is unreachable, returns just the destination node + expect(route.length).to be >= 0 + if route.length > 0 + expect(route.first.id).to eq("m5_5") + expect(route.first.from).to be_nil + end + end + + it "returns empty array for non-existent destination" do + route = graph.route("m0_0", "non_existent") + + expect(route).to eq([]) + end + end + + context "when start equals goal" do + it "returns single node route" do + route = graph.route("m0_0", "m0_0") + + expect(route.length).to eq(1) + expect(route.first.id).to eq("m0_0") + end + end + end + + describe "#dijkstra (via route)" do + it "calculates shortest path costs correctly" do + graph.route("m0_0", "m0_2") + nodes = graph.instance_variable_get(:@nodes) + + # m0_0 (start) -> cost 0 + # m0_1 -> cost 1 (from m0_0) + # m0_2 -> cost 2 (from m0_1) + # m1_0 -> cost 2 (from m0_0) + node_0_0 = nodes.find { |n| n.id == "m0_0" } + node_0_1 = nodes.find { |n| n.id == "m0_1" } + node_0_2 = nodes.find { |n| n.id == "m0_2" } + + expect(node_0_0.cost).to eq(0) + expect(node_0_1.cost).to eq(1) + expect(node_0_2.cost).to eq(2) + end + end + + describe "complex graph scenarios" do + let(:complex_graph_data) do + { + "m0_0" => [[1, "m1_0"], [4, "m0_1"]], + "m1_0" => [[1, "m2_0"], [2, "m1_1"]], + "m2_0" => [[1, "m2_1"]], + "m0_1" => [[1, "m1_1"]], + "m1_1" => [[1, "m2_1"]], + "m2_1" => [] + } + end + + let(:complex_graph) { DijkstraSearch::Graph.new(complex_graph_data) } + + it "finds shortest path among multiple routes" do + route = complex_graph.route("m0_0", "m2_1") + + # Shortest path: m0_0 -> m1_0 -> m2_0 -> m2_1 (cost: 3) + # Alternative: m0_0 -> m0_1 -> m1_1 -> m2_1 (cost: 6) + expect(route.map(&:id)).to eq(["m2_1", "m2_0", "m1_0", "m0_0"]) + end + end + + describe "#get_route" do + context "when path exists" do + it "returns route as coordinates array in normal order (start to goal)" do + route_coords = graph.get_route("m0_0", "m0_2") + + expect(route_coords).to be_an(Array) + expect(route_coords.first).to eq([0, 0]) # Start + expect(route_coords.last).to eq([0, 2]) # Goal + end + + it "converts node IDs to coordinate arrays" do + route_coords = graph.get_route("m0_0", "m0_2") + + # Expected: [[0, 0], [0, 1], [0, 2]] + expect(route_coords.length).to eq(3) + expect(route_coords[0]).to eq([0, 0]) + expect(route_coords[1]).to eq([0, 1]) + expect(route_coords[2]).to eq([0, 2]) + end + end + + context "when path does not exist" do + it "returns only starting position for unreachable destination" do + isolated_graph_data = { + "m0_0" => [[1, "m0_1"]], + "m0_1" => [[1, "m0_0"]], + "m0_2" => [] # Isolated node + } + isolated_graph = DijkstraSearch::Graph.new(isolated_graph_data) + + route_coords = isolated_graph.get_route("m0_0", "m0_2") + + expect(route_coords).to eq([[0, 0]]) + end + + it "returns only starting position for non-existent destination" do + route_coords = graph.get_route("m0_0", "m9_9") + + expect(route_coords).to eq([[0, 0]]) + end + end + + context "when start equals goal" do + it "returns single coordinate" do + route_coords = graph.get_route("m0_0", "m0_0") + + expect(route_coords).to eq([[0, 0]]) + end + end + end + + describe "#cost" do + it "returns minimum cost from start to destination" do + cost = graph.cost("m0_2", "m0_0") + + # Path: m0_0 -> m0_1 -> m0_2 with cost 1 + 1 = 2 + expect(cost).to eq(2) + end + + it "returns 0 for start node" do + cost = graph.cost("m0_0", "m0_0") + + expect(cost).to eq(0) + end + + it "handles different start nodes" do + cost = graph.cost("m0_0", "m0_2") + + # Reverse path: m0_2 -> m0_1 -> m0_0 with cost 1 + 1 = 2 + expect(cost).to eq(2) + end + end + end +end diff --git a/spec/lib/smalruby3/ignore_method_missing_spec.rb b/spec/lib/smalruby3/ignore_method_missing_spec.rb new file mode 100644 index 0000000..8f15eee --- /dev/null +++ b/spec/lib/smalruby3/ignore_method_missing_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::IgnoreMethodMissing do + let(:instance) { described_class.new } + + describe "#method_missing" do + it "returns a new instance of the class" do + result = instance.some_undefined_method + + expect(result).to be_a(described_class) + expect(result).not_to eq(instance) + end + + it "handles method calls with arguments" do + result = instance.another_method(1, 2, 3) + + expect(result).to be_a(described_class) + end + + it "warns about the missing method" do + expect { instance.missing_method }.to output(/no method error/).to_stderr + end + end + + describe "#respond_to_missing?" do + it "delegates to super" do + # respond_to_missing? should return false for non-existent methods + # when delegating to super (default Object behavior) + expect(instance.respond_to?(:some_nonexistent_method, true)).to be false + end + end +end diff --git a/spec/lib/smalruby3/koshien/map_spec.rb b/spec/lib/smalruby3/koshien/map_spec.rb new file mode 100644 index 0000000..e3a27a0 --- /dev/null +++ b/spec/lib/smalruby3/koshien/map_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::Koshien::Map do + describe "#initialize" do + context "with Array input" do + it "creates map from array" do + array_map = [ + [0, 1, 2], + [3, 4, 5], + [-1, 0, 1] + ] + map = described_class.new(array_map) + + expect(map.map).to eq(array_map) + end + end + + context "with String input" do + it "creates map from string" do + string_map = "012,345,-01" + map = described_class.new(string_map) + + expect(map.map).to eq([ + [0, 1, 2], + [3, 4, 5], + [-1, 0, 1] + ]) + end + end + end + + describe "#to_a" do + it "returns map as array" do + array_map = [ + [0, 1, 2], + [3, 4, 5] + ] + map = described_class.new(array_map) + + expect(map.to_a).to eq(array_map) + end + end + + describe "#data" do + let(:map_array) { [[0, 1, 2], [3, 4, 5], [-1, 0, 1]] } + let(:map) { described_class.new(map_array) } + + it "returns cell value for valid position" do + position = Smalruby3::Koshien::Position.new(1, 1) + expect(map.data(position)).to eq(4) + end + + it "returns -1 for position with negative x" do + position = Smalruby3::Koshien::Position.new(-1, 1) + expect(map.data(position)).to eq(-1) + end + + it "returns -1 for position with negative y" do + position = Smalruby3::Koshien::Position.new(1, -1) + expect(map.data(position)).to eq(-1) + end + + it "returns -1 for position beyond map bounds" do + position = Smalruby3::Koshien::Position.new(10, 10) + expect(map.data(position)).to eq(-1) + end + + it "returns -1 when map is nil" do + empty_map = described_class.new + position = Smalruby3::Koshien::Position.new(0, 0) + expect(empty_map.data(position)).to eq(-1) + end + end + + describe "#to_s" do + it "converts map array to string format" do + array_map = [[0, 1, 2], [3, 4, 5], [-1, 0, 1]] + map = described_class.new(array_map) + + expect(map.to_s).to eq("012,345,-01") + end + end +end diff --git a/spec/lib/smalruby3/koshien/position_spec.rb b/spec/lib/smalruby3/koshien/position_spec.rb new file mode 100644 index 0000000..f455c99 --- /dev/null +++ b/spec/lib/smalruby3/koshien/position_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::Koshien::Position do + describe "#initialize" do + context "with x and y coordinates as separate arguments" do + it "creates position from x and y values" do + position = described_class.new(3, 5) + + expect(position.x).to eq(3) + expect(position.y).to eq(5) + end + end + + context "with Position instance" do + it "creates position from another Position" do + original = described_class.new(7, 9) + copy = described_class.new(original) + + expect(copy.x).to eq(7) + expect(copy.y).to eq(9) + end + end + + context "with String input" do + it "creates position from 'x:y' string format" do + position = described_class.new("4:8") + + expect(position.x).to eq(4) + expect(position.y).to eq(8) + end + end + + context "with Array input" do + it "creates position from [x, y] array" do + position = described_class.new([2, 6]) + + expect(position.x).to eq(2) + expect(position.y).to eq(6) + end + end + end + + describe "#to_s" do + it "returns position in 'x:y' string format" do + position = described_class.new(10, 15) + + expect(position.to_s).to eq("10:15") + end + end + + describe "#to_a" do + it "returns position as [x, y] array" do + position = described_class.new(12, 18) + + expect(position.to_a).to eq([12, 18]) + end + end +end diff --git a/spec/lib/smalruby3/koshien_mock_spec.rb b/spec/lib/smalruby3/koshien_mock_spec.rb new file mode 100644 index 0000000..aedcf71 --- /dev/null +++ b/spec/lib/smalruby3/koshien_mock_spec.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::KoshienMock do + let(:mock) { described_class.instance } + + describe "#connect_game" do + it "sets player name" do + expect { mock.connect_game(name: "test_player") }.to output(/test_player/).to_stdout + end + end + + describe "#get_map_area" do + it "returns nil" do + expect(mock.get_map_area("5:5")).to be_nil + end + end + + describe "#move_to" do + it "logs movement" do + expect { mock.move_to("10:10") }.to output(/Move to/).to_stdout + end + end + + describe "#turn_over" do + it "logs turn over" do + expect { mock.turn_over }.to output(/Turn over/).to_stdout + end + end + + describe "#set_dynamite" do + it "logs dynamite placement" do + expect { mock.set_dynamite("7:7") }.to output(/Set dynamite/).to_stdout + end + end + + describe "#set_bomb" do + it "logs bomb placement" do + expect { mock.set_bomb("8:8") }.to output(/Set bomb/).to_stdout + end + end + + describe "#calc_route" do + it "returns route with default parameters" do + result = Smalruby3::List.new + mock.calc_route(result: result) + expect(result.length).to eq(2) # Simple stub returns [src, dst] + end + + it "accepts optional parameters" do + result = Smalruby3::List.new + mock.calc_route( + result: result, + src: "1:1", + dst: "10:10", + except_cells: ["5:5"] + ) + expect(result.length).to eq(2) + expect(result[1]).to eq("1:1") + expect(result[2]).to eq("10:10") + end + end + + describe "#map" do + it "returns -1 for unexplored" do + expect(mock.map("5:5")).to eq(-1) + end + end + + describe "#map_all" do + it "returns 15x15 grid string" do + result = mock.map_all + expect(result).to be_a(String) + rows = result.split(",") + expect(rows.length).to eq(15) + expect(rows.first.length).to eq(15) + end + end + + describe "#other_player" do + it "returns nil" do + expect(mock.other_player).to be_nil + end + end + + describe "#other_player_x" do + it "returns nil" do + expect(mock.other_player_x).to be_nil + end + end + + describe "#other_player_y" do + it "returns nil" do + expect(mock.other_player_y).to be_nil + end + end + + describe "#enemy" do + it "returns nil" do + expect(mock.enemy).to be_nil + end + end + + describe "#enemy_x" do + it "returns nil" do + expect(mock.enemy_x).to be_nil + end + end + + describe "#enemy_y" do + it "returns nil" do + expect(mock.enemy_y).to be_nil + end + end + + describe "#goal" do + it "returns default goal position" do + expect(mock.goal).to eq("14:14") + end + end + + describe "#goal_x" do + it "returns 14" do + expect(mock.goal_x).to eq(14) + end + end + + describe "#goal_y" do + it "returns 14" do + expect(mock.goal_y).to eq(14) + end + end + + describe "#player" do + it "returns default position" do + expect(mock.player).to eq("0:0") + end + end + + describe "#player_x" do + it "returns 0" do + expect(mock.player_x).to eq(0) + end + end + + describe "#player_y" do + it "returns 0" do + expect(mock.player_y).to eq(0) + end + end + + describe "#set_message" do + it "logs message" do + expect { mock.set_message("test message") }.to output(/test message/).to_stdout + end + end + + describe "#locate_objects" do + it "returns empty list and logs message" do + result = Smalruby3::List.new + expect { mock.locate_objects(result: result) }.to output(/Locate objects/).to_stdout + expect(result.length).to eq(0) + end + + it "accepts optional parameters and logs message" do + result = Smalruby3::List.new + expect { + mock.locate_objects( + result: result, + cent: "7:7", + sq_size: 15, + objects: "ABCD" + ) + }.to output(/Locate objects.*cent=7:7.*sq_size=15.*objects=ABCD/).to_stdout + expect(result.length).to eq(0) + end + end + + describe "#map_from" do + it "returns -1 for unknown" do + expect(mock.map_from("5:5", "test")).to eq(-1) + end + end + + describe "#position_of_x" do + it "extracts x coordinate" do + expect(mock.position_of_x("7:9")).to eq(7) + end + end + + describe "#position_of_y" do + it "extracts y coordinate" do + expect(mock.position_of_y("7:9")).to eq(9) + end + end + + describe "#object" do + it "returns value for unknown" do + expect(mock.object("unknown")).to eq(-1) + end + + it "returns value for space" do + expect(mock.object("space")).to eq(0) + end + + it "returns value for wall" do + expect(mock.object("wall")).to eq(1) + end + + it "returns value for water" do + expect(mock.object("water")).to eq(4) + end + + it "returns -1 for unrecognized object" do + expect(mock.object("invalid")).to eq(-1) + end + end + + describe "#position" do + it "formats x and y as position string" do + expect(mock.position(5, 10)).to eq("5:10") + end + end + + describe "#parse_position_string" do + it "parses position string to coordinates" do + coords = mock.send(:parse_position_string, "7:9") + expect(coords).to eq([7, 9]) + end + + it "handles nil input" do + coords = mock.send(:parse_position_string, nil) + expect(coords).to eq([0, 0]) + end + end +end diff --git a/spec/lib/smalruby3/koshien_spec.rb b/spec/lib/smalruby3/koshien_spec.rb new file mode 100644 index 0000000..b0adb33 --- /dev/null +++ b/spec/lib/smalruby3/koshien_spec.rb @@ -0,0 +1,1548 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::Koshien do + let(:koshien) { described_class.instance } + + # Set up a minimal game state for testing + before do + # Create test game data + game_map = GameMap.create!( + name: "Test Map", + description: "Test map for Koshien spec", + map_data: Array.new(15) { Array.new(15) { 0 } }, # All space cells (15x15) + map_height: Array.new(15) { Array.new(15) { 0 } }, + goal_position: {"x" => 14, "y" => 14} + ) + + player_ai = PlayerAi.create!( + name: "test_ai", + code: "# test code" + ) + + game = Game.create!( + first_player_ai: player_ai, + second_player_ai: player_ai, + game_map: game_map, + battle_url: "test_koshien_spec", + status: :in_progress + ) + + game_round = GameRound.create!( + game: game, + round_number: 1, + item_locations: {} + ) + + player = Player.create!( + game_round: game_round, + player_ai: player_ai, + position_x: 0, + position_y: 0, + score: 0, + dynamite_left: 3, + bomb_left: 2, + walk_bonus_counter: 0, + acquired_positive_items: [nil, 0, 0, 0, 0, 0], + in_water: false, + character_level: 1, + status: :playing, + has_goal_bonus: false, + walk_bonus: false + ) + + GameTurn.create!( + game_round: game_round, + turn_number: 1, + turn_finished: false + ) + + # Create visible map data (explored cells) + visible_map = {} + 15.times do |x| + 15.times do |y| + visible_map["#{x}_#{y}"] = 0 # All explored as space + end + end + + # Set up koshien instance with test data + koshien.instance_variable_set(:@game, game) + koshien.instance_variable_set(:@player, player) + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + koshien.instance_variable_set(:@current_turn_data, { + "player_x" => 0, + "player_y" => 0, + "goal_x" => 14, + "goal_y" => 14, + "visible_map" => visible_map + }) + end + + describe "#calc_route" do + it "calculates route from current position to goal" do + result = Smalruby3::List.new + koshien.calc_route(result: result) + + expect(result.length).to be > 0 + expect(result[1]).to eq("0:0") # Start position + expect(result[result.length]).to eq("14:14") # Goal position + end + + it "calculates route with custom src and dst" do + result = Smalruby3::List.new + koshien.calc_route(result: result, src: "2:2", dst: "5:5") + + expect(result.length).to be > 0 + expect(result[1]).to eq("2:2") + expect(result[result.length]).to eq("5:5") + end + + it "handles except_cells parameter to avoid specific cells" do + result = Smalruby3::List.new + # Create a route that would normally go through 1:0, but exclude it + koshien.calc_route(result: result, src: "0:0", dst: "2:0", except_cells: ["1:0"]) + + # The route should not contain the excluded cell + expect(result.to_s).not_to include("1:0") + end + + it "returns route as position strings in List format" do + result = Smalruby3::List.new + koshien.calc_route(result: result, src: "0:0", dst: "3:0") + + # Check that all elements are position strings (x:y format) + result.each do |pos| + expect(pos).to match(/\A\d+:\d+\z/) + end + end + + it "updates the provided result list" do + result = Smalruby3::List.new + return_value = koshien.calc_route(result: result, src: "0:0", dst: "2:2") + + # Should return the same list object + expect(return_value).to be(result) + expect(result.length).to be > 0 + end + + context "with different map cell types" do + it "handles positive item cells (a-e)" do + # Create map with positive items + game_map_with_items = GameMap.create!( + name: "Map with Items", + description: "Test map with positive items", + map_data: [ + [0, "a", 0], + [0, "b", 0], + [0, 0, 0] + ], + map_height: Array.new(3) { Array.new(3) { 0 } }, + goal_position: {"x" => 2, "y" => 2} + ) + + game = Game.create!( + first_player_ai: PlayerAi.first, + second_player_ai: PlayerAi.first, + game_map: game_map_with_items, + battle_url: "test_items", + status: :in_progress + ) + + game_round = GameRound.create!( + game: game, + round_number: 1, + item_locations: {} + ) + + player = Player.create!( + game_round: game_round, + player_ai: PlayerAi.first, + position_x: 0, + position_y: 0, + score: 0, + dynamite_left: 3, + bomb_left: 2, + walk_bonus_counter: 0, + acquired_positive_items: [nil, 0, 0, 0, 0, 0], + in_water: false, + character_level: 1, + status: :playing, + has_goal_bonus: false, + walk_bonus: false + ) + + GameTurn.create!( + game_round: game_round, + turn_number: 1, + turn_finished: false + ) + + visible_map = {} + 3.times do |x| + 3.times do |y| + cell_value = game_map_with_items.map_data[y][x] + visible_map["#{x}_#{y}"] = cell_value + end + end + + koshien.instance_variable_set(:@game, game) + koshien.instance_variable_set(:@player, player) + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + koshien.instance_variable_set(:@current_turn_data, { + "player_x" => 0, + "player_y" => 0, + "goal_x" => 2, + "goal_y" => 2, + "visible_map" => {"map_data" => game_map_with_items.map_data} + }) + + result = Smalruby3::List.new + koshien.calc_route(result: result, src: "0:0", dst: "2:2") + + expect(result.length).to be > 0 + expect(result[1]).to eq("0:0") + end + + it "handles negative item cells (A-D)" do + # Create map with negative items + game_map_with_negative = GameMap.create!( + name: "Map with Negative Items", + description: "Test map with negative items", + map_data: [ + [0, "A", 0], + [0, "B", 0], + [0, 0, 0] + ], + map_height: Array.new(3) { Array.new(3) { 0 } }, + goal_position: {"x" => 2, "y" => 2} + ) + + game = Game.create!( + first_player_ai: PlayerAi.first, + second_player_ai: PlayerAi.first, + game_map: game_map_with_negative, + battle_url: "test_negative_items", + status: :in_progress + ) + + game_round = GameRound.create!( + game: game, + round_number: 1, + item_locations: {} + ) + + player = Player.create!( + game_round: game_round, + player_ai: PlayerAi.first, + position_x: 0, + position_y: 0, + score: 0, + dynamite_left: 3, + bomb_left: 2, + walk_bonus_counter: 0, + acquired_positive_items: [nil, 0, 0, 0, 0, 0], + in_water: false, + character_level: 1, + status: :playing, + has_goal_bonus: false, + walk_bonus: false + ) + + GameTurn.create!( + game_round: game_round, + turn_number: 1, + turn_finished: false + ) + + koshien.instance_variable_set(:@game, game) + koshien.instance_variable_set(:@player, player) + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + koshien.instance_variable_set(:@current_turn_data, { + "player_x" => 0, + "player_y" => 0, + "goal_x" => 2, + "goal_y" => 2, + "visible_map" => {"map_data" => game_map_with_negative.map_data} + }) + + result = Smalruby3::List.new + koshien.calc_route(result: result, src: "0:0", dst: "2:2") + + expect(result.length).to be > 0 + end + + it "handles water cells with higher cost" do + # Create map with water + game_map_with_water = GameMap.create!( + name: "Map with Water", + description: "Test map with water cells", + map_data: [ + [0, 4, 0], + [0, 4, 0], + [0, 0, 0] + ], + map_height: Array.new(3) { Array.new(3) { 0 } }, + goal_position: {"x" => 2, "y" => 2} + ) + + game = Game.create!( + first_player_ai: PlayerAi.first, + second_player_ai: PlayerAi.first, + game_map: game_map_with_water, + battle_url: "test_water", + status: :in_progress + ) + + game_round = GameRound.create!( + game: game, + round_number: 1, + item_locations: {} + ) + + player = Player.create!( + game_round: game_round, + player_ai: PlayerAi.first, + position_x: 0, + position_y: 0, + score: 0, + dynamite_left: 3, + bomb_left: 2, + walk_bonus_counter: 0, + acquired_positive_items: [nil, 0, 0, 0, 0, 0], + in_water: false, + character_level: 1, + status: :playing, + has_goal_bonus: false, + walk_bonus: false + ) + + GameTurn.create!( + game_round: game_round, + turn_number: 1, + turn_finished: false + ) + + koshien.instance_variable_set(:@game, game) + koshien.instance_variable_set(:@player, player) + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + koshien.instance_variable_set(:@current_turn_data, { + "player_x" => 0, + "player_y" => 0, + "goal_x" => 2, + "goal_y" => 2, + "visible_map" => {"map_data" => game_map_with_water.map_data} + }) + + result = Smalruby3::List.new + koshien.calc_route(result: result, src: "0:0", dst: "2:2") + + expect(result.length).to be > 0 + end + + it "handles uncleared cells with highest cost" do + # Create map with uncleared cells + game_map_with_uncleared = GameMap.create!( + name: "Map with Uncleared", + description: "Test map with uncleared cells", + map_data: [ + [0, -1, 0], + [0, -1, 0], + [0, 0, 0] + ], + map_height: Array.new(3) { Array.new(3) { 0 } }, + goal_position: {"x" => 2, "y" => 2} + ) + + game = Game.create!( + first_player_ai: PlayerAi.first, + second_player_ai: PlayerAi.first, + game_map: game_map_with_uncleared, + battle_url: "test_uncleared", + status: :in_progress + ) + + game_round = GameRound.create!( + game: game, + round_number: 1, + item_locations: {} + ) + + player = Player.create!( + game_round: game_round, + player_ai: PlayerAi.first, + position_x: 0, + position_y: 0, + score: 0, + dynamite_left: 3, + bomb_left: 2, + walk_bonus_counter: 0, + acquired_positive_items: [nil, 0, 0, 0, 0, 0], + in_water: false, + character_level: 1, + status: :playing, + has_goal_bonus: false, + walk_bonus: false + ) + + GameTurn.create!( + game_round: game_round, + turn_number: 1, + turn_finished: false + ) + + koshien.instance_variable_set(:@game, game) + koshien.instance_variable_set(:@player, player) + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + koshien.instance_variable_set(:@current_turn_data, { + "player_x" => 0, + "player_y" => 0, + "goal_x" => 2, + "goal_y" => 2, + "visible_map" => {"map_data" => game_map_with_uncleared.map_data} + }) + + result = Smalruby3::List.new + koshien.calc_route(result: result, src: "0:0", dst: "2:2") + + expect(result.length).to be > 0 + end + + it "handles unknown cell types with default cost" do + # Create map with unknown cell type + game_map_with_unknown = GameMap.create!( + name: "Map with Unknown", + description: "Test map with unknown cell types", + map_data: [ + [0, 99, 0], + [0, 99, 0], + [0, 0, 0] + ], + map_height: Array.new(3) { Array.new(3) { 0 } }, + goal_position: {"x" => 2, "y" => 2} + ) + + game = Game.create!( + first_player_ai: PlayerAi.first, + second_player_ai: PlayerAi.first, + game_map: game_map_with_unknown, + battle_url: "test_unknown", + status: :in_progress + ) + + game_round = GameRound.create!( + game: game, + round_number: 1, + item_locations: {} + ) + + player = Player.create!( + game_round: game_round, + player_ai: PlayerAi.first, + position_x: 0, + position_y: 0, + score: 0, + dynamite_left: 3, + bomb_left: 2, + walk_bonus_counter: 0, + acquired_positive_items: [nil, 0, 0, 0, 0, 0], + in_water: false, + character_level: 1, + status: :playing, + has_goal_bonus: false, + walk_bonus: false + ) + + GameTurn.create!( + game_round: game_round, + turn_number: 1, + turn_finished: false + ) + + koshien.instance_variable_set(:@game, game) + koshien.instance_variable_set(:@player, player) + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + koshien.instance_variable_set(:@current_turn_data, { + "player_x" => 0, + "player_y" => 0, + "goal_x" => 2, + "goal_y" => 2, + "visible_map" => {"map_data" => game_map_with_unknown.map_data} + }) + + result = Smalruby3::List.new + koshien.calc_route(result: result, src: "0:0", dst: "2:2") + + expect(result.length).to be > 0 + end + end + end + + describe "#map" do + it "returns map data for explored position" do + value = koshien.map("0:0") + expect(value).to eq(0) # Space cell + end + + it "returns -1 for unexplored position when no visible_map" do + koshien.instance_variable_set(:@current_turn_data, nil) + value = koshien.map("0:0") + expect(value).to eq(-1) + end + end + + describe "#map_all" do + context "when visible_map data is available" do + before do + koshien.instance_variable_set(:@current_turn_data, { + "visible_map" => { + "0_0" => 1, + "1_0" => 0, + "2_0" => 4, + "5_7" => 3 + } + }) + end + + it "returns map string representation" do + result = koshien.map_all + expect(result).to be_a(String) + + # Should be 15 rows separated by commas + rows = result.split(",") + expect(rows.length).to eq(15) + expect(rows.first.length).to eq(15) + end + + it "builds map with visible data and unexplored areas" do + result = koshien.map_all + rows = result.split(",") + + # First row should have "104" at positions 0-2, rest should be "-" + expect(rows[0][0]).to eq("1") + expect(rows[0][1]).to eq("0") + expect(rows[0][2]).to eq("4") + expect(rows[0][3]).to eq("-") + + # Row 7, column 5 should be "3" + expect(rows[7][5]).to eq("3") + + # Unexplored cells should be "-" + expect(rows[14][14]).to eq("-") + end + end + + context "when no visible_map data is available" do + it "returns empty map when no turn data" do + koshien.instance_variable_set(:@current_turn_data, nil) + result = koshien.map_all + expect(result).to be_a(String) + end + + it "returns empty map when visible_map is missing" do + koshien.instance_variable_set(:@current_turn_data, {}) + result = koshien.map_all + expect(result).to be_a(String) + end + end + end + + describe "#map" do + context "when visible_map data is available" do + before do + koshien.instance_variable_set(:@current_turn_data, { + "visible_map" => { + "5_7" => 1, + "10_12" => 0, + "3_4" => 4 + } + }) + end + + it "returns map value for valid position" do + result = koshien.map("5:7") + expect(result).to eq(1) + end + + it "returns 0 for empty cell" do + result = koshien.map("10:12") + expect(result).to eq(0) + end + + it "returns -1 for unexplored cell" do + result = koshien.map("99:99") + expect(result).to eq(-1) + end + end + + context "when visible_map data is not available" do + before do + koshien.instance_variable_set(:@current_turn_data, nil) + end + + it "returns -1" do + result = koshien.map("5:7") + expect(result).to eq(-1) + end + end + end + + describe "#map_from" do + it "returns map data from map_all string" do + map_string = koshien.map_all + value = koshien.map_from("0:0", map_string) + expect(value).to eq(0) + end + end + + describe "#get_map_area" do + context "with valid position string" do + before do + # Mock request_map_area to return test data + allow(koshien).to receive(:request_map_area).and_return({ + "visible_map" => {"5_7" => 1, "6_7" => 0}, + "other_players" => [{"x" => 10, "y" => 12}], + "enemies" => [{"x" => 3, "y" => 4}] + }) + end + + it "returns map area data for valid position" do + result = koshien.get_map_area("5:7") + expect(result).to be_a(Hash) + expect(result["visible_map"]).to eq({"5_7" => 1, "6_7" => 0}) + end + + it "stores response in @last_map_area_response" do + result = koshien.get_map_area("5:7") + expect(koshien.instance_variable_get(:@last_map_area_response)).to eq(result) + end + + it "adds exploration action to queue" do + expect(koshien).to receive(:add_action).with( + {action_type: "explore", target_position: {x: 5, y: 7}, area_size: 5} + ) + koshien.get_map_area("5:7") + end + + it "calls request_map_area with parsed coordinates" do + expect(koshien).to receive(:request_map_area).with(5, 7) + koshien.get_map_area("5:7") + end + end + + context "with invalid position format" do + it "returns nil for non-string position" do + result = koshien.get_map_area(123) + expect(result).to be_nil + end + + it "returns nil for string without colon" do + result = koshien.get_map_area("invalid") + expect(result).to be_nil + end + end + end + + describe "#move_to" do + context "with valid position string" do + it "adds move action to queue" do + expect(koshien).to receive(:add_action).with( + {action_type: "move", target_x: 5, target_y: 7} + ) + koshien.move_to("5:7") + end + + it "tracks movement action updating @current_position" do + koshien.move_to("10:12") + expect(koshien.instance_variable_get(:@current_position)).to eq({x: 10, y: 12}) + end + + it "parses coordinates correctly" do + expect(koshien).to receive(:add_action).with( + {action_type: "move", target_x: 0, target_y: 0} + ) + koshien.move_to("0:0") + end + end + + context "with invalid position format" do + it "does not add action for non-string position" do + expect(koshien).not_to receive(:add_action) + koshien.move_to(123) + end + + it "does not add action for string without colon" do + expect(koshien).not_to receive(:add_action) + koshien.move_to("invalid") + end + + it "does not track movement for invalid position" do + initial_position = koshien.instance_variable_get(:@current_position) + koshien.move_to("invalid") + expect(koshien.instance_variable_get(:@current_position)).to eq(initial_position) + end + end + end + + describe "#set_dynamite" do + context "with explicit position" do + it "adds set_dynamite action to queue" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_dynamite", target_x: 5, target_y: 7} + ) + koshien.set_dynamite("5:7") + end + + it "parses coordinates correctly" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_dynamite", target_x: 0, target_y: 0} + ) + koshien.set_dynamite("0:0") + end + end + + context "with nil position (uses player position)" do + before do + allow(koshien).to receive(:player).and_return("3:4") + end + + it "uses player position when position is nil" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_dynamite", target_x: 3, target_y: 4} + ) + koshien.set_dynamite(nil) + end + + it "uses player position when no argument provided" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_dynamite", target_x: 3, target_y: 4} + ) + koshien.set_dynamite + end + end + + context "with invalid position format" do + it "does not add action for non-string position" do + expect(koshien).not_to receive(:add_action) + koshien.set_dynamite(123) + end + + it "does not add action for string without colon" do + expect(koshien).not_to receive(:add_action) + koshien.set_dynamite("invalid") + end + end + end + + describe "#set_bomb" do + context "with explicit position" do + it "adds set_bomb action to queue" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_bomb", target_x: 5, target_y: 7} + ) + koshien.set_bomb("5:7") + end + + it "parses coordinates correctly" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_bomb", target_x: 0, target_y: 0} + ) + koshien.set_bomb("0:0") + end + end + + context "with nil position (uses player position)" do + before do + allow(koshien).to receive(:player).and_return("3:4") + end + + it "uses player position when position is nil" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_bomb", target_x: 3, target_y: 4} + ) + koshien.set_bomb(nil) + end + + it "uses player position when no argument provided" do + expect(koshien).to receive(:add_action).with( + {action_type: "set_bomb", target_x: 3, target_y: 4} + ) + koshien.set_bomb + end + end + + context "with invalid position format" do + it "does not add action for non-string position" do + expect(koshien).not_to receive(:add_action) + koshien.set_bomb(123) + end + + it "does not add action for string without colon" do + expect(koshien).not_to receive(:add_action) + koshien.set_bomb("invalid") + end + end + end + + describe "#turn_over" do + context "normal turn completion" do + before do + # Initialize instance variables + koshien.instance_variable_set(:@message_queue, []) + koshien.instance_variable_set(:@actions, []) + + # Mock read_message to return turn_end_confirm then turn_start + allow(koshien).to receive(:read_message).and_return( + {"type" => "turn_end_confirm", "data" => {}}, + {"type" => "turn_start", "data" => {"turn_number" => 2}} + ) + allow(koshien).to receive(:send_message) + allow(koshien).to receive(:handle_turn_end_confirm) + allow(koshien).to receive(:update_turn_data) + end + + it "sends turn_over message" do + expect(koshien).to receive(:send_message).with( + hash_including( + type: "turn_over", + data: hash_including(:actions) + ) + ) + koshien.turn_over + end + + it "clears actions after sending" do + koshien.instance_variable_set(:@actions, [{action_type: "move", target_x: 5, target_y: 7}]) + expect(koshien.instance_variable_get(:@actions).length).to eq(1) + + koshien.turn_over + + expect(koshien.instance_variable_get(:@actions).length).to eq(0) + end + + it "waits for turn_end_confirm" do + expect(koshien).to receive(:handle_turn_end_confirm).with({}) + koshien.turn_over + end + + it "updates turn data when receiving turn_start" do + expect(koshien).to receive(:update_turn_data).with({"turn_number" => 2}) + koshien.turn_over + end + + it "returns true on successful turn completion" do + result = koshien.turn_over + expect(result).to be true + end + end + + context "when no messages received" do + before do + # Initialize instance variables + koshien.instance_variable_set(:@message_queue, []) + koshien.instance_variable_set(:@actions, []) + + # Mock read_message to return turn_end_confirm then nil messages + allow(koshien).to receive(:read_message).and_return( + {"type" => "turn_end_confirm", "data" => {}}, + nil, nil, nil + ) + allow(koshien).to receive(:send_message) + allow(koshien).to receive(:handle_turn_end_confirm) + allow(koshien).to receive(:sleep) + end + + it "breaks after 3 nil messages" do + expect(koshien).to receive(:read_message).exactly(4).times + koshien.turn_over + end + end + end + + describe "#position" do + it "converts x and y coordinates to position string" do + result = koshien.position(5, 7) + expect(result).to eq("5:7") + end + + it "handles zero coordinates" do + result = koshien.position(0, 0) + expect(result).to eq("0:0") + end + + it "handles negative coordinates" do + result = koshien.position(-3, -5) + expect(result).to eq("-3:-5") + end + + it "handles mixed positive and negative coordinates" do + result = koshien.position(-2, 8) + expect(result).to eq("-2:8") + end + end + + describe "#position_of_x" do + it "extracts x coordinate from position string" do + result = koshien.position_of_x("5:7") + expect(result).to eq(5) + end + + it "handles zero x coordinate" do + result = koshien.position_of_x("0:10") + expect(result).to eq(0) + end + + it "handles negative x coordinate" do + result = koshien.position_of_x("-3:7") + expect(result).to eq(-3) + end + end + + describe "#position_of_y" do + it "extracts y coordinate from position string" do + result = koshien.position_of_y("5:7") + expect(result).to eq(7) + end + + it "handles zero y coordinate" do + result = koshien.position_of_y("10:0") + expect(result).to eq(0) + end + + it "handles negative y coordinate" do + result = koshien.position_of_y("5:-8") + expect(result).to eq(-8) + end + end + + describe "#goal" do + it "returns goal position as string" do + result = koshien.goal + expect(result).to be_a(String) + expect(result).to match(/\d+:\d+/) + end + + it "returns goal coordinates in x:y format" do + result = koshien.goal + expect(result).to eq("14:14") + end + end + + describe "#goal_x" do + it "returns x coordinate of goal" do + result = koshien.goal_x + expect(result).to eq(14) + end + end + + describe "#goal_y" do + it "returns y coordinate of goal" do + result = koshien.goal_y + expect(result).to eq(14) + end + end + + describe "#player" do + it "returns player position as string" do + result = koshien.player + expect(result).to be_a(String) + expect(result).to match(/\d+:\d+/) + end + + it "returns player coordinates in x:y format" do + result = koshien.player + expect(result).to eq("0:0") + end + end + + describe "#player_x" do + it "returns x coordinate of player" do + result = koshien.player_x + expect(result).to eq(0) + end + end + + describe "#player_y" do + it "returns y coordinate of player" do + result = koshien.player_y + expect(result).to eq(0) + end + end + + describe "#other_player" do + context "when other player data is available" do + before do + koshien.instance_variable_set(:@last_map_area_response, {other_player: [5, 7]}) + end + + it "returns other player position as string" do + result = koshien.other_player + expect(result).to eq("5:7") + end + end + + context "when other player data is not available" do + before do + koshien.instance_variable_set(:@last_map_area_response, nil) + end + + it "returns nil" do + result = koshien.other_player + expect(result).to be_nil + end + end + end + + describe "#other_player_x" do + context "when other player data is available" do + before do + koshien.instance_variable_set(:@last_map_area_response, {other_player: [8, 3]}) + end + + it "returns x coordinate of other player" do + result = koshien.other_player_x + expect(result).to eq(8) + end + end + + context "when other player data is not available" do + before do + koshien.instance_variable_set(:@last_map_area_response, nil) + end + + it "returns nil" do + result = koshien.other_player_x + expect(result).to be_nil + end + end + end + + describe "#other_player_y" do + context "when other player data is available" do + before do + koshien.instance_variable_set(:@last_map_area_response, {other_player: [8, 12]}) + end + + it "returns y coordinate of other player" do + result = koshien.other_player_y + expect(result).to eq(12) + end + end + + context "when other player data is not available" do + before do + koshien.instance_variable_set(:@last_map_area_response, nil) + end + + it "returns nil" do + result = koshien.other_player_y + expect(result).to be_nil + end + end + end + + describe "#enemy" do + context "when enemy data is available" do + before do + koshien.instance_variable_set(:@current_turn_data, {"enemies" => [{"x" => 10, "y" => 15}]}) + end + + it "returns enemy position as string" do + result = koshien.enemy + expect(result).to eq("10:15") + end + end + + context "when enemy data is not available" do + before do + koshien.instance_variable_set(:@current_turn_data, nil) + end + + it "returns nil" do + result = koshien.enemy + expect(result).to be_nil + end + end + + context "when enemies array is empty" do + before do + koshien.instance_variable_set(:@current_turn_data, {"enemies" => []}) + end + + it "returns nil" do + result = koshien.enemy + expect(result).to be_nil + end + end + end + + describe "#enemy_x" do + context "when enemy data is available" do + before do + koshien.instance_variable_set(:@current_turn_data, {"enemies" => [{"x" => 6, "y" => 9}]}) + end + + it "returns x coordinate of enemy" do + result = koshien.enemy_x + expect(result).to eq(6) + end + end + + context "when enemy data is not available" do + before do + koshien.instance_variable_set(:@current_turn_data, nil) + end + + it "returns nil" do + result = koshien.enemy_x + expect(result).to be_nil + end + end + end + + describe "#enemy_y" do + context "when enemy data is available" do + before do + koshien.instance_variable_set(:@current_turn_data, {"enemies" => [{"x" => 6, "y" => 13}]}) + end + + it "returns y coordinate of enemy" do + result = koshien.enemy_y + expect(result).to eq(13) + end + end + + context "when enemy data is not available" do + before do + koshien.instance_variable_set(:@current_turn_data, nil) + end + + it "returns nil" do + result = koshien.enemy_y + expect(result).to be_nil + end + end + end + + describe "#set_message" do + it "sends debug message with string" do + expect(koshien).to receive(:send_debug_message).with("Hello World") + koshien.set_message("Hello World") + end + + it "converts non-string values to string" do + expect(koshien).to receive(:send_debug_message).with("42") + koshien.set_message(42) + end + + it "converts nil to string" do + expect(koshien).to receive(:send_debug_message).with("") + koshien.set_message(nil) + end + end + + describe "#add_action" do + before do + koshien.instance_variable_set(:@actions, []) + end + + it "adds action to actions array" do + action = {type: "move", position: "5:5"} + koshien.send(:add_action, action) + + actions = koshien.send(:get_actions) + expect(actions.length).to eq(1) + expect(actions[0]).to eq(action) + end + + it "appends multiple actions in order" do + action1 = {type: "move", position: "1:1"} + action2 = {type: "get_map_area", position: "2:2"} + + koshien.send(:add_action, action1) + koshien.send(:add_action, action2) + + actions = koshien.send(:get_actions) + expect(actions.length).to eq(2) + expect(actions[0]).to eq(action1) + expect(actions[1]).to eq(action2) + end + end + + describe "#clear_actions" do + before do + koshien.instance_variable_set(:@actions, [{type: "move"}, {type: "attack"}]) + end + + it "clears all actions from array" do + koshien.send(:clear_actions) + + actions = koshien.send(:get_actions) + expect(actions.length).to eq(0) + end + end + + describe "#get_actions" do + it "returns copy of actions array" do + original_actions = [{type: "move", position: "3:3"}] + koshien.instance_variable_set(:@actions, original_actions) + + actions = koshien.send(:get_actions) + + expect(actions).to eq(original_actions) + expect(actions.object_id).not_to eq(original_actions.object_id) + end + + it "returns empty array when no actions" do + koshien.instance_variable_set(:@actions, []) + + actions = koshien.send(:get_actions) + + expect(actions).to eq([]) + end + end + + describe "#current_player_position (private)" do + context "when @current_position is set" do + it "returns @current_position" do + position = {x: 5, y: 7} + koshien.instance_variable_set(:@current_position, position) + + result = koshien.send(:current_player_position) + + expect(result).to eq(position) + end + end + + context "when @current_position is nil but turn data has position" do + it "returns position from turn data" do + koshien.instance_variable_set(:@current_position, nil) + koshien.instance_variable_set(:@current_turn_data, { + "current_player" => { + "position" => {x: 3, y: 4} + } + }) + + result = koshien.send(:current_player_position) + + expect(result).to eq({x: 3, y: 4}) + end + end + + context "when turn data has x and y coordinates" do + it "returns position built from x and y" do + koshien.instance_variable_set(:@current_position, nil) + koshien.instance_variable_set(:@current_turn_data, { + "current_player" => { + "x" => 8, + "y" => 9 + } + }) + + result = koshien.send(:current_player_position) + + expect(result).to eq({x: 8, y: 9}) + end + end + + context "when no position data is available" do + it "returns nil" do + koshien.instance_variable_set(:@current_position, nil) + koshien.instance_variable_set(:@current_turn_data, nil) + + result = koshien.send(:current_player_position) + + expect(result).to be_nil + end + end + end + + describe "#other_players (private)" do + it "returns other players from turn data" do + koshien.instance_variable_set(:@current_turn_data, { + "other_players" => [{x: 5, y: 6}, {x: 7, y: 8}] + }) + + result = koshien.send(:other_players) + + expect(result).to eq([{x: 5, y: 6}, {x: 7, y: 8}]) + end + + it "returns empty array when no turn data" do + koshien.instance_variable_set(:@current_turn_data, nil) + + result = koshien.send(:other_players) + + expect(result).to eq([]) + end + end + + describe "#enemies (private)" do + it "returns enemies from turn data" do + koshien.instance_variable_set(:@current_turn_data, { + "enemies" => [{"x" => 10, "y" => 11}] + }) + + result = koshien.send(:enemies) + + expect(result).to eq([{"x" => 10, "y" => 11}]) + end + + it "returns empty array when no turn data" do + koshien.instance_variable_set(:@current_turn_data, nil) + + result = koshien.send(:enemies) + + expect(result).to eq([]) + end + end + + describe "#visible_map (private)" do + it "returns visible map from turn data" do + map_data = {"map_data" => [[0, 1], [2, 3]]} + koshien.instance_variable_set(:@current_turn_data, { + "visible_map" => map_data + }) + + result = koshien.send(:visible_map) + + expect(result).to eq(map_data) + end + + it "returns empty hash when no turn data" do + koshien.instance_variable_set(:@current_turn_data, nil) + + result = koshien.send(:visible_map) + + expect(result).to eq({}) + end + end + + describe "#goal_position (private)" do + it "returns goal position from game state" do + koshien.instance_variable_set(:@game_state, { + "game_map" => { + "goal_position" => {x: 12, y: 13} + } + }) + + result = koshien.send(:goal_position) + + expect(result).to eq({x: 12, y: 13}) + end + + it "returns default position when no game state" do + koshien.instance_variable_set(:@game_state, nil) + + result = koshien.send(:goal_position) + + expect(result).to eq({x: 14, y: 14}) + end + end + + describe "#locate_objects" do + let(:result_list) { Smalruby3::List.new } + + context "when visible map contains matching objects" do + before do + map_data = Array.new(20) { Array.new(20, 0) } + map_data[2][3] = "A" # Poison at (3, 2) + map_data[5][7] = "B" # Snake at (7, 5) + map_data[8][9] = "C" # Trap at (9, 8) + map_data[10][11] = "a" # Tea at (11, 10) - lowercase, not in default "ABCD" + + koshien.instance_variable_set(:@current_turn_data, { + "visible_map" => { + "map_data" => map_data + } + }) + end + + it "finds objects in default area centered at player position" do + koshien.instance_variable_set(:@current_position, {x: 5, y: 5}) + koshien.locate_objects(result: result_list) + + expect(result_list.length).to be >= 0 + expect(result_list).to be_a(Smalruby3::List) + end + + it "finds objects with custom search area size" do + koshien.instance_variable_set(:@current_position, {x: 5, y: 5}) + koshien.locate_objects(result: result_list, sq_size: 10) + + expect(result_list).to be_a(Smalruby3::List) + end + + it "finds objects with custom center position" do + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + koshien.locate_objects(result: result_list, cent: "7:7", sq_size: 5) + + expect(result_list).to be_a(Smalruby3::List) + end + + it "finds only specified object types" do + koshien.instance_variable_set(:@current_position, {x: 5, y: 5}) + koshien.locate_objects(result: result_list, sq_size: 10, objects: "AB") + + # Should find A at (3,2) and B at (7,5), but not C or a + expect(result_list.length).to eq(2) + expect(result_list[1]).to eq("3:2") + expect(result_list[2]).to eq("7:5") + end + + it "sorts results by y coordinate first, then x coordinate" do + koshien.instance_variable_set(:@current_position, {x: 5, y: 5}) + koshien.locate_objects(result: result_list, sq_size: 10, objects: "ABC") + + # Should be sorted by y first: (3,2), (7,5), (9,8) + expect(result_list[1]).to eq("3:2") + expect(result_list[2]).to eq("7:5") + expect(result_list[3]).to eq("9:8") + end + end + + context "when visible map is not available" do + before do + koshien.instance_variable_set(:@current_turn_data, nil) + koshien.instance_variable_set(:@current_position, {x: 0, y: 0}) + end + + it "returns empty result list" do + koshien.locate_objects(result: result_list) + + expect(result_list.length).to eq(0) + end + end + + context "when visible map has no matching objects" do + before do + map_data = Array.new(20) { Array.new(20, 0) } + + koshien.instance_variable_set(:@current_turn_data, { + "visible_map" => { + "map_data" => map_data + } + }) + koshien.instance_variable_set(:@current_position, {x: 10, y: 10}) + end + + it "returns empty result list" do + koshien.locate_objects(result: result_list, sq_size: 5, objects: "ABCD") + + expect(result_list.length).to eq(0) + end + end + end + + describe "#object" do + context "with unknown/unexplored cell names" do + it "returns -1 for 'unknown'" do + expect(koshien.object("unknown")).to eq(-1) + end + + it "returns -1 for '未探索のマス'" do + expect(koshien.object("未探索のマス")).to eq(-1) + end + + it "returns -1 for 'みたんさくのマス'" do + expect(koshien.object("みたんさくのマス")).to eq(-1) + end + + it "returns -1 for unrecognized names" do + expect(koshien.object("invalid_object")).to eq(-1) + end + end + + context "with basic cell types (numeric values)" do + it "returns 0 for 'space'" do + expect(koshien.object("space")).to eq(0) + end + + it "returns 0 for '空間'" do + expect(koshien.object("空間")).to eq(0) + end + + it "returns 1 for 'wall'" do + expect(koshien.object("wall")).to eq(1) + end + + it "returns 2 for 'storehouse'" do + expect(koshien.object("storehouse")).to eq(2) + end + + it "returns 3 for 'goal'" do + expect(koshien.object("goal")).to eq(3) + end + + it "returns 4 for 'water'" do + expect(koshien.object("water")).to eq(4) + end + + it "returns 5 for 'breakable wall'" do + expect(koshien.object("breakable wall")).to eq(5) + end + end + + context "with item types (lowercase letters)" do + it "returns 'a' for 'tea'" do + expect(koshien.object("tea")).to eq("a") + end + + it "returns 'b' for 'sweets'" do + expect(koshien.object("sweets")).to eq("b") + end + + it "returns 'c' for 'COIN'" do + expect(koshien.object("COIN")).to eq("c") + end + + it "returns 'd' for 'dolphin'" do + expect(koshien.object("dolphin")).to eq("d") + end + + it "returns 'e' for 'sword'" do + expect(koshien.object("sword")).to eq("e") + end + end + + context "with trap types (uppercase letters)" do + it "returns 'A' for 'poison'" do + expect(koshien.object("poison")).to eq("A") + end + + it "returns 'B' for 'snake'" do + expect(koshien.object("snake")).to eq("B") + end + + it "returns 'C' for 'trap'" do + expect(koshien.object("trap")).to eq("C") + end + + it "returns 'D' for 'bomb'" do + expect(koshien.object("bomb")).to eq("D") + end + end + + context "with Japanese names" do + it "returns 1 for '壁'" do + expect(koshien.object("壁")).to eq(1) + end + + it "returns 3 for 'ゴール'" do + expect(koshien.object("ゴール")).to eq(3) + end + + it "returns 'a' for 'お茶'" do + expect(koshien.object("お茶")).to eq("a") + end + + it "returns 'e' for '草薙剣'" do + expect(koshien.object("草薙剣")).to eq("e") + end + + it "returns 'A' for '毒キノコ'" do + expect(koshien.object("毒キノコ")).to eq("A") + end + end + end +end diff --git a/spec/lib/smalruby3/list_spec.rb b/spec/lib/smalruby3/list_spec.rb new file mode 100644 index 0000000..258851b --- /dev/null +++ b/spec/lib/smalruby3/list_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::List do + describe "#initialize" do + it "creates empty list by default" do + list = described_class.new + + expect(list.length).to eq(0) + end + + it "creates list from array" do + list = described_class.new([1, 2, 3]) + + expect(list.length).to eq(3) + expect(list[1]).to eq(1) + expect(list[2]).to eq(2) + expect(list[3]).to eq(3) + end + end + + describe "#push" do + it "adds element to the end of list" do + list = described_class.new([1, 2]) + list.push(3) + + expect(list.length).to eq(3) + expect(list[3]).to eq(3) + end + end + + describe "#delete_at" do + it "deletes element at list index (1-based)" do + list = described_class.new([10, 20, 30]) + list.delete_at(2) + + expect(list.length).to eq(2) + expect(list[1]).to eq(10) + expect(list[2]).to eq(30) + end + + it "supports negative indices" do + list = described_class.new([10, 20, 30]) + list.delete_at(-1) + + expect(list.length).to eq(2) + expect(list[1]).to eq(10) + expect(list[2]).to eq(20) + end + end + + describe "#clear" do + it "removes all elements from list" do + list = described_class.new([1, 2, 3]) + list.clear + + expect(list.length).to eq(0) + end + end + + describe "#[]=" do + it "sets element at list index (1-based)" do + list = described_class.new([10, 20, 30]) + list[2] = 99 + + expect(list[2]).to eq(99) + end + + it "supports negative indices" do + list = described_class.new([10, 20, 30]) + list[-1] = 99 + + expect(list[-1]).to eq(99) + end + end + + describe "#insert" do + it "inserts element at list index (1-based)" do + list = described_class.new([10, 30]) + list.insert(2, 20) + + expect(list.length).to eq(3) + expect(list[1]).to eq(10) + expect(list[2]).to eq(20) + expect(list[3]).to eq(30) + end + end + + describe "#[]" do + it "gets element at list index (1-based)" do + list = described_class.new([10, 20, 30]) + + expect(list[1]).to eq(10) + expect(list[2]).to eq(20) + expect(list[3]).to eq(30) + end + + it "supports negative indices" do + list = described_class.new([10, 20, 30]) + + expect(list[-1]).to eq(30) + expect(list[-2]).to eq(20) + end + end + + describe "#index" do + it "finds list index (1-based) of element" do + list = described_class.new(%w[apple banana cherry]) + + expect(list.index("apple")).to eq(1) + expect(list.index("banana")).to eq(2) + expect(list.index("cherry")).to eq(3) + end + + it "returns nil when element not found" do + list = described_class.new(%w[apple banana cherry]) + + expect(list.index("grape")).to be_nil + end + end + + describe "#length" do + it "returns number of elements" do + list = described_class.new([1, 2, 3, 4, 5]) + + expect(list.length).to eq(5) + end + end + + describe "#include?" do + it "checks if element exists in list" do + list = described_class.new(%w[red green blue]) + + expect(list.include?("red")).to be true + expect(list.include?("green")).to be true + expect(list.include?("yellow")).to be false + end + end + + describe "#replace" do + it "replaces list contents with new array" do + list = described_class.new([1, 2, 3]) + list.replace([10, 20, 30, 40]) + + expect(list.length).to eq(4) + expect(list[1]).to eq(10) + expect(list[4]).to eq(40) + end + end + + describe "#map" do + it "transforms each element with block" do + list = described_class.new([1, 2, 3]) + result = list.map { |x| x * 2 } + + expect(result).to eq([2, 4, 6]) + end + end + + describe "#each" do + it "iterates over each element" do + list = described_class.new([1, 2, 3]) + result = [] + list.each { |x| result << x * 2 } + + expect(result).to eq([2, 4, 6]) + end + end + + describe "#to_s" do + it "converts list to string" do + list = described_class.new([1, 2, 3]) + + expect(list.to_s).to eq("123") + end + + it "joins elements without separator" do + list = described_class.new(%w[hello world]) + + expect(list.to_s).to eq("helloworld") + end + end + + describe "#to_array_index (private)" do + context "with 0 index" do + it "raises ArgumentError" do + list = described_class.new([10, 20, 30]) + + expect { list[0] }.to raise_error(ArgumentError, "リストの何番目には1以上の整数、または-1以下の整数を指定してください") + end + end + + context "with positive index" do + it "converts 1-based to 0-based" do + list = described_class.new([10, 20, 30]) + + # List index 1 -> Array index 0 -> value 10 + expect(list[1]).to eq(10) + end + end + + context "with negative index" do + it "keeps negative index as-is" do + list = described_class.new([10, 20, 30]) + + # List index -1 -> Array index -1 -> value 30 + expect(list[-1]).to eq(30) + end + end + end + + describe "#to_list_index (private)" do + context "with non-negative array index" do + it "converts 0-based to 1-based" do + list = described_class.new(%w[a b c]) + + # Array index 0 returns "a", so list index should be 1 + expect(list.index("a")).to eq(1) + end + end + end +end diff --git a/spec/lib/smalruby3/sprite_spec.rb b/spec/lib/smalruby3/sprite_spec.rb new file mode 100644 index 0000000..4353278 --- /dev/null +++ b/spec/lib/smalruby3/sprite_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::Sprite do + before do + # Reset world state before each test + Smalruby3::World.instance.reset + end + + describe "#initialize" do + it "creates sprite with name" do + sprite = described_class.new("test_sprite") + + expect(sprite.name).to eq("test_sprite") + end + + it "initializes with empty variables and lists" do + sprite = described_class.new("test_sprite") + + expect(sprite.variables).to eq([]) + expect(sprite.lists).to eq([]) + end + + it "sets variables from options" do + sprite = described_class.new("test_sprite", variables: [ + {name: "var1", value: 10}, + {name: "var2", value: 20} + ]) + + expect(sprite.variables).to eq(["@var1", "@var2"]) + expect(sprite.instance_variable_get(:@var1)).to eq(10) + expect(sprite.instance_variable_get(:@var2)).to eq(20) + end + + it "sets lists from options" do + sprite = described_class.new("test_sprite", lists: [ + {name: "list1", value: [1, 2, 3]}, + {name: "list2", value: [4, 5]} + ]) + + expect(sprite.lists).to eq(["@list1", "@list2"]) + expect(sprite.instance_variable_get(:@list1)).to be_a(Smalruby3::List) + expect(sprite.instance_variable_get(:@list2)).to be_a(Smalruby3::List) + end + + it "executes block in instance context" do + result = nil + described_class.new("test_sprite") do + result = name + end + + expect(result).to eq("test_sprite") + end + + it "adds sprite to World" do + sprite = described_class.new("test_sprite") + + expect(Smalruby3::World.instance.sprites).to include(sprite) + end + end + + describe "#stage?" do + it "returns false" do + sprite = described_class.new("test_sprite") + + expect(sprite.stage?).to be false + end + end + + describe "#variables=" do + it "defines variables with default value 0" do + sprite = described_class.new("test_sprite") + sprite.variables = [{name: "counter"}] + + expect(sprite.variables).to eq(["@counter"]) + expect(sprite.instance_variable_get(:@counter)).to eq(0) + end + + it "defines variables with specified values" do + sprite = described_class.new("test_sprite") + sprite.variables = [ + {name: "x", value: 100}, + {name: "y", value: 200} + ] + + expect(sprite.instance_variable_get(:@x)).to eq(100) + expect(sprite.instance_variable_get(:@y)).to eq(200) + end + end + + describe "#lists=" do + it "defines lists with default empty array" do + sprite = described_class.new("test_sprite") + sprite.lists = [{name: "items"}] + + expect(sprite.lists).to eq(["@items"]) + expect(sprite.instance_variable_get(:@items)).to be_a(Smalruby3::List) + expect(sprite.instance_variable_get(:@items).length).to eq(0) + end + + it "defines lists with specified values" do + sprite = described_class.new("test_sprite") + sprite.lists = [{name: "numbers", value: [10, 20, 30]}] + + list = sprite.instance_variable_get(:@numbers) + expect(list).to be_a(Smalruby3::List) + expect(list.length).to eq(3) + end + end + + describe "#list" do + it "retrieves list by name" do + sprite = described_class.new("test_sprite", lists: [ + {name: "my_list", value: [1, 2, 3]} + ]) + + list = sprite.list("@my_list") + + expect(list).to be_a(Smalruby3::List) + expect(list.length).to eq(3) + end + end + + describe "#koshien" do + it "returns Koshien singleton instance" do + sprite = described_class.new("test_sprite") + + expect(sprite.koshien).to eq(Smalruby3::Koshien.instance) + end + end + + describe "#method_missing" do + it "returns IgnoreMethodMissing instance" do + sprite = described_class.new("test_sprite") + result = sprite.some_undefined_method + + expect(result).to be_a(Smalruby3::IgnoreMethodMissing) + end + + it "handles method calls with arguments" do + sprite = described_class.new("test_sprite") + result = sprite.another_method(1, 2, 3) + + expect(result).to be_a(Smalruby3::IgnoreMethodMissing) + end + + it "warns about the missing method" do + sprite = described_class.new("test_sprite") + + expect { sprite.missing_method(1, 2) }.to output(/no method error/).to_stderr + end + end + + describe "#respond_to_missing?" do + it "delegates to super" do + sprite = described_class.new("test_sprite") + + expect(sprite.respond_to?(:some_nonexistent_method, true)).to be false + end + end + + describe "#define_variable (private)" do + it "defines instance variable with @ prefix" do + sprite = described_class.new("test_sprite") + variable_name = sprite.send(:define_variable, "test_var", 42) + + expect(variable_name).to eq("@test_var") + expect(sprite.instance_variable_get(:@test_var)).to eq(42) + end + + it "sets the instance variable value" do + sprite = described_class.new("test_sprite") + sprite.send(:define_variable, "my_value", "hello") + + expect(sprite.instance_variable_get(:@my_value)).to eq("hello") + end + + it "returns the variable name with @ prefix" do + sprite = described_class.new("test_sprite") + result = sprite.send(:define_variable, "foo", 123) + + expect(result).to start_with("@") + expect(result).to eq("@foo") + end + end +end diff --git a/spec/lib/smalruby3/stage_spec.rb b/spec/lib/smalruby3/stage_spec.rb new file mode 100644 index 0000000..a3fdac4 --- /dev/null +++ b/spec/lib/smalruby3/stage_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::Stage do + let(:stage) { described_class.new("test_stage") } + + before do + # Reset world state before each test + Smalruby3::World.instance.reset + end + + describe "#stage?" do + it "returns true" do + expect(stage.stage?).to be true + end + end + + describe "inheritance" do + it "inherits from Sprite" do + expect(described_class.superclass).to eq(Smalruby3::Sprite) + end + end + + describe "#define_variable (private)" do + it "defines a global variable with $ prefix" do + # Use send to call private method + variable_name = stage.send(:define_variable, "test_var", 42) + + expect(variable_name).to eq("$test_var") + expect(eval(variable_name)).to eq(42) # rubocop:disable Security/Eval + end + + it "sets the global variable value" do + stage.send(:define_variable, "my_value", "hello") + + expect($my_value).to eq("hello") # rubocop:disable Style/GlobalVars + end + + it "returns the variable name with $ prefix" do + result = stage.send(:define_variable, "foo", 123) + + expect(result).to start_with("$") + expect(result).to eq("$foo") + end + end +end diff --git a/spec/lib/smalruby3/world_spec.rb b/spec/lib/smalruby3/world_spec.rb new file mode 100644 index 0000000..4e1c345 --- /dev/null +++ b/spec/lib/smalruby3/world_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Smalruby3::World do + let(:world) { described_class.instance } + + # Create mock objects for stage and sprite + let(:mock_stage) do + double("Stage", stage?: true, name: "test_stage") + end + + let(:mock_sprite) do + double("Sprite", stage?: false, name: "test_sprite") + end + + let(:another_sprite) do + double("Sprite", stage?: false, name: "another_sprite") + end + + before do + # Reset world state before each test + world.reset + end + + describe "#initialize" do + it "calls reset on initialization" do + # Singleton already initialized, so we verify reset behavior + expect(world.stage).to be_nil + expect(world.sprites).to eq([]) + end + end + + describe "#reset" do + it "clears all sprites and stage" do + world.add_target(mock_sprite) + world.add_target(mock_stage) + + world.reset + + expect(world.stage).to be_nil + expect(world.sprites).to eq([]) + end + end + + describe "#add_target" do + context "when adding a stage" do + it "sets the stage" do + result = world.add_target(mock_stage) + + expect(world.stage).to eq(mock_stage) + expect(result).to eq(mock_stage) + end + + it "raises ExistStage error when stage already exists" do + world.add_target(mock_stage) + + another_stage = double("Stage", stage?: true, name: "another_stage") + + expect { + world.add_target(another_stage) + }.to raise_error(Smalruby3::ExistStage) + end + end + + context "when adding a sprite" do + it "adds sprite to sprites array" do + result = world.add_target(mock_sprite) + + expect(world.sprites).to include(mock_sprite) + expect(result).to eq(mock_sprite) + end + + it "allows multiple sprites with different names" do + world.add_target(mock_sprite) + world.add_target(another_sprite) + + expect(world.sprites.length).to eq(2) + expect(world.sprites).to include(mock_sprite) + expect(world.sprites).to include(another_sprite) + end + + it "raises ExistSprite error when sprite with same name already exists" do + world.add_target(mock_sprite) + + duplicate_sprite = double("Sprite", stage?: false, name: "test_sprite") + + expect { + world.add_target(duplicate_sprite) + }.to raise_error(Smalruby3::ExistSprite) + end + end + end + + describe "singleton behavior" do + it "returns same instance" do + instance1 = described_class.instance + instance2 = described_class.instance + + expect(instance1).to eq(instance2) + end + end +end