commit b6a16df6f01f97f9caa69299c1f4d254b80aac46 Author: Axel Meyer Date: Tue Jun 9 13:28:52 2026 +0000 initial: composition v0.1.0 — template + tag-index + instantiation Phase-A.1 implementation per sporel-meta/docs/superpowers/specs/2026-06-09-phase-A-... Template-Only-Subset; slots/container/quality/condition produce loud-errors with explicit re-entry-phase hints. Position-Format revised to two flat scalar properties (position.x, position.y) per engine reality: no PROPERTY_TABLE exists. Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2026 Calic. All rights reserved. + +This software is part of the Sporel platform — **Tier 1 (Official / +Proprietary)** content per the Three-Tier Licensing Model documented in +`meta/docs/archive/design/vision.md §Licensing Model` (current source; +migration to `meta/docs/architecture/licensing-model.md` pending). + +⚠ **WIP — Legal review required before public launch.** The terms below +reflect design intent only; the formalized license framework will be +finalized through legal counsel before the first public release. Until +then, this notice serves as a placeholder defending the platform owner's +rights against unintentional re-licensing. + +No license is granted to copy, modify, distribute, sublicense, or otherwise +use this software in any form without prior written permission from the +copyright holder. + +References: +- Tier 1 (this file): all rights reserved, proprietary, sold/distributed + via official channels (Steam, etc.) +- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue- + share — see vision.md §Licensing Model +- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies + to community-uploaded libs/modules/assets, not this repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..b095e47 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# lib-core.composition + +Template + Instantiation + Tag-Index over `engine.entity`. Templates define +property defaults; `create` spawns an `engine.entity` with template +defaults + per-instance overrides; entities are indexed by template-id +and tag for cheap reverse-lookup. + +**Version:** 0.1.0 +**Lib-ID:** lib-core.composition +**Requires:** (none — uses engine `entity.*`, `domain.contribute` only) +**Tags:** composition, entity, template, tag-index + +## Topology + + +```mermaid +graph LR + this["lib-core.composition"] + engine["engine.*"] + this --> engine +``` + + +## Scope (v0.1.0 — Spine) + +Template-Only-Subset per [Phase-A Spec](../../../sporel-meta/docs/superpowers/specs/2026-06-09-phase-A-inactive-entities-composition-actor-reentry-design.md). + +**Supported:** +- `define_template{id, properties, tags}` +- `create{template, properties}` +- `list_by_template(id)`, `list_by_tag(tag)` +- `destroy(entity)` + +**Deferred (Loud-Error if attempted):** +- `slots` block → Phase D trigger (Composite Items with Sub-Items) +- `container` block → Phase B trigger (Items + Inventory) +- `quality`, `condition` → Phase J trigger (Damage / Wear) +- `parent:` template-inheritance → no consumer yet + +## API + +### `composition.define_template(def)` + +**Syntax:** `composition.define_template({id: string, properties?: table, tags?: array}) -> void` + +**Example:** +```lua +composition.define_template{ + id = "sign", + properties = { + sprite_path = "sprites/sign.png", + text = "default sign text", + position = {x = 0, y = 0}, -- auto-flattened to position.x + position.y + }, + tags = {"renderable"}, +} +``` + +**Description:** Registers a template under `id`. All `properties` are +auto-declared as `inert` via `domain.contribute` with type inferred +from the default-value. Nested tables in `properties` are flattened +to dotted scalar keys (e.g. `position = {x, y}` → declares `position.x` +and `position.y` as separate `number` properties). Tag-list registers +the template in the tag-index used by `list_by_tag`. + +Loud `error(...)` on: missing/non-string `id`, duplicate `id`, +non-table `properties`, malformed `tags`, attempted `slots` / +`container` / `quality` / `condition` / `parent:` block (each pointing +to its re-entry phase). + +### `composition.create(spec)` + +**Syntax:** `composition.create({template: string, properties?: table}) -> entity` + +**Example:** +```lua +local sign = composition.create{ + template = "sign", + properties = { + position = {x = 144, y = 200}, + text = "Hier steht: Willkommen im Spine.", + }, +} +``` + +**Description:** Spawns an `engine.entity`, applies template-defaults +shallow-overridden by per-instance `properties`. Nested-table +overrides (like `position = {x, y}`) are flattened the same way as in +`define_template`. Per-instance properties that weren't declared by +the template get declared on-the-fly as `inert`. Entity is registered +in the template-index + tag-index of the template. Returns the +`engine.entity` handle. + +Loud `error(...)` on: non-table `spec`, missing/unknown `template`, +non-table `properties`. + +### `composition.list_by_template(id)` + +**Syntax:** `composition.list_by_template(id: string) -> {entity, ...}` + +**Example:** +```lua +for _, sign in ipairs(composition.list_by_template("sign")) do + engine.print(sign:get_property("text")) +end +``` + +**Description:** Returns a shallow-copied list of all currently-alive +entities created with the given template-id. Empty list if template +is unknown or has no instances. + +### `composition.list_by_tag(tag)` + +**Syntax:** `composition.list_by_tag(tag: string) -> {entity, ...}` + +**Example:** +```lua +for _, e in ipairs(composition.list_by_tag("renderable")) do + local x = e:get_property("position.x") + local y = e:get_property("position.y") + -- ... render +end +``` + +**Description:** Returns all currently-alive entities whose template +declared `tag` in its `tags` array. Used by `lib-core.render` +`draw_entities{tag="..."}` (see A.2). Empty list if tag has no entities. + +### `composition.destroy(entity)` + +**Syntax:** `composition.destroy(entity) -> void` + +**Description:** Removes entity from template-index + tag-index + +destroys via `engine.entity.destroy`. Idempotent. If entity wasn't +composition-created, falls through to `entity.destroy`. + +### `composition.list_templates()` + +**Syntax:** `composition.list_templates() -> {id, ...}` + +**Description:** Debug/introspection — returns set of declared +template-ids. + +## Conventions + +- **Property-Value-Types:** number, string, boolean (engine §PROPERTY_* + catalog). Tables are flattened to dotted scalar keys; arrays inside + `properties` → loud error. +- **Position-Format:** stored as two scalar properties `position.x` + and `position.y` (per Phase-A Spec §5 A-Q5 revision 2026-06-09 — + engine has no PROPERTY_TABLE). +- **Tag-Index:** maintained in lib, not in engine. Tag-lookup is O(1) + by tag, list iteration is O(n) per tag. +- **`composition.reg_id`:** internal book-keeping property on each + created entity; used by `destroy` to find the meta-record. Do not + set or read manually. + +## Future Phases + +| Phase | Triggers in Template | Spec | +|---|---|---| +| B (Items) | `container = true` | inventory-model.md | +| D (Composites) | `slots = {...}` | composition-model.md (full Slot-System) | +| J (Damage/Wear) | `quality`, `condition` | damage-model.md | diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..b46b4f3 --- /dev/null +++ b/init.lua @@ -0,0 +1,349 @@ +-- ===================================================================== +-- lib-core.composition v0.1.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. +-- +-- DEFERRED (loud-error if attempted in v0.1): +-- - slots → Phase D Trigger (Composite Items with Sub-Items) +-- - container → Phase B Trigger (Items + Inventory) +-- - 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.1", + def.id)) + end + if def.container ~= nil then + error(string.format( + "composition.define_template '%s': 'container' block is " .. + "Phase-B trigger (Items + Inventory); not supported in v0.1", + def.id)) + 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.1", + def.id)) + end + if def.parent ~= nil then + error(string.format( + "composition.define_template '%s': 'parent:' inheritance has " .. + "no consumer in v0.1; 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, + } + 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. + local reg_id = next_reg_id + next_reg_id = next_reg_id + 1 + entity_meta[reg_id] = { template_id = tpl.id, tags = tpl.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 + +-- ---------- 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 + +return M diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..9a81d26 --- /dev/null +++ b/manifest.lib @@ -0,0 +1 @@ +{"id":"lib-core.composition","version":"0.1.0","api_min":"0.1","deps":[]}