From 4256edfd878ce79cc49f247f48db69b917bbd473 Mon Sep 17 00:00:00 2001 From: Samuel Lison Date: Fri, 19 Jun 2026 18:22:57 +1000 Subject: [PATCH] feat(plugin): add Survival Stats admin Studio plugin (deltas-only, locked tuning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 15 +- .gitignore | 1 + CONTRIBUTING.md | 6 +- docs/admin-plugin.md | 68 ++++++++ docs/architecture.md | 2 + docs/survival-stats.md | 20 ++- plugin.project.json | 6 + plugin/StatAdmin.luau | 350 +++++++++++++++++++++++++++++++++++++++ plugin/StatAdminUi.luau | 252 ++++++++++++++++++++++++++++ plugin/init.server.luau | 77 +++++++++ 10 files changed, 788 insertions(+), 9 deletions(-) create mode 100644 docs/admin-plugin.md create mode 100644 plugin.project.json create mode 100644 plugin/StatAdmin.luau create mode 100644 plugin/StatAdminUi.luau create mode 100644 plugin/init.server.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e215b6b..10d618b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index d5828c2..856f464 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Rojo / Roblox sourcemap.json +plugin-sourcemap.json globalTypes.d.luau *.rbxl *.rbxlx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3049b72..9649ddc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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.) diff --git a/docs/admin-plugin.md b/docs/admin-plugin.md new file mode 100644 index 0000000..50cb882 --- /dev/null +++ b/docs/admin-plugin.md @@ -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). diff --git a/docs/architecture.md b/docs/architecture.md index c3ed88c..f106f67 100644 --- a/docs/architecture.md +++ b/docs/architecture.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. diff --git a/docs/survival-stats.md b/docs/survival-stats.md index ae70d9b..e053cf6 100644 --- a/docs/survival-stats.md +++ b/docs/survival-stats.md @@ -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). diff --git a/plugin.project.json b/plugin.project.json new file mode 100644 index 0000000..7ef4ca6 --- /dev/null +++ b/plugin.project.json @@ -0,0 +1,6 @@ +{ + "name": "SurvivorCoreStatAdmin", + "tree": { + "$path": "plugin" + } +} diff --git a/plugin/StatAdmin.luau b/plugin/StatAdmin.luau new file mode 100644 index 0000000..c2fedf9 --- /dev/null +++ b/plugin/StatAdmin.luau @@ -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 diff --git a/plugin/StatAdminUi.luau b/plugin/StatAdminUi.luau new file mode 100644 index 0000000..2a9be94 --- /dev/null +++ b/plugin/StatAdminUi.luau @@ -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 diff --git a/plugin/init.server.luau b/plugin/init.server.luau new file mode 100644 index 0000000..f9591c6 --- /dev/null +++ b/plugin/init.server.luau @@ -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)