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>
This commit is contained in:
Samuel Lison
2026-06-19 18:22:57 +10:00
co-authored by Claude Opus 4.8
parent 63b1bbd6fb
commit 4256edfd87
10 changed files with 788 additions and 9 deletions
+13 -2
View File
@@ -26,7 +26,7 @@ jobs:
uses: CompeyDev/setup-rokit@v0.1.2 uses: CompeyDev/setup-rokit@v0.1.2
- name: StyLua (format check) - name: StyLua (format check)
run: stylua --check src demo assets run: stylua --check src demo assets plugin
- name: Selene (lint) - name: Selene (lint)
run: selene . run: selene .
@@ -51,9 +51,20 @@ jobs:
--ignore "ServerPackages/**" \ --ignore "ServerPackages/**" \
src demo assets/client src demo assets/client
# Prove both Rojo targets build: the drop-in engine model and the runnable demo place. # The Studio admin plugin is a separate Rojo tree (its requires resolve against its own
# sourcemap, not the demo's), so analyze it with the plugin sourcemap.
- name: Generate plugin sourcemap
run: rojo sourcemap plugin.project.json --output plugin-sourcemap.json
- name: luau-lsp (plugin)
run: luau-lsp analyze --sourcemap plugin-sourcemap.json --defs globalTypes.d.luau --no-strict-dm-types plugin
# Prove every Rojo target builds: the drop-in engine model, the demo place, the plugin.
- name: Build engine model (default.project.json) - name: Build engine model (default.project.json)
run: rojo build default.project.json --output SurvivorCore.rbxm run: rojo build default.project.json --output SurvivorCore.rbxm
- name: Build demo place (demo.project.json) - name: Build demo place (demo.project.json)
run: rojo build demo.project.json --output demo.rbxl run: rojo build demo.project.json --output demo.rbxl
- name: Build admin plugin (plugin.project.json)
run: rojo build plugin.project.json --output SurvivorCoreStatAdmin.rbxm
+1
View File
@@ -1,5 +1,6 @@
# Rojo / Roblox # Rojo / Roblox
sourcemap.json sourcemap.json
plugin-sourcemap.json
globalTypes.d.luau globalTypes.d.luau
*.rbxl *.rbxl
*.rbxlx *.rbxlx
+5 -1
View File
@@ -119,14 +119,18 @@ published (which auto-posts to Discussions → Announcements).
obvious. Keep the engine content-free. obvious. Keep the engine content-free.
4. **Test locally.** Play-test in Studio, then run the same checks CI does: 4. **Test locally.** Play-test in Studio, then run the same checks CI does:
```bash ```bash
stylua --check src demo assets stylua --check src demo assets plugin
selene . selene .
rojo sourcemap demo.project.json --output sourcemap.json rojo sourcemap demo.project.json --output sourcemap.json
curl -fsSL -o globalTypes.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau curl -fsSL -o globalTypes.d.luau https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/scripts/globalTypes.d.luau
luau-lsp analyze --sourcemap sourcemap.json --defs globalTypes.d.luau --no-strict-dm-types \ luau-lsp analyze --sourcemap sourcemap.json --defs globalTypes.d.luau --no-strict-dm-types \
--ignore "Packages/**" --ignore "DevPackages/**" --ignore "ServerPackages/**" src demo assets/client --ignore "Packages/**" --ignore "DevPackages/**" --ignore "ServerPackages/**" src demo assets/client
# the admin plugin is a separate Rojo tree — analyze it with its own sourcemap
rojo sourcemap plugin.project.json --output plugin-sourcemap.json
luau-lsp analyze --sourcemap plugin-sourcemap.json --defs globalTypes.d.luau --no-strict-dm-types plugin
rojo build default.project.json --output SurvivorCore.rbxm rojo build default.project.json --output SurvivorCore.rbxm
rojo build demo.project.json --output demo.rbxl rojo build demo.project.json --output demo.rbxl
rojo build plugin.project.json --output SurvivorCoreStatAdmin.rbxm
``` ```
(`stylua src demo` auto-formats; `sourcemap.json`, `globalTypes.d.luau`, and the build (`stylua src demo` auto-formats; `sourcemap.json`, `globalTypes.d.luau`, and the build
outputs are git-ignored.) outputs are git-ignored.)
+68
View File
@@ -0,0 +1,68 @@
# Survival Stats admin plugin
A small **Studio plugin** that gives the experience owner a friendly form to tune the survival
stats — instead of hand-editing Attributes on the `SurvivalStatsConfig` instance in the Explorer.
It's the first slice of the [Builder / Admin plugin](https://github.com/TemujinCalidius/SurvivorCore/issues/11).
## Install
Build it straight into your Studio plugins folder, then restart Studio:
```bash
rojo build plugin.project.json --plugin SurvivorCoreStatAdmin.rbxm
```
(Or build to a file — `rojo build plugin.project.json -o SurvivorCoreStatAdmin.rbxm` — and drop it
into the local Plugins folder yourself: Studio → **Plugins** tab → **Plugins Folder**.)
A **SurvivorCore Survival Stats** button appears in the toolbar; click it to toggle the dock widget.
## Using it
Each stat shows the seven tunable fields, each displaying its **effective** value (your override if
you've set one, otherwise the live engine default):
- **Rate / second** — how fast the stat drifts (the headline knob).
- **Max**, **Start**, **Warn at %** — range, spawn value, warning threshold.
- **Display** — whether it shows on the HUD.
- **Icon** — the HUD icon asset id.
- **Value format** — `fraction` (`99/100`) · `percent` · `value` · `none`.
Edit a field (type a number / click to toggle or cycle) and it's applied immediately. The dot at the
left of a row is **filled + blue when that field is overridden**, hollow when it's following the
engine default. Click the dot to **reset** the field. Every edit is a single **undo** step.
It reads the stat roster + defaults live from the engine in the place, so it always reflects the
version you're running. With no engine synced it shows an instructional empty state and writes
nothing.
## Why your tuning is "locked" — it survives re-syncs and engine updates
This is the important part. The engine resolves each stat as **engine default → `Config.override`
the `SurvivalStatsConfig` instance (highest priority)**, and it only ever *seeds* that instance
when it's missing — it never overwrites it. The plugin builds on that with a **deltas-only** rule:
- It **writes an attribute only when you change a field from the engine default.**
- **Resetting** a field (or typing the default back in) **removes** the attribute, so the field goes
back to following the engine default.
So two good things hold across a SurvivorCore update:
1. **Your explicit overrides are never lost** — they live on your instance, which the engine never
overwrites.
2. **Fields you didn't touch keep following engine defaults** — so a future release that improves a
default rate reaches your game, instead of being frozen at today's value.
The plugin can tune **only** the seven owner-facing fields above. It deliberately **cannot** touch
`Invert` or `DangerHigh` — those are engine-owned stat *semantics* (which way a bar fills / which end
is dangerous); the engine ignores them on the instance, and a guardrail in the plugin makes it
impossible to write them.
> **Caveat — the engine's own demo.** In this repo's demo, `SurvivalStatsConfig` is *Rojo-mounted*,
> so a `rojo serve` re-sync reverts plugin edits there. That's a demo artifact: a **real** game seeds
> the instance once (install-if-absent, not Rojo-managed), so the plugin's edits persist across engine
> updates — which is the whole point.
---
See also: [Survival Stats + HUD](survival-stats.md) · [Design Language](design-language.md).
+2
View File
@@ -58,5 +58,7 @@ layer → harvesting/wildlife sub-engine.
model, and Hooks. model, and Hooks.
- [Survival Stats + HUD](survival-stats.md) — the stat simulation, no-code tuning, and the - [Survival Stats + HUD](survival-stats.md) — the stat simulation, no-code tuning, and the
designer-editable HUD. designer-editable HUD.
- [Admin plugin](admin-plugin.md) — the Studio dock for tuning stats, and the deltas-only "locked"
model that keeps owner edits intact across engine updates.
- [Design Language](design-language.md) — the look the project holds to (UI, palette, icons). - [Design Language](design-language.md) — the look the project holds to (UI, palette, icons).
- [CONTRIBUTING](../CONTRIBUTING.md) — local dev setup, code style, and the branching model. - [CONTRIBUTING](../CONTRIBUTING.md) — local dev setup, code style, and the branching model.
+14 -6
View File
@@ -32,8 +32,10 @@ is tunable, and the HUD is a real ScreenGui you restyle in Studio with **zero co
> and the energy/movement coupling arrive in later versions. This version is the stat > and the energy/movement coupling arrive in later versions. This version is the stat
> simulation + HUD + tuning. > simulation + HUD + tuning.
`Invert` (per stat) controls only the HUD: an inverted bar shows the *healthy* amount, so Hunger Bar **fill direction is engine-owned**: a stat that's *dangerous when high* (Hunger, Thirst,
(0 = fed) renders as a bar that **depletes** as you get hungry. Poison…) fills **up** as it worsens and sits empty when you're safe, while a resource (Health,
Blood…) depletes toward empty. You don't set this per stat — it follows the stat's semantics. (A
custom HUD can still flip an individual bar with a per-bar `Invert` attribute; see the HUD section.)
## Tuning — no code (recommended for creators) ## Tuning — no code (recommended for creators)
@@ -44,13 +46,19 @@ It has one child `Configuration` per stat; edit their **Attributes** in Studio
|---|---| |---|---|
| `RatePerSecond` | signed change per second (`+` rises toward `Max`, `` falls toward 0) | | `RatePerSecond` | signed change per second (`+` rises toward `Max`, `` falls toward 0) |
| `Max` / `Start` | range and spawn value | | `Max` / `Start` | range and spawn value |
| `WarnAt` | warn when the displayed bar drops below this percent | | `WarnAt` | warn when the stat is within this percent of its dangerous end |
| `Invert` | bar full = healthy |
| `Display` | show this stat in the HUD | | `Display` | show this stat in the HUD |
| `Icon` | HUD icon asset id for this stat |
| `ValueFormat` | numeric readout: `fraction` · `percent` · `value` · `none` |
Edits take effect **live** — drag `Thirst.RatePerSecond` up and thirst drains faster These seven are the only owner-tunable fields; bar-fill and danger direction are engine-owned (see
above). Edits take effect **live** — drag `Thirst.RatePerSecond` up and thirst drains faster
immediately. immediately.
**Prefer a form?** The [Survival Stats admin plugin](admin-plugin.md) is a Studio dock that edits
exactly these fields with validation, per-field reset and undo — and writes only what you change,
so your tuning survives engine updates.
## Tuning — code (for developers) ## Tuning — code (for developers)
Both work, and both lose to the Studio `SurvivalStatsConfig` instance if it's present: Both work, and both lose to the Studio `SurvivalStatsConfig` instance if it's present:
@@ -148,4 +156,4 @@ in your place — it persists there normally.
--- ---
See also: [Extending SurvivorCore](extending.md) · [Architecture](architecture.md). See also: [Admin plugin](admin-plugin.md) · [Extending SurvivorCore](extending.md) · [Architecture](architecture.md).
+6
View File
@@ -0,0 +1,6 @@
{
"name": "SurvivorCoreStatAdmin",
"tree": {
"$path": "plugin"
}
}
+350
View File
@@ -0,0 +1,350 @@
--!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
+252
View File
@@ -0,0 +1,252 @@
--!nonstrict
--[[
StatAdminUi builds the dock-widget content. A thin layer over StatAdmin (the logic):
it never touches the config instance itself. Edits are routed through `applyEdit` /
`applyReset` callbacks the plugin main supplies (they wrap the StatAdmin call in a
ChangeHistoryService recording, then refresh). Styling follows docs/design-language.md.
]]
local StatAdminUi = {}
local COL_BG = Color3.fromRGB(18, 21, 28)
local COL_PANEL = Color3.fromRGB(28, 32, 42)
local COL_TEXT = Color3.fromRGB(235, 238, 245)
local COL_DIM = Color3.fromRGB(150, 160, 180)
local COL_ACCENT = Color3.fromRGB(120, 170, 255) -- marks an overridden field
local COL_FIELD = Color3.fromRGB(38, 43, 56)
local FONT = Enum.Font.GothamMedium
local ROW_H = 26
local function make(class: string, props: { [string]: any }, children: { Instance }?): Instance
local inst = Instance.new(class)
for key, value in props do
(inst :: any)[key] = value
end
if children then
for _, child in children do
child.Parent = inst
end
end
return inst
end
local function corner(radius: number): Instance
return make("UICorner", { CornerRadius = UDim.new(0, radius) })
end
-- A single field row: [reset] label .... control. `applyEdit/applyReset` take (stat, attr, …).
local function buildRow(stat: string, spec: any, eff: any, applyEdit, applyReset): Instance
local row = make("Frame", {
Size = UDim2.new(1, 0, 0, ROW_H),
BackgroundTransparency = 1,
})
-- reset dot/button — accent when overridden, dim otherwise; click removes the attribute.
local reset = make("TextButton", {
Size = UDim2.fromOffset(18, 18),
Position = UDim2.new(0, 0, 0.5, -9),
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = eff.hasOverride and "" or "",
TextColor3 = eff.hasOverride and COL_ACCENT or COL_DIM,
TextSize = 12,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
reset.Parent = row
reset.MouseButton1Click:Connect(function()
applyReset(stat, spec.attr)
end)
make("TextLabel", {
Size = UDim2.new(0.42, -28, 1, 0),
Position = UDim2.fromOffset(26, 0),
BackgroundTransparency = 1,
Text = spec.label,
TextColor3 = eff.hasOverride and COL_TEXT or COL_DIM,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 13,
Font = FONT,
}).Parent =
row
local controlPos = UDim2.new(0.42, 4, 0, 2)
local controlSize = UDim2.new(0.58, -4, 0, ROW_H - 4)
if spec.kind == "boolean" or spec.kind == "enum" then
-- a click-to-cycle button (boolean: true/false; enum: through the choices)
local control = make("TextButton", {
Size = controlSize,
Position = controlPos,
BackgroundColor3 = COL_FIELD,
AutoButtonColor = true,
Text = tostring(eff.value),
TextColor3 = COL_TEXT,
TextSize = 13,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
control.Parent = row
control.MouseButton1Click:Connect(function()
local nextValue: any
if spec.kind == "boolean" then
nextValue = not (eff.value == true)
else
local choices = spec.choices
local i = table.find(choices, tostring(eff.value)) or 0
nextValue = choices[(i % #choices) + 1]
end
applyEdit(stat, spec.attr, nextValue, eff.default)
end)
else
-- number / string: a text box committed on focus loss
local box = make("TextBox", {
Size = controlSize,
Position = controlPos,
BackgroundColor3 = COL_FIELD,
Text = tostring(eff.value),
PlaceholderText = tostring(eff.default),
TextColor3 = COL_TEXT,
TextSize = 13,
Font = FONT,
ClearTextOnFocus = false,
BorderSizePixel = 0,
}, { corner(4), make("UIPadding", { PaddingLeft = UDim.new(0, 6), PaddingRight = UDim.new(0, 6) }) })
box.Parent = row
box.FocusLost:Connect(function()
applyEdit(stat, spec.attr, box.Text, eff.default)
end)
end
return row
end
-- Mount the widget UI into `container`. Returns { refresh } — call refresh() after any edit
-- or when the engine sync changes. `StatAdmin` is the logic module; the two callbacks route
-- writes through the plugin main (ChangeHistory + refresh).
function StatAdminUi.mount(container: Instance, StatAdmin: any, applyEdit, applyReset): { refresh: () -> () }
for _, child in container:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
(container :: any).BackgroundColor3 = COL_BG
local header = make("Frame", {
Size = UDim2.new(1, 0, 0, 32),
BackgroundTransparency = 1,
})
header.Parent = container
make("TextLabel", {
Size = UDim2.new(1, -84, 1, 0),
Position = UDim2.fromOffset(12, 0),
BackgroundTransparency = 1,
Text = "Survival Stats",
TextColor3 = COL_TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 15,
Font = Enum.Font.GothamBold,
}).Parent =
header
local refreshBtn = make("TextButton", {
Size = UDim2.fromOffset(72, 22),
Position = UDim2.new(1, -80, 0.5, -11),
BackgroundColor3 = COL_PANEL,
AutoButtonColor = true,
Text = "Refresh",
TextColor3 = COL_TEXT,
TextSize = 12,
Font = FONT,
BorderSizePixel = 0,
}, { corner(4) })
refreshBtn.Parent = header
local scroll = make("ScrollingFrame", {
Size = UDim2.new(1, 0, 1, -32),
Position = UDim2.fromOffset(0, 32),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ScrollBarThickness = 6,
CanvasSize = UDim2.new(),
AutomaticCanvasSize = Enum.AutomaticSize.Y,
}, {
make("UIListLayout", { Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder }),
make("UIPadding", {
PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10),
PaddingTop = UDim.new(0, 6),
PaddingBottom = UDim.new(0, 12),
}),
})
scroll.Parent = container
local function refresh()
for _, child in scroll:GetChildren() do
if not child:IsA("UIBase") then
child:Destroy()
end
end
local roster = StatAdmin.readRoster()
if not roster.ok then
make("TextLabel", {
Size = UDim2.new(1, 0, 0, 80),
BackgroundTransparency = 1,
Text = roster.reason or "Engine not found.",
TextColor3 = COL_DIM,
TextWrapped = true,
TextSize = 13,
Font = FONT,
}).Parent =
scroll
return
end
local order = 0
for _, entry in roster.stats do
order += 1
local section = make("Frame", {
BackgroundColor3 = COL_PANEL,
BorderSizePixel = 0,
LayoutOrder = order,
Size = UDim2.new(1, 0, 0, 22 + ROW_H * #StatAdmin.TUNABLE + 8),
AutomaticSize = Enum.AutomaticSize.None,
}, {
corner(8),
make("UIPadding", {
PaddingLeft = UDim.new(0, 8),
PaddingRight = UDim.new(0, 8),
PaddingTop = UDim.new(0, 4),
PaddingBottom = UDim.new(0, 4),
}),
make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
})
section.Parent = scroll
make("TextLabel", {
Size = UDim2.new(1, 0, 0, 22),
BackgroundTransparency = 1,
Text = entry.name,
TextColor3 = COL_TEXT,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 14,
Font = Enum.Font.GothamBold,
LayoutOrder = 0,
}).Parent =
section
for i, spec in StatAdmin.TUNABLE do
local eff = StatAdmin.readEffective(entry.name, spec.attr, entry.defaults[spec.attr])
local row = buildRow(entry.name, spec, eff, applyEdit, applyReset)
row.LayoutOrder = i
row.Parent = section
end
end
end
refreshBtn.MouseButton1Click:Connect(refresh)
refresh()
return { refresh = refresh }
end
return StatAdminUi
+77
View File
@@ -0,0 +1,77 @@
--!nonstrict
--[[
SurvivorCore Survival Stats admin plugin (main).
A toolbar button toggles a dock widget that edits the survival-stats `SurvivalStatsConfig`
instance through StatAdmin the deltas-only, LOCKED model: it writes only fields the owner
changes from the engine default, removes them on reset, and can NEVER write the engine-owned
semantics (Invert / DangerHigh). Edits are wrapped in ChangeHistoryService recordings so each
is a single undo step. All real logic lives in StatAdmin (testable); the form in StatAdminUi.
First slice of the Builder / Admin plugin (issue #11).
]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local StatAdmin = require(script.StatAdmin)
local StatAdminUi = require(script.StatAdminUi)
local toolbar = plugin:CreateToolbar("SurvivorCore")
local button = toolbar:CreateButton("Survival Stats", "Tune survival-stat rates, thresholds and HUD options", "")
button.ClickableWhenViewportHidden = true
-- CreateDockWidgetPluginGui is flagged deprecated by the tooling, but it is still the only
-- API for a dockable plugin widget (there is no replacement) — so allow it here.
-- selene: allow(deprecated)
local widget = plugin:CreateDockWidgetPluginGui(
"SurvivorCoreStatAdmin",
DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 320, 520, 280, 360)
)
widget.Title = "Survival Stats"
widget.Name = "SurvivorCoreStatAdmin"
local container = Instance.new("Frame")
container.Size = UDim2.fromScale(1, 1)
container.BackgroundColor3 = Color3.fromRGB(18, 21, 28)
container.BorderSizePixel = 0
container.Parent = widget
local ui: { refresh: () -> () }? = nil
-- Wrap a single StatAdmin mutation in one ChangeHistory recording (so it's one undo step),
-- then refresh the form so the override indicators reflect the new state.
local function record(name: string, mutate: () -> any): any
local recording = ChangeHistoryService:TryBeginRecording(name)
local result = mutate()
if recording then
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
end
if ui then
ui.refresh()
end
return result
end
local function applyEdit(statName: string, attr: string, raw: any, default: any): any
return record(`Survival Stats: {statName}.{attr}`, function()
return StatAdmin.setOverride(statName, attr, raw, default)
end)
end
local function applyReset(statName: string, attr: string): any
return record(`Survival Stats: reset {statName}.{attr}`, function()
return StatAdmin.resetOverride(statName, attr)
end)
end
ui = StatAdminUi.mount(container, StatAdmin, applyEdit, applyReset)
button.Click:Connect(function()
widget.Enabled = not widget.Enabled
if widget.Enabled and ui then
ui.refresh()
end
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
button:SetActive(widget.Enabled)
end)