Module: Lich::Common::MapBase::ClassMethods

Defined in:
documented/common/map/map_base.rb

Overview

Class methods shared across all Map implementations

Constant Summary collapse

TAG_INDEX_BUILD_ATTEMPTS =

Tag name => Array of room ids. Private: callers must go through #rooms_by_tag or #tag_names so the memo cannot be mutated in place.

Rebuild attempts before giving up on caching the result.

3
TAG_INDEX_MUTEX =

Serialises publishing the memo against invalidating it. Only ever held across a couple of assignments, never across a build or a load, so it cannot invert the ordering against the load mutex.

Mutex.new

Instance Method Summary collapse

Instance Method Details

#[](val) ⇒ Object?

Look up a room by id, uid string, or fuzzy title/description text

Parameters:

  • val (Integer, String)

    room id, "u", or search text

Returns:

  • (Object, nil)

    the matching room



499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
# File 'documented/common/map/map_base.rb', line 499

def [](val)
  # One load attempt via the accessor, then work off that array; calling
  # #list again would retry the load on every lookup when it failed.
  rooms = list
  if val.is_a?(Integer) || val =~ /^[0-9]+$/
    rooms[val.to_i]
  elsif val =~ /^u(-?\d+)$/i
    uid_request = ::Regexp.last_match(1).dup.to_i
    # nil.to_i is 0, so an unknown uid used to resolve to room 0.
    id = ids_from_uid(uid_request)[0]
    id.nil? ? nil : rooms[id.to_i]
  else
    chkre = /#{val.strip.sub(/\.$/, '').gsub(/\.(?:\.\.)?/, '|')}/i
    chk = /#{Regexp.escape(val.strip)}/i
    # Title and exact-description matches share one pass; the loose
    # regex pass only runs when neither found anything. Same precedence
    # as the three sequential scans this replaces.
    live = rooms.compact
    by_title = nil
    by_desc = nil
    live.each do |room|
      if room.title.find { |title| title =~ chk }
        by_title = room
        break
      end
      by_desc = room if by_desc.nil? && room.description.find { |desc| desc =~ chk }
    end
    by_title || by_desc ||
      live.find { |room| room.description.find { |desc| desc =~ chkre } }
  end
end

#apply_wayto_overridesvoid

This method returns an undefined value.

Applies personal map wayto overrides and custom targets from YAML settings. Reads base_wayto_overrides, personal_wayto_overrides, and personal_map_targets from the user's profile via get_settings. Ensures the map is loaded before accessing room data, consistent with other ClassMethods.



691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
# File 'documented/common/map/map_base.rb', line 691

def apply_wayto_overrides
  self.load unless loaded?
  settings = get_settings
  base_overrides = settings.base_wayto_overrides || {}
  personal_overrides = settings.personal_wayto_overrides || {}
  wayto_overrides = base_overrides.merge(personal_overrides)

  wayto_overrides.each do |_key, values|
    next unless values.is_a?(Hash) && values['start_room'] && values['end_room']

    start_room_id = values['start_room'].to_i
    end_room_id = values['end_room'].to_s
    start_room = list[start_room_id]
    next unless start_room

    if values['str_proc']
      start_room.wayto[end_room_id] = StringProc.new(values['str_proc'].to_s)
    end
    if values['travel_time']
      new_timeto = Float(values['travel_time'], exception: false)
      new_timeto ||= StringProc.new(values['travel_time'].to_s)
      start_room.timeto[end_room_id] = new_timeto
    end
  end

  personal_map_targets = settings.personal_map_targets
  if personal_map_targets.is_a?(Hash)
    custom_targets = (GameSettings['custom targets'] || {})
    custom_targets.merge!(personal_map_targets)
    GameSettings['custom targets'] = custom_targets
  end
end

#dijkstra(source, destination = nil) ⇒ Array<Hash>? Also known as: dijkstra_hashes

Class-level dijkstra dispatcher

