mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 09:02:29 +00:00
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>
351 lines
14 KiB
Luau
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
|