Module: Lich::Common::Inventory

Defined in:
documented/common/inventory.rb

Overview

A read-only snapshot model of the Saga "extended feed" inventoryManager stream (confirmed live on both GemStone and DragonRealms).

Where GameObj exposes only the containers the passive stream has already streamed (hands, worn, a pack you just opened), an inventoryManager response returns the player's ENTIRE nested item tree -- every container's contents, each item's weight, container load (+in_encum+), and +closed+/+locked+ state -- none of which GameObj carries. That makes "find item X anywhere," "audit my whole inventory," and weight budgeting answerable from one snapshot. A large tree may arrive paginated across several responses (Inventory.refresh reassembles them transparently; see below).

When to reach for this vs GameObj

  • Use Inventory for whole-inventory questions: find-anywhere, total carried weight, per-container used/free weight, container flags.
  • Use GameObj for "what is in my hand / worn / in the pack I just opened" -- it is live and this model is a point-in-time snapshot.

Freshness contract

A snapshot reflects the moment its inventoryManager response was produced. It does NOT track later get/put/loot/hand changes. Call Inventory.refresh when you need current data, and judge staleness from Inventory.last_updated / Inventory.age.

GameObj integration

Each committed snapshot is mirrored into the two GameObj registries the extended feed authoritatively owns, keyed on the item's loc relation:

loc relation GameObj home
in,{id} / on,{id} GameObj.contents[{id}] (even for unopened containers)
worn,player GameObj.inv
righthand,player / lefthand,player not mirrored -- classic ++/++ owns them
room not mirrored -- classic room-objs owns GameObj.loot
atfeet,player identity only; see Inventory.at_feet

Hands and room loot are deliberately left to the classic stream, which is live and authoritative for those fast-changing slots (a ++/++ tag accompanies every hand change; the room-objs stream owns the floor). Mirroring them would only add redundant, occasionally-stale writes. Every relation not mirrored into a registry is still pooled identity-only via GameObj.index_or_create so Item#type/Item#sellable classify correctly.

Reconciliation is by ATOMIC wholesale replace, reusing the +begin_+/+commit_+ staging swap from GameObj PRs #1370/#1497: worn items replace @@inv via +begin_inv+/+commit_inv+, and each non-opaque container's contents replace @@contents[id] via +begin_container+/+commit_container+ -- so a moved or removed item simply isn't re-registered and is gone after the swap. Locked (opaque) containers are skipped (they report zero children, so replacing would wipe last-known contents); a container that vanishes entirely is dropped with delete_container. If a classic staged refresh for the same target is already open, Inventory skips that target this cycle and self-heals on the next response, so it can never truncate an in-flight classic fill. All mirroring runs on the parser thread, keeping registry writes single-threaded.

Threading

Inventory.observe is a passive tap driven from the game PARSER thread: it never mutates the downstream wire, never sends upstream, and never raises (it does write GameObj on that same parser thread, alongside the classic parser). Inventory.refresh runs on the CALLER (script) thread and sends the load verb itself. Never call Inventory.refresh from a Downstream/Upstream hook proc -- it blocks waiting for Inventory.observe to run on the parser thread and would stall the client pipeline.

Pagination

A large tree is split by the server: the initial response carries some items plus <continuation> markers, and each branch is fetched with a follow-up _inventory manager {id} continue {room} {root} {last} request (up to MAX_CONCURRENT_CONTINUATIONS in flight). Inventory.refresh drives this exchange on the caller thread and returns a single assembled snapshot. A PASSIVE paginated response seen by Inventory.observe is failed closed instead -- completing it would require sending upstream, which the observe tap must never do -- so prefer Inventory.refresh when a paginated inventory must be captured.

Examples:

Find an item anywhere and read its container's free weight

snap = Inventory.refresh
if snap && (sword = snap.find('claymore'))
  pack = sword.parent_item
  Lich.log "claymore is in #{pack&.noun}, #{pack&.space_left} lb free"
end

See Also:

Defined Under Namespace

Classes: Handler, Item, Snapshot

Constant Summary collapse

MAX_TREE_DEPTH =

Maximum tree depth walked by recursive traversals. Bounds a malformed or cyclic loc chain so traversal can never run away.

