get_children() returns fresh userdata wrappers without __eq; raw == compares Lua pointers (fails across separate get_children calls). slot_of now reads composition.reg_id from each child to find the matching slot, making contains/remove reliable across wrapper instances.
192 lines
6.9 KiB
Lua
192 lines
6.9 KiB
Lua
-- =====================================================================
|
|
-- lib-core.inventory-list v0.1.0 — List-Container Inventory
|
|
--
|
|
-- Manages a composition-entity acting as a list-container.
|
|
-- Items are attached as synthetic engine.entity children under
|
|
-- monotonically-increasing slot names (item.1, item.2, ...).
|
|
--
|
|
-- Surface:
|
|
-- inventory.add(container, item)
|
|
-- inventory.remove(container, item) -> item
|
|
-- inventory.contents(container) -> {item, ...}
|
|
-- inventory.contains(container, item) -> bool
|
|
-- inventory.count(container) -> number
|
|
--
|
|
-- Deps:
|
|
-- lib-core.composition (set_tag, get_container)
|
|
-- engine.entity.* (attach, detach, get_children, get_parent)
|
|
-- =====================================================================
|
|
|
|
local composition = require("lib-core.composition")
|
|
|
|
local M = {}
|
|
|
|
-- ---------- internal helpers ----------
|
|
|
|
-- Compute the next synthetic slot name for the given container.
|
|
-- Scans existing children for slots matching "item.<n>" and returns
|
|
-- "item.<max_n + 1>". This is fully derivable from the tree state,
|
|
-- so no counter needs to be persisted — reconstruction-safe after load.
|
|
local function next_slot(container)
|
|
local max_n = 0
|
|
for slot_name, _ in pairs(container:get_children()) do
|
|
local n = tonumber(slot_name:match("^item%.(%d+)$"))
|
|
if n and n > max_n then max_n = n end
|
|
end
|
|
return string.format("item.%d", max_n + 1)
|
|
end
|
|
|
|
-- Return a stable entity identity key for comparison.
|
|
-- Uses composition.reg_id (a monotonic integer set on all composition
|
|
-- entities). Falls back to nil for raw entities.
|
|
local function entity_id(e)
|
|
local rid = e:get_property("composition.reg_id")
|
|
-- reg_id is 0 for non-composition entities (or unset); treat as nil.
|
|
if rid and rid ~= 0 then return rid end
|
|
return nil
|
|
end
|
|
|
|
-- Find the slot name under which `item` is attached to `container`.
|
|
-- Returns the slot string or nil if not found.
|
|
-- Compares by composition.reg_id because get_children() returns fresh
|
|
-- userdata wrappers (no __eq on entity userdata — raw pointer differs).
|
|
local function slot_of(container, item)
|
|
local item_rid = entity_id(item)
|
|
-- get_children() returns fresh userdata wrappers each call; no cache.
|
|
for slot_name, child in pairs(container:get_children()) do
|
|
local child_rid = entity_id(child)
|
|
if item_rid ~= nil and child_rid == item_rid then
|
|
return slot_name
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Validate that container is a composition-entity with container-block
|
|
-- kind="list" and no constraint fields. Errors loudly on violation.
|
|
local function validate_container(container)
|
|
local block = composition.get_container(container)
|
|
if block == nil then
|
|
error("inventory.add: entity has no container block " ..
|
|
"(template must declare container={kind='list'})")
|
|
end
|
|
-- Defensive: composition.define_template currently rejects non-list
|
|
-- kinds at template-load, so this branch is unreachable via normal flow.
|
|
-- Kept as forward-protection if composition relaxes that constraint.
|
|
if block.kind ~= "list" then
|
|
error(string.format(
|
|
"inventory.add: container.kind must be 'list' (got '%s')",
|
|
tostring(block.kind)))
|
|
end
|
|
-- Defensive: composition v0.2 rejects declared constraint fields at
|
|
-- template-load (weight_max/volume_max/grid/accepts_fluid/accepts_gas/
|
|
-- restrictions), so this branch is unreachable via normal flow. Kept
|
|
-- as forward-protection if composition relaxes that constraint.
|
|
local constraint_fields = {
|
|
"weight_max", "volume_max", "grid",
|
|
"accepts_fluid", "accepts_gas", "restrictions",
|
|
}
|
|
for _, field in ipairs(constraint_fields) do
|
|
if block[field] ~= nil then
|
|
error(string.format(
|
|
"inventory.add: container declares '%s' which is a " ..
|
|
"Phase-F/G constraint not supported in v0.1",
|
|
field))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---------- public API ----------
|
|
|
|
-- add(container, item)
|
|
-- Validates container (list, no constraints) and item (has stack_mode,
|
|
-- stack_mode == "individual"). Detaches item from any current parent,
|
|
-- attaches to container under a fresh synthetic slot, hides from
|
|
-- renderable index.
|
|
function M.add(container, item)
|
|
-- 1. Validate container.
|
|
validate_container(container)
|
|
|
|
-- 2. Validate item has stack_mode.
|
|
local sm = item:get_property("stack_mode")
|
|
if sm == nil or sm == "" then
|
|
error("inventory.add: entity is not an item (no 'stack_mode' property)")
|
|
end
|
|
|
|
-- 3. Validate stack_mode == "individual".
|
|
if sm ~= "individual" then
|
|
error(string.format(
|
|
"inventory.add: stack_mode='%s' is not supported in v0.1 " ..
|
|
"(only 'individual')",
|
|
tostring(sm)))
|
|
end
|
|
|
|
-- 4. Detach from current parent if any.
|
|
local old_parent = item:get_parent()
|
|
if old_parent ~= nil then
|
|
local old_slot = slot_of(old_parent, item)
|
|
if old_slot then
|
|
entity.detach(old_parent, old_slot)
|
|
end
|
|
end
|
|
|
|
-- 5. Determine new slot and attach.
|
|
local slot = next_slot(container)
|
|
entity.attach(container, slot, item)
|
|
|
|
-- 6. Remove from renderable index.
|
|
composition.set_tag(item, "renderable", false)
|
|
end
|
|
|
|
-- remove(container, item) -> item
|
|
-- Detaches item from container, restores renderable tag, returns item.
|
|
-- Loud-Error if item is not in container.
|
|
function M.remove(container, item)
|
|
local slot = slot_of(container, item)
|
|
if slot == nil then
|
|
error("inventory.remove: item is not in this container")
|
|
end
|
|
entity.detach(container, slot)
|
|
composition.set_tag(item, "renderable", true)
|
|
return item
|
|
end
|
|
|
|
-- contents(container) -> {item, ...}
|
|
-- Returns a stable-ordered list of all items in the container.
|
|
-- Filters children by item-recognition rule (has stack_mode property).
|
|
-- Sorted by the numeric suffix of the synthetic slot for stable ordering.
|
|
function M.contents(container)
|
|
local children = container:get_children()
|
|
-- Collect slot/entity pairs whose slot matches item.<n>.
|
|
local entries = {}
|
|
for slot_name, child in pairs(children) do
|
|
local n = tonumber(slot_name:match("^item%.(%d+)$"))
|
|
if n ~= nil then
|
|
-- Item-recognition: entity must have stack_mode property.
|
|
local sm = child:get_property("stack_mode")
|
|
if sm ~= nil and sm ~= "" then
|
|
table.insert(entries, {n = n, entity = child})
|
|
end
|
|
end
|
|
end
|
|
-- Sort by n for stable ordering.
|
|
table.sort(entries, function(a, b) return a.n < b.n end)
|
|
local out = {}
|
|
for _, entry in ipairs(entries) do
|
|
table.insert(out, entry.entity)
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Returns true iff `item` is currently in `container`.
|
|
function M.contains(container, item)
|
|
return slot_of(container, item) ~= nil
|
|
end
|
|
|
|
-- Returns the number of items currently in `container`.
|
|
function M.count(container)
|
|
return #M.contents(container)
|
|
end
|
|
|
|
return M
|