Parameters:

  • source (Integer, String, Object)

    room, room id or lookup string

  • destination (Integer, Array, nil) (defaults to: nil)

    Target room(s) or nil for full graph

Returns:

  • (Array<Hash>, nil)

    see Room#dijkstra



561
562
563
564
565
566
567
568
569
570
# File 'documented/common/map/map_base.rb', line 561

def dijkstra(source, destination = nil)
  if source.is_a?(self)
    source.dijkstra(destination)
  elsif (room = self[source])
    room.dijkstra(destination)
  else
    echo 'Map.dijkstra: error: invalid source room'
    nil
  end
end

#estimate_time(array) ⇒ Float

Estimate total travel time for a path

Parameters:

  • array (Array<Integer>)

    Array of room IDs representing the path

Returns:

  • (Float)

    Total estimated time in seconds



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'documented/common/map/map_base.rb', line 270

def estimate_time(array)
  self.load unless loaded?
  unless array.is_a?(Array)
    raise Exception.exception('MapError'), 'Map.estimate_time was given something not an array!'
  end

  time = 0.0
  until array.length < 2
    room = array.shift
    # A path can name a room that is gone. Under the NilClass patch the
    # lookup yielded nil and the 0.2 default below applied; keep that
    # without relying on the patch.
    current = self[room]
    t = current.nil? ? nil : current.timeto[array.first.to_s]
    if t
      time += t.is_a?(StringProc) ? t.call.to_f : t.to_f
    else
      time += 0.2
    end
  end
  time
end

#findpath(source, destination) ⇒ Object

Find path between two rooms



624
625
626
627
628
629
630
631
632
633
# File 'documented/common/map/map_base.rb', line 624

def findpath(source, destination)
  if source.is_a?(self)
    source.path_to(destination)
  elsif (room = self[source])
    room.path_to(destination)
  else
    echo 'Map.findpath: error: invalid source room'
    nil
  end
end

#get_free_idInteger

Get the next available room ID

Returns:

  • (Integer)

    one past the highest room id in use, or 1 when the map holds no rooms



193
194
195
196
197
198
199
200
# File 'documented/common/map/map_base.rb', line 193

def get_free_id
  rooms = list.compact
  # An empty map yields 1, which is what nil.id + 1 produced via Lich's
  # NilClass patch. Stating it means this no longer depends on that.
  return 1 if rooms.empty?

  rooms.max_by(&:id).id + 1
end

#ids_from_uid(uid) ⇒ Object

Get room IDs from a UID



648
649
650
# File 'documented/common/map/map_base.rb', line 648

def ids_from_uid(uid)
  uids[uid] || []
end

#json_map_filesArray<String>

JSON map databases in the data directory, newest first

Returns:

  • (Array<String>)

    full paths, empty when the directory is absent



462
463
464
465
466
467
468
469
470
471
# File 'documented/common/map/map_base.rb', line 462

def json_map_files
  directory = File.join(DATA_DIR, XMLData.game)
  return [] unless Dir.exist?(directory)

  Dir.entries(directory)
     .find_all { |fn| fn =~ /^map-[0-9]+\.json$/i }
     .collect { |fn| File.join(directory, fn) }
     .sort
     .reverse
end

#legacy_map_filesArray<String>

Legacy map files sitting in the data directory, basenames only

Returns:



475
476
477
478
479
480
# File 'documented/common/map/map_base.rb', line 475

def legacy_map_files
  directory = File.join(DATA_DIR, XMLData.game)
  return [] unless Dir.exist?(directory)

  Dir.entries(directory).grep(/^map(?:-[0-9]+)?\.(?:dat|xml)$/i).sort
end

#load(filename = nil) ⇒ Boolean

Load the newest JSON map database, or a specific file

Parameters:

  • filename (String, nil) (defaults to: nil)

    explicit path, or nil to search DATA_DIR

Returns:

  • (Boolean)

    whether a map was loaded



534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
# File 'documented/common/map/map_base.rb', line 534

