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 =
{}
@@staging_all_contents =

Dedicated buffer for a full-replacement container refresh (DR INV LIST), kept separate from @@staging_contents so the whole-buffer swap/discard of a full refresh can never clobber an unrelated per-container begin_container refresh that happens to be in flight at the same time.

{}
@@staging_all_containers =

True while a full-replacement container refresh (DR INV LIST) is open. While set, new_inv routes container placements not already owned by an open per-container refresh into @@staging_all_contents instead of the live @@contents, so an interrupted listing never mutates the published model. See begin_all_containers.

false
@@pending_metadata =

Deferred +before_name+/+after_name+ refreshes for objects observed while a staged registry refresh is open. find_or_create returns the shared, still-published instance, so mutating its metadata mid-refresh would leak an uncommitted value to readers and could not be rolled back if the refresh is discarded. Instead the change is recorded here and applied by the matching commit_* (see apply_pending_metadata), or dropped by every discard path (+abort_*+, delete_container, clear_all_containers, an +begin_inv+/+begin_container+ restart, discard_inv_refresh, and discard_staged_refreshes).

Scoped PER staging buffer: the outer hash is keyed by the buffer Array's identity (+compare_by_identity+ -- two empty Arrays are ==, so content keying would collide), the inner by GameObj identity. Buffer scoping is what keeps two concurrently-open refreshes of the same object from overwriting each other's pending values and lets a discard drop exactly its own buffer's entries and no others.

{}.compare_by_identity

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



189
190
191
192
193
194
195
# File 'documented/common/gameobj.rb', line 189

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_name ⇒ String?

Returns text appended after the name in full display.

Returns:

  • (String, nil) —

    text appended after the name in full display



180
181
182
# File 'documented/common/gameobj.rb', line 180

def after_name
  @after_name
end

#before_name ⇒ String?

Returns text prepended before the name in full display.

Returns:

  • (String, nil) —

    text prepended before the name in full display



177
178
179
# File 'documented/common/gameobj.rb', line 177

def before_name
  @before_name
end

#id ⇒ String (readonly)

Returns the unique string ID of this object.

Returns:

  • (String) —

    the unique string ID of this object



168
169
170
# File 'documented/common/gameobj.rb', line 168

def id
  @id
end

#name ⇒ String?

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

Returns:

  • (String, nil) —

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



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

def name
  @name
end

#noun ⇒ String?

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

Returns:

  • (String, nil) —

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



171
172
173
# File 'documented/common/gameobj.rb', line 171

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:



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
1143
1144
1145
1146
1147
1148
# File 'documented/common/gameobj.rb', line 1115

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

.abort_container(container_id) ⇒ void

This method returns an undefined value.

Discards one container's open staging buffer WITHOUT publishing it, leaving the previously published @@contents[id] visible. Rolls back a begin_container whose fill failed partway; no-op when none is open. Unlike delete_container it does NOT remove the published contents.

Parameters:



989
990
991
992
# File 'documented/common/gameobj.rb', line 989

def self.abort_container(container_id)
  staged = @@staging_contents.delete(container_id)
  (staged) if staged
end

.abort_inv ⇒ void

This method returns an undefined value.

