mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 09:02:29 +00:00
Found by an adversarial review of the Build page before it was ever run — all three reviewers independently flagged the first one. 1. The "what is this object?" cards were dead. chooserCard parented a Size=fromScale(1,1) TextButton into Theme.panel() intending an overlay, but Theme.panel() contains a UIListLayout, which lays out EVERY GuiObject child — there is no opt-out, and ZIndex does not affect layout. So the button became another list row: clicking a card's title/summary did nothing, and the oversized button spilled past the card and took the click for the card BELOW, applying the WRONG component (which also strips the previous component's attributes). This was the page's primary interaction. Fixed with Theme.panelButton() — the card itself is the button, matching the pattern OverviewPage already uses. 2. Clear did nothing on an object whose only tag was unknown to this engine, yet reported success. The page renders exactly that branch with a Clear button. BuildAdmin.clear now takes the tags to remove explicitly, and the page passes the unknown tags it just displayed — never removing an unlisted tag speculatively, since it may belong to another plugin. It also reports "nothing to clear" instead of a false success. 3. FieldRow's help text set Position inside a UIListLayout parent, so its indent was silently dropped and every help line rendered flush left. Uses padding now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
311 lines
12 KiB
Luau
311 lines
12 KiB
Luau
--!nonstrict
|
|
--[[
|
|
BuildAdmin — headless logic for the Build page (issue #11): turn a selected Part or Model into
|
|
a SurvivorCore world object (a Gatherable node, a Mob, a quest giver) by applying its tag and
|
|
attributes, and edit those attributes afterwards.
|
|
|
|
The SCHEMA comes from the ENGINE — readSchema() requires
|
|
ReplicatedStorage.SurvivorCore.components.Schema live, so the plugin and the engine can never
|
|
disagree about a component's fields. (That module is dependency-free precisely so the plugin can
|
|
require it: the component modules themselves pull in server systems and must never be required
|
|
in Edit mode.)
|
|
|
|
Writes are DELTAS-ONLY, the same rule as the Stats and Engine Config editors:
|
|
• an attribute exists IFF its value differs from the component's default
|
|
• typing the default back (or blanking the box) REMOVES the attribute
|
|
• unset attributes keep following engine defaults across engine updates
|
|
|
|
NOTE the two namespaces: this module writes PascalCase INSTANCE attributes (`ItemId`, `HP`) on
|
|
world objects. The Content editor writes lowercase DEF fields (`item`, `hp`) on
|
|
SurvivorCoreContent Configurations. A `ref` on a field links them (pick a def id) — they are
|
|
never the same thing.
|
|
|
|
No `plugin` global and no ChangeHistory here: the plugin main wraps every call in record().
|
|
]]
|
|
|
|
local CollectionService = game:GetService("CollectionService")
|
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
local Selection = game:GetService("Selection")
|
|
|
|
local Field = require(script.Parent.Field)
|
|
|
|
local BuildAdmin = {}
|
|
|
|
local BOUND_ATTR = "_scBound" -- the engine's runtime bind marker
|
|
|
|
-- ── Schema (live from the engine) ────────────────────────────────────────────
|
|
|
|
local function findSchemaModule(): ModuleScript?
|
|
local engine = ReplicatedStorage:FindFirstChild("SurvivorCore")
|
|
local components = engine and engine:FindFirstChild("components")
|
|
local module = components and components:FindFirstChild("Schema")
|
|
if module and module:IsA("ModuleScript") then
|
|
return module
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- { ok = true, source, components = {ComponentSchema}, byTag } | { ok = false, reason, components = {} }
|
|
-- Never errors. Re-read on every refresh so a re-synced engine self-heals.
|
|
function BuildAdmin.readSchema(): any
|
|
local module = findSchemaModule()
|
|
if not module then
|
|
return {
|
|
ok = false,
|
|
components = {},
|
|
reason = "SurvivorCore engine not found in this place (expected"
|
|
.. " ReplicatedStorage.SurvivorCore.components.Schema — sync or insert the engine, then Refresh).",
|
|
}
|
|
end
|
|
local ok, schema = pcall(require, module)
|
|
if not ok or typeof(schema) ~= "table" or typeof(schema.COMPONENTS) ~= "table" then
|
|
return {
|
|
ok = false,
|
|
components = {},
|
|
reason = "Found the engine, but it declares no component schemas — Build needs"
|
|
.. " SurvivorCore ≥ 0.9. Update the engine, then Refresh.",
|
|
}
|
|
end
|
|
|
|
-- Build the ordered list defensively: ORDER may name an entry that no longer exists, and a
|
|
-- NEWER engine may add entries ORDER doesn't cover. Include both, ORDER first.
|
|
local list, seen = {}, {}
|
|
for _, name in schema.ORDER or {} do
|
|
local entry = schema.COMPONENTS[name]
|
|
if typeof(entry) == "table" and typeof(entry.tag) == "string" then
|
|
seen[name] = true
|
|
table.insert(list, entry)
|
|
end
|
|
end
|
|
local rest = {}
|
|
for name, entry in schema.COMPONENTS do
|
|
if not seen[name] and typeof(entry) == "table" and typeof(entry.tag) == "string" then
|
|
table.insert(rest, name)
|
|
end
|
|
end
|
|
table.sort(rest)
|
|
for _, name in rest do
|
|
table.insert(list, schema.COMPONENTS[name])
|
|
end
|
|
|
|
if #list == 0 then
|
|
return { ok = false, components = {}, reason = "This engine declares no components." }
|
|
end
|
|
|
|
local byTag = {}
|
|
for _, entry in list do
|
|
byTag[entry.tag] = entry
|
|
end
|
|
return { ok = true, source = module:GetFullName(), components = list, byTag = byTag }
|
|
end
|
|
|
|
-- Attributes are only meaningful with a label/kind; tolerate a schema field we don't understand.
|
|
local function specsOf(schema: any): { any }
|
|
return if typeof(schema) == "table" and typeof(schema.attributes) == "table" then schema.attributes else {}
|
|
end
|
|
|
|
function BuildAdmin.displayOf(schema: any): any
|
|
local d = schema and schema.display
|
|
if typeof(d) ~= "table" then
|
|
return { title = (schema and schema.name) or "Component", summary = "" }
|
|
end
|
|
return d
|
|
end
|
|
|
|
-- ── Selection ────────────────────────────────────────────────────────────────
|
|
|
|
function BuildAdmin.classify(instance: Instance): string
|
|
if instance:IsA("Model") then
|
|
return "Model"
|
|
elseif instance:IsA("BasePart") then
|
|
return "BasePart"
|
|
end
|
|
return "other"
|
|
end
|
|
|
|
-- Can this component be applied to this instance? Returns (ok, reason).
|
|
function BuildAdmin.isEligible(instance: Instance, schema: any): (boolean, string?)
|
|
local want: string = tostring(BuildAdmin.displayOf(schema).instance or "any")
|
|
local got: string = BuildAdmin.classify(instance)
|
|
if got == "other" then
|
|
return false, "select a Part or a Model"
|
|
end
|
|
if want == "Model" then
|
|
return got == "Model", "needs a Model (Humanoid + PrimaryPart)"
|
|
elseif want == "BasePart" then
|
|
return got == "BasePart", "needs a single Part"
|
|
end
|
|
return true, nil -- "any"
|
|
end
|
|
|
|
-- { instances, eligible, count } — `eligible` are Parts/Models (anything a component could go on).
|
|
function BuildAdmin.getSelection(): any
|
|
local instances = Selection:Get()
|
|
local eligible = {}
|
|
for _, instance in instances do
|
|
if BuildAdmin.classify(instance) ~= "other" then
|
|
table.insert(eligible, instance)
|
|
end
|
|
end
|
|
return { instances = instances, eligible = eligible, count = #instances }
|
|
end
|
|
|
|
-- What IS this instance already? { schema?, tag?, unknownTags, multi, stale }
|
|
-- multi = it carries more than one component tag (v1 assumes one; say so rather than guess)
|
|
-- stale = it carries the runtime bind marker in Edit mode (a copy-paste out of a Play session);
|
|
-- such an object silently never binds again, so every write path clears it.
|
|
function BuildAdmin.identify(instance: Instance, schemaRead: any): any
|
|
local found, unknown = {}, {}
|
|
for _, tag in CollectionService:GetTags(instance) do
|
|
local entry = schemaRead.ok and schemaRead.byTag[tag]
|
|
if entry then
|
|
table.insert(found, entry)
|
|
else
|
|
table.insert(unknown, tag)
|
|
end
|
|
end
|
|
return {
|
|
schema = found[1],
|
|
tag = found[1] and found[1].tag,
|
|
unknownTags = unknown,
|
|
multi = #found > 1,
|
|
stale = instance:GetAttribute(BOUND_ATTR) ~= nil,
|
|
}
|
|
end
|
|
|
|
-- ── Attribute read / write (deltas-only) ─────────────────────────────────────
|
|
|
|
-- { value, default, hasOverride, typeMismatch } — `typeMismatch` flags an attribute hand-set with
|
|
-- the wrong type (e.g. HP = "3"), which would error inside the component's onSetup at bind time.
|
|
function BuildAdmin.readField(instance: Instance, spec: any): any
|
|
local raw = instance:GetAttribute(spec.attr)
|
|
if raw == nil then
|
|
return { value = spec.default, default = spec.default, hasOverride = false, typeMismatch = false }
|
|
end
|
|
local mismatch = spec.default ~= nil and typeof(raw) ~= typeof(spec.default)
|
|
return { value = raw, default = spec.default, hasOverride = true, typeMismatch = mismatch }
|
|
end
|
|
|
|
local function alive(instance: Instance?): boolean
|
|
return instance ~= nil and instance.Parent ~= nil
|
|
end
|
|
|
|
-- Set (or clear) one attribute. Blank input = reset. Writes ONLY when the value differs from the
|
|
-- component default. Returns { ok, action = "write"|"remove"|"noop", error? }.
|
|
function BuildAdmin.setField(instance: Instance, spec: any, raw: any): any
|
|
if not alive(instance) then
|
|
return { ok = false, action = "noop", error = "that object is gone" }
|
|
end
|
|
if typeof(raw) == "string" and string.match(raw, "^%s*$") then
|
|
return BuildAdmin.resetField(instance, spec)
|
|
end
|
|
local coerced = Field.coerce(spec, raw)
|
|
if not coerced.ok then
|
|
return { ok = false, action = "noop", error = coerced.error }
|
|
end
|
|
if Field.equalsDefault(spec, coerced.value, spec.default) then
|
|
return BuildAdmin.resetField(instance, spec)
|
|
end
|
|
instance:SetAttribute(spec.attr, coerced.value)
|
|
instance:SetAttribute(BOUND_ATTR, nil) -- editing implies "bind me fresh next Play"
|
|
return { ok = true, action = "write" }
|
|
end
|
|
|
|
function BuildAdmin.resetField(instance: Instance, spec: any): any
|
|
if not alive(instance) then
|
|
return { ok = false, action = "noop", error = "that object is gone" }
|
|
end
|
|
if instance:GetAttribute(spec.attr) ~= nil then
|
|
instance:SetAttribute(spec.attr, nil)
|
|
instance:SetAttribute(BOUND_ATTR, nil)
|
|
return { ok = true, action = "remove" }
|
|
end
|
|
return { ok = true, action = "noop" }
|
|
end
|
|
|
|
-- Strip a component's declared attributes (and the engine's `_`-prefixed stamps) from an instance.
|
|
local function stripComponent(instance: Instance, schema: any)
|
|
for _, spec in specsOf(schema) do
|
|
instance:SetAttribute(spec.attr, nil)
|
|
end
|
|
end
|
|
|
|
local function stripEngineStamps(instance: Instance)
|
|
for name in instance:GetAttributes() do
|
|
if string.sub(name, 1, 1) == "_" then
|
|
instance:SetAttribute(name, nil)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Make each instance this component: add its tag, remove any OTHER component's tag and that
|
|
-- component's attributes, and clear engine stamps so it binds fresh on the next Play.
|
|
-- Deltas-only: no attributes are written, so the object follows every default until you change one.
|
|
-- Returns { applied, cleared, skipped }.
|
|
function BuildAdmin.applyType(instances: { Instance }, schema: any, all: { any }): any
|
|
local applied, cleared, skipped = 0, 0, 0
|
|
for _, instance in instances do
|
|
local ok = alive(instance) and BuildAdmin.isEligible(instance, schema)
|
|
if not ok then
|
|
skipped += 1
|
|
continue
|
|
end
|
|
for _, other in all do
|
|
if other.tag ~= schema.tag and CollectionService:HasTag(instance, other.tag) then
|
|
CollectionService:RemoveTag(instance, other.tag)
|
|
stripComponent(instance, other)
|
|
cleared += 1
|
|
end
|
|
end
|
|
stripEngineStamps(instance)
|
|
if not CollectionService:HasTag(instance, schema.tag) then
|
|
CollectionService:AddTag(instance, schema.tag)
|
|
end
|
|
applied += 1
|
|
end
|
|
return { applied = applied, cleared = cleared, skipped = skipped }
|
|
end
|
|
|
|
-- The honest inverse of applyType: remove every known component tag, its attributes, and the
|
|
-- engine's `_`-prefixed stamps. `extraTags` removes specific additional tags by name — the page
|
|
-- passes the unknown tags it just displayed, so its "Clear" button actually clears what the user
|
|
-- was shown. Tags are never removed speculatively: an unlisted tag may belong to another plugin.
|
|
-- Returns { cleared }.
|
|
function BuildAdmin.clear(instances: { Instance }, all: { any }, extraTags: { string }?): any
|
|
local cleared = 0
|
|
for _, instance in instances do
|
|
if not alive(instance) then
|
|
continue
|
|
end
|
|
local touched = false
|
|
for _, schema in all do
|
|
if CollectionService:HasTag(instance, schema.tag) then
|
|
CollectionService:RemoveTag(instance, schema.tag)
|
|
stripComponent(instance, schema)
|
|
touched = true
|
|
end
|
|
end
|
|
for _, tag in (extraTags or {}) :: { string } do
|
|
if CollectionService:HasTag(instance, tag) then
|
|
CollectionService:RemoveTag(instance, tag)
|
|
touched = true
|
|
end
|
|
end
|
|
if instance:GetAttribute(BOUND_ATTR) ~= nil then
|
|
touched = true
|
|
end
|
|
stripEngineStamps(instance)
|
|
if touched then
|
|
cleared += 1
|
|
end
|
|
end
|
|
return { cleared = cleared }
|
|
end
|
|
|
|
-- How many instances in this place currently carry the component (drives the page footer).
|
|
function BuildAdmin.countInPlace(schema: any): number
|
|
return #CollectionService:GetTagged(schema.tag)
|
|
end
|
|
|
|
return BuildAdmin
|