def load(filename = nil)
  file_list = filename.nil? ? json_map_files : [filename]

  # An explicitly named .dat or .xml would otherwise reach load_json and
  # raise a parse error rather than saying why it cannot be loaded.
  unsupported, file_list = file_list.partition { |fn| fn =~ /\.(?:dat|xml)\z/i }

  if file_list.empty?
    if unsupported.empty?
      respond '--- Lich: error: no map database found'
      report_unsupported_map_files(legacy_map_files)
    else
      report_unsupported_map_files(unsupported.map { |fn| File.basename(fn) })
    end
    return false
  end

  while (filename = file_list.shift)
    return true if load_json(filename)
  end
  false
end

#load_json(filename = nil) ⇒ Boolean

Load the newest usable JSON map database, falling back to older candidates when one is unreadable. The two game classes differ only in how a parsed room is constructed and what they announce, so those are hooks: #room_from_json and #map_loaded_message.

Parameters:

  • filename (String, nil) (defaults to: nil)

    a specific database, or nil to search

Returns:

  • (Boolean)

    true once a database loaded



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'documented/common/map/map_base.rb', line 371

def load_json(filename = nil)
  synchronize_load do
    return true if loaded?

    file_list = filename ? [filename] : json_map_files
    if file_list.empty?
      respond '--- Lich: error: no map database found'
      return false
    end

    while (filename = file_list.shift)
      next unless File.exist?(filename)
      next unless parse_map_json(filename)

      clear_tags_cache
      respond map_loaded_message(filename)
      mark_loaded
      load_uids
      return true
    end
    false
  end
end

#map_loaded_message(filename) ⇒ String

Announced once a database has loaded. Names the script that triggered the load when there is one; a load can also happen with no script running, and nil.name only survived via Lich's NilClass patch.

Parameters:

  • filename (String)

    database that was loaded

Returns:



438
439
440
441
# File 'documented/common/map/map_base.rb', line 438

def map_loaded_message(filename)
  name = Script.current&.name
  name ? "--- #{name} Map loaded #{filename}" : "--- Map loaded #{filename}"
end

#match_multi_ids(ids) ⇒ Integer?

Narrow a set of candidate ids to the one reachable from the current room

Parameters:

  • ids (Array)

    candidate room ids

Returns:

  • (Integer, nil)

    the single reachable id, or nil unless exactly one candidate is reachable from the current room



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'documented/common/map/map_base.rb', line 229

def match_multi_ids(ids)
  # current_room_id can be nil, stale, or point at a hole. Under Lich's
  # NilClass patch a nil id made Array#[] hand back the whole room list
  # and the subsequent .wayto then yielded nil, so the result was no
  # matches either way. Check before indexing rather than relying on it.
  return nil if current_room_id.nil?

  current = list[current_room_id]
  return nil if current.nil?

  matches = ids.find_all { |s| current.wayto.keys.include?(s.to_s) }
  return matches[0] if matches.size == 1

  nil
end

#match_no_uidObject?

Resolve the current room when the game gave no usable uid. Delegates to the game-specific matchers.

Returns:

  • (Object, nil)

    the resolved room



217
218
219
220
221
222
223
# File 'documented/common/map/map_base.rb', line 217

def match_no_uid
  if (script = Script.current)
    set_current(match_current(script))
  else
    set_fuzzy(match_fuzzy)
  end
end

#normalize_tag_lists(rooms = list) ⇒ nil

Re-wrap plain Array tags as TagList. Rooms that reach the list without going through the constructor, such as a caller assigning a list it built itself, otherwise hold tags that cannot invalidate the index.

Callers inside a load must pass the rooms explicitly, because the #list accessor triggers #load when the map is not yet loaded, and #load holds a non-reentrant mutex.

Parameters:

  • rooms (Array) (defaults to: list)

    rooms to normalize; defaults to the current list

Returns:

  • (nil)


611
612
613
614
615
616
617
618
619
620
621
# File 'documented/common/map/map_base.rb', line 611