Discards an open @@inv staging buffer WITHOUT publishing it, leaving the previously published @@inv visible. Rolls back a begin_inv whose fill failed partway (e.g. a second writer's mirror raised mid-registration) so a later cycle does not see inv_refresh_open? stuck open. No-op when none is open. Symmetric with commit_inv.



869
870
871
872
873
874
# File 'documented/common/gameobj.rb', line 869

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

  (@@staging_inv)
  @@staging_inv = nil
end

.apply_pending_metadata(buffer) ⇒ void

This method returns an undefined value.

Applies the metadata deferred for one staging buffer to its objects and clears that buffer's pending entries. Called by each commit_* just before it publishes the buffer, so the new +before_name+/+after_name+ become visible together with the new membership rather than mid-refresh. No-op for a buffer that deferred nothing.

Parameters:

  • buffer (Array, nil) —

    the staging buffer being committed



822
823
824
825
826
827
828
829
830
# File 'documented/common/gameobj.rb', line 822

def self.(buffer)
  pending = @@pending_metadata.delete(buffer)
  return unless pending

  pending.each do |obj, (before, after)|
    obj.before_name = before unless before.nil?
    obj.after_name  = after  unless after.nil?
  end
end

.begin_all_containers ⇒ void

This method returns an undefined value.

Opens a full-replacement refresh of ALL container contents, used by DR's INV LIST (a complete recursive scrape). Staging goes into a dedicated buffer (+@@staging_all_contents+), separate from the ordinary per-container @@staging_contents, so an unrelated begin_container refresh in flight at the same time is never clobbered by this refresh's discard/commit. While open, new_inv stages every not-already-owned container placement (see new_inv) and the live @@contents is left visible to readers. commit_all_containers_full then swaps the whole buffer in one reference assignment, so containers absent from the listing are dropped -- matching the old clear-then-fill semantics -- while an interrupted listing that never commits leaves the previous model intact (see discard_inv_refresh). Pair with begin_inv for worn items.



1032
1033
1034
1035
1036
1037
1038
# File 'documented/common/gameobj.rb', line 1032

def self.begin_all_containers
  # Restarting an already-open full refresh discards the prior buffers, so
  # drop their deferred metadata rather than orphan it (mirrors begin_inv).
  @@staging_all_contents.each_value { |buf| (buf) }
  @@staging_all_contents = {}
  @@staging_all_containers = true
end

.begin_container(container_id) ⇒ Array

Opens a refresh of a single container's contents. Restarting an already-open refresh (a second begin_container before commit) discards the prior buffer, so drop its deferred metadata too rather than orphan it.

Parameters:

Returns:

  • (Array)


967
968
969
970
# File 'documented/common/gameobj.rb', line 967

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

.begin_familiar ⇒ void

This method returns an undefined value.

Opens a refresh of the four familiar registries.



940
941
942
943
944
945
# File 'documented/common/gameobj.rb', line 940

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

.begin_inv ⇒ Array

Opens a refresh of worn inventory. Restarting an already-open refresh (a second begin_inv before commit) discards the prior buffer, so drop its deferred metadata too rather than orphan it (mirrors begin_container).

Returns:

  • (Array)


848
849
850
851
# File 'documented/common/gameobj.rb', line 848

def self.begin_inv
  (@@staging_inv)
  @@staging_inv = []
end

.begin_reserve ⇒ Array

Returns:

  • (Array)


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

def self.begin_reserve = @@staging_reserve = []

.begin_room_desc ⇒ Array

Returns:

  • (Array)


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

def self.begin_room_desc = @@staging_room_desc = []

.begin_room_objs ⇒ void

This method returns an undefined value.

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



890
891
892
893
894
# File 'documented/common/gameobj.rb', line 890

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

.begin_room_players ⇒ void

This method returns an undefined value.

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



911
912
913
914
# File 'documented/common/gameobj.rb', line 911

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

.clear_all_containers ⇒ void

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.



740
741
742
743
744
745
746
747
# File 'documented/common/gameobj.rb', line 740

def self.clear_all_containers
  @@staging_contents.each_value { |staged| (staged) }
  @@staging_all_contents.each_value { |staged| (staged) }
  @@staging_contents.clear
  @@staging_all_contents.clear
  @@staging_all_containers = false
  @@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)


754
755
756
# File 'documented/common/gameobj.rb', line 754

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

.clear_fam_loot ⇒ void

This method returns an undefined value.



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

def self.clear_fam_loot      = @@fam_loot.clear

.clear_fam_npcs ⇒ void

This method returns an undefined value.



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

def self.clear_fam_npcs      = @@fam_npcs.clear

.clear_fam_pcs ⇒ void

This method returns an undefined value.



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

def self.clear_fam_pcs       = @@fam_pcs.clear

.clear_fam_room_desc ⇒ void

This method returns an undefined value.



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

def self.clear_fam_room_desc = @@fam_room_desc.clear

.clear_inv ⇒ void

This method returns an undefined value.



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

def self.clear_inv           = @@inv.clear

.clear_loot ⇒ void

This method returns an undefined value.



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

def self.clear_loot          = @@loot.clear

.clear_npcs ⇒ void

This method returns an undefined value.



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

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

.clear_pcs ⇒ void

This method returns an undefined value.



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

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

.clear_reserve ⇒ void

This method returns an undefined value.



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

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

.clear_room_desc ⇒ void

This method returns an undefined value.



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

def self.clear_room_desc     = @@room_desc.clear