64
NO_WEIGHT_LIMIT_SENTINEL =

+in_max+/+on_max+ value the feed uses to mean "no weight limit" (it is sent verbatim on count-limited containers, which carry no weight cap). Treated as an unknown capacity (Item#capacity_lbs => nil).

99_990
REFRESH_TIMEOUTS_BEFORE_ABSENT =

Consecutive refresh timeouts (never seen a response) before the feed is treated as structurally absent. More than one so a single slow first load does not disable the feature.

2
MAX_CONCURRENT_CONTINUATIONS =

Maximum continuation requests refresh keeps in flight at once while assembling a paginated inventory, matching the official Saga client.

4
PROBE_BACKOFF_BASE_SECONDS =

First feed-absent re-probe delay, in seconds; doubles per miss up to PROBE_BACKOFF_MAX_SECONDS so a permanently-absent feed is not re-probed forever and never stalls a caller for the full timeout on every call.

30
PROBE_BACKOFF_MAX_SECONDS =

Ceiling for the feed-absent re-probe backoff, in seconds.

300
XML_ENTITIES =

The five standard XML entities; the feed only escapes these (chiefly &quot; inside long descriptions).

{ 'amp' => '&', 'lt' => '<', 'gt' => '>', 'quot' => '"', 'apos' => "'" }.freeze

Class Method Summary collapse

Class Method Details

.[](id) ⇒ Item?

Returns the item from the current snapshot.

Examples:

Inventory['40236126']

Parameters:

  • id (String, Integer) —

    exist id

Returns:

  • (Item, nil) —

    the item from the current snapshot



933
934
935
# File 'documented/common/inventory.rb', line 933

def [](id)
  @snapshot && @snapshot[id]
end

.age ⇒ Float?

Returns seconds since the current snapshot was built.

Returns:

  • (Float, nil) —

    seconds since the current snapshot was built



1000
1001
1002
# File 'documented/common/inventory.rb', line 1000

def age
  @snapshot && @snapshot.age
end

.all ⇒ Array<Item>

Returns all items in the current snapshot (empty if none).

Returns:

  • (Array<Item>) —

    all items in the current snapshot (empty if none)



925
926
927
# File 'documented/common/inventory.rb', line 925

def all
  @snapshot ? @snapshot.all : []
end

.at_feet ⇒ Array<Item>

Returns items at the player's feet in the current snapshot.

Returns:

  • (Array<Item>) —

    items at the player's feet in the current snapshot



975
976
977
# File 'documented/common/inventory.rb', line 975

def at_feet
  @snapshot ? @snapshot.at_feet : []
end

.containers ⇒ Array<Item>

Returns container items in the current snapshot.

Returns:

  • (Array<Item>) —

    container items in the current snapshot



955
956
957
# File 'documented/common/inventory.rb', line 955

def containers
  @snapshot ? @snapshot.containers : []
end

.current ⇒ Snapshot?

Returns the current global snapshot object.

Returns:

  • (Snapshot, nil) —

    the current global snapshot object



1016
1017
1018
# File 'documented/common/inventory.rb', line 1016

def current
  @snapshot
end

.feed_available? ⇒ Boolean

Whether an inventoryManager response has ever been observed. False until the first response and while the feed is treated as absent, so a non-Saga session leaves the feature inert.

Examples:

return unless Inventory.feed_available? || Inventory.refresh

Returns:

  • (Boolean)


1011
1012
1013
# File 'documented/common/inventory.rb', line 1011

def feed_available?
  @feed_seen == true
end

.find(pattern) ⇒ Item?

Returns first match in the current snapshot.

Examples:

Inventory.find('lootpouch')

Parameters:

  • pattern (String, Regexp) —

    substring or regexp

Returns:

  • (Item, nil) —

    first match in the current snapshot



941
942
943
# File 'documented/common/inventory.rb', line 941

def find(pattern)
  @snapshot && @snapshot.find(pattern)
end

.last_updated ⇒ Time?

Returns when the current snapshot was built.

Returns:

  • (Time, nil) —

    when the current snapshot was built



995
996
997
# File 'documented/common/inventory.rb', line 995

def last_updated
  @snapshot && @snapshot.last_updated
end

.left_hand ⇒ Item?

Returns the item in the player's left hand.

Returns:

  • (Item, nil) —

    the item in the player's left hand



970
971
972
# File 'documented/common/inventory.rb', line 970

def left_hand
  @snapshot && @snapshot.left_hand
end

.observe(server_string) ⇒ String

Note:

Runs on the game PARSER thread. Do not call from scripts; use refresh for an explicit, returned snapshot.

Passively taps a server line: if it is a COMPLETE initial inventoryManager response, absorb it as the new snapshot and mirror it into the GameObj registries (see the GameObj integration section on Lich::Common::Inventory); otherwise keep the prior snapshot. Always returns the input unchanged and NEVER raises (an uncaught error here would kill the game parser thread and drop the player's session), and never sends upstream.

Fails closed on: an Ox parse error, a line that does not structurally close, or a zero-item PASSIVE response over a non-empty snapshot (no snapshot swap, no GameObj writes). A PASSIVE paginated response is also failed closed -- assembling one requires sending continuation requests, which observe must never do; use refresh for paginated inventories.

A response whose id belongs to an in-progress refresh assembly is folded into that assembly HERE on the parser thread (validated, accumulated, and -- when the tree completes -- mirrored into GameObj); a directive is then posted to the refresh's latch, and the waiting caller thread only issues any continuation requests it asks for and returns the finished snapshot. observe itself stays passive and never sends.

Examples:

Wired into the parser path (games.rb)

Inventory.observe(server_string)

Parameters:

  • server_string (String) —

    a raw line from the game server

Returns:

  • (String) —

    server_string, unchanged



846
847
848
849
850
851
852
853
854
855
856
857
858
# File 'documented/common/inventory.rb', line 846

def observe(server_string)
  return server_string unless server_string.is_a?(String) && server_string.include?('<inventoryManager')

  begin
    handler = parse(server_string)
    closed  = structurally_closed?(server_string)
    route_response(handler, closed)
  rescue StandardError => e
    log("Inventory.observe error: #{e.class}: #{e.message}")
  end

  server_string
end

.refresh(timeout: 5) ⇒ Snapshot?

Note:

MUST run on a script thread. Never call from a Downstream/Upstream hook proc -- it would block waiting on the parser thread.

Requests a fresh inventory load and returns the resulting snapshot, assembling paginated (continuation) responses transparently. Sends _inventory manager {id}; the PARSER thread (observe) then folds the initial response and any continuation responses into the assembly and, when the tree is whole, mirrors it into GameObj -- so all registry writes stay on the parser thread. This caller thread only issues the continuation requests the parser thread asks for (+_inventory manager id continue room Top Level Namespace last+, up to MAX_CONCURRENT_CONTINUATIONS in flight) and waits for the finished snapshot or timeout.

Returns nil on timeout, on a malformed/mismatched response, or when the feed is known-absent (fast-fails without sending, re-probing on a capped exponential backoff). Callers should use the RETURNED object for a consistent view, since passive absorption may swap the global snapshot at any time.

Examples:

snap = Inventory.refresh
snap&.find('backpack')

Parameters:

  • timeout (Numeric) (defaults to: 5) —

    seconds to wait for the whole exchange (5s matches the official client)

Returns:

  • (Snapshot, nil) —

    the fresh snapshot, or nil on failure/absence



884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
# File 'documented/common/inventory.rb', line 884

def refresh(timeout: 5)
  return @snapshot unless probe_allowed?

  @refresh_mutex.synchronize do
    assembly = Assembly.new
    deadline = monotonic_now + timeout

    # The whole exchange -- route registration, the initial send, and the
    # wait/continue loop -- lives inside begin/ensure so that EVERY exit
    # removes this assembly's routing entries from +@assemblies+. That
    # includes +Thread#kill+ (how Lich terminates a script that is mid-
    # refresh), which unwinds through +ensure+ but is not a StandardError and
    # so never reaches the +rescue+. Without this a killed caller would
    # orphan its request ids and Assembly, and a late response to one of them
    # could still be folded and published on the parser thread. Once its
    # route is removed, an id minted by this module is rejected by
    # route_response even if its late response is complete and unpaginated.
    begin
      # +generate_id+ must be minted under +@mutex+, the same lock that guards
      # the parser thread's +drain_queue+ call site -- otherwise the two call
      # sites race on +@id_counter+ and could mint the same id. +@refresh_mutex+
      # serializes callers today, but this keeps the invariant true once that
      # serialization is relaxed (see the +@refresh_mutex+ note).
      initial = @mutex.synchronize do
        id = generate_id
        assembly.pending[id] = :initial
        @assemblies[id] = assembly
        id
      end
      Game._puts("_inventory manager #{initial}")
      drive_refresh(assembly, deadline)
    rescue StandardError => e
      log("Inventory.refresh error: #{e.class}: #{e.message}")
      nil
    ensure
      discard_assembly_routes(assembly)
    end
  end
end

.reset! ⇒ void

This method returns an undefined value.

Clears all snapshot and feed state, and drops the GameObj containers Inventory still owns so no stale container mirror survives. Worn items are left to the classic inv stream (which wholesale-replaces @@inv on its next refresh); hands and loot were never mirrored. Intended for a session reset / reconnect; also used to isolate tests.



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
# File 'documented/common/inventory.rb', line 1027

def reset!
  # Mutate shared state under +@mutex+, like every other mutator, so a
  # reconnect handler calling this off the parser thread can't tear state
  # out from under a still-draining +route_response+. +purge_owned_containers!+
  # touches only GameObj's own lock, so there's no re-entrant deadlock here.
  @mutex.synchronize do
    # Wake any callers blocked on an in-flight exchange so a reconnect does
    # not leave them parked until their timeout, THEN drop the routes.
    wake_pending_callers
    @snapshot           = nil
    @feed_seen          = false
    @consecutive_timeouts = 0
    @feed_absent_until  = nil
    @absent_backoff     = PROBE_BACKOFF_BASE_SECONDS
    # Keep the process namespace and issued-id high-water mark across
    # resets. Retired responses remain recognizable without retaining a
    # growing set of ids, and cannot become passive updates after reset.
    @request_prefix   ||= "im#{SecureRandom.hex(8)}".freeze
    @id_counter       ||= 0
    (@assemblies ||= {}).clear
    purge_owned_containers!
    @owned_container_ids = Set.new
    @container_parents = {}
  end
end

.right_hand ⇒ Item?

Returns the item in the player's right hand.

Returns:

  • (Item, nil) —

    the item in the player's right hand



965
966
967
# File 'documented/common/inventory.rb', line 965

def right_hand
  @snapshot && @snapshot.right_hand
end

.room ⇒ Array<Item>

Returns shared room-ground items in the current snapshot.

Returns:

  • (Array<Item>) —

    shared room-ground items in the current snapshot



980
981
982
# File 'documented/common/inventory.rb', line 980

def room
  @snapshot ? @snapshot.room : []
end

.room_id ⇒ String?

Returns the current snapshot's envelope room id.

Returns:

  • (String, nil) —

    the current snapshot's envelope room id



985
986
987
# File 'documented/common/inventory.rb', line 985

def room_id
  @snapshot && @snapshot.room_id
end

.total_weight ⇒ Integer

Returns total carried weight of the current snapshot.

Returns:

  • (Integer) —

    total carried weight of the current snapshot



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

def total_weight
  @snapshot ? @snapshot.total_weight : 0
end

.where(noun: nil, name: nil) ⇒ Array<Item>

Returns matching items in the current snapshot.

Examples:

Inventory.where(noun: 'ring')

Parameters:

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

    exact noun

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

    exact display name

Returns:

  • (Array<Item>) —

    matching items in the current snapshot



950
951
952
# File 'documented/common/inventory.rb', line 950

def where(noun: nil, name: nil)
  @snapshot ? @snapshot.where(noun: noun, name: name) : []
end

.worn ⇒ Array<Item>

Returns worn items in the current snapshot.

Returns:

  • (Array<Item>) —

    worn items in the current snapshot



960
961
962
# File 'documented/common/inventory.rb', line 960

def worn
  @snapshot ? @snapshot.worn : []
end