Files
SurvivorCore/plugin/Field.luau
T
Samuel LisonandClaude Opus 4.8 29bd20a93f feat(builder): schema-driven Build page for world objects (#11)
Select a Part or Model in Studio, answer "what is this object?", fill a form —
it becomes a gatherable node, a mob or a quest giver. Closes the gap between
authoring a def and setting up a world object, which until now meant knowing to
tag a part and hand-typing PascalCase attributes in the property panel.

Engine — components can declare an attribute SCHEMA:
- src/components/Schema.luau (new): AttributeSpec/Display/ComponentSchema types,
  normalize/defaults/get/list, and the schemas for Gatherable, Mob, QuestGiver.
  Dependency-free ON PURPOSE: the plugin requires it live at edit time, and the
  component modules themselves can't be required there (Harvesting asserts
  IsServer; Remotes creates instances in ReplicatedStorage).
- Components.define now accepts EITHER the legacy `attr = default` map or a
  schema array, normalizing both to one ordered spec list; bind() reads the
  derived default map, so binding is byte-identical. Legacy maps are sorted, as
  `pairs` order is arbitrary and would make a UI jitter. New getSchema/
  listSchemas. The three shipped components pull name/tag/display/attributes
  from the schema; their onSetup bodies are untouched (defaults verified
  identical, all 23 attributes).

Plugin — the Build page:
- Field.luau (new): coerce/format/equalsDefault, lifted from ConfigAdmin (which
  now delegates), shared by every schema-driven editor.
- FieldRow.luau (new): the shared [○/●] label … control + help row, including a
  ⌄ picker that cycles authored ids for fields declaring `ref`.
- BuildAdmin.luau (new): live schema read with three distinct empty states,
  selection/eligibility/identify, deltas-only attribute writes, applyType
  (tag + clear any other component) and clear.
- BuildAdminUi.luau (new): chooser cards, grouped form, multi-select apply,
  stale-bind-marker warning, SelectionChanged-driven refresh.
- init.server.luau: record()-wrapped buildActions + the page.

Docs: docs/admin-plugin.md Build section + a 60-second walkthrough,
docs/extending.md schema guide, a CONTRIBUTING rule that new creator components
declare one, CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:14:52 +10:00

130 lines
4.3 KiB
Luau

--!nonstrict
--[[
Field pure value plumbing shared by every schema-driven editor in the plugin (Engine Config
and Build). No UI, no `plugin` global, no Instances: given a field spec ({ kind, min, max,
integer, choices, check }) it coerces a raw input, formats a value for display, and decides
whether a value equals its default.
`kind` is one of "number" | "boolean" | "string" | "enum" | "color3" | "font". An UNKNOWN kind
falls through to string, so a newer engine's schema can add kinds without breaking an older
plugin.
]]
local Field = {}
local FLOAT_EPSILON = 1e-4
-- Coerce a raw input (usually a text box's string) to the field's type.
-- Returns { ok = true, value } | { ok = false, error }.
function Field.coerce(field: any, raw: any): any
if field.kind == "number" then
local n = if typeof(raw) == "number" then raw else tonumber(tostring(raw))
if n == nil then
return { ok = false, error = "expected a number" }
end
if n ~= n or n == math.huge or n == -math.huge then
return { ok = false, error = "expected a finite number" }
end
if field.min then
n = math.max(n, field.min)
end
if field.max then
n = math.min(n, field.max)
end
if field.integer then
n = math.floor(n + 0.5)
end
return { ok = true, value = n }
elseif field.kind == "boolean" then
if typeof(raw) == "boolean" then
return { ok = true, value = raw }
end
local s = string.lower(tostring(raw))
if s == "true" then
return { ok = true, value = true }
elseif s == "false" then
return { ok = true, value = false }
end
return { ok = false, error = "expected true or false" }
elseif field.kind == "enum" then
local s = tostring(raw)
for _, choice in field.choices or {} :: { string } do
if choice == s then
return { ok = true, value = s }
end
end
return { ok = false, error = `must be one of: {table.concat(field.choices or {}, ", ")}` }
elseif field.kind == "color3" then
if typeof(raw) == "Color3" then
return { ok = true, value = raw }
end
local r, g, b = string.match(tostring(raw), "^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*$")
if not r then
return { ok = false, error = "expected R, G, B (0-255 each)" }
end
local rn, gn, bn = tonumber(r), tonumber(g), tonumber(b)
if rn > 255 or gn > 255 or bn > 255 then
return { ok = false, error = "channels are 0-255" }
end
return { ok = true, value = Color3.fromRGB(rn, gn, bn) }
elseif field.kind == "font" then
local s = tostring(raw)
local ok, font = pcall(function()
return (Enum.Font :: any)[s]
end)
if not ok or typeof(font) ~= "EnumItem" then
return { ok = false, error = `'{s}' is not an Enum.Font name` }
end
return { ok = true, value = s } -- stored as the NAME string; the engine resolves it
else -- "string", and any kind this plugin version doesn't know
local s = tostring(raw)
if field.check == "keycode" then
local ok, keyCode = pcall(function()
return (Enum.KeyCode :: any)[s]
end)
if not ok or typeof(keyCode) ~= "EnumItem" then
return { ok = false, error = `'{s}' is not an Enum.KeyCode name` }
end
end
return { ok = true, value = s }
end
end
-- A value as the creator should see it in a text box / button.
function Field.format(field: any, value: any): string
if value == nil then
return ""
end
if field.kind == "color3" and typeof(value) == "Color3" then
return string.format(
"%d, %d, %d",
math.round(value.R * 255),
math.round(value.G * 255),
math.round(value.B * 255)
)
end
if field.kind == "font" and typeof(value) == "EnumItem" then
return value.Name
end
return tostring(value)
end
-- Deltas-only editors store a value ONLY when it differs from the default, so this decides whether
-- a write becomes a remove. Numbers compare with a RELATIVE epsilon: an absolute one would swallow
-- genuine overrides of very small values.
function Field.equalsDefault(field: any, value: any, default: any): boolean
if field.kind == "number" then
if typeof(value) ~= "number" or typeof(default) ~= "number" then
return value == default
end
local scale = math.max(math.abs(value), math.abs(default), 1e-6)
return math.abs(value - default) <= FLOAT_EPSILON * scale
elseif field.kind == "font" then
local defaultName = if typeof(default) == "EnumItem" then default.Name else tostring(default)
return value == defaultName
end
return value == default
end
return Field