.commit_all_containers ⇒ void

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.

A full INV LIST refresh (begin_all_containers) uses its own dedicated buffer (+@@staging_all_contents+) and is published only through its clean path (commit_all_containers_full) or discarded on interruption (discard_inv_refresh) -- never here. While one is open this defers the ordinary per-container publish until the listing closes, so the two commit paths never interleave at an interrupting prompt.



1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
# File 'documented/common/gameobj.rb', line 1007

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

  @@staging_contents.each do |id, staged|
    (staged)
    @@contents[id] = staged
  end
  @@staging_contents.clear
end

.commit_all_containers_full ⇒ void

This method returns an undefined value.

Publishes a full container refresh opened by begin_all_containers with a single reference swap and closes it. No-op if no full refresh is open, so an interrupted listing simply keeps the previous published model.



1045
1046
1047
1048
1049
1050
1051
1052
# File 'documented/common/gameobj.rb', line 1045

def self.commit_all_containers_full
  return unless @@staging_all_containers

  @@staging_all_contents.each_value { |buf| (buf) }
  @@contents = @@staging_all_contents
  @@staging_all_contents = {}
  @@staging_all_containers = false
end

.commit_container(container_id) ⇒ void

This method returns an undefined value.

Parameters:



974
975
976
977
978
979
980
# File 'documented/common/gameobj.rb', line 974

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

  (staged)
  @@contents[container_id] = staged
end

.commit_familiar ⇒ void

This method returns an undefined value.



948
949
950
951
952
953
954
955
956
957
958
959
# File 'documented/common/gameobj.rb', line 948

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_inv ⇒ void

This method returns an undefined value.



854
855
856
857
858
859
860
# File 'documented/common/gameobj.rb', line 854

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

  (@@staging_inv)
  @@inv         = @@staging_inv
  @@staging_inv = nil
end

.commit_reserve ⇒ void

This method returns an undefined value.



880
881
882
883
884
885
# File 'documented/common/gameobj.rb', line 880

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

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

.commit_room_desc ⇒ void

This method returns an undefined value.



930
931
932
933
934
935
# File 'documented/common/gameobj.rb', line 930

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

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

.commit_room_objs ⇒ void

This method returns an undefined value.



897
898
899
900
901
902
903
904
905
906
# File 'documented/common/gameobj.rb', line 897

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_players ⇒ void

This method returns an undefined value.



917
918
919
920
921
922
923
924
# File 'documented/common/gameobj.rb', line 917

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

.container_refresh_open?(container_id) ⇒ Boolean

Whether a staged refresh for one container's contents is currently open. Same purpose as inv_refresh_open?, scoped to a single container id.

A full INV LIST refresh (begin_all_containers) owns every container the instant it opens, but only allocates a per-container buffer when an item line for that container arrives. A container not yet seen in the listing therefore has no key in @@staging_contents and would look unowned -- letting a second writer publish into or delete it mid-refresh. Report a full refresh as owning all containers so those writers defer.

Parameters:

Returns:

  • (Boolean)


791
792
793
# File 'documented/common/gameobj.rb', line 791

def self.container_refresh_open?(container_id)
  @@staging_all_containers || @@staging_contents.key?(container_id)
end

.containers ⇒ Hash{String => Array<GameObj>}

Returns a snapshot of all container contents: the outer hash and every inner array are duplicated, so a caller iterating containers[id] is not affected by concurrent in-place mutation of the live registry (e.g. a hand pickup's remove_inv_item reject!, or +new_inv+'s push). Matches the dup-on-read contract of the other registry accessors and instance #contents. The GameObj elements themselves are shared, not copied.

Returns:



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

def self.containers  = @@contents.transform_values(&:dup)

.dead ⇒ Array<GameObj>?

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

Returns:



1187
1188
1189
1190
# File 'documented/common/gameobj.rb', line 1187

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:



765
766
767
768
769
# File 'documented/common/gameobj.rb', line 765

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

.discard_inv_refresh ⇒ void

This method returns an undefined value.

Discards an in-flight INV LIST refresh (worn + full container staging) without publishing it, leaving the previously published model visible. Unlike discard_staged_refreshes this touches only the INV LIST buffers, so it will not abort an unrelated in-flight room/familiar refresh -- nor an unrelated per-container begin_container refresh, which lives in the separate @@staging_contents hash and commits on its own at the next prompt.



