Class: Lich::Common::GameObj

Inherits:
Object
  • Object
show all
Defined in:
documented/common/gameobj.rb

Overview

Represents a game object (NPC, loot, inventory item, room description, etc.) within the Lich game automation framework.

GameObj tracks all in-game entities across categorized class-level registries (loot, NPCs, PCs, inventory, room descriptions, familiar counterparts, and hands). Each registry deduplicates entries by the composite key of id, name, and noun.

Examples:

Create a new NPC

npc = GameObj.new_npc('1234', 'goblin', 'a snarling goblin')

Look up an object by ID string

obj = GameObj['1234']

Direct Known Subclasses

RoomObj

Constant Summary collapse

@@loot =

Class-level registries

[]
@@npcs =
[]
@@npc_status =
{}
@@pcs =
[]
@@pc_status =
{}
@@inv =
[]
@@reserve =
nil
@@contents =
{}
@@right_hand =
nil
@@left_hand =
nil
@@room_desc =
[]
@@fam_loot =
[]
@@fam_npcs =
[]
@@fam_pcs =
[]
@@fam_room_desc =
[]
@@type_data =
{}
@@type_cache =
{}
@@sellable_data =
{}
@@index =

Shared identity index - single persistent O(1) lookup pool with TTL.

Maps composite key String "id|noun|name" to a two-element array [GameObj, last_seen_at] where last_seen_at is a Float timestamp (from Process.clock_gettime(Process::CLOCK_MONOTONIC)) recording when the entry was last accessed by find_or_create.

All registries share this one index because a GameObj with the same id, noun, and name is the same logical game entity regardless of which registry it belongs to.

The index is intentionally not flushed when a registry is cleared. Room transitions call multiple clear_* methods in quick succession; flushing on every clear would cause every re-encountered object to be needlessly reallocated. Instead, stale index entries self-heal: when find_or_create finds an entry for an object that was cleared from its registry, it simply re-adds that same instance to the target registry and refreshes its last_seen_at timestamp.

