Files
sporel-lib-core.composition/init.lua
Axel Meyer ccc4bb5643 feat(composition): expose get_container for inventory readback
Add composition.get_container(entity) -> table or nil. Returns the
container block from the entity's template (e.g. {kind="list"}), or
nil if the template declared no container block. Loud-Error if the
entity was not created by composition.

The internal storage (tpl.container) was already present since v0.2.0
(stashed on the template record with a comment "B.2 reads this") but
had no public getter. This fills the oversight without any semantic
change to v0.2.0.
2026-06-13 17:30:49 +02:00

448 lines
17 KiB
Lua

-- =====================================================================
-- lib-core.composition v0.2.0 — Template + Instantiation + Tag-Index
-- See: meta/docs/superpowers/specs/2026-06-09-phase-A-inactive-entities-...
--
-- v0.1.0 Template-Only-Subset:
-- - composition.define_template{id, properties, tags} — registers a
-- template; auto-declares all properties as inert via
-- domain.contribute. Nested tables (e.g. position = {x, y}) are
-- flattened to dotted keys (position.x, position.y).
-- - composition.create{template, properties} — instantiates an
-- engine.entity, applies template-defaults + per-instance overrides
-- via set_property. Registers entity in template-index + tag-index.
-- - composition.list_by_template(id) -> {entity, ...}
-- - composition.list_by_tag(tag) -> {entity, ...}
-- - composition.destroy(entity) — removes from indices + destroys
-- engine.entity.
--
-- v0.2.0 additions:
-- - composition.set_tag(entity, tag, present) — adds (present=true) or
-- removes (present=false) entity in index_by_tag + entity_meta.
-- Idempotent. Loud-Error on entity not created by composition.
-- - define_template accepts optional container={kind="list"} block.
-- All constraint fields (weight_max, volume_max, grid, accepts_fluid,
-- accepts_gas, restrictions) → Loud-Error (Phase F/G deferred).
--
-- DEFERRED (loud-error if attempted):
-- - slots → Phase D Trigger (Composite Items with Sub-Items)
-- - container constraint fields (weight_max, volume_max, grid,
-- accepts_fluid, accepts_gas, restrictions) → Phase F/G Trigger
-- - quality → Phase J Trigger (Damage / Wear)
-- - condition → Phase J Trigger (Damage / Wear)
-- - parent: → Template-Inheritance, no consumer yet
-- - composite-property values (e.g. position = {x, y} as single
-- property) — engine PROPERTY_TABLE doesn't exist; nested-tables
-- are auto-flattened to dotted scalar keys.
-- =====================================================================
local M = {}
-- ---------- internal state ----------
-- template_id (string) → template-record:
-- { id, properties_flat (key → default-value), tags (set) }
local templates = {}
-- template_id (string) → list of engine.entity handles
local index_by_template = {}
-- tag (string) → list of engine.entity handles
local index_by_tag = {}
-- weak reverse-lookup: entity → { template, tags } (for destroy())
-- Lua-tables can't key on userdata reliably across GC, so we store
-- by stable entity-id derived from get_property("id"). engine assigns id.
-- For v0.1 we keep the parallel structure simpler: each entity records
-- its own template + tags in a Lua-side table keyed by a per-entity
-- monotonic registration-id we issue in create().
local entity_meta = {} -- reg_id → { template_id, tags, handle }
local next_reg_id = 1
-- ---------- internal helpers ----------
local function is_array(t)
if type(t) ~= "table" then return false end
local n = 0
for k, _ in pairs(t) do
if type(k) ~= "number" then return false end
n = n + 1
end
return n == #t
end
-- Flatten nested-table property-values into dotted scalar keys.
-- Input: { position = {x=144, y=200}, text = "Hi" }
-- Output: { ["position.x"] = 144, ["position.y"] = 200, text = "Hi" }
-- Arrays inside properties (e.g. tags) are NOT flattened; they belong
-- in `tags`, not `properties`. Reject arrays here loudly.
local function flatten_props(t, prefix, out)
out = out or {}
for k, v in pairs(t) do
if type(k) ~= "string" then
error(string.format(
"composition: property keys must be strings, got %s",
type(k)))
end
local key = prefix and (prefix .. "." .. k) or k
if type(v) == "table" then
if is_array(v) then
error(string.format(
"composition: property '%s' is an array; arrays in " ..
"properties are not supported (use tags=... at " ..
"template-level for tag-lists)", key))
end
flatten_props(v, key, out)
elseif type(v) == "number" or type(v) == "string" or type(v) == "boolean" then
out[key] = v
elseif v == nil then
-- nil default means "declared but no default" — skip
else
error(string.format(
"composition: property '%s' has unsupported value-type '%s' " ..
"(supported: number, string, boolean, or nested table for flattening)",
key, type(v)))
end
end
return out
end
-- Infer the engine property-type from a default-value.
local function infer_type(v)
local t = type(v)
if t == "number" then return "number" end
if t == "string" then return "string" end
if t == "boolean" then return "boolean" end
error("composition: cannot infer property type from value of type " .. t)
end
-- Set the engine property using the auto-detected setter (engine handles
-- typed writes via set_property based on Lua type).
local function set_prop(entity, key, value)
entity:set_property(key, value)
end
-- Declare a property via domain.contribute if not already declared.
-- Type is inferred from the default-value. inert=true so subsequent
-- set_property writes are accepted (per engine §11 inert-write rule).
local declared = {} -- "key" → true, idempotent across multiple templates
local function declare_inert(key, default_value)
if declared[key] then return end
domain.contribute("property", {
id = key,
type = infer_type(default_value),
inert = true,
})
declared[key] = true
end
-- ---------- public API ----------
function M.define_template(def)
if type(def) ~= "table" then
error("composition.define_template: expected table, got " .. type(def))
end
if type(def.id) ~= "string" or def.id == "" then
error("composition.define_template: 'id' must be a non-empty string")
end
if templates[def.id] then
error(string.format(
"composition.define_template: template '%s' already defined",
def.id))
end
-- Phase B/D/J reject — explicit Loud-Error pointing to re-entry phase.
if def.slots ~= nil then
error(string.format(
"composition.define_template '%s': 'slots' block is Phase-D " ..
"trigger (Composite Items with Sub-Items); not supported in v0.2",
def.id))
end
if def.container ~= nil then
if type(def.container) ~= "table" then
error(string.format(
"composition.define_template '%s': 'container' must be a table",
def.id))
end
if def.container.kind ~= "list" then
error(string.format(
"composition.define_template '%s': container.kind must be " ..
"'list' (only kind='list' is supported in v0.2; got %s)",
def.id, tostring(def.container.kind)))
end
-- Reject each unimplemented constraint field individually so the
-- error message names the offending field (Capability-by-Declaration:
-- no silent ignorance of declared constraints).
local deferred_fields = {
"weight_max", "volume_max", "grid",
"accepts_fluid", "accepts_gas", "restrictions",
}
for _, field in ipairs(deferred_fields) do
if def.container[field] ~= nil then
error(string.format(
"composition.define_template '%s': container.%s is a " ..
"Phase-F/G trigger (Constraint fields not implemented in v0.2)",
def.id, field))
end
end
-- Reject any unknown container keys (forward-protects against typos).
local known_container_keys = { kind = true,
weight_max = true, volume_max = true, grid = true,
accepts_fluid = true, accepts_gas = true, restrictions = true }
for k, _ in pairs(def.container) do
if not known_container_keys[k] then
error(string.format(
"composition.define_template '%s': unknown container " ..
"field '%s'",
def.id, tostring(k)))
end
end
end
if def.quality ~= nil or def.condition ~= nil then
error(string.format(
"composition.define_template '%s': 'quality' / 'condition' " ..
"are Phase-J triggers (Damage / Wear); not supported in v0.2",
def.id))
end
if def.parent ~= nil then
error(string.format(
"composition.define_template '%s': 'parent:' inheritance has " ..
"no consumer in v0.2; declare inline until a real use-case appears",
def.id))
end
-- Properties block (optional but recommended).
local props_in = def.properties or {}
if type(props_in) ~= "table" then
error(string.format(
"composition.define_template '%s': 'properties' must be a table",
def.id))
end
local props_flat = flatten_props(props_in)
-- Tags block (optional list of strings).
local tags_set = {}
if def.tags ~= nil then
if type(def.tags) ~= "table" or not is_array(def.tags) then
error(string.format(
"composition.define_template '%s': 'tags' must be an array " ..
"of strings",
def.id))
end
for _, tag in ipairs(def.tags) do
if type(tag) ~= "string" or tag == "" then
error(string.format(
"composition.define_template '%s': tag entries must be " ..
"non-empty strings",
def.id))
end
tags_set[tag] = true
end
end
-- Declare each property as inert via domain.contribute. Type inferred
-- from default-value. nil-defaults skipped (no inference possible).
for key, val in pairs(props_flat) do
declare_inert(key, val)
end
templates[def.id] = {
id = def.id,
props_flat = props_flat,
tags = tags_set,
container = def.container, -- nil when not declared; B.2 reads this
}
index_by_template[def.id] = index_by_template[def.id] or {}
end
function M.create(spec)
if type(spec) ~= "table" then
error("composition.create: expected table, got " .. type(spec))
end
if type(spec.template) ~= "string" or spec.template == "" then
error("composition.create: 'template' must be a non-empty string")
end
local tpl = templates[spec.template]
if not tpl then
error(string.format(
"composition.create: unknown template '%s' (was define_template " ..
"called?)",
spec.template))
end
-- Build flat merged property-bag: template-defaults overridden by
-- per-instance properties. Nested-tables in overrides are flattened
-- too. Override-flatten happens AFTER template-flatten, so nested
-- overrides naturally land on dotted keys.
local overrides_flat = {}
if spec.properties ~= nil then
if type(spec.properties) ~= "table" then
error("composition.create: 'properties' must be a table")
end
overrides_flat = flatten_props(spec.properties)
end
-- Any override-key that wasn't declared via define_template gets
-- declared on-the-fly with type inferred from the override value.
-- This lets per-instance properties live without forcing the template
-- to enumerate every possible override.
for key, val in pairs(overrides_flat) do
if templates[spec.template].props_flat[key] == nil then
declare_inert(key, val)
end
end
-- Spawn engine entity.
local entity = entity.create()
-- Apply template defaults first.
for key, val in pairs(tpl.props_flat) do
local final = overrides_flat[key]
if final == nil then final = val end
set_prop(entity, key, final)
end
-- Apply override-only properties (those not in template defaults).
for key, val in pairs(overrides_flat) do
if tpl.props_flat[key] == nil then
set_prop(entity, key, val)
end
end
-- Register in indices + meta.
-- Copy tpl.tags so per-entity set_tag calls don't mutate the template's
-- tag-set (which would affect future create() calls for the same template).
local reg_id = next_reg_id
next_reg_id = next_reg_id + 1
local entity_tags = {}
for t, v in pairs(tpl.tags) do entity_tags[t] = v end
entity_meta[reg_id] = { template_id = tpl.id, tags = entity_tags, handle = entity }
table.insert(index_by_template[tpl.id], entity)
for tag, _ in pairs(tpl.tags) do
index_by_tag[tag] = index_by_tag[tag] or {}
table.insert(index_by_tag[tag], entity)
end
-- Stash reg_id on the entity via a reserved property so destroy()
-- can find the meta-record. We use a composition-internal key.
declare_inert("composition.reg_id", 0)
set_prop(entity, "composition.reg_id", reg_id)
return entity
end
function M.list_by_template(id)
if type(id) ~= "string" then
error("composition.list_by_template: 'id' must be a string")
end
local list = index_by_template[id]
if not list then return {} end
-- Return a shallow copy so callers can't mutate our index.
local out = {}
for i, e in ipairs(list) do out[i] = e end
return out
end
function M.list_by_tag(tag)
if type(tag) ~= "string" then
error("composition.list_by_tag: 'tag' must be a string")
end
local list = index_by_tag[tag]
if not list then return {} end
local out = {}
for i, e in ipairs(list) do out[i] = e end
return out
end
-- Remove entity from indices + destroy engine-side. Idempotent.
local function remove_from_list(list, entity)
if not list then return end
for i, e in ipairs(list) do
if e == entity then
table.remove(list, i)
return
end
end
end
function M.destroy(target_entity)
if not target_entity then return end
local reg_id = target_entity:get_property("composition.reg_id")
if not reg_id or reg_id == 0 then
-- Not composition-created or already destroyed.
entity.destroy(target_entity)
return
end
local meta = entity_meta[reg_id]
if meta then
remove_from_list(index_by_template[meta.template_id], target_entity)
for tag, _ in pairs(meta.tags) do
remove_from_list(index_by_tag[tag], target_entity)
end
entity_meta[reg_id] = nil
end
entity.destroy(target_entity)
end
function M.set_tag(target_entity, tag, present)
-- Validate tag argument.
if type(tag) ~= "string" or tag == "" then
error("composition.set_tag: 'tag' must be a non-empty string")
end
-- Validate present argument.
if type(present) ~= "boolean" then
error("composition.set_tag: 'present' must be a boolean")
end
-- Resolve entity → meta via the composition.reg_id property.
local reg_id = target_entity and target_entity:get_property("composition.reg_id")
if not reg_id or reg_id == 0 then
error("composition.set_tag: entity was not created by composition")
end
local meta = entity_meta[reg_id]
if not meta then
error("composition.set_tag: entity was not created by composition")
end
if present then
-- Idempotent add.
if meta.tags[tag] then return end
meta.tags[tag] = true
index_by_tag[tag] = index_by_tag[tag] or {}
table.insert(index_by_tag[tag], target_entity)
else
-- Idempotent remove.
if meta.tags[tag] == nil then return end
meta.tags[tag] = nil
remove_from_list(index_by_tag[tag], target_entity)
end
end
-- ---------- introspection (debug/testing) ----------
-- Returns the set of declared template-ids (for tests + debug).
function M.list_templates()
local out = {}
for id, _ in pairs(templates) do
table.insert(out, id)
end
return out
end
-- Returns the container block from the entity's template, or nil if the
-- template declared no container block. Loud-Error if entity was not
-- created by composition.
function M.get_container(target_entity)
local reg_id = target_entity and target_entity:get_property("composition.reg_id")
if not reg_id or reg_id == 0 then
error("composition.get_container: entity was not created by composition")
end
local meta = entity_meta[reg_id]
if not meta then
error("composition.get_container: entity was not created by composition")
end
local tpl = templates[meta.template_id]
if not tpl then return nil end
return tpl.container -- nil when not declared
end
return M