1062
1063
1064
1065
1066
1067
1068
# File 'documented/common/gameobj.rb', line 1062

def self.discard_inv_refresh
  (@@staging_inv)
  @@staging_all_contents.each_value { |buf| (buf) }
  @@staging_inv = nil
  @@staging_all_contents = {}
  @@staging_all_containers = false
end

.discard_staged_refreshes ⇒ void

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.



1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
# File 'documented/common/gameobj.rb', line 1085

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
  @@staging_all_contents.clear
  @@staging_all_containers = false
  # Every open buffer is being discarded, so no deferred metadata can still
  # belong to a refresh that will commit; drop all of it.
  @@pending_metadata.clear
end

.drop_pending_metadata(buffer) ⇒ void

This method returns an undefined value.

Drops the metadata deferred for one staging buffer without applying it -- the abort counterpart to apply_pending_metadata. Called by every path that discards a buffer so a rolled-back refresh cannot later publish its staged values. No-op for a buffer that deferred nothing.

Parameters:

  • buffer (Array, nil) —

    the staging buffer being discarded



839
840
841
# File 'documented/common/gameobj.rb', line 839

def self.(buffer)
  @@pending_metadata.delete(buffer)
end

.fam_loot ⇒ Array<GameObj>?

Returns:



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

def self.fam_loot    = registry_or_nil(@@fam_loot)

.fam_npcs ⇒ Array<GameObj>?

Returns:



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

def self.fam_npcs    = registry_or_nil(@@fam_npcs)

.fam_pcs ⇒ Array<GameObj>?

Returns:



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

def self.fam_pcs     = registry_or_nil(@@fam_pcs)

.fam_room_desc ⇒ Array<GameObj>?

Returns:



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

def self.fam_room_desc = registry_or_nil(@@fam_room_desc)

.hidden_targets ⇒ Array<String>

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

Returns:



1173
1174
1175
# File 'documented/common/gameobj.rb', line 1173

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 refreshed from a differing non-nil incoming value; a nil observation never clobbers a known value. (Hand slots pass no before/after, so in practice this path only ever backfills.)

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) —

    sets before_name from a differing non-nil observation

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

    sets after_name from a differing non-nil observation

Returns:



626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
# File 'documented/common/gameobj.rb', line 626

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]
      # Refresh command metadata whenever a differing non-nil observation
      # arrives -- e.g. a name-preserving move updates +before_name+ from
      # "get #id in #old" to "get #id in #new". A nil observation never
      # clobbers a known value (a sighting from a source that carries no
      # command, such as the hand slot, must not blank it).
      existing.before_name = before if !before.nil? && existing.before_name != before
      existing.after_name  = after  if !after.nil?  && existing.after_name  != after
      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


1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
# File 'documented/common/gameobj.rb', line 1330

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

.inv ⇒ Array<GameObj>?

Returns:



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

def self.inv         = registry_or_nil(@@inv)

.inv_refresh_open? ⇒ Boolean

Whether a top-level inventory (+@@inv+) staged refresh is currently open. Lets a second writer (e.g. the Inventory read-model) skip its own +begin_inv+/+commit_inv+ cycle so it cannot prematurely publish or truncate an in-flight classic @@inv refresh.

Returns:

  • (Boolean)


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

def self.inv_refresh_open? = !@@staging_inv.nil?

.left_hand ⇒ Array<GameObj>?

Returns:



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

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



1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
# File 'documented/common/gameobj.rb', line 1431

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

.loot ⇒ Array<GameObj>?

Returns:



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

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)


1422
1423
1424
# File 'documented/common/gameobj.rb', line 1422

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:



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

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:



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

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:



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

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:



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

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:



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'documented/common/gameobj.rb', line 391