def normalize_tag_lists(rooms = list)
  rooms.compact.each do |room|
    existing = room.tags
    next if existing.is_a?(TagList)

    # Round trip through the writer, which is what rewraps the plain
    # Array as a TagList bound to this class.
    room.tags = existing
  end
  reset_tag_index
end

#parse_map_json(filename) ⇒ Boolean

Returns false when the file was unusable.

Parameters:

  • filename (String)

    database to read

Returns:

  • (Boolean)

    false when the file was unusable



397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'documented/common/map/map_base.rb', line 397

def parse_map_json(filename)
  File.open(filename) do |f|
    JSON.parse(f.read).each do |room|
      validate_room_json!(room, filename)
      # Defaulted before the loops below read .keys on them.
      # Every field except the id is optional in a real mapdb.
      room['title'] ||= []
      room['description'] ||= []
      room['paths'] ||= []
      room['wayto'] ||= {}
      room['timeto'] ||= {}
      room['tags'] ||= []
      room['uid'] ||= []
      room['wayto'].keys.each do |k|
        room['wayto'][k] = StringProc.new(room['wayto'][k][3..]) if room['wayto'][k][0..2] == ';e '
      end
      room['timeto'].keys.each do |k|
        if room['timeto'][k].is_a?(String) && room['timeto'][k][0..2] == ';e '
          room['timeto'][k] = StringProc.new(room['timeto'][k][3..])
        end
      end
      room_from_json(room)
    end
  end
  true
rescue StandardError => e
  # A corrupt or unreadable database must not abort the load or leave a
  # half-built map behind. Report it, drop whatever was registered, and
  # let the caller try an older candidate. raw_list because the load
  # mutex is held and #list would re-enter it.
  respond "--- Lich: error: failed to load #{filename}: #{e.message}"
  raw_list.clear
  clear_tags_cache
  false
end

#previous_uidInteger?

The uid the game last navigated away from

Returns:

  • (Integer, nil)


210
211
212
# File 'documented/common/map/map_base.rb', line 210

def previous_uid
  XMLData.previous_nav_rm
end

#reloadObject

Reload the map database



636
637
638
639
# File 'documented/common/map/map_base.rb', line 636

def reload
  clear
  load
end

#report_unsupported_map_files(files) ⇒ nil

Explain why an old map database no longer loads. The Marshal (.dat) and XML formats were deprecated for years and support has been removed, so "no map database found" on its own would be misleading for anyone whose data directory still holds one.

Parameters:

  • files (Array<String>)

    legacy file names to name in the message

Returns:

  • (nil)


488
489
490
491
492
493
494
# File 'documented/common/map/map_base.rb', line 488

def report_unsupported_map_files(files)
  return if files.empty?

  respond "--- Lich: found map data in a format that is no longer supported: #{files.sort.join(', ')}"
  respond '--- Lich: download the current JSON map database to continue.'
  nil
end

#reset_tag_indexnil

Drop the tag memo. Call after mutating any room's tags in place.

Returns:

  • (nil)


590
591
592
593
594
595
596
597
598
599
600
# File 'documented/common/map/map_base.rb', line 590

def reset_tag_index
  host = tag_cache_host
  TAG_INDEX_MUTEX.synchronize do
    # Under the same mutex as publication: bumping the generation outside
    # it could land between a publisher's validation and its assignment.
    host.instance_variable_set(:@tag_index_generation,
                               (host.instance_variable_get(:@tag_index_generation) || 0) + 1)
    host.instance_variable_set(:@tag_index, nil)
  end
  nil
end

#rooms_by_tag(tag_name) ⇒ Array<Integer>

Room ids carrying a tag, nearest-agnostic and in room id order

Parameters:

  • tag_name (String)

    Tag to look up

Returns:

  • (Array<Integer>)

    a copy, empty when the tag is unknown



584
585
586
# File 'documented/common/map/map_base.rb', line 584

def rooms_by_tag(tag_name)
  (tag_index[tag_name] || []).dup
