diff --git a/README.md b/README.md index b095e47..87630e1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 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 +**Version:** 0.2.0 **Lib-ID:** lib-core.composition **Requires:** (none — uses engine `entity.*`, `domain.contribute` only) **Tags:** composition, entity, template, tag-index @@ -21,19 +21,22 @@ graph LR ``` -## Scope (v0.1.0 — Spine) +## Scope (v0.2.0) -Template-Only-Subset per [Phase-A Spec](../../../sporel-meta/docs/superpowers/specs/2026-06-09-phase-A-inactive-entities-composition-actor-reentry-design.md). +Template-Only-Subset + set_tag + container declaration. **Supported:** - `define_template{id, properties, tags}` +- `define_template{..., container={kind="list"}}` — v0.2: `kind="list"` accepted - `create{template, properties}` - `list_by_template(id)`, `list_by_tag(tag)` - `destroy(entity)` +- `set_tag(entity, tag, present)` — v0.2: runtime tag add/remove **Deferred (Loud-Error if attempted):** - `slots` block → Phase D trigger (Composite Items with Sub-Items) -- `container` block → Phase B trigger (Items + Inventory) +- `container` constraint fields (`weight_max`, `volume_max`, `grid`, + `accepts_fluid`, `accepts_gas`, `restrictions`) → Phase F/G trigger - `quality`, `condition` → Phase J trigger (Damage / Wear) - `parent:` template-inheritance → no consumer yet @@ -63,10 +66,31 @@ 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`. +The optional `container` block declares this template as an item container. +Only `kind = "list"` is supported in v0.2. Any constraint field +(`weight_max`, `volume_max`, `grid`, `accepts_fluid`, `accepts_gas`, +`restrictions`) loud-errors at template-load time (Capability-by-Declaration: +no constraint is silently ignored). + +**Example with container:** +```lua +composition.define_template{ + id = "backpack", + properties = { + sprite_atlas = "sprites/items.png", + sprite_uv = "0 0 32 32", + position = {x = 0, y = 0}, + }, + tags = {"renderable"}, + container = { kind = "list" }, +} +``` + 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). +`quality` / `condition` / `parent:` block (each pointing to its +re-entry phase); `container.kind` other than `"list"`; any +unimplemented constraint field in `container`. ### `composition.create(spec)` @@ -126,6 +150,28 @@ end 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.set_tag(entity, tag, present)` + +**Syntax:** `composition.set_tag(entity, tag: string, present: boolean) -> void` + +**Example:** +```lua +-- Remove from renderable index (e.g. item picked up into inventory) +composition.set_tag(item_entity, "renderable", false) + +-- Restore renderable index (e.g. item dropped back into world) +composition.set_tag(item_entity, "renderable", true) +``` + +**Description:** Adds or removes `entity` from the tag-index for `tag`. +`present=true` inserts the entity (idempotent — calling again with the +same tag is a no-op). `present=false` removes it (idempotent — calling +again when already absent is a no-op). Both `entity_meta` and +`index_by_tag` are updated atomically. Loud-Error if `entity` was not +created by composition (e.g. a raw `entity.create()` handle). Used by +`lib-core.inventory` `add`/`remove` to flip the `"renderable"` tag +when items move between world and inventory. + ### `composition.destroy(entity)` **Syntax:** `composition.destroy(entity) -> void` @@ -159,6 +205,7 @@ template-ids. | Phase | Triggers in Template | Spec | |---|---|---| -| B (Items) | `container = true` | inventory-model.md | +| B (Items) | `container={kind="list"}` — accepted in v0.2; constraint fields deferred to F/G | inventory-model.md | | D (Composites) | `slots = {...}` | composition-model.md (full Slot-System) | +| F/G (Constraints) | `weight_max`, `volume_max`, `grid`, `accepts_fluid`, `accepts_gas`, `restrictions` | constraint-model.md | | J (Damage/Wear) | `quality`, `condition` | damage-model.md | diff --git a/init.lua b/init.lua index b46b4f3..2a5b4a5 100644 --- a/init.lua +++ b/init.lua @@ -1,5 +1,5 @@ -- ===================================================================== --- lib-core.composition v0.1.0 — Template + Instantiation + Tag-Index +-- 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: @@ -15,9 +15,18 @@ -- - composition.destroy(entity) — removes from indices + destroys -- engine.entity. -- --- DEFERRED (loud-error if attempted in v0.1): +-- 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 → Phase B Trigger (Items + Inventory) +-- - 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 @@ -145,25 +154,59 @@ function M.define_template(def) 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", + "trigger (Composite Items with Sub-Items); not supported in v0.2", 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)) + 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.1", + "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.1; declare inline until a real use-case appears", + "no consumer in v0.2; declare inline until a real use-case appears", def.id)) end @@ -206,6 +249,7 @@ function M.define_template(def) 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 @@ -264,9 +308,13 @@ function M.create(spec) 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 - entity_meta[reg_id] = { template_id = tpl.id, tags = tpl.tags, handle = entity } + 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 @@ -335,6 +383,39 @@ function M.destroy(target_entity) 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). diff --git a/manifest.lib b/manifest.lib index 9a81d26..b4d60e4 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1 +1 @@ -{"id":"lib-core.composition","version":"0.1.0","api_min":"0.1","deps":[]} +{"id":"lib-core.composition","version":"0.2.0","api_min":"0.1","deps":[]}