def self.new_inv(id, noun, name, container = nil, before = nil, after = nil)
  # A write is "staged" when it targets an open staging buffer rather than
  # the live registry. Metadata refreshes are then deferred into that same
  # buffer so an uncommitted (or later discarded) value is never published
  # on the shared instance -- see +@@pending_metadata+.
  if container
    staged =
      if @@staging_contents.key?(container)
        # An explicit per-container refresh (+begin_container+, e.g. a
        # +clearContainer+ fill) already owns this container -- stage into
        # it even during a full INV LIST refresh so the two paths never
        # share a buffer.
        @@staging_contents[container]
      elsif @@staging_all_containers
        # Full INV LIST refresh: stage every other container into the
        # dedicated buffer so nothing touches the live model until the
        # listing commits cleanly.
        @@staging_all_contents[container] ||= []
      end
    # +staged+ is the target staging buffer (either kind) or nil for a live
    # write; either way it is both the placement target and the deferral
    # scope, so an uncommitted metadata change lands in that same buffer.
    target = staged || (@@contents[container] ||= [])
    find_or_create(target, id, noun, name, before, after, defer_to: staged)
  else
    find_or_create(@@staging_inv || @@inv, id, noun, name, before, after, defer_to: @@staging_inv)
  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:



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

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:



365
366
367
# File 'documented/common/gameobj.rb', line 365

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:



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

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:



376
377
378
379
380
# File 'documented/common/gameobj.rb', line 376

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:



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

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:



578
579
580
# File 'documented/common/gameobj.rb', line 578

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:



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

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

.npcs ⇒ Array<GameObj>?

Returns:



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

def self.npcs        = registry_or_nil(@@npcs)

.pcs ⇒ Array<GameObj>?

Returns:



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

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


1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
# File 'documented/common/gameobj.rb', line 1246

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)


1412
1413
1414
# File 'documented/common/gameobj.rb', line 1412

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

.remove_inv_item(id) ⇒ void

This method returns an undefined value.

Removes every placement of id from the worn inventory (+@@inv+) and from all container contents (+@@contents+). Used as the removal half of upsert_inv and to reconcile the model when an item leaves inventory for a hand (a held item is neither worn nor in a container). Leaves +@@right_hand+/+@@left_hand+ and the shared identity index untouched.

A nil or empty id is an explicit no-op: empty hands stream a hand tag with no exist id, so this is called constantly with nil, and matching obj.id == nil would wrongly wipe any (future) nil-id entry -- and also scan the whole inventory for nothing. The reject! runs under @@index_mutex (the lock find_or_create holds for registry writes) so it cannot race the off-thread prune sweep that walks these registries.

Parameters:



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

def self.remove_inv_item(id)
  str_id = id.is_a?(Integer) ? id.to_s : id
  return if str_id.nil? || str_id.empty?

  @@index_mutex.synchronize do
    @@inv.reject! { |obj| obj.id == str_id }
    @@contents.each_value { |list| list.reject! { |obj| obj.id == str_id } }
    # Scrub any in-flight staging buffers too: a hand pickup during a full
    # INV LIST refresh must not be resurrected in its old container when the
    # staged listing commits.
    @@staging_inv&.reject! { |obj| obj.id == str_id }
    @@staging_contents.each_value { |list| list.reject! { |obj| obj.id == str_id } }
    @@staging_all_contents.each_value { |list| list.reject! { |obj| obj.id == str_id } }
  end
  nil
end

.reserve ⇒ Array<GameObj>?

Returns:



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

def self.reserve     = @@reserve&.dup

.right_hand ⇒ Array<GameObj>?

Returns:



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

def self.right_hand  = @@right_hand&.dup

.room_desc ⇒ Array<GameObj>?

Returns:



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

def self.room_desc   = registry_or_nil(@@room_desc)

.sellable_data ⇒ Hash

Returns the loaded sellable classification data.

Returns:

  • (Hash) —

    the loaded sellable classification data



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

def self.sellable_data = @@sellable_data

.target ⇒ GameObj?

Returns the single NPC or PC matching XMLData.current_target_id.

Returns:



1180
1181
1182
# File 'documented/common/gameobj.rb', line 1180

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

.targets ⇒ Array<GameObj>

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

Returns:



1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
# File 'documented/common/gameobj.rb', line 1158

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_cache ⇒ Hash

Returns the memoized type lookup cache.

Returns:

  • (Hash) —

    the memoized type lookup cache



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

def self.type_cache    = @@type_cache

.type_data ⇒ Hash

Returns the loaded type classification data.

Returns:

  • (Hash) —

    the loaded type classification data



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

def self.type_data     = @@type_data

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

