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 + |
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 +
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.
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
locchain 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
"insidelongdescriptions). { 'amp' => '&', 'lt' => '<', 'gt' => '>', 'quot' => '"', 'apos' => "'" }.freeze
Class Method Summary collapse
-
.[](id) ⇒ Item?
The item from the current snapshot.
-
.age ⇒ Float?
Seconds since the current snapshot was built.
-
.all ⇒ Array<Item>
All items in the current snapshot (empty if none).
-
.at_feet ⇒ Array<Item>
Items at the player's feet in the current snapshot.
-
.containers ⇒ Array<Item>
Container items in the current snapshot.
-
.current ⇒ Snapshot?
The current global snapshot object.
-
.feed_available? ⇒ Boolean
Whether an
inventoryManagerresponse has ever been observed. -
.find(pattern) ⇒ Item?
First match in the current snapshot.
-
.last_updated ⇒ Time?
When the current snapshot was built.
-
.left_hand ⇒ Item?
The item in the player's left hand.
-
.observe(server_string) ⇒ String
Passively taps a server line: if it is a COMPLETE initial
inventoryManagerresponse, absorb it as the new snapshot and mirror it into the GameObj registries (see the GameObj integration section on Inventory); otherwise keep the prior snapshot. -
.refresh(timeout: 5) ⇒ Snapshot?
Requests a fresh inventory load and returns the resulting snapshot, assembling paginated (continuation) responses transparently.
-
.reset! ⇒ void
Clears all snapshot and feed state, and drops the GameObj containers Inventory still owns so no stale container mirror survives.
-
.right_hand ⇒ Item?
The item in the player's right hand.
-
.room ⇒ Array<Item>
Shared room-ground items in the current snapshot.
-
.room_id ⇒ String?
The current snapshot's envelope room id.
-
.total_weight ⇒ Integer
Total carried weight of the current snapshot.
-
.where(noun: nil, name: nil) ⇒ Array<Item>
Matching items in the current snapshot.
-
.worn ⇒ Array<Item>
Worn items in the current snapshot.
Class Method Details
.[](id) ⇒ Item?
Returns 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.
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).
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.
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.
955 956 957 |
# File 'documented/common/inventory.rb', line 955 def containers @snapshot ? @snapshot.containers : [] end |
.current ⇒ Snapshot?
Returns 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.
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.
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.
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.
970 971 972 |
# File 'documented/common/inventory.rb', line 970 def left_hand @snapshot && @snapshot.left_hand end |
.observe(server_string) ⇒ String
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.
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.}") end server_string end |
.refresh(timeout: 5) ⇒ Snapshot?
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.
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.}") 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.
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.
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.
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.
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.
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.
960 961 962 |
# File 'documented/common/inventory.rb', line 960 def worn @snapshot ? @snapshot.worn : [] end |