-- ===================================================================== -- lib-core.composition v0.3.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). -- -- v0.3.0 additions: -- - composition.template_of(entity) — returns the template_id this -- entity was created from, or nil if the entity is not -- composition-managed. Loud-Error if entity is nil. -- -- v0.4.0 additions (Phase J — Quality / Condition): -- - `quality` / `condition` are now accepted as ordinary inert numeric -- properties (no longer a loud-error). composition stores them and knows -- NO semantics — no clamp, no default, no decay, no RNG. Producers own the -- meaning (crafting quality-formula, wear-decrement, module acquisition -- roll). See design/2026-08-03-crafting-quality-condition-design.md. -- -- 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 -- - 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 -- v0.4.0 (Phase J): `quality` / `condition` are no longer rejected. They are -- ordinary inert numeric properties — composition stores them and knows NO -- semantics (no clamp, no default, no decay, no RNG). Producers set them: -- crafting's quality-formula, a wear-decrement, or a module's acquisition -- roll. A template may declare them as plain properties (e.g. a tool with a -- default `condition = 1.0`); they flatten/declare like any other scalar. -- See design/2026-08-03-crafting-quality-condition-design.md §2. 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 -- Returns the template_id this entity was created from, or nil if -- the entity is not composition-managed (no reg_id property). -- Loud-Error if entity is nil. function M.template_of(target_entity) if target_entity == nil then error("composition.template_of: entity must not be nil", 2) end local reg_id = target_entity:get_property("composition.reg_id") if not reg_id or reg_id == 0 then return nil end local meta = entity_meta[reg_id] if not meta then return nil end return meta.template_id 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