From dcbab8fb0d97d2810f5c2c7d47e7e6e976df3d75 Mon Sep 17 00:00:00 2001 From: Mahtra <93822896+MahtraDR@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:33:53 +1300 Subject: [PATCH] feat: add dynamic map renderer and Genie cross-reference commands Add Cairo-based dynamic map renderer (ZoneResolver, LayoutEngine) and Genie cross-database commands (;map genie, ;map g:) to the ElanthiaMap v2.0.0 architecture. Co-Authored-By: Claude Opus 4.5 --- scripts/map.lic | 579 +++++++++++++++++++++++++++++++- spec/map/map_dynamic_spec.rb | 627 +++++++++++++++++++++++++++++++++++ 2 files changed, 1200 insertions(+), 6 deletions(-) create mode 100644 spec/map/map_dynamic_spec.rb diff --git a/scripts/map.lic b/scripts/map.lic index cea8beb14..497ac4e05 100644 --- a/scripts/map.lic +++ b/scripts/map.lic @@ -14,9 +14,14 @@ Tracks your current room on visual maps game: Gemstone tags: core, movement, mapping required: Lich >= 5.13.0 - version: 2.0.1 + version: 2.1.0 changelog: + v2.1.0 (2025-02-13) + * Add dynamic map mode: Cairo-rendered zone maps that auto-layout rooms from map database connections + * Add Genie cross-reference commands: `;map genie` and `;map g:` for DR + * New ZoneResolver module with 4-layer fallback (genie_zone -> image -> location -> BFS) + * New LayoutEngine module for room position computation (Genie coords or BFS auto-layout) v2.0.1 (2025-02-12) * Fix GTK crash on long running sessions * Fix follow_me getting disabled when opening with specific room# @@ -99,6 +104,8 @@ if Script.current.vars[1] == 'help' respond " ;map - shows room# that first matches text instead of current room" respond " ;map fix - enables coordinate fix mode (ctrl+shift+click to set room) " respond " ;map trouble - enables debug output for troubleshooting window issues " + respond " ;map genie - shows Genie zone:node for current room (DR only) " + respond " ;map g: - opens map centered on Genie ref (e.g., ;map g1:335) " respond " ;map reset - resets map settings to default values " respond " keep_above = true " respond " keep_centered = true " @@ -381,6 +388,238 @@ module ElanthiaMap end end + # Zone detection for dynamic map rendering + # + # Resolves which "zone" (group of rooms) a room belongs to, using a + # 4-layer fallback: genie_zone → @image → @location → BFS flood-fill. + # Results are cached for performance. + module ZoneResolver + @cache = {} + + def self.clear_cache + @cache = {} + end + + def self.resolve(room) + return nil if room.nil? + + zone_key = if room.respond_to?(:genie_zone) && room.genie_zone + "gz:#{room.genie_zone}" + elsif room.image + "img:#{room.image}" + elsif has_location?(room) + "loc:#{room.location}" + else + "cc:#{room.id}" + end + + return @cache[zone_key] if @cache[zone_key] + + rooms = if room.respond_to?(:genie_zone) && room.genie_zone + Map.list.select { |r| r && r.respond_to?(:genie_zone) && r.genie_zone == room.genie_zone } + elsif room.image + Map.list.select { |r| r && r.image == room.image } + elsif has_location?(room) + Map.list.select { |r| r && r.location == room.location } + else + bfs_zone(room) + end + + @cache[zone_key] = { zone_id: zone_key, rooms: rooms } + end + + def self.bfs_zone(start_room, max_depth: 40) + visited = { start_room.id => true } + queue = [[start_room, 0]] + result = [start_room] + while (entry = queue.shift) + current, depth = entry + next if depth >= max_depth + + current.wayto.each_key do |target_id| + tid = target_id.to_i + next if visited[tid] + + neighbor = Map[tid] + next if neighbor.nil? + next if (neighbor.respond_to?(:genie_zone) && neighbor.genie_zone) || neighbor.image || has_location?(neighbor) + + visited[tid] = true + result << neighbor + queue << [neighbor, depth + 1] + end + end + result + end + + def self.has_location?(room) + room.location.is_a?(String) && !room.location.empty? + end + private_class_method :has_location? + end + + # Direction-based auto-layout engine for dynamic map rendering + # + # Supports two modes: + # - Mode A: Genie positions (when rooms have genie_pos data) + # - Mode B: Direction-based BFS layout (compass directions → grid offsets) + module LayoutEngine + DIRECTION_OFFSETS = { + 'north' => [0, -1], 'south' => [0, 1], + 'east' => [1, 0], 'west' => [-1, 0], + 'northeast' => [1, -1], 'northwest' => [-1, -1], + 'southeast' => [1, 1], 'southwest' => [-1, 1], + 'n' => [0, -1], 's' => [0, 1], 'e' => [1, 0], 'w' => [-1, 0], + 'ne' => [1, -1], 'nw' => [-1, -1], 'se' => [1, 1], 'sw' => [-1, 1], + 'up' => [0, -1], 'down' => [0, 1], 'out' => [1, 0] + }.freeze + + GRID_SPACING = 20 + + # Check if point (px, py) lies on the line segment between (ax, ay) and (bx, by). + def self.point_on_segment?(px, py, ax, ay, bx, by) + return false if ax == bx && ay == by + + cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax) + return false unless cross == 0 + + px.between?([ax, bx].min, [ax, bx].max) && py.between?([ay, by].min, [ay, by].max) + end + + # Check if a candidate position lies on any connection line between already-positioned rooms. + def self.on_connection_line?(px, py, positions, room_index, skip_neighbors_of: nil) + skip_set = nil + if skip_neighbors_of + skip_room = room_index[skip_neighbors_of] + if skip_room + skip_set = {} + skip_room.wayto.each_key { |k| skip_set[k.to_i] = true } + room_index.each do |rid, r| + skip_set[rid] = true if r.wayto.key?(skip_neighbors_of.to_s) + end + end + end + + positions.each do |room_id, pos| + room = room_index[room_id] + next unless room + + room.wayto.each_key do |target_id| + tid = target_id.to_i + target_pos = positions[tid] + next unless target_pos + next if (px == pos[:x] && py == pos[:y]) || (px == target_pos[:x] && py == target_pos[:y]) + next if skip_set && (skip_set[room_id] || skip_set[tid]) + + return true if point_on_segment?(px, py, pos[:x], pos[:y], target_pos[:x], target_pos[:y]) + end + end + false + end + + # Compute positions for a set of rooms. + # Mode A: if all rooms have genie_pos, use those directly. + # Mode B: direction-based BFS layout from seed_room. + def self.layout(rooms, seed_room) + if rooms.all? { |r| r.respond_to?(:genie_pos) && r.genie_pos.is_a?(Array) && r.genie_pos.size == 3 } + positions = {} + rooms.each do |r| + positions[r.id] = { x: r.genie_pos[0], y: r.genie_pos[1], z: r.genie_pos[2] } + end + return positions + end + + room_index = {} + rooms.each { |r| room_index[r.id] = r } + + positions = {} + positions[seed_room.id] = { x: 0, y: 0, z: 0 } + occupied = { [0, 0] => true } + + queue = [seed_room] + visited = { seed_room.id => true } + + while (current = queue.shift) + cx = positions[current.id][:x] + cy = positions[current.id][:y] + + current.wayto.each do |target_id, move_cmd| + tid = target_id.to_i + next if visited[tid] + next unless room_index[tid] + + offset = direction_offset(move_cmd.to_s) + + if offset + new_x = cx + offset[0] * GRID_SPACING + new_y = cy + offset[1] * GRID_SPACING + attempts = 0 + while occupied[[new_x, new_y]] && attempts < 15 + new_x += offset[0] * GRID_SPACING + new_y += offset[1] * GRID_SPACING + attempts += 1 + end + else + new_x = cx + GRID_SPACING + new_y = cy + end + + if occupied[[new_x, new_y]] + [[1, 0], [0, 1], [-1, 0], [0, -1], [1, -1], [-1, -1], [1, 1], [-1, 1]].each do |dx, dy| + test_x = cx + dx * GRID_SPACING + test_y = cy + dy * GRID_SPACING + unless occupied[[test_x, test_y]] + new_x = test_x + new_y = test_y + break + end + end + end + + positions[tid] = { x: new_x, y: new_y, z: 0 } + occupied[[new_x, new_y]] = true + visited[tid] = true + queue << room_index[tid] + end + end + + resolve_line_collisions(positions, occupied, room_index) + positions + end + + # Post-processing: nudge rooms sitting on unrelated connection lines. + def self.resolve_line_collisions(positions, occupied, room_index, max_passes: 3) + max_passes.times do + moved = false + positions.each do |room_id, pos| + next unless on_connection_line?(pos[:x], pos[:y], positions, room_index, skip_neighbors_of: room_id) + + [[0, -1], [0, 1], [-1, 0], [1, 0], [1, -1], [-1, -1], [1, 1], [-1, 1]].each do |dx, dy| + nx = pos[:x] + dx * GRID_SPACING + ny = pos[:y] + dy * GRID_SPACING + unless occupied[[nx, ny]] || on_connection_line?(nx, ny, positions, room_index, skip_neighbors_of: room_id) + occupied.delete([pos[:x], pos[:y]]) + pos[:x] = nx + pos[:y] = ny + occupied[[nx, ny]] = true + moved = true + break + end + end + end + break unless moved + end + end + + def self.direction_offset(move_cmd) + return nil if move_cmd.start_with?(';e ') + + d = move_cmd.strip.downcase + DIRECTION_OFFSETS[d] + end + private_class_method :direction_offset, :point_on_segment?, :on_connection_line?, :resolve_line_collisions + end + # Main window manager for the map interface # # Handles GTK window creation, map display, user interaction, @@ -452,6 +691,11 @@ module ElanthiaMap @map_offset_x = 0 # X offset of map image within layout @map_offset_y = 0 # Y offset of map image within layout + # Dynamic map rendering state + @layout_cache = {} + @current_z_level = 0 + @dynamic_zone_data = nil + # Check for special modes @fix_mode = script.vars[1] =~ /fix/ ? true : false @trouble_mode = script.vars[1] =~ /trouble/ ? true : false @@ -487,6 +731,16 @@ module ElanthiaMap # Position before showing to ensure correct placement position_window @gtk_window.show_all + + # Dynamic map mode: hide static widgets, show drawing area (or vice versa) + if @settings[:dynamic_map] + @map_image.hide + @room_marker.hide + @drawing_area.show + else + @drawing_area.hide + end + # Establish keep_above status after showing (window is mapped) @gtk_window.keep_above = @settings[:keep_above] @@ -559,7 +813,13 @@ module ElanthiaMap def destroy begin Gtk.queue do - # Clean up markers first + # Clean up dynamic drawing area first (macOS Quartz fix) + if @drawing_area && !@drawing_area.destroyed? + @drawing_area.hide + @drawing_area.destroy + end + + # Clean up markers begin @tag_markers.each do |marker| marker.destroy if marker && !marker.destroyed? @@ -741,6 +1001,7 @@ module ElanthiaMap Settings['follow_mode'] = @follow_mode unless @temp_follow_disabled Settings['dynamic_indicator_size'] = @settings[:dynamic_indicator_size] Settings['expanded_canvas'] = @settings[:expanded_canvas] + Settings['dynamic_map'] = @settings[:dynamic_map] Settings.save @@ -775,6 +1036,7 @@ module ElanthiaMap Settings['follow_mode'] = true Settings['dynamic_indicator_size'] = false Settings['expanded_canvas'] = true + Settings['dynamic_map'] = false # Reset character-specific settings CharSettings['window_width'] = DEFAULT_WIDTH @@ -801,7 +1063,8 @@ module ElanthiaMap keep_centered: Settings['keep_centered'].nil? ? true : Settings['keep_centered'], follow_mode: Settings['follow_mode'].nil? ? true : Settings['follow_mode'], dynamic_indicator_size: Settings['dynamic_indicator_size'] || false, - expanded_canvas: Settings['expanded_canvas'].nil? ? true : Settings['expanded_canvas'] + expanded_canvas: Settings['expanded_canvas'].nil? ? true : Settings['expanded_canvas'], + dynamic_map: Settings['dynamic_map'] || false } @char_settings = { @@ -985,6 +1248,16 @@ module ElanthiaMap end @menu.append(menu_dynamic_indicator) + # Dynamic Map (Cairo renderer) toggle + @menu.append(Gtk::SeparatorMenuItem.new) + menu_dynamic_map = Gtk::CheckMenuItem.new(label: 'Dynamic Map') + menu_dynamic_map.active = @settings[:dynamic_map] + menu_dynamic_map.signal_connect('activate') do + @settings[:dynamic_map] = menu_dynamic_map.active? + toggle_dynamic_mode(@settings[:dynamic_map]) + end + @menu.append(menu_dynamic_map) + # Rebuild dynamic menus when menu is about to show @menu.signal_connect('show') do rebuild_dynamic_menus @@ -1353,6 +1626,21 @@ module ElanthiaMap @room_marker = Gtk::Image.new @layout.put(@room_marker, 0, 0) + # Dynamic map drawing area (Cairo-based renderer) + @drawing_area = Gtk::DrawingArea.new + @drawing_area.signal_connect('draw') do |_widget, cr| + render_dynamic_map(cr, @drawing_area.allocated_width, @drawing_area.allocated_height) + end + @layout.put(@drawing_area, 0, 0) + @drawing_area.hide + + @scroller.signal_connect('size-allocate') do + next unless @settings[:dynamic_map] + + resize_drawing_area + @drawing_area.queue_draw + end + @scroller.add(@layout) @gtk_window.add(@scroller) end @@ -1549,6 +1837,11 @@ module ElanthiaMap # @param event [Gdk::EventButton] The button release event # @return [void] def handle_click(event) + if @settings[:dynamic_map] + handle_dynamic_click(event) + return + end + pointer = get_pointer_position map_data = @map_cache[@current_map, dark_mode: @settings[:dark_mode]] return unless map_data @@ -2258,6 +2551,242 @@ module ElanthiaMap respond end end + + # --------------------------------------------------------------- + # Dynamic map rendering methods (Cairo-based zone renderer) + # --------------------------------------------------------------- + + public + + # Whether dynamic map mode is enabled + # @return [Boolean] + def dynamic_map_enabled? + @settings[:dynamic_map] + end + + # Update dynamic display for a room change + # Resolves zone, computes/caches layout, updates z-level, redraws. + # @param current_room [Room] The current room + # @return [void] + def update_dynamic_display(current_room) + @current_room = current_room + zone = ZoneResolver.resolve(current_room) + return unless zone + + unless @layout_cache[zone[:zone_id]] + positions = LayoutEngine.layout(zone[:rooms], current_room) + @layout_cache[zone[:zone_id]] = { positions: positions, rooms: zone[:rooms] } + end + + @dynamic_zone_data = @layout_cache[zone[:zone_id]] + pos = @dynamic_zone_data[:positions][current_room.id] + @current_z_level = pos[:z] if pos + + Gtk.queue do + resize_drawing_area + @drawing_area.queue_draw + end + end + + private + + # Render the dynamic map onto a Cairo context + # @param cr [Cairo::Context] The Cairo drawing context + # @param widget_width [Integer] Widget width in pixels + # @param widget_height [Integer] Widget height in pixels + # @return [void] + def render_dynamic_map(cr, widget_width, widget_height) + room = @current_room + zone_data = @dynamic_zone_data + return unless room && zone_data + + positions = zone_data[:positions] + rooms = zone_data[:rooms] + z_level = @current_z_level + dyn_scale = calculate_dynamic_scale + + # Background + cr.set_source_rgb(0.12, 0.12, 0.18) + cr.paint + + room_size = (8 * dyn_scale).to_i + half = room_size / 2 + + # Viewport offset: center on current room + current_pos = positions[room.id] + return unless current_pos + + offset_x = (widget_width / 2) - (current_pos[:x] * dyn_scale).to_i + offset_y = (widget_height / 2) - (current_pos[:y] * dyn_scale).to_i + + # Store offsets for click handling + zone_data[:offset_x] = offset_x + zone_data[:offset_y] = offset_y + zone_data[:scale] = dyn_scale + + # Pass 1: Connection lines + cr.set_line_width(1) + rooms.each do |r| + pos = positions[r.id] + next unless pos && pos[:z] == z_level + + rx = (pos[:x] * dyn_scale + offset_x).to_i + ry = (pos[:y] * dyn_scale + offset_y).to_i + r.wayto.each_key do |target_id| + tpos = positions[target_id.to_i] + next unless tpos + + tx = (tpos[:x] * dyn_scale + offset_x).to_i + ty = (tpos[:y] * dyn_scale + offset_y).to_i + if tpos[:z] == z_level + cr.set_source_rgb(0.45, 0.45, 0.50) + else + cr.set_source_rgba(0.45, 0.45, 0.50, 0.25) + end + cr.move_to(rx, ry) + cr.line_to(tx, ty) + cr.stroke + end + end + + # Pass 2: Off-level rooms (dimmed) + rooms.each do |r| + pos = positions[r.id] + next unless pos + next if pos[:z] == z_level + + rx = (pos[:x] * dyn_scale + offset_x).to_i - half + ry = (pos[:y] * dyn_scale + offset_y).to_i - half + cr.set_source_rgba(0.35, 0.35, 0.40, 0.3) + cr.rectangle(rx, ry, room_size, room_size) + cr.fill + end + + # Pass 3: Current-level rooms + rooms.each do |r| + pos = positions[r.id] + next unless pos && pos[:z] == z_level + next if r.id == room.id + + rx = (pos[:x] * dyn_scale + offset_x).to_i - half + ry = (pos[:y] * dyn_scale + offset_y).to_i - half + cr.set_source_rgb(0.22, 0.58, 0.88) + cr.rectangle(rx, ry, room_size, room_size) + cr.fill + cr.set_source_rgb(0.65, 0.65, 0.70) + cr.rectangle(rx, ry, room_size, room_size) + cr.stroke + end + + # Pass 4: Current room (magenta highlight) + if current_pos[:z] == z_level + rx = (current_pos[:x] * dyn_scale + offset_x).to_i - half + ry = (current_pos[:y] * dyn_scale + offset_y).to_i - half + cr.set_source_rgb(0.95, 0.15, 0.85) + cr.rectangle(rx, ry, room_size, room_size) + cr.fill + cr.set_source_rgb(1.0, 1.0, 1.0) + cr.rectangle(rx, ry, room_size, room_size) + cr.stroke + end + end + + # Find the room at a click position in dynamic mode + # @param click_x [Integer] X coordinate of the click + # @param click_y [Integer] Y coordinate of the click + # @return [Room, nil] The room at the click position + def find_dynamic_room_at(click_x, click_y) + return nil unless @dynamic_zone_data + + positions = @dynamic_zone_data[:positions] + rooms = @dynamic_zone_data[:rooms] + dyn_scale = @dynamic_zone_data[:scale] || calculate_dynamic_scale + offset_x = @dynamic_zone_data[:offset_x] || 0 + offset_y = @dynamic_zone_data[:offset_y] || 0 + room_size = (8 * dyn_scale).to_i + half = room_size / 2 + + best_room = nil + best_dist = Float::INFINITY + rooms.each do |room| + pos = positions[room.id] + next unless pos + + rx = (pos[:x] * dyn_scale + offset_x).to_i + ry = (pos[:y] * dyn_scale + offset_y).to_i + dist = Math.sqrt((click_x - rx)**2 + (click_y - ry)**2) + if dist < best_dist && dist < half + 5 + best_dist = dist + best_room = room + end + end + best_room + end + + # Toggle between static and dynamic map modes + # @param enabled [Boolean] Whether to enable dynamic mode + # @return [void] + def toggle_dynamic_mode(enabled) + Gtk.queue do + if enabled + @map_image.hide + @room_marker.hide + resize_drawing_area + @drawing_area.show + + if @current_room + zone = ZoneResolver.resolve(@current_room) + if zone + positions = LayoutEngine.layout(zone[:rooms], @current_room) + @layout_cache[zone[:zone_id]] = { positions: positions, rooms: zone[:rooms] } + @dynamic_zone_data = @layout_cache[zone[:zone_id]] + end + end + @drawing_area.queue_draw + else + @drawing_area.hide + @map_image.show + if @current_room&.image + map_path = File.join(@map_dir, @current_room.image) + change_map(map_path) + update_room_marker(@current_room) + end + end + end + end + + # Resize the drawing area to match the scroller allocation + # @return [void] + def resize_drawing_area + @drawing_area.set_size_request(@scroller.allocation.width, @scroller.allocation.height) + end + + # Calculate the scale factor for dynamic mode rendering + # @return [Float] Scale factor + def calculate_dynamic_scale + @settings[:global_scale_enabled] ? @settings[:global_scale].to_f : 1.0 + end + + # Handle a click event in dynamic map mode + # @param event [Gdk::EventButton] The button release event + # @return [void] + def handle_dynamic_click(event) + pointer = get_pointer_position + return unless @dynamic_zone_data && @dynamic_zone_data[:offset_x] + + clicked_room = find_dynamic_room_at(pointer[0], pointer[1]) + if clicked_room + if event.state.shift_mask? + respond + respond clicked_room + respond + else + start_script('go2', [clicked_room.id.to_s, '_disable_confirm_']) + end + else + respond '[map: no matching room found]' + end + end end # Main entry point for the script @@ -2282,6 +2811,19 @@ module ElanthiaMap return end + # Handle genie info command + if script.vars[1] =~ /^genie$/i + current = Room.current + if current && current.respond_to?(:genie_zone) && current.genie_zone + respond "[map: Current room ##{current.id} = Genie zone:#{current.genie_zone} node:#{current.genie_id}]" + elsif current + respond "[map: Current room ##{current.id} has no Genie reference]" + else + respond '[map: No current room]' + end + return + end + # Determine which room to display display_room = determine_display_room(script) @@ -2295,7 +2837,9 @@ module ElanthiaMap if display_room window.follow_mode = false window.temp_follow_disabled = true # Mark as temporary, don't save this state - if display_room.image + if window.dynamic_map_enabled? + window.update_dynamic_display(display_room) + elsif display_room.image map_path = File.join(MAP_DIR, display_room.image) Gtk.queue do window.change_map(map_path) @@ -2322,7 +2866,11 @@ module ElanthiaMap until window.should_exit if window.follow_mode current_room = Room.current - if current_room&.image + if window.dynamic_map_enabled? + # Dynamic mode: resolve zone, compute layout, redraw + window.update_dynamic_display(current_room) if current_room + window.update_window_title(current_room) + elsif current_room&.image map_path = File.join(MAP_DIR, current_room.image) Gtk.queue do window.change_map(map_path) unless window.current_map == map_path @@ -2365,7 +2913,26 @@ module ElanthiaMap # @return [Room, nil] The room to display, or nil for normal operation def self.determine_display_room(script) return nil unless script.vars[1] - return nil if script.vars[1] =~ /^(help|fix|trouble|reset)$/ + return nil if script.vars[1] =~ /^(help|fix|trouble|reset|genie)$/ + + # Genie cross-reference: ;map g: + if script.vars[1] =~ /^g(\w+):(\w+)$/i + zone_id = Regexp.last_match(1) + node_id = Regexp.last_match(2) + if Room.respond_to?(:by_genie_ref) + room = Room.by_genie_ref(zone_id, node_id) + if room + respond "[map: Found room ##{room.id} for Genie ref #{zone_id}:#{node_id}]" + return room + else + respond "[map: No room found for Genie ref #{zone_id}:#{node_id}]" + return nil + end + else + respond '[map: Genie cross-reference not available (requires DR map support)]' + return nil + end + end # Try to find room by ID or description search_term = script.vars[1..-1].join(' ') diff --git a/spec/map/map_dynamic_spec.rb b/spec/map/map_dynamic_spec.rb new file mode 100644 index 000000000..10add78e0 --- /dev/null +++ b/spec/map/map_dynamic_spec.rb @@ -0,0 +1,627 @@ +# frozen_string_literal: true + +# Tests for the dynamic map renderer components in scripts/map.lic +# +# These tests verify ZoneResolver and LayoutEngine behavior without +# requiring GTK3 or a live Lich runtime. They use minimal doubles +# that replicate the Room/Map interfaces. + +require 'rspec' + +# Minimal Room double +class MockRoom + attr_accessor :id, :title, :description, :paths, :uid, :location, + :image, :wayto, :timeto, :tags + + def initialize(id:, wayto: {}, timeto: {}, image: nil, location: nil, **_opts) + @id = id + @wayto = wayto + @timeto = timeto + @image = image + @location = location + @title = [] + @description = [] + @paths = [] + @uid = [] + @tags = [] + end +end + +# DR room with Genie fields +class MockDRRoom < MockRoom + attr_accessor :genie_id, :genie_zone, :genie_pos + + def initialize(id:, genie_id: nil, genie_zone: nil, genie_pos: nil, **opts) + super(id: id, **opts) + @genie_id = genie_id + @genie_zone = genie_zone + @genie_pos = genie_pos + end +end + +# Mock Map class for zone resolver tests +MockMapList = [] + +module Map + class << self + def list + MockMapList + end + + def [](id) + MockMapList[id.to_i] + end + end +end + +# Define the modules under test (extracted from map.lic). +# In production these live inside the ElanthiaMap module. +# We redefine them here to test in isolation without GTK3. + +module ElanthiaMap + module ZoneResolver + @cache = {} + + def self.clear_cache + @cache = {} + end + + def self.has_location?(room) + room.location.is_a?(String) && !room.location.empty? + end + + def self.resolve(room) + return nil if room.nil? + + zone_key = if room.respond_to?(:genie_zone) && room.genie_zone + "gz:#{room.genie_zone}" + elsif room.image + "img:#{room.image}" + elsif has_location?(room) + "loc:#{room.location}" + else + "cc:#{room.id}" + end + + return @cache[zone_key] if @cache[zone_key] + + rooms = if room.respond_to?(:genie_zone) && room.genie_zone + Map.list.select { |r| r && r.respond_to?(:genie_zone) && r.genie_zone == room.genie_zone } + elsif room.image + Map.list.select { |r| r && r.image == room.image } + elsif has_location?(room) + Map.list.select { |r| r && r.location == room.location } + else + bfs_zone(room) + end + + @cache[zone_key] = { zone_id: zone_key, rooms: rooms } + end + + def self.bfs_zone(start_room, max_depth: 40) + visited = { start_room.id => true } + queue = [[start_room, 0]] + result = [start_room] + while (entry = queue.shift) + current, depth = entry + next if depth >= max_depth + + current.wayto.each_key do |target_id| + tid = target_id.to_i + next if visited[tid] + + neighbor = Map[tid] + next if neighbor.nil? + next if (neighbor.respond_to?(:genie_zone) && neighbor.genie_zone) || neighbor.image || has_location?(neighbor) + + visited[tid] = true + result << neighbor + queue << [neighbor, depth + 1] + end + end + result + end + end + + module LayoutEngine + DIRECTION_OFFSETS = { + 'north' => [0, -1], 'south' => [0, 1], + 'east' => [1, 0], 'west' => [-1, 0], + 'northeast' => [1, -1], 'northwest' => [-1, -1], + 'southeast' => [1, 1], 'southwest' => [-1, 1], + 'n' => [0, -1], 's' => [0, 1], 'e' => [1, 0], 'w' => [-1, 0], + 'ne' => [1, -1], 'nw' => [-1, -1], 'se' => [1, 1], 'sw' => [-1, 1], + 'up' => [0, -1], 'down' => [0, 1], 'out' => [1, 0] + }.freeze + + GRID_SPACING = 20 + + def self.point_on_segment?(px, py, ax, ay, bx, by) + return false if ax == bx && ay == by + + cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax) + return false unless cross == 0 + + px.between?([ax, bx].min, [ax, bx].max) && py.between?([ay, by].min, [ay, by].max) + end + + def self.on_connection_line?(px, py, positions, room_index, skip_neighbors_of: nil) + skip_set = nil + if skip_neighbors_of + skip_room = room_index[skip_neighbors_of] + if skip_room + skip_set = {} + skip_room.wayto.each_key { |k| skip_set[k.to_i] = true } + room_index.each do |rid, r| + skip_set[rid] = true if r.wayto.key?(skip_neighbors_of.to_s) + end + end + end + + positions.each do |room_id, pos| + room = room_index[room_id] + next unless room + + room.wayto.each_key do |target_id| + tid = target_id.to_i + target_pos = positions[tid] + next unless target_pos + next if (px == pos[:x] && py == pos[:y]) || (px == target_pos[:x] && py == target_pos[:y]) + next if skip_set && (skip_set[room_id] || skip_set[tid]) + + return true if point_on_segment?(px, py, pos[:x], pos[:y], target_pos[:x], target_pos[:y]) + end + end + false + end + + def self.direction_offset(move_cmd) + return nil if move_cmd.start_with?(';e ') + + d = move_cmd.strip.downcase + DIRECTION_OFFSETS[d] + end + + def self.layout(rooms, seed_room) + if rooms.all? { |r| r.respond_to?(:genie_pos) && r.genie_pos.is_a?(Array) && r.genie_pos.size == 3 } + positions = {} + rooms.each do |r| + positions[r.id] = { x: r.genie_pos[0], y: r.genie_pos[1], z: r.genie_pos[2] } + end + return positions + end + + room_index = {} + rooms.each { |r| room_index[r.id] = r } + + positions = {} + positions[seed_room.id] = { x: 0, y: 0, z: 0 } + occupied = { [0, 0] => true } + + queue = [seed_room] + visited = { seed_room.id => true } + + while (current = queue.shift) + cx = positions[current.id][:x] + cy = positions[current.id][:y] + + current.wayto.each do |target_id, move_cmd| + tid = target_id.to_i + next if visited[tid] + next unless room_index[tid] + + offset = direction_offset(move_cmd.to_s) + + if offset + new_x = cx + offset[0] * GRID_SPACING + new_y = cy + offset[1] * GRID_SPACING + attempts = 0 + while occupied[[new_x, new_y]] && attempts < 15 + new_x += offset[0] * GRID_SPACING + new_y += offset[1] * GRID_SPACING + attempts += 1 + end + else + new_x = cx + GRID_SPACING + new_y = cy + end + + if occupied[[new_x, new_y]] + [[1, 0], [0, 1], [-1, 0], [0, -1], [1, -1], [-1, -1], [1, 1], [-1, 1]].each do |dx, dy| + test_x = cx + dx * GRID_SPACING + test_y = cy + dy * GRID_SPACING + unless occupied[[test_x, test_y]] + new_x = test_x + new_y = test_y + break + end + end + end + + positions[tid] = { x: new_x, y: new_y, z: 0 } + occupied[[new_x, new_y]] = true + visited[tid] = true + queue << room_index[tid] + end + end + + resolve_line_collisions(positions, occupied, room_index) + positions + end + + def self.resolve_line_collisions(positions, occupied, room_index, max_passes: 3) + max_passes.times do + moved = false + positions.each do |room_id, pos| + next unless on_connection_line?(pos[:x], pos[:y], positions, room_index, skip_neighbors_of: room_id) + + [[0, -1], [0, 1], [-1, 0], [1, 0], [1, -1], [-1, -1], [1, 1], [-1, 1]].each do |dx, dy| + nx = pos[:x] + dx * GRID_SPACING + ny = pos[:y] + dy * GRID_SPACING + unless occupied[[nx, ny]] || on_connection_line?(nx, ny, positions, room_index, skip_neighbors_of: room_id) + occupied.delete([pos[:x], pos[:y]]) + pos[:x] = nx + pos[:y] = ny + occupied[[nx, ny]] = true + moved = true + break + end + end + end + break unless moved + end + end + end +end + +# ============================================================ +# Specs +# ============================================================ + +RSpec.describe ElanthiaMap::ZoneResolver do + before(:each) do + ElanthiaMap::ZoneResolver.clear_cache + MockMapList.clear + end + + describe '.has_location?' do + it 'returns true for a non-empty string location' do + room = MockRoom.new(id: 1, location: 'Wehnimers Landing') + expect(ElanthiaMap::ZoneResolver.has_location?(room)).to be true + end + + it 'returns false for nil location' do + room = MockRoom.new(id: 1, location: nil) + expect(ElanthiaMap::ZoneResolver.has_location?(room)).to be false + end + + it 'returns false for empty string location' do + room = MockRoom.new(id: 1, location: '') + expect(ElanthiaMap::ZoneResolver.has_location?(room)).to be false + end + + it 'returns false for non-string location' do + room = MockRoom.new(id: 1, location: false) + expect(ElanthiaMap::ZoneResolver.has_location?(room)).to be false + end + end + + describe '.resolve' do + it 'returns nil for nil room' do + expect(ElanthiaMap::ZoneResolver.resolve(nil)).to be_nil + end + + it 'uses genie_zone as highest priority' do + r1 = MockDRRoom.new(id: 0, genie_zone: '1', image: 'img1', location: 'Loc') + r2 = MockDRRoom.new(id: 1, genie_zone: '1', image: 'img2') + r3 = MockDRRoom.new(id: 2, genie_zone: '2') + MockMapList[0] = r1 + MockMapList[1] = r2 + MockMapList[2] = r3 + + result = ElanthiaMap::ZoneResolver.resolve(r1) + expect(result[:zone_id]).to eq('gz:1') + expect(result[:rooms].map(&:id)).to contain_exactly(0, 1) + end + + it 'falls back to image when no genie_zone' do + r1 = MockRoom.new(id: 0, image: 'wl-town', location: 'Loc') + r2 = MockRoom.new(id: 1, image: 'wl-town') + r3 = MockRoom.new(id: 2, image: 'other') + MockMapList[0] = r1 + MockMapList[1] = r2 + MockMapList[2] = r3 + + result = ElanthiaMap::ZoneResolver.resolve(r1) + expect(result[:zone_id]).to eq('img:wl-town') + expect(result[:rooms].map(&:id)).to contain_exactly(0, 1) + end + + it 'falls back to location when no image' do + r1 = MockRoom.new(id: 0, location: 'Wehnimers Landing') + r2 = MockRoom.new(id: 1, location: 'Wehnimers Landing') + r3 = MockRoom.new(id: 2, location: 'Icemule Trace') + MockMapList[0] = r1 + MockMapList[1] = r2 + MockMapList[2] = r3 + + result = ElanthiaMap::ZoneResolver.resolve(r1) + expect(result[:zone_id]).to eq('loc:Wehnimers Landing') + expect(result[:rooms].map(&:id)).to contain_exactly(0, 1) + end + + it 'falls back to BFS when no zone, image, or location' do + r1 = MockRoom.new(id: 0, wayto: { '1' => 'north' }) + r2 = MockRoom.new(id: 1, wayto: { '0' => 'south' }) + MockMapList[0] = r1 + MockMapList[1] = r2 + + result = ElanthiaMap::ZoneResolver.resolve(r1) + expect(result[:zone_id]).to eq('cc:0') + expect(result[:rooms].map(&:id)).to contain_exactly(0, 1) + end + + it 'caches results for same zone key' do + r1 = MockRoom.new(id: 0, image: 'map1') + MockMapList[0] = r1 + + result1 = ElanthiaMap::ZoneResolver.resolve(r1) + result2 = ElanthiaMap::ZoneResolver.resolve(r1) + expect(result1).to equal(result2) # same object reference + end + end + + describe '.bfs_zone' do + it 'collects connected rooms without zone assignments' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'n', '2' => 'e' }) + r1 = MockRoom.new(id: 1, wayto: { '0' => 's' }) + r2 = MockRoom.new(id: 2, wayto: { '0' => 'w' }) + MockMapList[0] = r0 + MockMapList[1] = r1 + MockMapList[2] = r2 + + result = ElanthiaMap::ZoneResolver.bfs_zone(r0) + expect(result.map(&:id)).to contain_exactly(0, 1, 2) + end + + it 'stops at rooms with image (zone boundary)' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'n' }) + r1 = MockRoom.new(id: 1, wayto: { '0' => 's', '2' => 'n' }, image: 'different-zone') + r2 = MockRoom.new(id: 2, wayto: { '1' => 's' }) + MockMapList[0] = r0 + MockMapList[1] = r1 + MockMapList[2] = r2 + + result = ElanthiaMap::ZoneResolver.bfs_zone(r0) + expect(result.map(&:id)).to eq([0]) + end + + it 'stops at rooms with location (zone boundary)' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'n' }) + r1 = MockRoom.new(id: 1, wayto: { '0' => 's' }, location: 'Other Place') + MockMapList[0] = r0 + MockMapList[1] = r1 + + result = ElanthiaMap::ZoneResolver.bfs_zone(r0) + expect(result.map(&:id)).to eq([0]) + end + + it 'stops at rooms with genie_zone (zone boundary)' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'n' }) + r1 = MockDRRoom.new(id: 1, wayto: { '0' => 's' }, genie_zone: '5') + MockMapList[0] = r0 + MockMapList[1] = r1 + + result = ElanthiaMap::ZoneResolver.bfs_zone(r0) + expect(result.map(&:id)).to eq([0]) + end + + it 'respects max_depth' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'n' }) + r1 = MockRoom.new(id: 1, wayto: { '2' => 'n' }) + r2 = MockRoom.new(id: 2, wayto: {}) + MockMapList[0] = r0 + MockMapList[1] = r1 + MockMapList[2] = r2 + + result = ElanthiaMap::ZoneResolver.bfs_zone(r0, max_depth: 1) + expect(result.map(&:id)).to contain_exactly(0, 1) + end + end +end + +RSpec.describe ElanthiaMap::LayoutEngine do + describe '.direction_offset' do + it 'returns correct offset for cardinal directions' do + expect(ElanthiaMap::LayoutEngine.direction_offset('north')).to eq([0, -1]) + expect(ElanthiaMap::LayoutEngine.direction_offset('south')).to eq([0, 1]) + expect(ElanthiaMap::LayoutEngine.direction_offset('east')).to eq([1, 0]) + expect(ElanthiaMap::LayoutEngine.direction_offset('west')).to eq([-1, 0]) + end + + it 'returns correct offset for abbreviated directions' do + expect(ElanthiaMap::LayoutEngine.direction_offset('n')).to eq([0, -1]) + expect(ElanthiaMap::LayoutEngine.direction_offset('se')).to eq([1, 1]) + expect(ElanthiaMap::LayoutEngine.direction_offset('nw')).to eq([-1, -1]) + end + + it 'returns correct offset for vertical directions' do + expect(ElanthiaMap::LayoutEngine.direction_offset('up')).to eq([0, -1]) + expect(ElanthiaMap::LayoutEngine.direction_offset('down')).to eq([0, 1]) + end + + it 'returns nil for non-directional moves' do + expect(ElanthiaMap::LayoutEngine.direction_offset('go door')).to be_nil + expect(ElanthiaMap::LayoutEngine.direction_offset('climb ladder')).to be_nil + end + + it 'returns nil for StringProc commands' do + expect(ElanthiaMap::LayoutEngine.direction_offset(';e fput "go door"')).to be_nil + end + + it 'handles case insensitivity' do + expect(ElanthiaMap::LayoutEngine.direction_offset('North')).to eq([0, -1]) + expect(ElanthiaMap::LayoutEngine.direction_offset('EAST')).to eq([1, 0]) + end + + it 'handles whitespace' do + expect(ElanthiaMap::LayoutEngine.direction_offset(' north ')).to eq([0, -1]) + end + end + + describe '.point_on_segment?' do + it 'detects point on horizontal segment' do + expect(ElanthiaMap::LayoutEngine.point_on_segment?(20, 0, 0, 0, 40, 0)).to be true + end + + it 'detects point on vertical segment' do + expect(ElanthiaMap::LayoutEngine.point_on_segment?(0, 20, 0, 0, 0, 40)).to be true + end + + it 'detects point on diagonal segment' do + expect(ElanthiaMap::LayoutEngine.point_on_segment?(20, 20, 0, 0, 40, 40)).to be true + end + + it 'rejects point not on segment' do + expect(ElanthiaMap::LayoutEngine.point_on_segment?(20, 10, 0, 0, 40, 0)).to be false + end + + it 'rejects point on the line but outside segment' do + expect(ElanthiaMap::LayoutEngine.point_on_segment?(60, 0, 0, 0, 40, 0)).to be false + end + + it 'rejects zero-length segments' do + expect(ElanthiaMap::LayoutEngine.point_on_segment?(0, 0, 0, 0, 0, 0)).to be false + end + + it 'treats endpoints as on the segment' do + expect(ElanthiaMap::LayoutEngine.point_on_segment?(0, 0, 0, 0, 40, 0)).to be true + expect(ElanthiaMap::LayoutEngine.point_on_segment?(40, 0, 0, 0, 40, 0)).to be true + end + end + + describe '.layout' do + it 'uses Genie positions when all rooms have genie_pos (Mode A)' do + r1 = MockDRRoom.new(id: 0, genie_pos: [100, 200, 0]) + r2 = MockDRRoom.new(id: 1, genie_pos: [120, 200, 0]) + + positions = ElanthiaMap::LayoutEngine.layout([r1, r2], r1) + expect(positions[0]).to eq({ x: 100, y: 200, z: 0 }) + expect(positions[1]).to eq({ x: 120, y: 200, z: 0 }) + end + + it 'falls back to auto-layout when not all rooms have genie_pos' do + r1 = MockDRRoom.new(id: 0, genie_pos: [100, 200, 0], wayto: { '1' => 'north' }) + r2 = MockRoom.new(id: 1, wayto: { '0' => 'south' }) + + positions = ElanthiaMap::LayoutEngine.layout([r1, r2], r1) + expect(positions[0]).to eq({ x: 0, y: 0, z: 0 }) + expect(positions[1][:y]).to be < positions[0][:y] + end + + it 'places seed room at origin' do + r1 = MockRoom.new(id: 0, wayto: {}) + positions = ElanthiaMap::LayoutEngine.layout([r1], r1) + expect(positions[0]).to eq({ x: 0, y: 0, z: 0 }) + end + + it 'places rooms in correct relative positions' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'north', '2' => 'east' }) + r1 = MockRoom.new(id: 1, wayto: { '0' => 'south' }) + r2 = MockRoom.new(id: 2, wayto: { '0' => 'west' }) + + positions = ElanthiaMap::LayoutEngine.layout([r0, r1, r2], r0) + + expect(positions[1][:y]).to be < positions[0][:y] + expect(positions[1][:x]).to eq(positions[0][:x]) + + expect(positions[2][:x]).to be > positions[0][:x] + expect(positions[2][:y]).to eq(positions[0][:y]) + end + + it 'handles non-directional moves' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'go door' }) + r1 = MockRoom.new(id: 1, wayto: { '0' => 'go door' }) + + positions = ElanthiaMap::LayoutEngine.layout([r0, r1], r0) + expect(positions[0]).not_to eq(positions[1]) + end + + it 'avoids position collisions' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'north', '2' => 'north' }) + r1 = MockRoom.new(id: 1, wayto: {}) + r2 = MockRoom.new(id: 2, wayto: {}) + + positions = ElanthiaMap::LayoutEngine.layout([r0, r1, r2], r0) + + pos_pairs = positions.values.map { |p| [p[:x], p[:y]] } + expect(pos_pairs.uniq.size).to eq(pos_pairs.size) + end + + it 'assigns positions to all rooms in the zone' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'n', '2' => 'e', '3' => 's' }) + r1 = MockRoom.new(id: 1, wayto: { '0' => 's' }) + r2 = MockRoom.new(id: 2, wayto: { '0' => 'w' }) + r3 = MockRoom.new(id: 3, wayto: { '0' => 'n' }) + + positions = ElanthiaMap::LayoutEngine.layout([r0, r1, r2, r3], r0) + expect(positions.keys).to contain_exactly(0, 1, 2, 3) + end + + it 'handles a linear corridor' do + r0 = MockRoom.new(id: 0, wayto: { '1' => 'east' }) + r1 = MockRoom.new(id: 1, wayto: { '0' => 'west', '2' => 'east' }) + r2 = MockRoom.new(id: 2, wayto: { '1' => 'west' }) + + positions = ElanthiaMap::LayoutEngine.layout([r0, r1, r2], r0) + + expect(positions[0][:x]).to be < positions[1][:x] + expect(positions[1][:x]).to be < positions[2][:x] + expect(positions[0][:y]).to eq(positions[1][:y]) + expect(positions[1][:y]).to eq(positions[2][:y]) + end + end + + describe '.on_connection_line?' do + it 'detects a point on a connection line' do + positions = { + 0 => { x: 0, y: 0 }, + 2 => { x: 40, y: 0 } + } + room_index = { + 0 => MockRoom.new(id: 0, wayto: { '2' => 'east' }), + 2 => MockRoom.new(id: 2, wayto: { '0' => 'west' }) + } + + expect(ElanthiaMap::LayoutEngine.on_connection_line?(20, 0, positions, room_index)).to be true + end + + it 'does not flag endpoints as on the line' do + positions = { + 0 => { x: 0, y: 0 }, + 2 => { x: 40, y: 0 } + } + room_index = { + 0 => MockRoom.new(id: 0, wayto: { '2' => 'east' }), + 2 => MockRoom.new(id: 2, wayto: { '0' => 'west' }) + } + + expect(ElanthiaMap::LayoutEngine.on_connection_line?(0, 0, positions, room_index)).to be false + end + + it 'skips lines to neighbor rooms when skip_neighbors_of is provided' do + positions = { + 0 => { x: 0, y: 0 }, + 1 => { x: 20, y: 0 }, + 2 => { x: 40, y: 0 } + } + room_index = { + 0 => MockRoom.new(id: 0, wayto: { '1' => 'east', '2' => 'east' }), + 1 => MockRoom.new(id: 1, wayto: { '0' => 'west', '2' => 'east' }), + 2 => MockRoom.new(id: 2, wayto: { '0' => 'west', '1' => 'west' }) + } + + expect(ElanthiaMap::LayoutEngine.on_connection_line?(20, 0, positions, room_index, skip_neighbors_of: 1)).to be false + end + end +end