Upserts an inventory item by id for a PARTIAL (filtered) scrape -- e.g. INV SEARCH <word> or INV <category> full, which list only the matching items and so must not clear unrelated inventory the way a full INV LIST refresh does. Any prior placement of id is removed from @@inv and every @@contents list first, so an item that moved containers (or whose name changed, e.g. gained "(closed)") is refreshed in exactly one place with neither a duplicate nor a stale copy left behind. It is then (re)placed via new_inv: worn (+container+ nil) into @@inv, otherwise into @@contents[container].

Because a filtered scrape only reports matches, it can add or relocate items but can never prove an item is gone -- removals are left to the next full refresh.

The removal runs under @@index_mutex (the same lock find_or_create holds for registry writes) so it cannot race the off-thread prune_index! sweep that walks +@@inv+/+@@contents+. It is a separate acquisition from the new_inv that follows -- Ruby's Mutex is not reentrant, and new_inv takes the lock itself -- so remove and re-add are not one atomic step; that is fine because the parser thread is the sole writer and a reader can at worst momentarily not see the item.

Parameters:

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

Returns:



449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
# File 'documented/common/gameobj.rb', line 449

def self.upsert_inv(id, noun, name, container = nil, before = nil, after = nil)
  str_id = id.is_a?(Integer) ? id.to_s : id
  remove_inv_item(str_id)
  obj = new_inv(str_id, noun, name, container, before, after)

  # A name change keys a NEW index entry ("id|noun|newname"); the prior
  # "id|noun|oldname" instance was just pulled from every registry by
  # remove_inv_item, but its index key lingers until the TTL prune. On the
  # cold scrape path, evict any index entry for this id whose instance is no
  # longer live so a frequently-renamed item (e.g. a pouch toggling
  # "(closed)") can't accumulate orphaned variants between sweeps. Guarded by
  # object identity via +live_registry_objects+ (computed outside the lock,
  # matching +sweep_stale!+) so a variant still held anywhere -- the instance
  # just re-added, or a hand slot -- is kept.
  live = live_registry_objects
  @@index_mutex.synchronize do
    @@index.delete_if { |_key, (indexed, _ts)| indexed.id == str_id && !live.include?(indexed) }
  end

  obj
end

Instance Method Details

#contents ⇒ Array<GameObj>?

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

Returns:



223
224
225
# File 'documented/common/gameobj.rb', line 223

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

#empty? ⇒ false

Always returns false; GameObj instances are never considered empty.

Returns:

  • (false)


216
217
218
# File 'documented/common/gameobj.rb', line 216

def empty?
  false
end

#full_name ⇒ String

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

Returns:



230
231
232
233
# File 'documented/common/gameobj.rb', line 230

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

#GameObj ⇒ String?

Deprecated.

Use #noun or #to_s instead.

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

Returns:



209
210
211
# File 'documented/common/gameobj.rb', line 209

def GameObj
  @noun
end

#sellable ⇒ String?

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

Returns:



280
281
282
283
284
285
286
287
288
# File 'documented/common/gameobj.rb', line 280

def sellable
  # See +#type+: degrade to +nil+ (loudly once, then quietly) when the data
  # file is missing/corrupt rather than raising on +nil.empty?+/+nil.keys+.
  GameObj.load_data if @@sellable_data && @@sellable_data.empty?
  return nil if @@sellable_data.nil?

  matches = matching_data_keys(@@sellable_data)
  matches.empty? ? nil : matches.join(',')
end

#status ⇒ String?

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:



314
315
316
317
318
319
320
321
# File 'documented/common/gameobj.rb', line 314

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:



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

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_s ⇒ String?

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

Returns:



200
201
202
# File 'documented/common/gameobj.rb', line 200

def to_s
  @noun
end

#type ⇒ String?

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:



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'documented/common/gameobj.rb', line 251

def type
  # +load_data+ nils +@@type_data+ (not +{}+) when the data file is missing
  # or corrupt, and returns false rather than raising. Only attempt the load
  # from the pristine +{}+ state (a nil is falsy under +&&+, so a failed load
  # isn't retried on every call -- it echoes its error once, then degrades),
  # and guard the lookup so a broken +gameobj-data.xml+ yields +nil+ instead
  # of crashing every +#type+ caller -- including every Inventory::Item,
  # which now exposes this as public API.
  GameObj.load_data if @@type_data && @@type_data.empty?
  return nil if @@type_data.nil?

  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)


273
274
275
# File 'documented/common/gameobj.rb', line 273

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