Garbage collection is handled by prune_index!, which removes entries whose last_seen_at is older than a given TTL (default 15 minutes). Call it at natural session breakpoints (e.g. after a room transition or from a script's idle loop). It is safe to call frequently - entries that were just accessed will never be pruned regardless of how often it runs.

Use index_stats to inspect the current state of the index at any time.

For very long automated sessions an alternative LruIndex drop-in is also available - see Lich::Common::LruIndex below.

{}
@@index_mutex =

Serializes structural access to @@index (inserts and the delete_if sweeps). The index is mutated from at least two threads - the game parser thread on every object insert, and the MemoryReleaser background worker via prune_index! - so without this a sweep could iterate the hash while another thread inserts a key, raising "can't add a new key into hash during iteration". Held only around the structural operations, never while computing the live-object set.

Mutex.new
@@staging_inv =

Staging buffers for atomic registry refresh.

While a stream or component is being parsed, incoming objects accumulate in a private staging buffer instead of the published registry. A matching commit_* swaps the buffer in with a single reference assignment, so a reader never observes an empty or half-filled registry mid-stream - it sees the previous complete snapshot until commit, then the new one.

Each buffer is nil when no refresh is in flight and an Array (or Hash, for the paired status maps) while one is open. This mirrors the existing @dr_active_spells_tmp swap-on-prompt pattern in xmlparser.rb.

Under MRI the reference swap is atomic and the reader's dup is consistent, so no lock is required.

nil
@@staging_reserve =
nil
@@staging_loot =
nil
@@staging_npcs =
nil
@@staging_npc_status =
nil
@@staging_pcs =
nil
@@staging_pc_status =
nil
@@staging_room_desc =
nil
@@staging_fam_room_desc =
nil
@@staging_fam_loot =
nil
@@staging_fam_npcs =
nil
@@staging_fam_pcs =
nil
@@staging_contents =
{}

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(id, noun, name, before = nil, after = nil) ⇒ GameObj

Initializes a new GameObj, normalizing certain irregular noun values.

Parameters:

  • id (Integer, String)

    the object's unique game ID

  • noun (String, nil)

    the object's noun

  • name (String, nil)

    the object's descriptive name

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

    optional text before the name

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

    optional text after the name



162
163
164
165
166
167
168
# File 'documented/common/gameobj.rb', line 162

def initialize(id, noun, name, before = nil, after = nil)
  @id          = id.is_a?(Integer) ? id.to_s : id
  @noun        = normalize_noun(noun, name)
  @name        = name
  @before_name = before
  @after_name  = after
end

Instance Attribute Details

#after_nameString?

Returns text appended after the name in full display.

Returns:

  • (String, nil)

    text appended after the name in full display



153
154
155
# File 'documented/common/gameobj.rb', line 153

def after_name
  @after_name
end

#before_nameString?

Returns text prepended before the name in full display.

Returns:

  • (String, nil)

    text prepended before the name in full display



150
151
152
# File 'documented/common/gameobj.rb', line 150

def before_name
  @before_name
end

#idString (readonly)

Returns the unique string ID of this object.

Returns:

  • (String)

    the unique string ID of this object



141
142
143
# File 'documented/common/gameobj.rb', line 141

def id
  @id
end

#nameString?

Returns full descriptive name (e.g. "a snarling goblin").

Returns:

  • (String, nil)

    full descriptive name (e.g. "a snarling goblin")



147
148
149
# File 'documented/common/gameobj.rb', line 147

def name
  @name
end

#nounString?

Returns noun used to refer to this object (e.g. "goblin").

Returns:

  • (String, nil)

    noun used to refer to this object (e.g. "goblin")



144
145
146
# File 'documented/common/gameobj.rb', line 144

def noun
  @noun
end

Class Method Details

.[](val) ⇒ GameObj?

Finds a GameObj by ID (numeric string), noun (single word), or name. Also accepts a Regexp for name-based matching.

Parameters:

  • val (String, Integer, Regexp)

Returns:



793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
# File 'documented/common/gameobj.rb', line 793

def self.[](val)
  unless val.is_a?(String) || val.is_a?(Regexp)
    respond "--- Lich: error: GameObj[] passed with #{val.class} #{val} via caller: #{caller[0]}"
    respond "--- Lich: error: GameObj[] supports String or Regexp only"
    Lich.log "--- Lich: error: GameObj[] passed with #{val.class} #{val} via caller: #{caller[0]}\n\t"
    Lich.log "--- Lich: error: GameObj[] supports String or Regexp only\n\t"

    if val.is_a?(Integer)
      respond "--- Lich: error: GameObj[] converted Integer #{val} to String to continue"
      val = val.to_s
    else
      return nil
    end
  end

  if val.is_a?(Regexp)
    return search_registries { |o| o.name =~ val }
  end

  if val =~ /^\-?[0-9]+$/
    # Numeric ID lookup (room_desc excluded from primary, appended last for completeness)
    search_registries { |o| o.id == val }
  elsif val.split(' ').length == 1
    # Single-word noun lookup
    search_registries { |o| o.noun == val }
  else
    # Name lookup - exact first, then suffix, then fuzzy suffix
    escaped     = Regexp.escape(val.strip)
    fuzzy       = Regexp.escape(val).sub(' ', ' .*')
    search_registries { |o| o.name == val } ||
      search_registries { |o| o.name =~ /\b#{escaped}$/i } ||
      search_registries { |o| o.name =~ /\b#{fuzzy}$/i }
  end
end

.begin_container(container_id) ⇒ Array

Opens a refresh of a single container's contents.

Parameters:

Returns:

  • (Array)


729
# File 'documented/common/gameobj.rb', line 729

def self.begin_container(container_id) = @@staging_contents[container_id] = []

.begin_familiarvoid

This method returns an undefined value.

Opens a refresh of the four familiar registries.



704
705
706
707
708
709
# File 'documented/common/gameobj.rb', line 704

def self.begin_familiar
  @@staging_fam_room_desc = []
  @@staging_fam_loot      = []
  @@staging_fam_npcs      = []
  @@staging_fam_pcs       = []
end

.begin_invArray

Returns:

  • (Array)


630
# File 'documented/common/gameobj.rb', line 630

def self.begin_inv = @@staging_inv = []

.begin_reserveArray

Returns:

  • (Array)


641
# File 'documented/common/gameobj.rb', line 641

def self.begin_reserve = @@staging_reserve = []

.begin_room_descArray

Returns:

  • (Array)


691
# File 'documented/common/gameobj.rb', line 691

def self.begin_room_desc = @@staging_room_desc = []

.begin_room_objsvoid

This method returns an undefined value.

Opens a refresh of the room object registries (loot + npcs + npc status).



654
655
656
657
658
# File 'documented/common/gameobj.rb', line 654

def self.begin_room_objs
  @@staging_loot       = []
  @@staging_npcs       = []
  @@staging_npc_status = {}
end

.begin_room_playersvoid

This method returns an undefined value.

Opens a refresh of the room player registries (pcs + pc status).



675
676
677
678
# File 'documented/common/gameobj.rb', line 675

def self.begin_room_players
  @@staging_pcs       = []
  @@staging_pc_status = {}
end

.clear_all_containersvoid

This method returns an undefined value.

Clears all container registries. Any in-flight staged container refreshes are aborted as well. The shared identity index is preserved so previously seen objects are reused if re-encountered.



584
585
586
587
# File 'documented/common/gameobj.rb', line 584

def self.clear_all_containers
  @@staging_contents.clear
  @@contents.clear
end

.clear_container(container_id) ⇒ Array

Resets a single container's contents to an empty array. The shared identity index is preserved.

Parameters:

Returns:

  • (Array)


594
595
596
# File 'documented/common/gameobj.rb', line 594

def self.clear_container(container_id)
  @@contents[container_id] = []
end

.clear_fam_lootvoid

This method returns an undefined value.



571
# File 'documented/common/gameobj.rb', line 571

def self.clear_fam_loot      = @@fam_loot.clear

.clear_fam_npcsvoid

This method returns an undefined value.



574
# File 'documented/common/gameobj.rb', line 574

def self.clear_fam_npcs      = @@fam_npcs.clear

.clear_fam_pcsvoid

This method returns an undefined value.



577
# File 'documented/common/gameobj.rb', line 577

def self.clear_fam_pcs       = @@fam_pcs.clear

.clear_fam_room_descvoid

This method returns an undefined value.



568
# File 'documented/common/gameobj.rb', line 568

def self.clear_fam_room_desc = @@fam_room_desc.clear

.clear_invvoid

This method returns an undefined value.



559
# File 'documented/common/gameobj.rb', line 559

def self.clear_inv           = @@inv.clear

.clear_lootvoid

This method returns an undefined value.



550
# File 'documented/common/gameobj.rb', line 550

def self.clear_loot          = @@loot.clear

.clear_npcsvoid

This method returns an undefined value.



553
# File 'documented/common/gameobj.rb', line 553

def self.clear_npcs          = (@@npcs.clear; @@npc_status.clear)

.clear_pcsvoid

This method returns an undefined value.



556
# File 'documented/common/gameobj.rb', line 556

def self.clear_pcs           = (@@pcs.clear; @@pc_status.clear)

.clear_reservevoid

This method returns an undefined value.



562
# File 'documented/common/gameobj.rb', line 562

def self.clear_reserve       = (@@reserve = [])

.clear_room_descvoid

This method returns an undefined value.



565
# File 'documented/common/gameobj.rb', line 565

def self.clear_room_desc     = @@room_desc.clear

.commit_all_containersvoid

This method returns an undefined value.

Publishes every open container staging buffer. Called at the prompt that terminates a command burst, the reliable close signal for the clearContainer ... inv fill sequence (which has no closing tag). No-op when no container refresh is open.



746
747
748
749
750
751
# File 'documented/common/gameobj.rb', line 746

def self.commit_all_containers
  return if @@staging_contents.empty?

  @@staging_contents.each { |id, staged| @@contents[id] = staged }
  @@staging_contents.clear
end

.commit_container(container_id) ⇒ void

This method returns an undefined value.

Parameters:



733
734
735
736
737
738
# File 'documented/common/gameobj.rb', line 733

def self.commit_container(container_id)
  staged = @@staging_contents.delete(container_id)
  return if staged.nil?

  @@contents[container_id] = staged
end

.commit_familiarvoid

This method returns an undefined value.



712
713
714
715
716
717
718
719
720
721
722
723
# File 'documented/common/gameobj.rb', line 712

def self.commit_familiar
  return if @@staging_fam_npcs.nil?

  @@fam_room_desc         = @@staging_fam_room_desc
  @@fam_loot              = @@staging_fam_loot
  @@fam_npcs              = @@staging_fam_npcs
  @@fam_pcs               = @@staging_fam_pcs
  @@staging_fam_room_desc = nil
  @@staging_fam_loot      = nil
  @@staging_fam_npcs      = nil
  @@staging_fam_pcs       = nil
end

.commit_invvoid

This method returns an undefined value.



633
634
635
636
637
638
# File 'documented/common/gameobj.rb', line 633

def self.commit_inv
  return if @@staging_inv.nil?

  @@inv         = @@staging_inv
  @@staging_inv = nil
end

.commit_reservevoid

This method returns an undefined value.



644
645
646
647
648
649
# File 'documented/common/gameobj.rb', line 644

def self.commit_reserve
  return if @@staging_reserve.nil?

  @@reserve         = @@staging_reserve
  @@staging_reserve = nil
end

.commit_room_descvoid

This method returns an undefined value.



694
695
696
697
698
699
# File 'documented/common/gameobj.rb', line 694

def self.commit_room_desc
  return if @@staging_room_desc.nil?

  @@room_desc         = @@staging_room_desc
  @@staging_room_desc = nil
end

.commit_room_objsvoid

This method returns an undefined value.



661
662
663
664
665
666
667
668
669
670
# File 'documented/common/gameobj.rb', line 661

def self.commit_room_objs
  return if @@staging_npcs.nil?

  @@loot               = @@staging_loot
  @@npcs               = @@staging_npcs
  @@npc_status         = @@staging_npc_status
  @@staging_loot       = nil
  @@staging_npcs       = nil
  @@staging_npc_status = nil
end

.commit_room_playersvoid

This method returns an undefined value.



681
682
683
684
685
686
687
688
# File 'documented/common/gameobj.rb', line 681

def self.commit_room_players
  return if @@staging_pcs.nil?

  @@pcs               = @@staging_pcs
  @@pc_status         = @@staging_pc_status
  @@staging_pcs       = nil
  @@staging_pc_status = nil
end

.containersHash{String => Array<GameObj>}

Returns:



543
# File 'documented/common/gameobj.rb', line 543

def self.containers  = @@contents.dup

.deadArray<GameObj>?

Returns all NPCs with a status of "dead", or nil if none.

Returns:



865
866
867
868
# File 'documented/common/gameobj.rb', line 865

def self.dead
  dead_list = @@npcs.select { |obj| obj.status == 'dead' }
  dead_list.empty? ? nil : dead_list
end

.delete_container(container_id) ⇒ GameObj?

Removes a container and all its contents from the registry. Any in-flight staged refresh for the same container is aborted so a later commit cannot resurrect the deleted key. The shared identity index is preserved.

Parameters:

Returns:



605
606
607
608
# File 'documented/common/gameobj.rb', line 605

def self.delete_container(container_id)
  @@staging_contents.delete(container_id)
  @@contents.delete(container_id)
end

.discard_staged_refreshesvoid

This method returns an undefined value.

Discards every in-flight staged refresh without publishing it.

Called by XMLParser#reset after a malformed or truncated fragment forces the parser to resynchronize. Any refresh open at that moment is known to be incomplete, so its buffer is dropped rather than published: the previously published snapshot stays visible, which is the same failure mode as an interrupted stream that never commits.

Without this, an interrupted container fill would be published as authoritative by the next commit_all_containers at the following prompt, and objects held only in an abandoned buffer would keep appearing in live_registry_objects (blocking prune_index!) until the next refresh of that same registry replaced it.



768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
# File 'documented/common/gameobj.rb', line 768

def self.discard_staged_refreshes
  @@staging_inv           = nil
  @@staging_reserve       = nil
  @@staging_loot          = nil
  @@staging_npcs          = nil
  @@staging_npc_status    = nil
  @@staging_pcs           = nil
  @@staging_pc_status     = nil
  @@staging_room_desc     = nil
  @@staging_fam_room_desc = nil
  @@staging_fam_loot      = nil
  @@staging_fam_npcs      = nil
  @@staging_fam_pcs       = nil
  @@staging_contents.clear
end

.fam_lootArray<GameObj>?

Returns:



534
# File 'documented/common/gameobj.rb', line 534

def self.fam_loot    = registry_or_nil(@@fam_loot)

.fam_npcsArray<GameObj>?

Returns:



537
# File 'documented/common/gameobj.rb', line 537

def self.fam_npcs    = registry_or_nil(@@fam_npcs)

.fam_pcsArray<GameObj>?

Returns:



540
# File 'documented/common/gameobj.rb', line 540

def self.fam_pcs     = registry_or_nil(@@fam_pcs)

.fam_room_descArray<GameObj>?

Returns:



531
# File 'documented/common/gameobj.rb', line 531

def self.fam_room_desc = registry_or_nil(@@fam_room_desc)

.hidden_targetsArray<String>

Returns IDs in the current target list that do not correspond to a known NPC.

Returns:



851
852
853
# File 'documented/common/gameobj.rb', line 851

def self.hidden_targets
  XMLData.current_target_ids.reject { |id| @@npcs.any? { |n| n.id == id } }
end

.index_or_create(id, noun, name, before = nil, after = nil) ⇒ GameObj

Looks up an existing GameObj in the shared identity index by composite key (+id+, noun, name), or creates and indexes a new one.

Unlike find_or_create, this method does not push the object into any registry array. It is intended for callers that manage their own storage slot (e.g. +new_right_hand+/+new_left_hand+) or for external code that constructs GameObj instances via GameObj.new but wants to participate in the shared identity index so objects are reused and tracked for TTL- based garbage collection.

When a matching entry is found, before_name and after_name are backfilled if they were previously nil and the incoming values are non-nil. Existing non-nil values are never overwritten.

Examples:

Replace a bare GameObj.new call

# Before:
obj = GameObj.new(id, noun, name, before, after)

# After - participates in the shared index:
obj = GameObj.index_or_create(id, noun, name, before, after)

Parameters:

  • id (Integer, String)
  • noun (String, nil)
  • name (String, nil)
  • before (String, nil) (defaults to: nil)

    backfills before_name if previously unset

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

    backfills after_name if previously unset

Returns:



482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'documented/common/gameobj.rb', line 482

def self.index_or_create(id, noun, name, before = nil, after = nil)
  str_id = id.is_a?(Integer) ? id.to_s : id
  key    = "#{str_id}|#{noun}|#{name}"
  now    = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  @@index_mutex.synchronize do
    if (entry = @@index[key])
      existing, _ts        = entry
      @@index[key]         = [existing, now]
      existing.before_name = before if existing.before_name.nil? && !before.nil?
      existing.after_name  = after  if existing.after_name.nil?  && !after.nil?
      existing
    else
      new_obj      = GameObj.new(id, noun, name, before, after)
      @@index[key] = [new_obj, now]
      new_obj
    end
  end
end

.index_stats(verbose: false) ⇒ Hash

Returns a Hash describing the current memory and age state of the index.

Useful for diagnosing memory growth in long sessions. The :age_buckets breakdown shows how many entries fall into each staleness window so you can tune the TTL passed to prune_index! accordingly.

When verbose: true, prints a formatted report to stdout.

Examples:

Silent stats (default)

stats = GameObj.index_stats
puts stats[:stale_entries]

Print a full formatted report

GameObj.index_stats(verbose: true)

Parameters:

  • verbose (Boolean) (defaults to: false)

    when true, prints a report to stdout (default: false)

Returns:

  • (Hash)

    with the following keys:

    • :total_entries [Integer] - total keys in @@index
    • :live_in_registries [Integer] - objects in at least one registry
    • :stale_entries [Integer] - objects in no active registry
    • :oldest_entry_seconds [Float] - age of the oldest entry in seconds
    • :age_buckets [Hash=> Integer] - entry counts by last-seen age: under5m, 5-15m, 15-30m, 30-60m, over60m
    • :gameobj_bytes [Integer] - estimated memory held by all indexed GameObj instances (via ObjectSpace.memsize_of)
    • :heap_bytes [Integer] - current Ruby heap size


1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
# File 'documented/common/gameobj.rb', line 1008

def self.index_stats(verbose: false)
  require 'objspace'
  return empty_index_stats if @@index.empty?

  now        = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  live_objs  = live_registry_objects
  # Snapshot the entries under the mutex so we never iterate the index
  # while another thread inserts into or prunes it.
  entries    = @@index_mutex.synchronize { @@index.values }
  total      = entries.size
  buckets    = { 'under5m' => 0, '5-15m' => 0,
                 '15-30m' => 0, '30-60m' => 0, 'over60m' => 0 }
  stale      = 0
  oldest_age = 0.0

  entries.each do |obj, last_seen|
    age        = now - last_seen
    oldest_age = age if age > oldest_age
    stale     += 1 unless live_objs.include?(obj)

    buckets[case age
            when 0...300    then 'under5m'
            when 300...900  then '5-15m'
            when 900...1800 then '15-30m'
            when 1800...3600 then '30-60m'
            else 'over60m'
            end] += 1
  end

  obj_mem  = gameobj_memory_bytes
  heap_mem = ruby_heap_bytes

  result = {
    total_entries: total,
    live_in_registries: total - stale,
    stale_entries: stale,
    oldest_entry_seconds: oldest_age.round(1),
    age_buckets: buckets,
    gameobj_bytes: obj_mem,
    heap_bytes: heap_mem
  }

  if verbose
    oldest_fmt = if oldest_age < 60
                   "#{oldest_age.round(1)}s"
                 elsif oldest_age < 3600
                   "#{(oldest_age / 60).round(1)}m"
                 else
                   "#{(oldest_age / 3600).round(2)}h"
                 end

    w = 28
    puts "=" * 52
    puts "  GameObj.index_stats"
    puts "=" * 52
    puts format("  %-#{w}s %d", "Total index entries:",  total)
    puts format("  %-#{w}s %d", "Live in registries:",   result[:live_in_registries])
    puts format("  %-#{w}s %d", "Stale (index-only):",   stale)
    puts format("  %-#{w}s %s", "Oldest entry:",         oldest_fmt)
    puts "-" * 52
    puts "  Age distribution:"
    buckets.each do |label, count|
      bar = "#" * [count, 30].min
      puts format("  %-10s %4d  %s", label, count, bar)
    end
    puts "-" * 52
    puts format("  %-#{w}s %s", "GameObj object memory:", format_bytes(obj_mem))
    puts format("  %-#{w}s %s", "Ruby heap size:", format_bytes(heap_mem))
    puts "=" * 52
  end

  result
end

.invArray<GameObj>?

Returns:



522
# File 'documented/common/gameobj.rb', line 522

def self.inv         = registry_or_nil(@@inv)

.left_handArray<GameObj>?

Returns:



510
# File 'documented/common/gameobj.rb', line 510

def self.left_hand   = @@left_hand&.dup

.load_data(filename = nil) ⇒ Boolean

Loads type and sellable classification data from XML. Merges custom overrides from gameobj-custom/gameobj-data.xml if present.

Parameters:

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

    path override; defaults to DATA_DIR/gameobj-data.xml

Returns:

  • (Boolean)

    true on success, false on failure



1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
# File 'documented/common/gameobj.rb', line 1109

def self.load_data(filename = nil)
  primary = filename || File.join(DATA_DIR, 'gameobj-data.xml')

  unless File.exist?(primary)
    @@type_data = @@sellable_data = nil
    echo "error: GameObj.load_data: file does not exist: #{primary}"
    return false
  end

  begin
    @@type_data     = {}
    @@sellable_data = {}
    @@type_cache    = {}
    parse_data_file(primary)
  rescue => e
    @@type_data = @@sellable_data = nil
    echo "error: GameObj.load_data: #{e}"
    respond e.backtrace[0..1]
    return false
  end

  custom = File.join(DATA_DIR, 'gameobj-custom', 'gameobj-data.xml')
  if File.exist?(custom)
    begin
      parse_data_file(custom, merge: true)
    rescue => e
      echo "error: Custom GameObj.load_data: #{e}"
      respond e.backtrace[0..1]
      return false
    end
  end

  true
end

.lootArray<GameObj>?

Returns:



516
# File 'documented/common/gameobj.rb', line 516

def self.loot        = registry_or_nil(@@loot)

.merge_data(existing, new_value) ⇒ Regexp

Merges two Regexp values via Regexp.union, or returns the new value if the existing one is not yet a Regexp.

Parameters:

  • existing (Regexp, nil)
  • new_value (Regexp)

Returns:

  • (Regexp)


1100
1101
1102
# File 'documented/common/gameobj.rb', line 1100

def self.merge_data(existing, new_value)
  existing.is_a?(Regexp) ? Regexp.union(existing, new_value) : new_value
end

.new_fam_loot(id, noun, name) ⇒ GameObj

Creates and registers a new familiar loot object.

Parameters:

Returns:



400
401
402
# File 'documented/common/gameobj.rb', line 400

def self.new_fam_loot(id, noun, name)
  find_or_create(@@staging_fam_loot || @@fam_loot, id, noun, name)
end

.new_fam_npc(id, noun, name) ⇒ GameObj

Creates and registers a new familiar NPC.

Parameters:

Returns:



410
411
412
# File 'documented/common/gameobj.rb', line 410

def self.new_fam_npc(id, noun, name)
  find_or_create(@@staging_fam_npcs || @@fam_npcs, id, noun, name)
end

.new_fam_pc(id, noun, name) ⇒ GameObj

Creates and registers a new familiar PC.

Parameters:

Returns:



420
421
422
# File 'documented/common/gameobj.rb', line 420

def self.new_fam_pc(id, noun, name)
  find_or_create(@@staging_fam_pcs || @@fam_pcs, id, noun, name)
end

.new_fam_room_desc(id, noun, name) ⇒ GameObj

Creates and registers a new familiar room description object.

Parameters:

Returns:



390
391
392
# File 'documented/common/gameobj.rb', line 390

def self.new_fam_room_desc(id, noun, name)
  find_or_create(@@staging_fam_room_desc || @@fam_room_desc, id, noun, name)
end

.new_inv(id, noun, name, container = nil, before = nil, after = nil) ⇒ GameObj

Creates and registers a new inventory item, optionally in a container.

Parameters:

  • id (Integer, String)
  • noun (String, nil)
  • name (String, nil)
  • container (String, nil) (defaults to: nil)

    ID of the containing object, or nil for top-level inv

  • before (String, nil) (defaults to: nil)
  • after (String, nil) (defaults to: nil)

Returns:



351
352
353
354
355
356
357
358
# File 'documented/common/gameobj.rb', line 351

def self.new_inv(id, noun, name, container = nil, before = nil, after = nil)
  if container
    target = @@staging_contents[container] || (@@contents[container] ||= [])
    find_or_create(target, id, noun, name, before, after)
  else
    find_or_create(@@staging_inv || @@inv, id, noun, name, before, after)
  end
end

.new_left_hand(id, noun, name) ⇒ GameObj

Sets the left-hand object, replacing any existing one.

Routes through the shared identity index so the same item picked up again returns the existing GameObj instance rather than allocating a new one. Replace semantics are preserved - @@left_hand is always overwritten with the result.

Parameters:

Returns:



450
451
452
# File 'documented/common/gameobj.rb', line 450

def self.new_left_hand(id, noun, name)
  @@left_hand = index_or_create(id, noun, name)
end

.new_loot(id, noun, name) ⇒ GameObj

Creates and registers a new loot object.

Parameters:

Returns:



325
326
327
# File 'documented/common/gameobj.rb', line 325

def self.new_loot(id, noun, name)
  find_or_create(@@staging_loot || @@loot, id, noun, name)
end

.new_npc(id, noun, name, status = nil) ⇒ GameObj

Creates and registers a new NPC.

Parameters:

Returns:



313
314
315
316
317
# File 'documented/common/gameobj.rb', line 313

def self.new_npc(id, noun, name, status = nil)
  obj = find_or_create(@@staging_npcs || @@npcs, id, noun, name)
  (@@staging_npc_status || @@npc_status)[obj.id] = status
  obj
end

.new_pc(id, noun, name, status = nil) ⇒ GameObj

Creates and registers a new PC.

Parameters:

Returns:



336
337
338
339
340
# File 'documented/common/gameobj.rb', line 336

def self.new_pc(id, noun, name, status = nil)
  obj = find_or_create(@@staging_pcs || @@pcs, id, noun, name)
  (@@staging_pc_status || @@pc_status)[obj.id] = status
  obj
end

.new_reserve(id, noun, name) ⇒ GameObj

Creates and registers a new reserve slot item.

@@reserve is nil until the first reserve stream is seen; thereafter it is always an Array (possibly empty).

Parameters:

Returns:



369
370
371
372
# File 'documented/common/gameobj.rb', line 369

def self.new_reserve(id, noun, name)
  @@reserve ||= []
  find_or_create(@@staging_reserve || @@reserve, id, noun, name)
end

.new_right_hand(id, noun, name) ⇒ GameObj

Sets the right-hand object, replacing any existing one.

Routes through the shared identity index so the same item picked up again returns the existing GameObj instance rather than allocating a new one. Replace semantics are preserved - @@right_hand is always overwritten with the result.

Parameters:

Returns:



435
436
437
# File 'documented/common/gameobj.rb', line 435

def self.new_right_hand(id, noun, name)
  @@right_hand = index_or_create(id, noun, name)
end

.new_room_desc(id, noun, name) ⇒ GameObj

Creates and registers a new room description object.

Parameters:

Returns:



380
381
382
# File 'documented/common/gameobj.rb', line 380

def self.new_room_desc(id, noun, name)
  find_or_create(@@staging_room_desc || @@room_desc, id, noun, name)
end

.npcsArray<GameObj>?

Returns:



513
# File 'documented/common/gameobj.rb', line 513

def self.npcs        = registry_or_nil(@@npcs)

.pcsArray<GameObj>?

Returns:



519
# File 'documented/common/gameobj.rb', line 519

def self.pcs         = registry_or_nil(@@pcs)

.prune_index!(ttl: 900, verbose: false) ⇒ Hash

Removes entries from the shared identity index whose last_seen_at timestamp is older than ttl seconds ago and whose object is not currently present in any active registry, then GC-hints Ruby.

The live-registry check is the critical guard: an object that is still held in @@npcs, @@loot, @@inv, or any other registry must never be pruned regardless of how long ago it was last re-registered. Pruning a live entry would cause the next find_or_create call for that object to allocate a brand-new instance, silently breaking the identity guarantee.

An entry is only eligible for pruning when both conditions are true:

1. +last_seen_at+ is older than +ttl+ seconds ago
2. The object's ID is not present in any active registry

Safe to call at any time and as frequently as desired. Entries that are live in registries are always skipped. Entries accessed within the TTL window are always skipped.

Recommended call sites: after a room transition, in a script's idle loop, or whenever index_stats shows :stale_entries growing large.

When verbose: true, prints a before/after report to stdout showing:

- GameObj count and estimated object memory before and after pruning
- Ruby heap size before and after, with the net change
- Number of entries pruned, skipped (live), and remaining
- Time taken

Examples:

Silent prune (default)

GameObj.prune_index!

Prune with a 5-minute TTL and printed report

GameObj.prune_index!(ttl: 300, verbose: true)

Parameters:

  • ttl (Integer) (defaults to: 900)

    seconds since last access before a stale entry is eligible for eviction (default: 900 - 15 minutes)

  • verbose (Boolean) (defaults to: false)

    when true, prints a memory report to stdout (default: false)

Returns:

  • (Hash)

    with the following keys:

    • :pruned [Integer] - stale entries removed
    • :skipped_live [Integer] - entries skipped because object is still present in at least one active registry
    • :remaining [Integer] - entries still in the index
    • :gameobj_bytes_before [Integer] - estimated GameObj memory before prune
    • :gameobj_bytes_after [Integer] - estimated GameObj memory after prune
    • :gameobj_bytes_freed [Integer] - difference (before - after)
    • :heap_bytes_before [Integer] - Ruby heap size before GC hint
    • :heap_bytes_after [Integer] - Ruby heap size after GC hint
    • :heap_bytes_freed [Integer] - difference (before - after)
    • :elapsed_ms [Float] - wall time of the prune operation


924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
# File 'documented/common/gameobj.rb', line 924

def self.prune_index!(ttl: 900, verbose: false)
  require 'objspace'
  t_start   = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  cutoff    = t_start - ttl

  obj_before  = gameobj_memory_bytes
  heap_before = ruby_heap_bytes

  pruned, skipped_live = sweep_stale!(cutoff)

  GC.start(full_mark: false, immediate_sweep: false) if pruned.positive?

  obj_after  = gameobj_memory_bytes
  heap_after = ruby_heap_bytes
  elapsed    = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_start) * 1000

  result = {
    pruned: pruned,
    skipped_live: skipped_live,
    remaining: @@index.size,
    gameobj_bytes_before: obj_before,
    gameobj_bytes_after: obj_after,
    gameobj_bytes_freed: obj_before - obj_after,
    heap_bytes_before: heap_before,
    heap_bytes_after: heap_after,
    heap_bytes_freed: heap_before - heap_after,
    elapsed_ms: elapsed.round(3)
  }

  if verbose
    w = 28
    puts "=" * 52
    puts "  GameObj.prune_index! - TTL: #{ttl}s"
    puts "=" * 52
    puts format("  %-#{w}s %s -> %s  (%s)",
                "GameObj object memory:",
                format_bytes(obj_before),
                format_bytes(obj_after),
                format_delta(result[:gameobj_bytes_freed]))
    puts format("  %-#{w}s %s -> %s  (%s)",
                "Ruby heap size:",
                format_bytes(heap_before),
                format_bytes(heap_after),
                format_delta(result[:heap_bytes_freed]))
    puts format("  %-#{w}s %d removed, %d skipped (live), %d remaining",
                "Index entries:",
                pruned,
                skipped_live,
                @@index.size)
    puts format("  %-#{w}s %.3f ms", "Elapsed:", elapsed)
    puts "=" * 52
  end

  result
end

.reload(filename = nil) ⇒ Boolean

Reloads type and sellable data from disk.

Parameters:

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

    path to the XML data file, or nil for default

Returns:

  • (Boolean)


1090
1091
1092
# File 'documented/common/gameobj.rb', line 1090

def self.reload(filename = nil)
  load_data(filename)
end

.reserveArray<GameObj>?

Returns:



525
# File 'documented/common/gameobj.rb', line 525

def self.reserve     = @@reserve&.dup

.right_handArray<GameObj>?

Returns:



507
# File 'documented/common/gameobj.rb', line 507

def self.right_hand  = @@right_hand&.dup

.room_descArray<GameObj>?

Returns:



528
# File 'documented/common/gameobj.rb', line 528

def self.room_desc   = registry_or_nil(@@room_desc)

.sellable_dataHash

Returns the loaded sellable classification data.

Returns:

  • (Hash)

    the loaded sellable classification data



1151
# File 'documented/common/gameobj.rb', line 1151

def self.sellable_data = @@sellable_data

.targetGameObj?

Returns the single NPC or PC matching XMLData.current_target_id.

Returns:



858
859
860
# File 'documented/common/gameobj.rb', line 858

def self.target
  (@@npcs + @@pcs).find { |n| n.id == XMLData.current_target_id }
end

.targetsArray<GameObj>

Returns the list of active (non-dead, non-animated, non-appendage) NPCs that are currently targeted via XMLData.current_target_ids.

Returns:



836
837
838
839
840
841
842
843
844
845
846
# File 'documented/common/gameobj.rb', line 836

def self.targets
  XMLData.current_target_ids.filter_map do |id|
    npc = @@npcs.find { |n| n.id == id }
    next unless npc
    next if npc.status.to_s =~ /dead|gone/i
    next if npc.name  =~ /^animated\b/i && npc.name !~ /^animated slush/i
    next if npc.noun  =~ /^(?:arm|appendage|claw|limb|pincer|tentacle)s?$|^(?:palpus|palpi)$/i &&
            npc.name !~ /(?:amaranthine|ghostly|grizzled|ancient) kraken tentacle/i
    npc
  end
end

.type_cacheHash

Returns the memoized type lookup cache.

Returns:

  • (Hash)

    the memoized type lookup cache



1148
# File 'documented/common/gameobj.rb', line 1148

def self.type_cache    = @@type_cache

.type_dataHash

Returns the loaded type classification data.

Returns:

  • (Hash)

    the loaded type classification data



1145
# File 'documented/common/gameobj.rb', line 1145

def self.type_data     = @@type_data

Instance Method Details

#contentsArray<GameObj>?

Returns a duplicated snapshot of the object's container contents.

Returns:



196
197
198
# File 'documented/common/gameobj.rb', line 196

def contents
  @@contents[@id]&.dup
end

#empty?false

Always returns false; GameObj instances are never considered empty.

Returns:

  • (false)


189
190
191
# File 'documented/common/gameobj.rb', line 189

def empty?
  false
end

#full_nameString

Returns the full display name, assembling before/name/after parts.

Returns:



203
204
205
206
# File 'documented/common/gameobj.rb', line 203

def full_name
  parts = [@before_name, @name, @after_name]
  parts.compact.reject(&:empty?).join(' ')
end

#GameObjString?

Deprecated.

Use #noun or #to_s instead.

Legacy coercion method - returns the noun. Kept for backwards-compatibility with scripts calling obj.GameObj.

Returns:



182
183
184
# File 'documented/common/gameobj.rb', line 182

def GameObj
  @noun
end

#sellableString?

Returns a comma-separated string of sellable categories, or nil.

Returns:



244
245
246
247
248
# File 'documented/common/gameobj.rb', line 244

def sellable
  GameObj.load_data if @@sellable_data.empty?
  matches = matching_data_keys(@@sellable_data)
  matches.empty? ? nil : matches.join(',')
end

#statusString?

Returns the current status string of this object, or nil if present but unstated, or "gone" if not found in any registry.

The published status maps are consulted first, then the staging maps. That order is deliberate: while a refresh is in flight a reader must still see the previous complete snapshot (see the staging notes above), so a staged value must never shadow a published one. The staging maps are only a fallback for an object that has no published entry at all, which would otherwise be reported as "gone" despite being mid-refresh.

Note that a dedupe hit in find_or_create means a staged object and its published counterpart are frequently the same instance, so this method cannot distinguish which of the two a caller holds. Callers that need the in-flight value (i.e. the parser) must track it themselves and write through #status= rather than reading it back through here.

The "gone" sentinel is a frozen literal and must not be mutated in place; build a new String from it instead.

Returns:



274
275
276
277
278
279
280
281
# File 'documented/common/gameobj.rb', line 274

def status
  return @@npc_status[@id] if @@npc_status.key?(@id)
  return @@pc_status[@id]  if @@pc_status.key?(@id)
  return @@staging_npc_status[@id] if @@staging_npc_status&.key?(@id)
  return @@staging_pc_status[@id]  if @@staging_pc_status&.key?(@id)

  present_in_any_registry? ? nil : 'gone'
end

#status=(val) ⇒ String?

Sets the status of this NPC or PC by ID.

When a room-objects or room-players refresh is in flight, the object lives in the staging buffer rather than the published registry, so this checks the staging pools first and writes to the staging status map. When no refresh is open it behaves exactly as before.

Parameters:

  • val (String, nil)

    the new status value

Returns:



292
293
294
295
296
297
298
299
300
# File 'documented/common/gameobj.rb', line 292

def status=(val)
  npc_pool = @@staging_npcs || @@npcs
  pc_pool  = @@staging_pcs  || @@pcs
  if npc_pool.any? { |npc| npc.id == @id }
    (@@staging_npc_status || @@npc_status)[@id] = val
  elsif pc_pool.any? { |pc| pc.id == @id }
    (@@staging_pc_status || @@pc_status)[@id] = val
  end
end

#to_sString?

Returns a human-readable representation of the object (its noun).

Returns:



173
174
175
# File 'documented/common/gameobj.rb', line 173

def to_s
  @noun
end

#typeString?

Returns a comma-separated string of matching type tags for this object, or nil if no types match.

Results are memoized in @@type_cache under a composite key combining noun, name, and #full_name - i.e. every value #matching_data_keys inspects. Two objects that differ in any matcher input (for example a shared full_name but a different noun, or differing +before_name+/+after_name+) are therefore cached independently rather than sharing an entry. The +"|"+-delimited form mirrors the composite keys used by @@index.

Returns:



224
225
226
227
228
229
230
231
# File 'documented/common/gameobj.rb', line 224

def type
  GameObj.load_data if @@type_data.empty?
  cache_key = "#{@noun}|#{@name}|#{full_name}"
  return @@type_cache[cache_key] if @@type_cache.key?(cache_key)

  matches = matching_data_keys(@@type_data)
  @@type_cache[cache_key] = matches.empty? ? nil : matches.join(',')
end

#type?(type_to_check) ⇒ Boolean

Returns whether this object matches the given type tag.

Parameters:

  • type_to_check (String)

    a single type string (e.g. "herb")

Returns:

  • (Boolean)


237
238
239
# File 'documented/common/gameobj.rb', line 237

def type?(type_to_check)
  type.to_s.split(',').include?(type_to_check)
end