mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 00:58:01 +00:00
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:
co-authored by
Claude Opus 4.8
parent
63b1bbd6fb
commit
4256edfd87
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: CompeyDev/setup-rokit@v0.1.2
|
||||
|
||||
- name: StyLua (format check)
|
||||
run: stylua --check src demo assets
|
||||
run: stylua --check src demo assets plugin
|
||||
|
||||
- name: Selene (lint)
|
||||
run: selene .
|
||||
@@ -51,9 +51,20 @@ jobs:
|
||||
--ignore "ServerPackages/**" \
|
||||
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)
|
||||
run: rojo build default.project.json --output SurvivorCore.rbxm
|
||||
|
||||
- name: Build demo place (demo.project.json)
|
||||
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,5 +1,6 @@
|
||||
# Rojo / Roblox
|
||||
sourcemap.json
|
||||
plugin-sourcemap.json
|
||||
globalTypes.d.luau
|
||||
*.rbxl
|
||||
*.rbxlx
|
||||
|
||||
+5
-1
@@ -119,14 +119,18 @@ published (which auto-posts to Discussions → Announcements).
|
||||
obvious. Keep the engine content-free.
|
||||
4. **Test locally.** Play-test in Studio, then run the same checks CI does:
|
||||
```bash
|
||||
stylua --check src demo assets
|
||||
stylua --check src demo assets plugin
|
||||
selene .
|
||||
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
|
||||
luau-lsp analyze --sourcemap sourcemap.json --defs globalTypes.d.luau --no-strict-dm-types \
|
||||
--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 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
|
||||
outputs are git-ignored.)
|
||||
|
||||
@@ -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).
|
||||
@@ -58,5 +58,7 @@ layer → harvesting/wildlife sub-engine.
|
||||
model, and Hooks.
|
||||
- [Survival Stats + HUD](survival-stats.md) — the stat simulation, no-code tuning, and the
|
||||
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).
|
||||
- [CONTRIBUTING](../CONTRIBUTING.md) — local dev setup, code style, and the branching model.
|
||||
|
||||
+14
-6
@@ -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
|
||||
> simulation + HUD + tuning.
|
||||
|
||||
`Invert` (per stat) controls only the HUD: an inverted bar shows the *healthy* amount, so Hunger
|
||||
(0 = fed) renders as a bar that **depletes** as you get hungry.
|
||||
Bar **fill direction is engine-owned**: a stat that's *dangerous when high* (Hunger, Thirst,
|
||||
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)
|
||||
|
||||
@@ -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) |
|
||||
| `Max` / `Start` | range and spawn value |
|
||||
| `WarnAt` | warn when the displayed bar drops below this percent |
|
||||
| `Invert` | bar full = healthy |
|
||||
| `WarnAt` | warn when the stat is within this percent of its dangerous end |
|
||||
| `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.
|
||||
|
||||
**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)
|
||||
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "SurvivorCoreStatAdmin",
|
||||
"tree": {
|
||||
"$path": "plugin"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user