end

#save_json(filename = nil) ⇒ Object Also known as: save

Save map as JSON file



659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
# File 'documented/common/map/map_base.rb', line 659

def save_json(filename = nil)
  filename ||= File.join(DATA_DIR, XMLData.game, "map-#{Time.now.to_i}.json")
  if File.exist?(filename)
    respond 'File exists!  Backing it up before proceeding...'
    begin
      File.open(filename, 'rb') do |infile|
        File.open("#{filename}.bak", 'wb:UTF-8') do |outfile|
          outfile.write(infile.read)
        end
      end
    rescue StandardError => e
      respond "--- Lich: error: #{e}\n\t#{e.backtrace[0..1].join("\n\t")}"
      Lich.log "error: #{e}\n\t#{e.backtrace.join("\n\t")}"
    end
  end
  File.open(filename, 'wb:UTF-8') { |file| file.write(to_json) }
  respond "#{filename} saved"
  # Reload if the map index appears corrupted: the last entry's id should
  # index back to itself. Nothing to check when the map holds no rooms,
  # where self[-1] is nil and only the NilClass patch made this pass.
  last = list.compact.last
  reload if !last.nil? && self[last.id]&.id != last.id
end

#set_current(id) ⇒ Object?

Record the room the game moved to, remembering the one it left

Parameters:

  • id (Integer, nil)

    the new current room id

Returns:

  • (Object, nil)

    the room, or nil when given nil



248
249
250
251
252
253
254
# File 'documented/common/map/map_base.rb', line 248

def set_current(id)
  self.previous_room_id = current_room_id if id != current_room_id
  self.current_room_id = id
  return nil if id.nil?

  list[id]
end

#set_fuzzy(id) ⇒ Object?

As #set_current, but a nil id leaves the previous room untouched

Parameters:

  • id (Integer, nil)

    the fuzzily matched room id

Returns:

  • (Object, nil)

    the room, or nil when given nil



259
260
261
262
263
264
265
# File 'documented/common/map/map_base.rb', line 259

def set_fuzzy(id)
  self.previous_room_id = current_room_id if !id.nil? && id != current_room_id
  self.current_room_id = id
  return nil if id.nil?

  list[id]
end

#tag_namesArray<String>

Tag names present anywhere in the room list, in room id order

Returns:



577
578
579
# File 'documented/common/map/map_base.rb', line 577

def tag_names
  tag_index.keys
end

#tagsArray<String>

Tag names present anywhere in the room list

Returns:



204
205
206
# File 'documented/common/map/map_base.rb', line 204

def tags
  tag_names
end

#to_json(*args) ⇒ Object

Convert map to JSON



653
654
655
656
# File 'documented/common/map/map_base.rb', line 653

def to_json(*args)
  list.delete_if(&:nil?)
  list.sort_by(&:id).to_json(args)
end

#uids_add(uid, id) ⇒ Object

Add a UID mapping



642
643
644
645
# File 'documented/common/map/map_base.rb', line 642

def uids_add(uid, id)
  uids[uid] ||= []
  uids[uid] << id unless uids[uid].include?(id)
end

#validate_room_json!(room, filename) ⇒ nil

The id is the only field a room cannot do without: it indexes the room into the backing array, and a non-Integer would raise there with a message that says nothing about the database. Everything else is optional and defaulted in #parse_map_json - the shipped mapdb has rooms with no description or paths, such as the fog transitions, so requiring those fields would reject a valid database outright.

Parameters:

  • room (Hash)

    one parsed room

  • filename (String)

    database being read, for the message

Returns:

  • (nil)

Raises:

  • (RuntimeError)

    when the id is missing or not an Integer



453
454
455
456
457
458
# File 'documented/common/map/map_base.rb', line 453

def validate_room_json!(room, filename)
  id = room['id']
  return nil if id.is_a?(Integer)

  raise "#{File.basename(filename)}: room id is not an Integer: #{id.inspect}"
end