Files
SurvivorCore/plugin/StatAdmin.luau
T
Samuel LisonandClaude Opus 4.8 4256edfd87 feat(plugin): add Survival Stats admin Studio plugin (deltas-only, locked tuning)
A Studio dock widget that lets the experience owner tune the survival stats
through a validated form instead of hand-editing Attributes on the
SurvivalStatsConfig instance — the first slice of the Builder/Admin plugin (#11).

The point is compatibility: edits are LOCKED against engine updates. StatAdmin
(the pure, headlessly-testable logic layer) writes deltas only — it sets an
attribute solely when the owner changes a field from the live engine default,
and removes it on reset / edit-back-to-default. So unset fields keep following
the (improvable) engine defaults across a SurvivorCore release, while explicit
overrides live on the owner's instance, which the engine only ever seeds and
never overwrites. Nothing tuned is lost; nothing left alone is frozen.

Hard guardrail: the plugin can read/write only the seven owner-tunable fields
(STUDIO_ATTR_MAP). A write() assert makes it impossible to ever set the
engine-owned semantics Invert / DangerHigh — re-freezing the affliction
fill-direction bug is structurally unreachable. Runtime-verified in Studio:
every write path exercised (including rejected Invert/DangerHigh attempts) left
zero banned attributes on the instance.

- plugin/StatAdmin.luau     — logic: roster, effective values, deltas-only writes
- plugin/StatAdminUi.luau   — the dock-widget form (per-field reset, override dots)
- plugin/init.server.luau   — toolbar/widget wiring + ChangeHistory undo steps
- plugin.project.json       — separate Rojo tree; build with --plugin to install
- CI: stylua + a plugin-sourcemap luau-lsp pass + a plugin build
- docs/admin-plugin.md + cross-links; fix stale Invert row in the no-code table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:22:57 +10:00

351 lines
14 KiB
Luau

--!nonstrict
--[[
StatAdmin the pure, requirable LOGIC layer of the Stat Admin plugin. NO Studio
widget, NO `plugin` global, NO ChangeHistoryService: every public function can be
required and exercised headlessly (e.g. via execute_luau) without opening the dock.
The UI (StatAdminUi) and the plugin main (init.server) are thin layers over this.
THE CRITICAL CORRECTNESS PROPERTY a deltas-only, LOCKED model.
The engine resolves a stat field as: engine defaults -> Config.override -> the
`SurvivalStatsConfig` instance (HIGHEST priority). That instance is OWNER data the
engine only ever install-if-absent seeds; it never overwrites it. So:
* We WRITE an attribute ONLY when the owner's value differs from the engine default.
* We REMOVE the attribute (Reset, or editing back to the default) so the field falls
back to the (improvable) engine default — never a frozen copy of today's default.
Unset fields therefore keep following engine defaults across updates; explicit
overrides survive forever. Nothing tuned is lost; nothing left alone is frozen.
HARD GUARDRAIL we write ONLY the seven owner-tunable attributes (exactly the engine's
STUDIO_ATTR_MAP): RatePerSecond, Max, Start, WarnAt, Display, Icon, ValueFormat.
We NEVER read-as-editable or WRITE `Invert` or `DangerHigh`: those are engine-owned stat
SEMANTICS the engine deliberately ignores on the instance. Writing them would re-freeze
the just-fixed Hunger/Thirst/Poison fill-direction bug. There is literally no code path
here that can SetAttribute either name (see the TUNABLE allow-list + the assert in write()).
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StatAdmin = {}
StatAdmin.INSTANCE_NAME = "SurvivalStatsConfig"
-- Floats from the engine (e.g. perMinutes(30) = 0.0555…) are stored rounded in the model
-- JSON (0.055556). Treat values within this epsilon as "equal to default" so typing the
-- displayed default back in REMOVES the override rather than freezing a near-duplicate.
local FLOAT_EPSILON = 1e-4
export type Kind = "number" | "boolean" | "string" | "enum"
export type FieldSpec = {
attr: string, -- attribute name on the per-stat Configuration child (and the engine map key)
field: string, -- the resolved field name on StatDefs.DEFAULTS records
kind: Kind,
label: string, -- friendly label for the UI
choices: { string }?, -- for kind == "enum"
}
-- THE allow-list. The ONLY attributes the plugin ever reads-as-editable or writes. Order
-- is the UI render order; RatePerSecond is first (the headline knob). Note the deliberate
-- ABSENCE of Invert and DangerHigh — they are engine-owned semantics, never tunable here.
StatAdmin.TUNABLE = {
{ attr = "RatePerSecond", field = "ratePerSecond", kind = "number", label = "Rate / second" },
{ attr = "Max", field = "max", kind = "number", label = "Max" },
{ attr = "Start", field = "start", kind = "number", label = "Start" },
{ attr = "WarnAt", field = "warnAt", kind = "number", label = "Warn at %" },
{ attr = "Display", field = "display", kind = "boolean", label = "Display" },
{ attr = "Icon", field = "icon", kind = "string", label = "Icon" },
{
attr = "ValueFormat",
field = "valueFormat",
kind = "enum",
label = "Value format",
choices = { "fraction", "percent", "value", "none" },
},
} :: { FieldSpec }
-- attr -> FieldSpec, for O(1) lookup + the write-time allow-list check.
local SPEC_BY_ATTR: { [string]: FieldSpec } = {}
for _, spec in StatAdmin.TUNABLE do
SPEC_BY_ATTR[spec.attr] = spec
end
StatAdmin.VALUE_FORMATS = { "fraction", "percent", "value", "none" }
export type StatEntry = {
name: string,
-- Engine default for each of the seven tunable attributes (keyed by ATTRIBUTE name, so the
-- UI, the diff and the write path all speak the same vocabulary as STUDIO_ATTR_MAP).
defaults: { [string]: any },
}
export type Roster = {
ok: boolean,
source: string?, -- human-readable data source, e.g. "ReplicatedStorage.SurvivorCore"
reason: string?, -- when ok == false, why (for the empty state)
stats: { StatEntry },
}
-- ---------------------------------------------------------------------------
-- Roster: the LIVE engine roster + defaults, read at call time (never baked).
-- ---------------------------------------------------------------------------
-- Normalize one StatDefs.DEFAULTS record into the seven tunable attributes, supplying the
-- two the array omits (icon "" and valueFormat "fraction" — matching StatDefs.install()).
-- dangerHigh / invert are intentionally NOT read here: they are not tunable.
local function toDefaults(def: any): { [string]: any }
return {
RatePerSecond = def.ratePerSecond,
Max = def.max,
Start = def.start,
WarnAt = def.warnAt,
Display = def.display,
Icon = def.icon or "",
ValueFormat = def.valueFormat or "fraction",
}
end
-- Reads the live engine roster from ReplicatedStorage.SurvivorCore.stats.StatDefs.DEFAULTS.
-- DEFAULTS is the shipped roster AS AUTHORED (pre-merge), a plain array safe to require
-- read-only — so the plugin's notion of "engine default" provably matches what the engine
-- resolves absent the instance. Returns { ok = false, … } (never errors) when the engine
-- isn't in the place, so the UI can render an instructional empty state and write nothing.
function StatAdmin.readRoster(): Roster
local sc = ReplicatedStorage:FindFirstChild("SurvivorCore")
if not sc then
return {
ok = false,
reason = "SurvivorCore engine not found in ReplicatedStorage. Sync the engine "
.. "(rojo serve) or insert SurvivorCore.rbxm, then press Refresh.",
stats = {},
}
end
local statsFolder = sc:FindFirstChild("stats")
local statDefsModule = statsFolder and statsFolder:FindFirstChild("StatDefs")
if not statDefsModule or not statDefsModule:IsA("ModuleScript") then
return {
ok = false,
reason = "Found SurvivorCore but not stats.StatDefs — is the engine fully synced?",
stats = {},
}
end
local ok, defsOrErr = pcall(require, statDefsModule)
if not ok or typeof(defsOrErr) ~= "table" or typeof(defsOrErr.DEFAULTS) ~= "table" then
return {
ok = false,
reason = "Could not read StatDefs.DEFAULTS from the engine.",
stats = {},
}
end
local stats: { StatEntry } = {}
for _, def in defsOrErr.DEFAULTS do
table.insert(stats, { name = def.name, defaults = toDefaults(def) })
end
return {
ok = true,
source = "ReplicatedStorage.SurvivorCore",
stats = stats,
}
end
-- ---------------------------------------------------------------------------
-- Instance / child resolution. INSTALL-IF-ABSENT, never clobbering.
-- ---------------------------------------------------------------------------
-- The live SurvivalStatsConfig instance, or nil. Read-only — never creates.
function StatAdmin.getInstance(): Instance?
return ReplicatedStorage:FindFirstChild(StatAdmin.INSTANCE_NAME)
end
-- The per-stat child Configuration, or nil. Read-only — never creates.
function StatAdmin.getNode(statName: string): Instance?
local instance = StatAdmin.getInstance()
return instance and instance:FindFirstChild(statName) or nil
end
-- Find-or-create the SurvivalStatsConfig Configuration. Creates ONLY when missing; never
-- touches an existing one. Called lazily, only from the write path (a real override).
function StatAdmin.ensureInstance(): Instance
local instance = StatAdmin.getInstance()
if not instance then
instance = Instance.new("Configuration")
instance.Name = StatAdmin.INSTANCE_NAME
instance.Parent = ReplicatedStorage
end
return instance
end
-- Find-or-create the per-stat child Configuration. A freshly created node carries ZERO
-- attributes, so every field still follows the engine default until one is explicitly set.
function StatAdmin.ensureNode(statName: string): Instance
local instance = StatAdmin.ensureInstance()
local node = instance:FindFirstChild(statName)
if not node then
node = Instance.new("Configuration")
node.Name = statName
node.Parent = instance
end
return node
end
-- ---------------------------------------------------------------------------
-- Effective values: instance override (if present) else engine default.
-- ---------------------------------------------------------------------------
export type Effective = {
value: any, -- the value the engine would resolve: override if present, else default
default: any, -- the engine default
hasOverride: boolean, -- true iff the attribute exists on the per-stat child
}
-- Resolves one field's effective value + whether it is currently overridden on the instance.
-- `default` is the engine default for that (stat, attr), as read from the roster.
function StatAdmin.readEffective(statName: string, attr: string, default: any): Effective
assert(SPEC_BY_ATTR[attr], `StatAdmin: '{attr}' is not an owner-tunable attribute`)
local node = StatAdmin.getNode(statName)
local override = node and node:GetAttribute(attr) or nil
if override ~= nil then
return { value = override, default = default, hasOverride = true }
end
return { value = default, default = default, hasOverride = false }
end
-- ---------------------------------------------------------------------------
-- Coercion + default-equality (the diff that drives write-vs-remove).
-- ---------------------------------------------------------------------------
-- Coerce/validate a raw UI value for a given attr. Returns (ok, coercedValue, errMessage).
-- Numbers parse via tonumber; WarnAt clamps to 0-100; ValueFormat is constrained to the
-- four legal strings; Display -> boolean; Icon -> string ("" allowed).
function StatAdmin.coerce(attr: string, raw: any): (boolean, any, string?)
local spec = SPEC_BY_ATTR[attr]
if not spec then
return false, nil, `'{attr}' is not tunable`
end
if spec.kind == "number" then
local n = tonumber(raw)
if n == nil then
return false, nil, "not a number"
end
if attr == "WarnAt" then
n = math.clamp(n, 0, 100)
end
return true, n, nil
elseif spec.kind == "boolean" then
if typeof(raw) == "boolean" then
return true, raw, nil
end
if raw == "true" then
return true, true, nil
elseif raw == "false" then
return true, false, nil
end
return false, nil, "not a boolean"
elseif spec.kind == "enum" then
local s = tostring(raw)
if not table.find(spec.choices :: { string }, s) then
return false, nil, "not a valid value format"
end
return true, s, nil
else -- string (Icon)
return true, tostring(raw), nil
end
end
-- True when `value` is (effectively) the engine default — numbers within FLOAT_EPSILON,
-- booleans/strings compared exactly. Used to collapse a no-op edit into an attribute removal.
function StatAdmin.equalsDefault(attr: string, value: any, default: any): boolean
local spec = SPEC_BY_ATTR[attr]
if spec and spec.kind == "number" then
local v, d = tonumber(value), tonumber(default)
if v == nil or d == nil then
return false
end
return math.abs(v - d) <= FLOAT_EPSILON
end
return value == default
end
-- ---------------------------------------------------------------------------
-- The single write decision point + reset.
-- ---------------------------------------------------------------------------
-- Low-level guarded SetAttribute. The assert makes it IMPOSSIBLE to write a non-tunable
-- attribute (Invert / DangerHigh / anything else) — the engine-owned semantics are safe.
local function write(node: Instance, attr: string, value: any?)
assert(SPEC_BY_ATTR[attr], `StatAdmin: refusing to write non-tunable attribute '{attr}'`)
node:SetAttribute(attr, value)
end
export type SetResult = {
ok: boolean,
action: "write" | "remove" | "noop",
error: string?,
}
-- THE deltas-only write rule. Coerce + validate, then:
-- * value EQUALS the engine default -> REMOVE the attribute (falls back to the default).
-- * value DIFFERS from the default -> WRITE it (create instance/child on demand).
-- We only ever touch the ONE attribute passed; sibling attributes / other stats / unknown
-- owner data are never read-modify-rewritten, so nothing the owner set elsewhere is clobbered.
function StatAdmin.setOverride(statName: string, attr: string, raw: any, default: any): SetResult
if not SPEC_BY_ATTR[attr] then
return { ok = false, action = "noop", error = `'{attr}' is not an owner-tunable attribute` }
end
local okCoerce, value, err = StatAdmin.coerce(attr, raw)
if not okCoerce then
return { ok = false, action = "noop", error = err }
end
if StatAdmin.equalsDefault(attr, value, default) then
-- No real delta: ensure the attribute is absent (typed-back-to-default == reset).
local node = StatAdmin.getNode(statName)
if node and node:GetAttribute(attr) ~= nil then
write(node, attr, nil)
return { ok = true, action = "remove" }
end
return { ok = true, action = "noop" }
end
-- Genuine override: create instance/child only now, write only this attribute.
local node = StatAdmin.ensureNode(statName)
write(node, attr, value)
return { ok = true, action = "write" }
end
-- Reset a single field: remove its attribute so it follows the engine default. No-op if
-- the child / attribute doesn't exist. Never creates anything.
function StatAdmin.resetOverride(statName: string, attr: string): SetResult
if not SPEC_BY_ATTR[attr] then
return { ok = false, action = "noop", error = `'{attr}' is not an owner-tunable attribute` }
end
local node = StatAdmin.getNode(statName)
if node and node:GetAttribute(attr) ~= nil then
write(node, attr, nil)
return { ok = true, action = "remove" }
end
return { ok = true, action = "noop" }
end
-- Reset every tunable field on one stat (removes all seven attributes if present).
function StatAdmin.resetStat(statName: string)
for _, spec in StatAdmin.TUNABLE do
StatAdmin.resetOverride(statName, spec.attr)
end
end
-- Reset every tunable field on every stat in the live roster.
function StatAdmin.resetAll()
local roster = StatAdmin.readRoster()
for _, entry in roster.stats do
StatAdmin.resetStat(entry.name)
end
end
return StatAdmin