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
|
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,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
@@ -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.)
|
||||||
|
|||||||
@@ -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.
|
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
@@ -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).
|
||||||
|
|||||||
@@ -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